Skip to content

Commit 38f2ca6

Browse files
committed
feat: expose in-page functions through WebMCP
1 parent 2c7979b commit 38f2ca6

16 files changed

Lines changed: 432 additions & 148 deletions

File tree

docs/content/1.guide/12.in-page-channel.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ Channel names are namespaced with the devframe id, like RPC ids. Function names
6464

6565
## The page script endpoint
6666

67-
The required `functions` and `events` options declare every incoming name on the endpoint's protocol side; use `{}` for an empty direction. Functions require a `handler`. Events accept an optional `handler`, and `{}` registers an event for runtime subscriptions through `on()`. Handlers are contextually typed from the shared protocol and support Standard-Schema argument validation and `jsonSerializable` metadata. `defineChannelFunction` retains the named definition shape for lower-level authoring.
67+
The required `functions` and `events` options declare every incoming name on the endpoint's protocol side; use `{}` for an empty direction. Functions require a `handler`. Events accept an optional `handler`, and `{}` registers an event for runtime subscriptions through `on()`. Handlers are contextually typed from the shared protocol and support Standard-Schema argument validation, `jsonSerializable`, and `agent` metadata. `defineChannelFunction` retains the named definition shape for lower-level authoring.
6868

6969
`call()` accepts names from `functions`, including actions returning `void` or `Promise<void>`: callers can await completion and catch errors or timeouts. `emit()`, its deprecated alias `callEvent()`, and `on()` use the names declared in `events`. Function and event names have separate namespaces.
7070

@@ -77,7 +77,14 @@ import { MY_CHANNEL } from '../shared/protocol'
7777
const pageChannel = createPageScriptChannel<MyChannelProtocol>({
7878
name: MY_CHANNEL,
7979
functions: {
80-
reset: { type: 'action', handler: async () => clearSelections() },
80+
reset: {
81+
type: 'action',
82+
jsonSerializable: true,
83+
agent: {
84+
description: 'Clear the inspected page selection. Use when starting a fresh inspection.',
85+
},
86+
handler: async () => clearSelections(),
87+
},
8188
measure: { // request/response (the default `query` type)
8289
handler: (selector) => {
8390
const rect = document.querySelector(selector)!.getBoundingClientRect()
@@ -100,6 +107,12 @@ pageChannel.events.on('panel:disconnected', () => pauseWorkIfNobodyWatches())
100107

101108
`emit` on the page-script endpoint fans out to every connected panel endpoint. Functions declared under `functions.panel` are called through a specific `pageChannel.panels[0].call()` peer handle.
102109

110+
### Agent tools over WebMCP
111+
112+
A function carrying `agent` metadata is also registered on its endpoint document's experimental WebMCP model context, independently of any panel connection. The channel name qualifies the otherwise-bare function name: `devframes:plugin:my-tool:reset` becomes the WebMCP-safe `devframes_plugin_my-tool_reset`. Only functions are exposed; events remain channel-only. Agent functions require `jsonSerializable: true`, and their Standard-Schema `args` produce the advertised `arg0` / `arg1` / … input schema.
113+
114+
The registration follows the endpoint lifecycle: `pageChannel.close()` unregisters page-script tools, and `panelChannel.close()` unregisters panel-document tools. Pass `webmcp: false` to either endpoint to disable this projection. WebMCP is experimental and absent browsers simply skip registration.
115+
103116
## The panel endpoint
104117

105118
```ts

docs/content/1.guide/15.agent-native.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ Restart; tools appear in the drawer, resources as `devframe://resource/<id>` / `
139139

140140
## Browser-side tools over WebMCP
141141

142-
The same `agent` signature works on the browser side: a client RPC function (a function the node side calls on the browser, registered on `rpc.client` or through a scoped `client.scope('my-plugin').rpc.register(...)`) carrying an `agent` field is mirrored onto the page's [WebMCP](https://github.com/webmachinelearning/webmcp) model context (`document.modelContext` / `navigator.modelContext`) as a callable tool, so in-page and browser-integrated agents can drive browser-side functionality directly. Wire names, `arg0`/`arg1`/… input schemas, and safety annotations match the MCP projection above.
142+
The same `agent` signature works on the browser side: client RPC functions and [in-page channel functions](/guide/in-page-channel#agent-tools-over-webmcp) carrying an `agent` field are mirrored onto their document's [WebMCP](https://github.com/webmachinelearning/webmcp) model context (`document.modelContext` / `navigator.modelContext`) as callable tools, so in-page and browser-integrated agents can drive browser-side functionality directly. Wire names, `arg0`/`arg1`/… input schemas, and safety annotations match the MCP projection above.
143143

144144
```ts
145145
const rpc = await connectDevframe()
@@ -155,7 +155,7 @@ rpc.client.register({
155155
})
156156
```
157157

158-
`connectDevframe()` wires this on its own when the browser provides a model context; `webmcp: false` keeps the browser side off the WebMCP surface. `registerWebMcpTools(collector)` (from `devframe/client`) applies the same projection to a hand-built collector and returns a dispose that unregisters every tool.
158+
`connectDevframe()` and the in-page channel endpoints wire this on their own when the browser provides a model context; `webmcp: false` keeps that browser endpoint off the WebMCP surface. `registerWebMcpTools(collector)` (from `devframe/client`) applies the same projection to a hand-built collector and returns a dispose that unregisters every tool.
159159

160160
> [!WARNING]
161161
> WebMCP is an experimental proposal; `registerWebMcpTools` tracks the current draft (`AbortSignal`-based unregistration) and earlier handle-returning drafts, but the browser API may still change.

docs/content/6.errors/DF0078.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
title: 'DF0078: Agent Requires JSON-Serializable In-Page Function'
3+
description: 'An in-page channel function with agent metadata must declare jsonSerializable true.'
4+
---
5+
6+
## Message
7+
8+
> In-page channel function "`{name}`" has `agent` set but `jsonSerializable` is not `true`; WebMCP requires JSON-serializable data.
9+
10+
## Cause
11+
12+
The `agent` field exposes an in-page channel function as a WebMCP tool. WebMCP arguments and results are JSON-shaped, so the endpoint rejects agent functions without an explicit strict-JSON contract.
13+
14+
## Fix
15+
16+
Set `jsonSerializable: true` if the function's arguments and result are JSON-safe, or remove `agent` to keep it channel-only.
17+
18+
## Source
19+
20+
- [`packages/devframe/src/in-page-channel/internal.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/in-page-channel/internal.ts)

docs/content/6.errors/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ Emitted by `devframe`: the framework-neutral host, RPC, streaming, assets, servi
8484
| [DF0075](/errors/DF0075) | warn | No RPC Transport On This Runtime |
8585
| [DF0076](/errors/DF0076) | error | WebSocket Upgrade Unsupported On This Runtime |
8686
| [DF0077](/errors/DF0077) | error | In-Page Channel Event Not Registered |
87+
| [DF0078](/errors/DF0078) | error | Agent Requires JSON-Serializable In-Page Function |
8788

8889
## Hub: context & lifecycle (DF80xx)
8990

docs/content/8.references/5.browser-api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ The browser-only endpoint methods of the [in-page channel](/guide/in-page-channe
5252

5353
`InPageChannelProtocol` separates `functions` and `events`. Each section has optional `pageScript` and `panel` maps naming the receiving direction. Endpoint options require a complete `functions` map with handlers and a complete `events` map with optional handlers; use `{}` for empty maps. `call()` uses function names regardless of return type, while `emit()`, `callEvent()` (deprecated), and `on()` use event names. A function returning `void` or `Promise<void>` remains an awaitable request/response call.
5454

55+
Both endpoint constructors accept `webmcp: false` to disable their default projection of `agent`-flagged local functions onto that document's experimental WebMCP model context. Agent functions require `jsonSerializable: true`; events cannot be exposed.
56+
5557
| Method or property | Page-script endpoint | Panel endpoint |
5658
|--------------------|-------------|-------|
5759
| `emit(name, ...args)` | Fans an event out to every connected panel. | Sends an event to the page script, buffering while connecting. |
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import type { RpcArgsSchema, RpcFunctionAgentOptions, RpcFunctionType } from 'devframe/rpc'
2+
import type { WebMcpModelContext, WebMcpToolResult } from './webmcp'
3+
import { toAgentToolName } from 'devframe/utils/agent-tool-name'
4+
import { argsToJsonSchema } from '../adapters/mcp/to-json-schema'
5+
import { coerceAgentPositionalArgs } from '../node/agent-args'
6+
7+
interface WebMcpModelContextCarrier {
8+
modelContext?: WebMcpModelContext
9+
}
10+
11+
/** Resolve the experimental WebMCP context provided by the current document. */
12+
export function resolveWebMcpModelContext(): WebMcpModelContext | undefined {
13+
if (typeof document !== 'undefined') {
14+
const context = (document as WebMcpModelContextCarrier).modelContext
15+
if (context)
16+
return context
17+
}
18+
if (typeof navigator !== 'undefined') {
19+
const context = (navigator as WebMcpModelContextCarrier).modelContext
20+
if (context)
21+
return context
22+
}
23+
return undefined
24+
}
25+
26+
/** Function metadata required to project one local browser function to WebMCP. */
27+
export interface WebMcpFunctionDefinition {
28+
name: string
29+
type?: RpcFunctionType
30+
args?: RpcArgsSchema
31+
agent?: RpcFunctionAgentOptions
32+
}
33+
34+
/** A local browser function registry that can be projected to WebMCP. */
35+
export interface WebMcpFunctionSource<Definition extends WebMcpFunctionDefinition> {
36+
definitions: ReadonlyMap<string, Definition>
37+
invoke: (definition: Definition, args: unknown[]) => unknown | Promise<unknown>
38+
onChanged?: (listener: (id?: string) => void) => () => void
39+
resolveToolId?: (definition: Definition) => string
40+
}
41+
42+
/** Project agent-flagged functions from a local browser registry to WebMCP. */
43+
export function registerWebMcpFunctionTools<Definition extends WebMcpFunctionDefinition>(
44+
source: WebMcpFunctionSource<Definition>,
45+
modelContext: WebMcpModelContext | undefined,
46+
): () => void {
47+
if (!modelContext)
48+
return () => {}
49+
const context = modelContext
50+
51+
/** Unregister callbacks keyed by the source registry's definition id. */
52+
const registered = new Map<string, () => void>()
53+
/** Wire name → qualified tool id, to detect sanitization collisions. */
54+
const wireNames = new Map<string, string>()
55+
56+
function register(sourceId: string, def: Definition, agent: RpcFunctionAgentOptions): void {
57+
const toolId = source.resolveToolId?.(def) ?? def.name
58+
const name = toAgentToolName(toolId)
59+
const owner = wireNames.get(name)
60+
if (owner && owner !== toolId) {
61+
console.warn(`[devframe] WebMCP tool name "${name}" (from "${toolId}") collides with "${owner}"; keeping the first registration.`)
62+
return
63+
}
64+
65+
const controller = new AbortController()
66+
const result = context.registerTool({
67+
name,
68+
description: agent.description,
69+
inputSchema: argsToJsonSchema(def.args),
70+
annotations: {
71+
title: agent.title ?? def.name,
72+
readOnlyHint: resolveSafety(def, agent) === 'read',
73+
destructiveHint: resolveSafety(def, agent) === 'destructive',
74+
},
75+
execute: args => executeFunctionTool(def, source.invoke, args),
76+
}, { signal: controller.signal })
77+
// Registration may reject when the frame's permissions policy denies
78+
// `tools`; the surface is simply unavailable there.
79+
if (result && 'then' in result)
80+
void result.then(() => {}, () => {})
81+
82+
wireNames.set(name, toolId)
83+
registered.set(sourceId, () => {
84+
controller.abort()
85+
if (result && 'unregister' in result && typeof result.unregister === 'function')
86+
result.unregister()
87+
wireNames.delete(name)
88+
})
89+
}
90+
91+
function sync(id?: string): void {
92+
const names = id ? [id] : [...source.definitions.keys()]
93+
for (const name of names) {
94+
registered.get(name)?.()
95+
registered.delete(name)
96+
const def = source.definitions.get(name)
97+
const agent = def?.agent
98+
if (def && agent)
99+
register(name, def, agent)
100+
}
101+
}
102+
103+
sync()
104+
const unsubscribe = source.onChanged?.(id => sync(id)) ?? (() => {})
105+
106+
return () => {
107+
unsubscribe()
108+
for (const unregister of registered.values())
109+
unregister()
110+
registered.clear()
111+
}
112+
}
113+
114+
function resolveSafety(
115+
def: WebMcpFunctionDefinition,
116+
agent: RpcFunctionAgentOptions,
117+
): 'read' | 'action' | 'destructive' {
118+
if (agent.safety)
119+
return agent.safety
120+
const type: RpcFunctionType = def.type ?? 'query'
121+
return type === 'static' || type === 'query' ? 'read' : 'action'
122+
}
123+
124+
async function executeFunctionTool<Definition extends WebMcpFunctionDefinition>(
125+
def: Definition,
126+
invoke: WebMcpFunctionSource<Definition>['invoke'],
127+
args: Record<string, unknown>,
128+
): Promise<WebMcpToolResult> {
129+
try {
130+
const positional = coerceAgentPositionalArgs(args, def.args as readonly unknown[] | undefined, 'wrap')
131+
const result = await invoke(def, positional)
132+
return { content: [{ type: 'text', text: stringifyResult(result) }] }
133+
}
134+
catch (error) {
135+
return {
136+
isError: true,
137+
content: [{ type: 'text', text: formatError(error) }],
138+
}
139+
}
140+
}
141+
142+
function stringifyResult(value: unknown): string {
143+
if (value === undefined)
144+
return 'undefined'
145+
if (typeof value === 'string')
146+
return value
147+
return JSON.stringify(value, null, 2)
148+
}
149+
150+
function formatError(error: unknown): string {
151+
if (!(error instanceof Error))
152+
return String(error)
153+
const cause = error.cause instanceof Error ? ` (cause: ${error.cause.message})` : ''
154+
return `${error.name}: ${error.message}${cause}`
155+
}

0 commit comments

Comments
 (0)