Skip to content

Commit c9c686e

Browse files
committed
feat(client): expose agent-flagged client RPC functions over WebMCP
1 parent 6ba7c59 commit c9c686e

8 files changed

Lines changed: 462 additions & 4 deletions

File tree

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

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@
22
title: 'Agent-Native Devframe'
33
navigation:
44
icon: i-lucide-bot
5-
description: 'Devframe exposes its browser-side API (RPC functions, resources, shared state) to coding agents over MCP, opt-in per function.'
5+
description: 'Devframe exposes its API (RPC functions, resources, shared state) to agents, over MCP on the node side and WebMCP on the browser side, opt-in per function.'
66
---
77

8-
Devframe exposes its browser-side API (RPC functions, resources, shared state) to coding agents over MCP, opt-in per function.
8+
Devframe exposes its API (RPC functions, resources, shared state) to agents, over MCP on the node side and [WebMCP](#browser-side-tools-over-webmcp) on the browser side, opt-in per function.
99

1010
## How it works
1111

12-
Three pieces: the **`agent` field** on `defineRpcFunction`, **`ctx.agent`** (non-RPC tools + resources), and the **MCP adapter** (`devframe/adapters/mcp`) serving an [MCP](https://modelcontextprotocol.io) server.
12+
Three pieces: the **`agent` field** on `defineRpcFunction`, **`ctx.agent`** (non-RPC tools + resources), and the **MCP adapter** (`devframe/adapters/mcp`) serving an [MCP](https://modelcontextprotocol.io) server. The same `agent` field on a *client* RPC function surfaces it [over WebMCP](#browser-side-tools-over-webmcp) instead.
1313

1414
## Exposing an RPC function
1515

@@ -137,6 +137,29 @@ In `claude_desktop_config.json`:
137137

138138
Restart; tools appear in the drawer, resources as `devframe://resource/<id>` / `devframe://state/<key>` URIs.
139139

140+
## Browser-side tools over WebMCP
141+
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.
143+
144+
```ts
145+
const rpc = await connectDevframe()
146+
147+
rpc.client.register({
148+
name: 'my-plugin:highlight-node',
149+
type: 'action',
150+
jsonSerializable: true,
151+
agent: {
152+
description: 'Highlight a node in the open inspector view. Use it to point the user at a finding.',
153+
},
154+
handler: (id: string) => highlightNode(id),
155+
})
156+
```
157+
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.
159+
160+
> [!WARNING]
161+
> 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.
162+
140163
## Writing descriptions agents act on
141164

142165
Describe *when* to use a tool, not just its return:

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ The options of `connectDevframe()` / `getDevframeRpcClient()`: [Client](/guide/c
2121
| `wsOptions` | Transport overrides: `onConnected` / `onError` / `onDisconnected` hooks, socket URL. |
2222
| `rpcOptions` | Forwarded to `birpc`. |
2323
| `connectionMeta` | Descriptor that skips the `__connection.json` fetch. |
24+
| `webmcp` | Mirror `agent`-flagged client RPC functions onto the page's WebMCP model context as tools; `false` opts out. Default `true` (applies only when the browser provides one). See [Agent-Native](/guide/agent-native#browser-side-tools-over-webmcp). |
2425

2526
## RPC client events
2627

‎packages/devframe/src/client/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@ export * from './rpc-streaming'
99
export { resolveWsUrl, type WsUrlLocation } from './rpc-ws'
1010
export * from './scope'
1111
export * from './settings'
12+
export * from './webmcp'
1213

1314
export const connectDevframe = getDevframeRpcClient

‎packages/devframe/src/client/rpc.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { createStaticRpcClientMode } from './rpc-static'
2121
import { createRpcStreamingClientHost } from './rpc-streaming'
2222
import { createWsRpcClientMode } from './rpc-ws'
2323
import { createScopedClientContext } from './scope'
24+
import { registerWebMcpTools } from './webmcp'
2425

2526
export interface DevframeRpcContext {
2627
/**
@@ -99,6 +100,18 @@ export interface DevframeRpcClientOptions extends SetupDevframeConnectionOptions
99100
sseOptions?: Partial<SseRpcChannelOptions>
100101
rpcOptions?: Partial<BirpcOptions<DevframeRpcServerFunctions, DevframeRpcClientFunctions, boolean>>
101102
cacheOptions?: boolean | Partial<RpcCacheOptions>
103+
/**
104+
* Mirror `agent`-flagged client RPC functions (functions registered on
105+
* `rpc.client` with an `agent` field) onto the page's WebMCP model
106+
* context (`document.modelContext` / `navigator.modelContext`) as
107+
* callable tools, so in-page and browser-integrated agents can invoke
108+
* them; see `registerWebMcpTools`. Applies only when the browser
109+
* provides a model context. Set `false` to keep the browser side off
110+
* the WebMCP surface.
111+
*
112+
* @default true
113+
*/
114+
webmcp?: boolean
102115
/**
103116
* Reject a pending `rpc.call(...)` if the server hasn't answered within this
104117
* many milliseconds, with a {@link DevframeConnectionError} of kind
@@ -332,6 +345,8 @@ export async function getDevframeRpcClient(
332345
rpc: undefined!,
333346
}
334347
const clientRpc: DevframeClientRpcHost = new RpcFunctionsCollectorBase<DevframeRpcClientFunctions, DevframeRpcContext>(context)
348+
// No-op when the browser provides no WebMCP model context.
349+
const disposeWebMcp = options.webmcp === false ? undefined : registerWebMcpTools(clientRpc)
335350

336351
async function fetchJsonFromBases(path: string): Promise<any> {
337352
const candidates = [
@@ -470,7 +485,10 @@ export async function getDevframeRpcClient(
470485
streaming: undefined!,
471486
cacheManager,
472487
scope: undefined!,
473-
close: () => mode.close?.(),
488+
close: () => {
489+
disposeWebMcp?.()
490+
mode.close?.()
491+
},
474492
}
475493

476494
rpc.sharedState = createRpcSharedStateClientHost(rpc)
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import type { StandardSchemaV1 } from '@standard-schema/spec'
2+
import type { WebMcpModelContext, WebMcpToolDescriptor } from './webmcp'
3+
import { RpcFunctionsCollectorBase } from 'devframe/rpc'
4+
import { describe, expect, it, vi } from 'vitest'
5+
import { registerWebMcpTools } from './webmcp'
6+
7+
/** A Standard Schema that also implements the Standard JSON Schema converter (like zod 4). */
8+
function withJsonSchema(json: Record<string, unknown>): StandardSchemaV1 {
9+
return {
10+
'~standard': {
11+
version: 1,
12+
vendor: 'test',
13+
validate: (value: unknown) => ({ value }),
14+
jsonSchema: {
15+
input: () => json,
16+
output: () => json,
17+
},
18+
} as StandardSchemaV1['~standard'],
19+
}
20+
}
21+
22+
/** Spec-shaped model context: unregisters by aborting the passed signal. */
23+
function createFakeModelContext() {
24+
const tools = new Map<string, WebMcpToolDescriptor>()
25+
const modelContext: WebMcpModelContext = {
26+
registerTool(tool, options) {
27+
tools.set(tool.name, tool)
28+
options?.signal?.addEventListener('abort', () => tools.delete(tool.name))
29+
return Promise.resolve()
30+
},
31+
}
32+
return { modelContext, tools }
33+
}
34+
35+
function createCollector() {
36+
return new RpcFunctionsCollectorBase<Record<string, any>, undefined>(undefined)
37+
}
38+
39+
describe('registerWebMcpTools', () => {
40+
it('registers only agent-flagged functions, under their wire names', () => {
41+
const collector = createCollector()
42+
collector.register({
43+
name: 'my-plugin:greet',
44+
jsonSerializable: true,
45+
agent: { description: 'Greet someone by name.' },
46+
args: [withJsonSchema({ type: 'string' })],
47+
returns: withJsonSchema({ type: 'string' }),
48+
handler: (name: string) => `Hello ${name}`,
49+
})
50+
collector.register({
51+
name: 'my-plugin:internal',
52+
handler: () => 'hidden',
53+
})
54+
55+
const { modelContext, tools } = createFakeModelContext()
56+
const dispose = registerWebMcpTools(collector, { modelContext })
57+
58+
expect([...tools.keys()]).toEqual(['my-plugin_greet'])
59+
const tool = tools.get('my-plugin_greet')!
60+
expect(tool.description).toBe('Greet someone by name.')
61+
expect(tool.inputSchema).toEqual({
62+
type: 'object',
63+
properties: { arg0: { type: 'string' } },
64+
required: ['arg0'],
65+
additionalProperties: false,
66+
})
67+
// `query` (default type) infers read-only.
68+
expect(tool.annotations).toMatchObject({ readOnlyHint: true, destructiveHint: false })
69+
70+
dispose()
71+
expect(tools.size).toBe(0)
72+
})
73+
74+
it('executes with arg0/argN coercion and returns a text result', async () => {
75+
const collector = createCollector()
76+
collector.register({
77+
name: 'add',
78+
jsonSerializable: true,
79+
agent: { description: 'Add two numbers.' },
80+
args: [withJsonSchema({ type: 'number' }), withJsonSchema({ type: 'number' })],
81+
returns: withJsonSchema({ type: 'object' }),
82+
handler: (a: number, b: number) => ({ sum: a + b }),
83+
})
84+
85+
const { modelContext, tools } = createFakeModelContext()
86+
registerWebMcpTools(collector, { modelContext })
87+
88+
const result = await tools.get('add')!.execute({ arg0: 2, arg1: 3 })
89+
expect(result.isError).toBeUndefined()
90+
expect(JSON.parse(result.content[0]!.text)).toEqual({ sum: 5 })
91+
})
92+
93+
it('surfaces a thrown error as an isError text result', async () => {
94+
const collector = createCollector()
95+
collector.register({
96+
name: 'boom',
97+
type: 'action',
98+
jsonSerializable: true,
99+
agent: { description: 'Always fails.' },
100+
handler: () => {
101+
throw new Error('nope')
102+
},
103+
})
104+
105+
const { modelContext, tools } = createFakeModelContext()
106+
registerWebMcpTools(collector, { modelContext })
107+
108+
const result = await tools.get('boom')!.execute({})
109+
expect(result.isError).toBe(true)
110+
expect(result.content[0]!.text).toBe('Error: nope')
111+
})
112+
113+
it('follows later register/update calls until disposed', async () => {
114+
const collector = createCollector()
115+
const { modelContext, tools } = createFakeModelContext()
116+
const dispose = registerWebMcpTools(collector, { modelContext })
117+
expect(tools.size).toBe(0)
118+
119+
collector.register({
120+
name: 'greet',
121+
jsonSerializable: true,
122+
agent: { description: 'Greet.' },
123+
handler: () => 'hi',
124+
})
125+
expect(tools.has('greet')).toBe(true)
126+
127+
collector.update({
128+
name: 'greet',
129+
jsonSerializable: true,
130+
agent: { description: 'Greet politely.' },
131+
handler: () => 'good day',
132+
})
133+
expect(tools.get('greet')!.description).toBe('Greet politely.')
134+
const updated = await tools.get('greet')!.execute({})
135+
expect(updated.content[0]!.text).toBe('good day')
136+
137+
dispose()
138+
expect(tools.size).toBe(0)
139+
140+
// Post-dispose registrations no longer reach the model context.
141+
collector.register({
142+
name: 'late',
143+
jsonSerializable: true,
144+
agent: { description: 'Too late.' },
145+
handler: () => 'late',
146+
})
147+
expect(tools.size).toBe(0)
148+
})
149+
150+
it('unregisters through a legacy handle when registerTool returns one', () => {
151+
const unregister = vi.fn()
152+
const modelContext: WebMcpModelContext = {
153+
registerTool: () => ({ unregister }),
154+
}
155+
const collector = createCollector()
156+
collector.register({
157+
name: 'legacy',
158+
jsonSerializable: true,
159+
agent: { description: 'Legacy handle.' },
160+
handler: () => 'ok',
161+
})
162+
163+
const dispose = registerWebMcpTools(collector, { modelContext })
164+
dispose()
165+
expect(unregister).toHaveBeenCalledTimes(1)
166+
})
167+
168+
it('is a no-op without a model context', () => {
169+
const collector = createCollector()
170+
collector.register({
171+
name: 'greet',
172+
jsonSerializable: true,
173+
agent: { description: 'Greet.' },
174+
handler: () => 'hi',
175+
})
176+
expect(() => registerWebMcpTools(collector)()).not.toThrow()
177+
})
178+
})

0 commit comments

Comments
 (0)