Visitar URL original
feat: eagerly expose in-page tools to browser agents by posva · Pull Request #376 · devframes/devframe · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
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>
  • Loading branch information
antfubot and agent committed Sep 16, 2026
commit b85af55e84d5f978bace7c699fa0e9024c6cea2f
2 changes: 1 addition & 1 deletion docs/content/1.guide/12.in-page-channel.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ Channel names are namespaced with the devframe id, like RPC ids. Function names

## The page script endpoint

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.
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.

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

Expand Down
44 changes: 44 additions & 0 deletions docs/content/6.errors/DF0080.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
title: 'DF0080: Agent In-Page Function Not JSON-Serializable'
description: 'An in-page channel function sets `agent` but does not set `jsonSerializable: true`.'
---

## Message

> In-page channel function "{name}" has `agent` set but `jsonSerializable` is not `true`; MCP requires JSON-serializable data.

## Cause

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.

## Example

```ts
import { createPageScriptChannel } from 'devframe/in-page-channel'

createPageScriptChannel({
name: 'devframes:example',
functions: {
addTodo: {
agent: { description: 'Add a todo item.' },
handler: (text: string) => ({ added: text }), // ✗ `agent` without `jsonSerializable: true`
},
},
})
```

## Fix

Set `jsonSerializable: true` when the payload is JSON-safe, or remove `agent` to keep the function channel-only.

```ts
const addTodo = {
agent: { description: 'Add a todo item.' },
jsonSerializable: true, // ✓
handler: (text: string) => ({ added: text }),
}
```

## Source

- [`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`.
17 changes: 7 additions & 10 deletions packages/agentic/src/connect/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,12 @@ export interface ConnectServerHandle {
}

/** One discovered instance in the `list-instances` payload: the registry record plus its probed MCP surface. */
interface IndexedInstanceTools extends Pick<Tool, 'name' | 'title' | 'description' | 'inputSchema' | 'outputSchema' | 'annotations'> {}

interface IndexedInstance extends Omit<DevframeInstanceRecord, 'mcp'> {
mcp: {
url: string
tools?: { name: string, description?: string }[]
tools?: IndexedInstanceTools[]
error?: string
} | null
hint?: string
Expand Down Expand Up @@ -214,14 +216,8 @@ async function probePort(port: number, timeoutMs?: number): Promise<DevframeInst
}
}

async function listInstanceTools(url: string, token: string | undefined): Promise<{ name: string, description?: string }[]> {
return withInstanceClient(url, token, async (client) => {
const listed = await client.listTools()
return listed.tools.map((tool: { name: string, description?: string }) => ({
name: tool.name,
description: tool.description,
}))
})
async function listInstanceTools(url: string, token: string | undefined): Promise<IndexedInstanceTools[]> {
return withInstanceClient(url, token, async client => (await client.listTools()).tools)
}

async function call(
Expand All @@ -235,7 +231,8 @@ async function call(
instancesDir: options.instancesDir,
timeoutMs: options.timeoutMs,
})
const record = live.find(r => r.port === args.port) ?? await probePort(args.port, options.timeoutMs)
const record = live.find(record => record.port === args.port && record.mcp)
?? await probePort(args.port, options.timeoutMs)
if (!record)
throw diagnostics.DF0050({ port: args.port })
if (!record.mcp)
Expand Down
52 changes: 52 additions & 0 deletions packages/devframe/src/client/browser-agent-rpc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { BrowserAgentToolManifest } from './browser-agent'
import type { BrowserAgentInvocationDefinition } from './browser-agent-rpc'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { registerBrowserAgentTool } from './browser-agent'
import { setupBrowserAgentRpcBridge } from './browser-agent-rpc'

describe('browser agent RPC bridge', () => {
const disposals: (() => void)[] = []
afterEach(() => disposals.splice(0).forEach(dispose => dispose()))

it('synchronizes manifests and invokes the original browser tool', async () => {
const handlers = new Map<string, (...args: any[]) => unknown>()
const callOptional = vi.fn().mockResolvedValue(undefined)
const rpc = {
client: {
register(definition: BrowserAgentInvocationDefinition) {
handlers.set(definition.name, definition.handler)
},
},
callOptional(
method: 'devframe:agent:sync-client-tools',
tools: BrowserAgentToolManifest[],
) {
return callOptional(method, tools)
},
events: { on: () => () => {} },
}

disposals.push(registerBrowserAgentTool({
id: 'todos:add',
description: 'Add a todo.',
safety: 'action',
inputSchema: { type: 'object' },
invoke: args => ({ added: args.text }),
}))
disposals.push(setupBrowserAgentRpcBridge(rpc))
await vi.waitFor(() => expect(callOptional).toHaveBeenCalledWith(
'devframe:agent:sync-client-tools',
[{
id: 'todos:add',
description: 'Add a todo.',
safety: 'action',
inputSchema: { type: 'object' },
}],
))

await expect(handlers.get('devframe:agent:invoke-client-tool')!(
'todos:add',
{ text: 'milk' },
)).resolves.toEqual({ added: 'milk' })
})
})
76 changes: 76 additions & 0 deletions packages/devframe/src/client/browser-agent-rpc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { BrowserAgentToolManifest } from './browser-agent'
import type { DevframeConnectionStatus } from './connection'
import {
listBrowserAgentTools,
onBrowserAgentToolsChanged,
} from './browser-agent'

export interface BrowserAgentInvocationDefinition {
name: 'devframe:agent:invoke-client-tool'
type: 'action'
jsonSerializable: true
handler: (id: string, args: Record<string, unknown>) => Promise<unknown>
}

interface BrowserAgentRpcClient {
client: { register: (definition: BrowserAgentInvocationDefinition) => void }
callOptional: (
method: 'devframe:agent:sync-client-tools',
tools: BrowserAgentToolManifest[],
) => Promise<unknown>
events: {
on: (
event: 'connection:status',
listener: (status: DevframeConnectionStatus, previous: DevframeConnectionStatus) => void,
) => () => void
}
}

/** Mirror this document's browser-agent registry over its existing RPC connection. */
export function setupBrowserAgentRpcBridge(rpc: BrowserAgentRpcClient): () => void {
rpc.client.register({
name: 'devframe:agent:invoke-client-tool',
type: 'action',
jsonSerializable: true,
handler: async (id: string, args: Record<string, unknown>) => {
const tool = listBrowserAgentTools().find(tool => tool.id === id)
if (!tool)
throw new Error(`[devframe/agent] browser tool "${id}" not found`)
return await tool.invoke(args)
},
})

let queued = false
let disposed = false
let lastSyncedCount = 0
const sync = (): void => {
if (queued || disposed)
return
queued = true
queueMicrotask(async () => {
queued = false
if (disposed)
return
const manifests = listBrowserAgentTools().map(({ invoke: _, ...manifest }) => manifest)
// Skip the no-op sync when nothing is registered and nothing was ever
// mirrored; a page with no browser-agent tools stays off the wire.
if (manifests.length === 0 && lastSyncedCount === 0)
return
lastSyncedCount = manifests.length
await rpc.callOptional('devframe:agent:sync-client-tools', manifests).catch(() => {})
})
}

const stopTools = onBrowserAgentToolsChanged(sync)
const stopConnection = rpc.events.on('connection:status', (status) => {
if (status === 'connected')
sync()
})
sync()

return () => {
disposed = true
stopTools()
stopConnection()
}
}
64 changes: 64 additions & 0 deletions packages/devframe/src/client/browser-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { RpcFunctionAgentOptions } from 'devframe/rpc'

export interface BrowserAgentToolManifest {
id: string
title?: string
description: string
safety: 'read' | 'action' | 'destructive'
tags?: readonly string[]
inputSchema?: unknown
}

export interface BrowserAgentTool extends BrowserAgentToolManifest {
invoke: (args: Record<string, unknown>) => unknown | Promise<unknown>
}

interface BrowserAgentRegistryState {
tools: Map<symbol, BrowserAgentTool>
listeners: Set<() => void>
}

const REGISTRY_KEY = Symbol.for('devframe:browser-agent-registry')
const state = ((globalThis as any)[REGISTRY_KEY] ??= {
tools: new Map(),
listeners: new Set(),
}) as BrowserAgentRegistryState
const { tools, listeners } = state

function notifyChanged(): void {
for (const listener of listeners)
listener()
}

export function registerBrowserAgentTool(tool: BrowserAgentTool): () => void {
const key = Symbol(tool.id)
tools.set(key, tool)
notifyChanged()
return () => {
if (tools.delete(key))
notifyChanged()
}
}

export function listBrowserAgentTools(): BrowserAgentTool[] {
const unique = new Map<string, BrowserAgentTool>()
for (const tool of tools.values()) {
if (!unique.has(tool.id))
unique.set(tool.id, tool)
}
return [...unique.values()]
}

export function onBrowserAgentToolsChanged(listener: () => void): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}

export function resolveBrowserAgentSafety(
type: string | undefined,
agent: RpcFunctionAgentOptions,
): BrowserAgentToolManifest['safety'] {
if (agent.safety)
return agent.safety
return type === 'static' || type === 'query' || type == null ? 'read' : 'action'
}
5 changes: 5 additions & 0 deletions packages/devframe/src/client/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { DEVFRAME_OTP_URL_PARAM } from 'devframe/constants'
import { RpcCacheManager, RpcFunctionsCollectorBase } from 'devframe/rpc'
import { createEventEmitter } from 'devframe/utils/events'
import { withBase } from 'devframe/utils/url'
import { setupBrowserAgentRpcBridge } from './browser-agent-rpc'
import { setupDevframeConnection } from './connection'
import { storeAuthToken } from './connection-storage'
import { authenticateWithUrlOtp } from './otp'
Expand Down Expand Up @@ -356,6 +357,7 @@ export async function getDevframeRpcClient(
const clientRpc: DevframeClientRpcHost = new RpcFunctionsCollectorBase<DevframeRpcClientFunctions, DevframeRpcContext>(context)
// No-op when the browser provides no WebMCP model context.
const disposeWebMcp = options.webmcp === false ? undefined : registerWebMcpTools(clientRpc)
let disposeBrowserAgentBridge: (() => void) | undefined

async function fetchJsonFromBases(path: string): Promise<any> {
const candidates = [
Expand Down Expand Up @@ -448,6 +450,7 @@ export async function getDevframeRpcClient(
/** Release authentication and transport resources even if another disposer fails. */
function closeRpcClient(): void {
try {
disposeBrowserAgentBridge?.()
disposeWebMcp?.()
}
finally {
Expand Down Expand Up @@ -596,6 +599,8 @@ export async function getDevframeRpcClient(
() => { bootstrapAuthSettled = true },
)

disposeBrowserAgentBridge = setupBrowserAgentRpcBridge(rpc)

// Listen for auth updates from other tabs (e.g., the auth page, or another
// tab that just completed a code exchange).
if (authChannel) {
Expand Down
Loading
Loading