Skip to content

Commit b85af55

Browse files
antfubotagent
andcommitted
feat: expose in-page tools to coding agents via MCP
Rebase onto the 0.10 main and drop this branch's own eager client-script implementation, which now lives in main via #387. Keep the browser-to-node agent bridge that turns in-page channel functions into MCP tools. An in-page channel function may now carry `agent` metadata (requiring `jsonSerializable: true`). Page-script and panel endpoints register those as browser-agent tools, mirror the manifest to node over an RPC bridge, and a node-side tool provider registers them on the agent host so they surface over MCP. Invocations round-trip back into the browser through `devframe:agent:invoke-client-tool`. Adapt to the 0.10 MCP move: the discovery-metadata improvement now lands in `@devframes/agentic`'s connect surface, the browser-safe JSON-schema and positional-arg helpers replace the pre-move paths, and the new diagnostic is renumbered to DF0080 to avoid the DF0078 agentic collision. Co-authored-by: agent <agent@opencode>
1 parent 3e7f0fe commit b85af55

20 files changed

Lines changed: 534 additions & 19 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
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` option and optional `events` option 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` option and optional `events` option 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. A function with `agent` metadata must set `jsonSerializable: true` and is available to coding agents through MCP. `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()` and `on()` use the names declared in `events`. Function and event names have separate namespaces.
7070

docs/content/6.errors/DF0080.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
title: 'DF0080: Agent In-Page Function Not JSON-Serializable'
3+
description: 'An in-page channel function sets `agent` but does not set `jsonSerializable: true`.'
4+
---
5+
6+
## Message
7+
8+
> In-page channel function "{name}" has `agent` set but `jsonSerializable` is not `true`; MCP requires JSON-serializable data.
9+
10+
## Cause
11+
12+
A function exposed to coding agents crosses the browser-to-node agent bridge and is surfaced over MCP, whose payloads must be JSON-serializable. Declaring `agent` without `jsonSerializable: true` leaves the channel free to move non-JSON values (through structured clone) that MCP cannot represent.
13+
14+
## Example
15+
16+
```ts
17+
import { createPageScriptChannel } from 'devframe/in-page-channel'
18+
19+
createPageScriptChannel({
20+
name: 'devframes:example',
21+
functions: {
22+
addTodo: {
23+
agent: { description: 'Add a todo item.' },
24+
handler: (text: string) => ({ added: text }), // ✗ `agent` without `jsonSerializable: true`
25+
},
26+
},
27+
})
28+
```
29+
30+
## Fix
31+
32+
Set `jsonSerializable: true` when the payload is JSON-safe, or remove `agent` to keep the function channel-only.
33+
34+
```ts
35+
const addTodo = {
36+
agent: { description: 'Add a todo item.' },
37+
jsonSerializable: true, //
38+
handler: (text: string) => ({ added: text }),
39+
}
40+
```
41+
42+
## Source
43+
44+
- [`packages/devframe/src/in-page-channel/internal.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/in-page-channel/internal.ts): `createLocalFunctionRegistry().register()` throws this when a definition carries `agent` without `jsonSerializable: true`.

packages/agentic/src/connect/index.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,12 @@ export interface ConnectServerHandle {
6767
}
6868

6969
/** One discovered instance in the `list-instances` payload: the registry record plus its probed MCP surface. */
70+
interface IndexedInstanceTools extends Pick<Tool, 'name' | 'title' | 'description' | 'inputSchema' | 'outputSchema' | 'annotations'> {}
71+
7072
interface IndexedInstance extends Omit<DevframeInstanceRecord, 'mcp'> {
7173
mcp: {
7274
url: string
73-
tools?: { name: string, description?: string }[]
75+
tools?: IndexedInstanceTools[]
7476
error?: string
7577
} | null
7678
hint?: string
@@ -214,14 +216,8 @@ async function probePort(port: number, timeoutMs?: number): Promise<DevframeInst
214216
}
215217
}
216218

217-
async function listInstanceTools(url: string, token: string | undefined): Promise<{ name: string, description?: string }[]> {
218-
return withInstanceClient(url, token, async (client) => {
219-
const listed = await client.listTools()
220-
return listed.tools.map((tool: { name: string, description?: string }) => ({
221-
name: tool.name,
222-
description: tool.description,
223-
}))
224-
})
219+
async function listInstanceTools(url: string, token: string | undefined): Promise<IndexedInstanceTools[]> {
220+
return withInstanceClient(url, token, async client => (await client.listTools()).tools)
225221
}
226222

227223
async function call(
@@ -235,7 +231,8 @@ async function call(
235231
instancesDir: options.instancesDir,
236232
timeoutMs: options.timeoutMs,
237233
})
238-
const record = live.find(r => r.port === args.port) ?? await probePort(args.port, options.timeoutMs)
234+
const record = live.find(record => record.port === args.port && record.mcp)
235+
?? await probePort(args.port, options.timeoutMs)
239236
if (!record)
240237
throw diagnostics.DF0050({ port: args.port })
241238
if (!record.mcp)
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: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
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+
let lastSyncedCount = 0
46+
const sync = (): void => {
47+
if (queued || disposed)
48+
return
49+
queued = true
50+
queueMicrotask(async () => {
51+
queued = false
52+
if (disposed)
53+
return
54+
const manifests = listBrowserAgentTools().map(({ invoke: _, ...manifest }) => manifest)
55+
// Skip the no-op sync when nothing is registered and nothing was ever
56+
// mirrored; a page with no browser-agent tools stays off the wire.
57+
if (manifests.length === 0 && lastSyncedCount === 0)
58+
return
59+
lastSyncedCount = manifests.length
60+
await rpc.callOptional('devframe:agent:sync-client-tools', manifests).catch(() => {})
61+
})
62+
}
63+
64+
const stopTools = onBrowserAgentToolsChanged(sync)
65+
const stopConnection = rpc.events.on('connection:status', (status) => {
66+
if (status === 'connected')
67+
sync()
68+
})
69+
sync()
70+
71+
return () => {
72+
disposed = true
73+
stopTools()
74+
stopConnection()
75+
}
76+
}
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 'devframe/utils/url'
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 = [
@@ -448,6 +450,7 @@ export async function getDevframeRpcClient(
448450
/** Release authentication and transport resources even if another disposer fails. */
449451
function closeRpcClient(): void {
450452
try {
453+
disposeBrowserAgentBridge?.()
451454
disposeWebMcp?.()
452455
}
453456
finally {
@@ -596,6 +599,8 @@ export async function getDevframeRpcClient(
596599
() => { bootstrapAuthSettled = true },
597600
)
598601

602+
disposeBrowserAgentBridge = setupBrowserAgentRpcBridge(rpc)
603+
599604
// Listen for auth updates from other tabs (e.g., the auth page, or another
600605
// tab that just completed a code exchange).
601606
if (authChannel) {

0 commit comments

Comments
 (0)