Skip to content

Commit efa762e

Browse files
committed
feat: expose in-page functions through MCP
1 parent f7e16c3 commit efa762e

19 files changed

Lines changed: 481 additions & 7 deletions

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,12 @@ pageChannel.events.on('panel:disconnected', () => pauseWorkIfNobodyWatches())
100100

101101
`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.
102102

103+
### Agent tools over MCP
104+
105+
A function carrying `agent` metadata is registered with the page's DevFrame client and becomes available through the node MCP endpoint used by `devframe connect`. The channel name qualifies the otherwise-bare function name. 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.
106+
107+
The registration follows the endpoint and browser connection lifecycle. Closing the channel or browser tab removes its tools. No panel needs to be open: loading the page establishes the bridge.
108+
103109
## The panel endpoint
104110

105111
```ts

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,8 @@ rpc.client.register({
157157

158158
`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.
159159

160+
[In-page channel functions](/guide/in-page-channel#agent-tools-over-mcp) use the page's DevFrame connection instead: adding `agent` makes the original handler available through the regular node MCP endpoint without exposing it through WebMCP.
161+
160162
> [!WARNING]
161163
> 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.
162164

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; `events` is optional, and when provided can include optional handlers (use `{}` to declare an event without a handler for `channel.on()`). `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+
In-page endpoint functions carrying `agent` are synchronized through the page's DevFrame connection to the node MCP endpoint. 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: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { BrowserAgentToolManifest } from './browser-agent'
2+
import type { BrowserAgentInvocationDefinition } from './browser-agent-rpc'
3+
import { afterEach, describe, expect, it, vi } from 'vitest'
4+
import { registerBrowserAgentTool } from './browser-agent'
5+
import { setupBrowserAgentRpcBridge } from './browser-agent-rpc'
6+
7+
describe('browser agent RPC bridge', () => {
8+
const disposals: (() => void)[] = []
9+
afterEach(() => disposals.splice(0).forEach(dispose => dispose()))
10+
11+
it('synchronizes manifests and invokes the original browser tool', async () => {
12+
const handlers = new Map<string, (...args: any[]) => unknown>()
13+
const callOptional = vi.fn().mockResolvedValue(undefined)
14+
const rpc = {
15+
client: {
16+
register(definition: BrowserAgentInvocationDefinition) {
17+
handlers.set(definition.name, definition.handler)
18+
},
19+
},
20+
callOptional(
21+
method: 'devframe:agent:sync-client-tools',
22+
tools: BrowserAgentToolManifest[],
23+
) {
24+
return callOptional(method, tools)
25+
},
26+
events: { on: () => () => {} },
27+
}
28+
29+
disposals.push(registerBrowserAgentTool({
30+
id: 'todos:add',
31+
description: 'Add a todo.',
32+
safety: 'action',
33+
inputSchema: { type: 'object' },
34+
invoke: args => ({ added: args.text }),
35+
}))
36+
disposals.push(setupBrowserAgentRpcBridge(rpc))
37+
await vi.waitFor(() => expect(callOptional).toHaveBeenCalledWith(
38+
'devframe:agent:sync-client-tools',
39+
[{
40+
id: 'todos:add',
41+
description: 'Add a todo.',
42+
safety: 'action',
43+
inputSchema: { type: 'object' },
44+
}],
45+
))
46+
47+
await expect(handlers.get('devframe:agent:invoke-client-tool')!(
48+
'todos:add',
49+
{ text: 'milk' },
50+
)).resolves.toEqual({ added: 'milk' })
51+
})
52+
})
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import type { BrowserAgentToolManifest } from './browser-agent'
2+
import type { DevframeConnectionStatus } from './connection'
3+
import {
4+
listBrowserAgentTools,
5+
onBrowserAgentToolsChanged,
6+
} from './browser-agent'
7+
8+
export interface BrowserAgentInvocationDefinition {
9+
name: 'devframe:agent:invoke-client-tool'
10+
type: 'action'
11+
jsonSerializable: true
12+
handler: (id: string, args: Record<string, unknown>) => Promise<unknown>
13+
}
14+
15+
interface BrowserAgentRpcClient {
16+
client: { register: (definition: BrowserAgentInvocationDefinition) => void }
17+
callOptional: (
18+
method: 'devframe:agent:sync-client-tools',
19+
tools: BrowserAgentToolManifest[],
20+
) => Promise<unknown>
21+
events: {
22+
on: (
23+
event: 'connection:status',
24+
listener: (status: DevframeConnectionStatus, previous: DevframeConnectionStatus) => void,
25+
) => () => void
26+
}
27+
}
28+
29+
/** Mirror this document's browser-agent registry over its existing RPC connection. */
30+
export function setupBrowserAgentRpcBridge(rpc: BrowserAgentRpcClient): () => void {
31+
rpc.client.register({
32+
name: 'devframe:agent:invoke-client-tool',
33+
type: 'action',
34+
jsonSerializable: true,
35+
handler: async (id: string, args: Record<string, unknown>) => {
36+
const tool = listBrowserAgentTools().find(tool => tool.id === id)
37+
if (!tool)
38+
throw new Error(`[devframe/agent] browser tool "${id}" not found`)
39+
return await tool.invoke(args)
40+
},
41+
})
42+
43+
let queued = false
44+
let disposed = false
45+
const sync = (): void => {
46+
if (queued || disposed)
47+
return
48+
queued = true
49+
queueMicrotask(async () => {
50+
queued = false
51+
const manifests = listBrowserAgentTools().map(({ invoke: _, ...manifest }) => manifest)
52+
await rpc.callOptional('devframe:agent:sync-client-tools', manifests).catch(() => {})
53+
})
54+
}
55+
56+
const stopTools = onBrowserAgentToolsChanged(sync)
57+
const stopConnection = rpc.events.on('connection:status', (status) => {
58+
if (status === 'connected')
59+
sync()
60+
})
61+
sync()
62+
63+
return () => {
64+
disposed = true
65+
stopTools()
66+
stopConnection()
67+
}
68+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { RpcFunctionAgentOptions } from 'devframe/rpc'
2+
3+
export interface BrowserAgentToolManifest {
4+
id: string
5+
title?: string
6+
description: string
7+
safety: 'read' | 'action' | 'destructive'
8+
tags?: readonly string[]
9+
inputSchema?: unknown
10+
}
11+
12+
export interface BrowserAgentTool extends BrowserAgentToolManifest {
13+
invoke: (args: Record<string, unknown>) => unknown | Promise<unknown>
14+
}
15+
16+
interface BrowserAgentRegistryState {
17+
tools: Map<symbol, BrowserAgentTool>
18+
listeners: Set<() => void>
19+
}
20+
21+
const REGISTRY_KEY = Symbol.for('devframe:browser-agent-registry')
22+
const state = ((globalThis as any)[REGISTRY_KEY] ??= {
23+
tools: new Map(),
24+
listeners: new Set(),
25+
}) as BrowserAgentRegistryState
26+
const { tools, listeners } = state
27+
28+
function notifyChanged(): void {
29+
for (const listener of listeners)
30+
listener()
31+
}
32+
33+
export function registerBrowserAgentTool(tool: BrowserAgentTool): () => void {
34+
const key = Symbol(tool.id)
35+
tools.set(key, tool)
36+
notifyChanged()
37+
return () => {
38+
if (tools.delete(key))
39+
notifyChanged()
40+
}
41+
}
42+
43+
export function listBrowserAgentTools(): BrowserAgentTool[] {
44+
const unique = new Map<string, BrowserAgentTool>()
45+
for (const tool of tools.values()) {
46+
if (!unique.has(tool.id))
47+
unique.set(tool.id, tool)
48+
}
49+
return [...unique.values()]
50+
}
51+
52+
export function onBrowserAgentToolsChanged(listener: () => void): () => void {
53+
listeners.add(listener)
54+
return () => listeners.delete(listener)
55+
}
56+
57+
export function resolveBrowserAgentSafety(
58+
type: string | undefined,
59+
agent: RpcFunctionAgentOptions,
60+
): BrowserAgentToolManifest['safety'] {
61+
if (agent.safety)
62+
return agent.safety
63+
return type === 'static' || type === 'query' || type == null ? 'read' : 'action'
64+
}

packages/devframe/src/client/rpc.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { DEVFRAME_OTP_URL_PARAM } from 'devframe/constants'
1111
import { RpcCacheManager, RpcFunctionsCollectorBase } from 'devframe/rpc'
1212
import { createEventEmitter } from 'devframe/utils/events'
1313
import { withBase } from 'ufo'
14+
import { setupBrowserAgentRpcBridge } from './browser-agent-rpc'
1415
import { setupDevframeConnection } from './connection'
1516
import { storeAuthToken } from './connection-storage'
1617
import { authenticateWithUrlOtp } from './otp'
@@ -356,6 +357,7 @@ export async function getDevframeRpcClient(
356357
const clientRpc: DevframeClientRpcHost = new RpcFunctionsCollectorBase<DevframeRpcClientFunctions, DevframeRpcContext>(context)
357358
// No-op when the browser provides no WebMCP model context.
358359
const disposeWebMcp = options.webmcp === false ? undefined : registerWebMcpTools(clientRpc)
360+
let disposeBrowserAgentBridge: (() => void) | undefined
359361

360362
async function fetchJsonFromBases(path: string): Promise<any> {
361363
const candidates = [
@@ -496,6 +498,7 @@ export async function getDevframeRpcClient(
496498
cacheManager,
497499
scope: undefined!,
498500
close: () => {
501+
disposeBrowserAgentBridge?.()
499502
disposeWebMcp?.()
500503
mode.close?.()
501504
},
@@ -584,6 +587,8 @@ export async function getDevframeRpcClient(
584587
() => { bootstrapAuthSettled = true },
585588
)
586589

590+
disposeBrowserAgentBridge = setupBrowserAgentRpcBridge(rpc)
591+
587592
// Listen for auth updates from other tabs (e.g., the auth page, or another
588593
// tab that just completed a code exchange).
589594
if (authChannel) {
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import type { StandardSchemaV1 } from '@standard-schema/spec'
2+
import { describe, expect, it } from 'vitest'
3+
import { listBrowserAgentTools } from '../client/browser-agent'
4+
import { createPageScriptChannel } from './page-script'
5+
6+
interface TestProtocol {
7+
pageScript: {
8+
add: (a: number, b: number) => { sum: number }
9+
hidden: () => string
10+
}
11+
}
12+
13+
function schema<T>(json: Record<string, unknown>): StandardSchemaV1<T> {
14+
return {
15+
'~standard': {
16+
version: 1,
17+
vendor: 'test',
18+
validate: (value: unknown) => ({ value: value as T }),
19+
jsonSchema: {
20+
input: () => json,
21+
output: () => json,
22+
},
23+
} as StandardSchemaV1<T>['~standard'],
24+
}
25+
}
26+
27+
describe('in-page channel agent tools', () => {
28+
it('registers the original handler for browser-to-node agent transport', async () => {
29+
const channel = createPageScriptChannel<TestProtocol>({
30+
name: 'devframes:test',
31+
window: false,
32+
heartbeat: false,
33+
functions: {
34+
add: {
35+
jsonSerializable: true,
36+
agent: { description: 'Add two numbers.' },
37+
args: [schema<number>({ type: 'number' }), schema<number>({ type: 'number' })],
38+
returns: schema<{ sum: number }>({ type: 'object' }),
39+
handler: (a, b) => ({ sum: a + b }),
40+
},
41+
hidden: { handler: () => 'internal' },
42+
},
43+
})
44+
45+
const tool = listBrowserAgentTools().find(tool => tool.id === 'devframes:test:add')!
46+
expect(tool).toMatchObject({
47+
description: 'Add two numbers.',
48+
safety: 'read',
49+
inputSchema: {
50+
type: 'object',
51+
properties: { arg0: { type: 'number' }, arg1: { type: 'number' } },
52+
required: ['arg0', 'arg1'],
53+
additionalProperties: false,
54+
},
55+
})
56+
await expect(tool.invoke({ arg0: 2, arg1: 3 })).resolves.toEqual({ sum: 5 })
57+
58+
channel.close()
59+
expect(listBrowserAgentTools().some(tool => tool.id === 'devframes:test:add')).toBe(false)
60+
})
61+
62+
it('rejects agent exposure without strict JSON serialization', () => {
63+
expect(() => createPageScriptChannel<TestProtocol>({
64+
name: 'devframes:invalid',
65+
window: false,
66+
functions: {
67+
add: {
68+
agent: { description: 'Add two numbers.' },
69+
handler: (a, b) => ({ sum: a + b }),
70+
},
71+
hidden: { handler: () => 'internal' },
72+
},
73+
})).toThrowError(/MCP requires JSON-serializable/)
74+
})
75+
})

packages/devframe/src/in-page-channel/diagnostics.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,10 @@ export const diagnostics = /* #__PURE__ */ defineDiagnostics({
77
why: (p: { name: string }) => `In-page channel function "${p.name}" is not registered on this endpoint.`,
88
fix: 'Declare the function in this endpoint\'s `functions` option.',
99
},
10+
DF0078: {
11+
why: (p: { name: string }) =>
12+
`In-page channel function "${p.name}" has \`agent\` set but \`jsonSerializable\` is not \`true\`; MCP requires JSON-serializable data.`,
13+
fix: 'Set `jsonSerializable: true` if the payload is JSON-safe, or remove `agent` to keep it channel-only.',
14+
},
1015
},
1116
})

0 commit comments

Comments
 (0)