From b85af55e84d5f978bace7c699fa0e9024c6cea2f Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 16 Sep 2026 00:53:06 +0000 Subject: [PATCH 1/4] 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 --- docs/content/1.guide/12.in-page-channel.md | 2 +- docs/content/6.errors/DF0080.md | 44 +++++++++++ packages/agentic/src/connect/index.ts | 17 ++-- .../src/client/browser-agent-rpc.test.ts | 52 +++++++++++++ .../devframe/src/client/browser-agent-rpc.ts | 76 ++++++++++++++++++ packages/devframe/src/client/browser-agent.ts | 64 +++++++++++++++ packages/devframe/src/client/rpc.ts | 5 ++ .../src/in-page-channel/agent.test.ts | 78 +++++++++++++++++++ .../src/in-page-channel/diagnostics.ts | 5 ++ .../devframe/src/in-page-channel/internal.ts | 39 +++++++++- .../src/in-page-channel/page-script.ts | 3 + .../devframe/src/in-page-channel/panel.ts | 3 + .../devframe/src/in-page-channel/types.ts | 23 ++++-- .../src/node/__tests__/client-agent.test.ts | 45 +++++++++++ packages/devframe/src/node/client-agent.ts | 70 +++++++++++++++++ packages/devframe/src/node/host-functions.ts | 2 + .../src/node/rpc/agent-sync-client-tools.ts | 16 ++++ packages/devframe/src/node/rpc/index.ts | 3 + packages/devframe/src/types/rpc-augments.ts | 4 + .../tsnapi/devframe/index.snapshot.d.ts | 2 + 20 files changed, 534 insertions(+), 19 deletions(-) create mode 100644 docs/content/6.errors/DF0080.md create mode 100644 packages/devframe/src/client/browser-agent-rpc.test.ts create mode 100644 packages/devframe/src/client/browser-agent-rpc.ts create mode 100644 packages/devframe/src/client/browser-agent.ts create mode 100644 packages/devframe/src/in-page-channel/agent.test.ts create mode 100644 packages/devframe/src/node/__tests__/client-agent.test.ts create mode 100644 packages/devframe/src/node/client-agent.ts create mode 100644 packages/devframe/src/node/rpc/agent-sync-client-tools.ts diff --git a/docs/content/1.guide/12.in-page-channel.md b/docs/content/1.guide/12.in-page-channel.md index d23a34352..b03ed67de 100644 --- a/docs/content/1.guide/12.in-page-channel.md +++ b/docs/content/1.guide/12.in-page-channel.md @@ -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`: 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. diff --git a/docs/content/6.errors/DF0080.md b/docs/content/6.errors/DF0080.md new file mode 100644 index 000000000..24e9cdfb5 --- /dev/null +++ b/docs/content/6.errors/DF0080.md @@ -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`. diff --git a/packages/agentic/src/connect/index.ts b/packages/agentic/src/connect/index.ts index a280f9950..0bf388069 100644 --- a/packages/agentic/src/connect/index.ts +++ b/packages/agentic/src/connect/index.ts @@ -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 {} + interface IndexedInstance extends Omit { mcp: { url: string - tools?: { name: string, description?: string }[] + tools?: IndexedInstanceTools[] error?: string } | null hint?: string @@ -214,14 +216,8 @@ async function probePort(port: number, timeoutMs?: number): Promise { - 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 { + return withInstanceClient(url, token, async client => (await client.listTools()).tools) } async function call( @@ -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) diff --git a/packages/devframe/src/client/browser-agent-rpc.test.ts b/packages/devframe/src/client/browser-agent-rpc.test.ts new file mode 100644 index 000000000..66faea589 --- /dev/null +++ b/packages/devframe/src/client/browser-agent-rpc.test.ts @@ -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 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' }) + }) +}) diff --git a/packages/devframe/src/client/browser-agent-rpc.ts b/packages/devframe/src/client/browser-agent-rpc.ts new file mode 100644 index 000000000..514f67e4f --- /dev/null +++ b/packages/devframe/src/client/browser-agent-rpc.ts @@ -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) => Promise +} + +interface BrowserAgentRpcClient { + client: { register: (definition: BrowserAgentInvocationDefinition) => void } + callOptional: ( + method: 'devframe:agent:sync-client-tools', + tools: BrowserAgentToolManifest[], + ) => Promise + 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) => { + 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() + } +} diff --git a/packages/devframe/src/client/browser-agent.ts b/packages/devframe/src/client/browser-agent.ts new file mode 100644 index 000000000..d01c6677a --- /dev/null +++ b/packages/devframe/src/client/browser-agent.ts @@ -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) => unknown | Promise +} + +interface BrowserAgentRegistryState { + tools: Map + 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() + 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' +} diff --git a/packages/devframe/src/client/rpc.ts b/packages/devframe/src/client/rpc.ts index b7cc7ee9d..a80bd41ff 100644 --- a/packages/devframe/src/client/rpc.ts +++ b/packages/devframe/src/client/rpc.ts @@ -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' @@ -356,6 +357,7 @@ export async function getDevframeRpcClient( const clientRpc: DevframeClientRpcHost = new RpcFunctionsCollectorBase(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 { const candidates = [ @@ -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 { @@ -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) { diff --git a/packages/devframe/src/in-page-channel/agent.test.ts b/packages/devframe/src/in-page-channel/agent.test.ts new file mode 100644 index 000000000..f57f5be4d --- /dev/null +++ b/packages/devframe/src/in-page-channel/agent.test.ts @@ -0,0 +1,78 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec' +import type { InPageChannelProtocol } from './types' +import { describe, expect, it } from 'vitest' +import { listBrowserAgentTools } from '../client/browser-agent' +import { createPageScriptChannel } from './page-script' + +interface TestProtocol extends InPageChannelProtocol { + functions: { + pageScript: { + add: (a: number, b: number) => { sum: number } + hidden: () => string + } + } +} + +function schema(json: Record): StandardSchemaV1 { + return { + '~standard': { + version: 1, + vendor: 'test', + validate: (value: unknown) => ({ value: value as T }), + jsonSchema: { + input: () => json, + output: () => json, + }, + } as StandardSchemaV1['~standard'], + } +} + +describe('in-page channel agent tools', () => { + it('registers the original handler for browser-to-node agent transport', async () => { + const channel = createPageScriptChannel({ + name: 'devframes:test', + window: false, + heartbeat: false, + functions: { + add: { + jsonSerializable: true, + agent: { description: 'Add two numbers.' }, + args: [schema({ type: 'number' }), schema({ type: 'number' })], + returns: schema<{ sum: number }>({ type: 'object' }), + handler: (a, b) => ({ sum: a + b }), + }, + hidden: { handler: () => 'internal' }, + }, + }) + + const tool = listBrowserAgentTools().find(tool => tool.id === 'devframes:test:add')! + expect(tool).toMatchObject({ + description: 'Add two numbers.', + safety: 'read', + inputSchema: { + type: 'object', + properties: { arg0: { type: 'number' }, arg1: { type: 'number' } }, + required: ['arg0', 'arg1'], + additionalProperties: false, + }, + }) + await expect(tool.invoke({ arg0: 2, arg1: 3 })).resolves.toEqual({ sum: 5 }) + + channel.close() + expect(listBrowserAgentTools().some(tool => tool.id === 'devframes:test:add')).toBe(false) + }) + + it('rejects agent exposure without strict JSON serialization', () => { + expect(() => createPageScriptChannel({ + name: 'devframes:invalid', + window: false, + functions: { + add: { + agent: { description: 'Add two numbers.' }, + handler: (a, b) => ({ sum: a + b }), + }, + hidden: { handler: () => 'internal' }, + }, + })).toThrowError(/MCP requires JSON-serializable/) + }) +}) diff --git a/packages/devframe/src/in-page-channel/diagnostics.ts b/packages/devframe/src/in-page-channel/diagnostics.ts index e5dba6346..dd2686f22 100644 --- a/packages/devframe/src/in-page-channel/diagnostics.ts +++ b/packages/devframe/src/in-page-channel/diagnostics.ts @@ -7,5 +7,10 @@ export const diagnostics = /* #__PURE__ */ defineDiagnostics({ why: (p: { name: string }) => `In-page channel function "${p.name}" is not registered on this endpoint.`, fix: 'Declare the function in this endpoint\'s `functions` option.', }, + DF0080: { + why: (p: { name: string }) => + `In-page channel function "${p.name}" has \`agent\` set but \`jsonSerializable\` is not \`true\`; MCP requires JSON-serializable data.`, + fix: 'Set `jsonSerializable: true` if the payload is JSON-safe, or remove `agent` to keep it channel-only.', + }, }, }) diff --git a/packages/devframe/src/in-page-channel/internal.ts b/packages/devframe/src/in-page-channel/internal.ts index 88fc5d08e..bc46f12d5 100644 --- a/packages/devframe/src/in-page-channel/internal.ts +++ b/packages/devframe/src/in-page-channel/internal.ts @@ -4,6 +4,9 @@ import type { RpcArgsSchema } from '../rpc/types' import type { InPageChannelControlFrame } from './protocol' import type { InPageFunctionDefinitionAny, InPageFunctionType } from './types' import { createBirpc } from 'birpc' +import { argsToJsonSchema } from '../agent/to-json-schema' +import { registerBrowserAgentTool, resolveBrowserAgentSafety } from '../client/browser-agent' +import { toolInputToRpcArgs } from '../tool-input' import { diagnostics } from './diagnostics' import { isControlFrame } from './protocol' @@ -172,16 +175,22 @@ const FUNCTION_METHOD_PREFIX = channelMethod('function', '') * hook, `jsonSerializable` enforcement, Standard-Schema argument validation, * then serialize hook + `jsonSerializable` enforcement on the result. */ -export function createLocalFunctionRegistry(codec: InPageChannelSerialization): { +export interface InPageLocalFunctionRegistry { + readonly definitions: ReadonlyMap register: (definition: InPageFunctionDefinitionAny) => void registerInternal: (method: string, handler: (...args: unknown[]) => unknown) => void on: (name: string, listener: (...args: unknown[]) => void) => () => void resolve: (name: string) => ((...args: unknown[]) => unknown) | undefined -} { +} + +export function createLocalFunctionRegistry(codec: InPageChannelSerialization): InPageLocalFunctionRegistry { const definitions = new Map() const listeners = new Map void>>() return { + definitions, register(definition) { + if ('agent' in definition && definition.agent && definition.jsonSerializable !== true) + throw diagnostics.DF0080({ name: definition.name }) definitions.set(channelMethod(definition.type, definition.name), definition) }, // The shared-state layer keys its handlers by their own fully-qualified @@ -253,6 +262,32 @@ export function resolveLocalHandler( return undefined } +/** Register an endpoint's local agent functions for browser-backed transports. */ +export function registerInPageAgentTools( + channelName: string, + registry: InPageLocalFunctionRegistry, +): () => void { + const disposals: (() => void)[] = [] + for (const definition of registry.definitions.values()) { + if (definition.type === 'event' || !('agent' in definition) || !definition.agent) + continue + const agent = definition.agent + disposals.push(registerBrowserAgentTool({ + id: `${channelName}:${definition.name}`, + title: agent.title ?? definition.name, + description: agent.description, + safety: resolveBrowserAgentSafety(definition.type, agent), + tags: agent.tags, + inputSchema: argsToJsonSchema(definition.args), + invoke: (args) => { + const positional = toolInputToRpcArgs(args, definition.args?.length) + return registry.resolve(channelMethod(definition.type, definition.name))!(...positional) + }, + })) + } + return () => disposals.forEach(dispose => dispose()) +} + type RemoteFunctions = Record any> export interface AttachChannelPortOptions { diff --git a/packages/devframe/src/in-page-channel/page-script.ts b/packages/devframe/src/in-page-channel/page-script.ts index dbcab8205..140aa6f50 100644 --- a/packages/devframe/src/in-page-channel/page-script.ts +++ b/packages/devframe/src/in-page-channel/page-script.ts @@ -14,6 +14,7 @@ import { createLocalFunctionRegistry, DEFAULT_CALL_TIMEOUT_MS, deserializeResult, + registerInPageAgentTools, resolveHeartbeat, resolveLocalHandler, serializeArgs, @@ -69,6 +70,7 @@ export function createPageScriptChannel

( registry.register({ ...definition, name: fnName }) for (const [eventName, definition] of Object.entries(options.events ?? {})) registry.register({ ...definition, name: eventName, type: 'event' }) + const disposeAgentTools = registerInPageAgentTools(name, registry) const stateHost = createPageScriptStateHost

(function* () { for (const peer of peers.values()) { @@ -208,6 +210,7 @@ export function createPageScriptChannel

( if (closed) return closed = true + disposeAgentTools() win?.removeEventListener('message', onWindowMessage) for (const id of [...peers.keys()]) removePeer(id, { bye: true, reason: 'the page script closed the channel' }) diff --git a/packages/devframe/src/in-page-channel/panel.ts b/packages/devframe/src/in-page-channel/panel.ts index c5d75ebc9..c0c278cc3 100644 --- a/packages/devframe/src/in-page-channel/panel.ts +++ b/packages/devframe/src/in-page-channel/panel.ts @@ -15,6 +15,7 @@ import { DEFAULT_CALL_TIMEOUT_MS, deserializeResult, InPageChannelError, + registerInPageAgentTools, resolveHeartbeat, resolveLocalHandler, serializeArgs, @@ -68,6 +69,7 @@ export function connectPanelChannel

( registry.register({ ...definition, name: fnName }) for (const [eventName, definition] of Object.entries(options.events ?? {})) registry.register({ ...definition, name: eventName, type: 'event' }) + const disposeAgentTools = registerInPageAgentTools(name, registry) let status: InPageChannelStatus = 'connecting' let attached: AttachedChannelPort | undefined @@ -298,6 +300,7 @@ export function connectPanelChannel

( if (status === 'closed') return setStatus('closed') + disposeAgentTools() stopTimers() win?.removeEventListener('message', onWindowMessage) attached?.dispose({ bye: true, reason: 'the panel closed the channel' }) diff --git a/packages/devframe/src/in-page-channel/types.ts b/packages/devframe/src/in-page-channel/types.ts index 5748c732d..1b1f8c69c 100644 --- a/packages/devframe/src/in-page-channel/types.ts +++ b/packages/devframe/src/in-page-channel/types.ts @@ -1,6 +1,6 @@ import type { EventEmitter } from 'devframe/types' import type { SharedState } from 'devframe/utils/shared-state' -import type { RpcArgsSchema, RpcReturnSchema, Thenable } from '../rpc/types' +import type { RpcArgsSchema, RpcFunctionAgentOptions, RpcReturnSchema, Thenable } from '../rpc/types' import type { InferArgsType, InferReturnType } from '../rpc/utils' /** @@ -64,12 +64,17 @@ type ProtocolHandler = F extends (...args: any[]) => any */ export type InPageFunctionType = 'action' | 'event' | 'query' -interface InPageFunctionDefinitionBase { +interface InPageDefinitionBase { name: NAME jsonSerializable?: boolean } -interface InPageEventFunctionDefinition extends InPageFunctionDefinitionBase { +interface InPageFunctionDefinitionBase extends InPageDefinitionBase { + /** Expose this function through DevFrame's browser-to-node agent bridge. */ + agent?: RpcFunctionAgentOptions +} + +interface InPageEventFunctionDefinition extends InPageDefinitionBase { type: 'event' handler?: HANDLER } @@ -122,7 +127,8 @@ type InPageFunctionDefinitionHandler< * An in-page channel function definition: the `defineRpcFunction` authoring * shape (`name`, `type`, Standard-Schema `args`/`returns`, * `jsonSerializable`, `handler`) narrowed to the browser: there is no - * `dump`/`snapshot`/`cacheable`/`agent`. When `jsonSerializable` is `true`, + * `dump`/`snapshot`/`cacheable`. An optional `agent` exposes the function to + * DevFrame's browser-to-node agent bridge. When `jsonSerializable` is `true`, * payloads are strictly validated at the receiving endpoint and misshapen * values reject the call with a descriptive `InPageChannelError` instead of * a cryptic `DataCloneError` in the port. Event definitions may omit their @@ -153,7 +159,7 @@ export type InPageFunctionDefinitionAny = InPageFunctionDefinition extends InPageFunctionOptionBase { +interface InPageFunctionOptionBase extends InPageDefinitionOptionBase { + /** Expose this function through DevFrame's browser-to-node agent bridge. */ + agent?: RpcFunctionAgentOptions +} + +interface InPageEventFunctionOption extends InPageDefinitionOptionBase { type?: 'event' handler?: ProtocolHandler } diff --git a/packages/devframe/src/node/__tests__/client-agent.test.ts b/packages/devframe/src/node/__tests__/client-agent.test.ts new file mode 100644 index 000000000..e7f3638ff --- /dev/null +++ b/packages/devframe/src/node/__tests__/client-agent.test.ts @@ -0,0 +1,45 @@ +import type { AgentToolInput } from 'devframe/types' +import { describe, expect, it, vi } from 'vitest' +import { removeClientAgentSession, syncClientAgentTools } from '../client-agent' + +describe('client agent tools', () => { + it('projects a browser manifest and invokes its originating RPC session', async () => { + let provider: (() => readonly AgentToolInput[]) | undefined + const notifyChanged = vi.fn() + const context = { + agent: { + registerToolProvider(next: () => readonly AgentToolInput[]) { + provider = next + return { notifyChanged, unregister() {} } + }, + }, + } + const callRaw = vi.fn().mockResolvedValue(['refetched']) + const session = { + meta: { id: 1, subscribedStates: new Set() }, + rpc: { $callRaw: callRaw }, + } + + syncClientAgentTools(context, session, [{ + id: 'pinia-colada:refetch', + description: 'Refetch matching queries.', + safety: 'action', + inputSchema: { type: 'object' }, + }]) + const [tool] = provider!() + expect(tool).toMatchObject({ + id: 'pinia-colada:refetch', + description: 'Refetch matching queries.', + inputSchema: { type: 'object' }, + }) + await expect(tool!.handler!({ arg0: {} })).resolves.toEqual(['refetched']) + expect(callRaw).toHaveBeenCalledWith({ + method: 'devframe:agent:invoke-client-tool', + args: ['pinia-colada:refetch', { arg0: {} }], + }) + + removeClientAgentSession(context, session.meta) + expect(provider!()).toEqual([]) + expect(notifyChanged).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/devframe/src/node/client-agent.ts b/packages/devframe/src/node/client-agent.ts new file mode 100644 index 000000000..1ce06c5d1 --- /dev/null +++ b/packages/devframe/src/node/client-agent.ts @@ -0,0 +1,70 @@ +import type { AgentToolInput, DevframeAgentHost, DevframeNodeRpcSessionMeta } from 'devframe/types' +import type { BrowserAgentToolManifest } from '../client/browser-agent' + +interface ClientAgentContext { + agent: Pick +} + +interface ClientAgentSession { + meta: DevframeNodeRpcSessionMeta + rpc: { + $callRaw: (request: { method: string, args: unknown[] }) => Promise + } +} + +interface ClientAgentState { + sessions: Map + notifyChanged: () => void +} + +const states = new WeakMap() + +function getState(context: ClientAgentContext): ClientAgentState { + let state = states.get(context) + if (state) + return state + + const sessions: ClientAgentState['sessions'] = new Map() + const provider = context.agent.registerToolProvider(() => { + const tools = new Map() + for (const { session, tools: manifests } of sessions.values()) { + for (const manifest of manifests) { + if (tools.has(manifest.id)) + continue + tools.set(manifest.id, { + ...manifest, + handler: args => session.rpc.$callRaw({ + method: 'devframe:agent:invoke-client-tool', + args: [manifest.id, args], + }), + }) + } + } + return [...tools.values()] + }) + state = { sessions, notifyChanged: provider.notifyChanged } + states.set(context, state) + return state +} + +export function syncClientAgentTools( + context: ClientAgentContext, + session: ClientAgentSession, + tools: BrowserAgentToolManifest[], +): void { + const state = getState(context) + state.sessions.set(session.meta, { session, tools }) + state.notifyChanged() +} + +export function removeClientAgentSession( + context: ClientAgentContext, + meta: DevframeNodeRpcSessionMeta, +): void { + const state = states.get(context) + if (state?.sessions.delete(meta)) + state.notifyChanged() +} diff --git a/packages/devframe/src/node/host-functions.ts b/packages/devframe/src/node/host-functions.ts index 88344d0f7..ea2b2c9c7 100644 --- a/packages/devframe/src/node/host-functions.ts +++ b/packages/devframe/src/node/host-functions.ts @@ -3,6 +3,7 @@ import type { DevframeNodeContext, DevframeNodeRpcSession, DevframeNodeRpcSessio import type { AsyncLocalStorage } from 'node:async_hooks' import { RpcFunctionsCollectorBase } from 'devframe/rpc' import { createDebug } from 'obug' +import { removeClientAgentSession } from './client-agent' import { diagnostics } from './diagnostics' import { createRpcSharedStateServerHost } from './rpc-shared-state' import { createRpcStreamingServerHost } from './rpc-streaming' @@ -53,6 +54,7 @@ export class RpcFunctionsHostImpl extends RpcFunctionsCollectorBase ({ + handler(tools: BrowserAgentToolManifest[]): void { + const session = context.rpc.getCurrentRpcSession() + if (session) + syncClientAgentTools(context, session, tools) + }, + }), +}) diff --git a/packages/devframe/src/node/rpc/index.ts b/packages/devframe/src/node/rpc/index.ts index 88d8573d1..9a09dc2bb 100644 --- a/packages/devframe/src/node/rpc/index.ts +++ b/packages/devframe/src/node/rpc/index.ts @@ -2,6 +2,7 @@ import { agentInvokeTool } from './agent-invoke-tool' import { agentListResources } from './agent-list-resources' import { agentListTools } from './agent-list-tools' import { agentReadResource } from './agent-read-resource' +import { agentSyncClientTools } from './agent-sync-client-tools' /** * Built-in agent introspection RPC functions. Registered automatically @@ -13,6 +14,7 @@ export const BUILTIN_AGENT_RPC = [ agentInvokeTool, agentListResources, agentReadResource, + agentSyncClientTools, ] as const declare module 'devframe/types' { @@ -21,5 +23,6 @@ declare module 'devframe/types' { 'devframe:agent:invoke-tool': (id: string, args: unknown) => Promise 'devframe:agent:list-resources': () => Promise 'devframe:agent:read-resource': (id: string) => Promise + 'devframe:agent:sync-client-tools': (tools: import('../../client/browser-agent').BrowserAgentToolManifest[]) => Promise } } diff --git a/packages/devframe/src/types/rpc-augments.ts b/packages/devframe/src/types/rpc-augments.ts index b851fd105..1a910c611 100644 --- a/packages/devframe/src/types/rpc-augments.ts +++ b/packages/devframe/src/types/rpc-augments.ts @@ -2,6 +2,8 @@ * To be extended */ export interface DevframeRpcClientFunctions { + /** Invoke a tool registered in this browser document. @internal */ + 'devframe:agent:invoke-client-tool': (id: string, args: Record) => Promise /** * Server→client notification that this connection's auth token has been * revoked. The client drops to untrusted on receipt. Broadcast by @@ -51,6 +53,8 @@ export interface DevframeRpcClientFunctions { * To be extended */ export interface DevframeRpcServerFunctions { + /** Replace this connection's browser-agent tool manifest. @internal */ + 'devframe:agent:sync-client-tools': (tools: import('../client/browser-agent').BrowserAgentToolManifest[]) => Promise /** * Authenticate a connection with a previously-issued bearer token; resolves * whether the connection is now trusted. The interactive handler is provided diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index c01926729..35adf361e 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -226,6 +226,7 @@ export interface DevframeNodeRpcSessionMeta { uploadingStreams?: Set; } export interface DevframeRpcClientFunctions { + 'devframe:agent:invoke-client-tool': (_: string, _: Record) => Promise; 'devframe:auth:revoked': () => Promise; 'devframe:streaming:chunk': (_: string, _: string, _: number, _: any) => Promise; 'devframe:streaming:end': (_: string, _: string, _?: { @@ -254,6 +255,7 @@ export interface DevframeRpcOptions { snapshot?: DevframeSnapshotRpcEntry[]; } export interface DevframeRpcServerFunctions { + 'devframe:agent:sync-client-tools': (_: BrowserAgentToolManifest[]) => Promise; 'anonymous:devframe:auth': (_: { authToken: string; ua: string; From 6ff544e8fd28e0678fcfc637838bdced6506c3b7 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 16 Sep 2026 01:28:54 +0000 Subject: [PATCH 2/4] refactor: infer JSON serialization for agent in-page functions and dedupe safety Align the in-page `agent` option with #379: setting `agent` now implies `jsonSerializable: true` instead of requiring it, and DF0080 only fires on an explicit `jsonSerializable: false`. Collapse the three copies of the agent safety inference (WebMCP, the MCP host, and the in-page bridge) into one shared `resolveAgentSafety` helper, and drop the redundant id de-duplication in the browser-agent registry since the node-side tool provider already dedupes. Co-authored-by: agent --- docs/content/1.guide/12.in-page-channel.md | 2 +- docs/content/6.errors/DF0080.md | 16 +++++++-------- packages/devframe/src/agent/safety.ts | 15 ++++++++++++++ packages/devframe/src/client/browser-agent.ts | 18 +---------------- packages/devframe/src/client/webmcp.ts | 18 +++++------------ .../src/in-page-channel/agent.test.ts | 20 ++++++++++++++++++- .../src/in-page-channel/diagnostics.ts | 4 ++-- .../devframe/src/in-page-channel/internal.ts | 14 +++++++++---- packages/devframe/src/node/host-agent.ts | 12 +++-------- 9 files changed, 64 insertions(+), 55 deletions(-) create mode 100644 packages/devframe/src/agent/safety.ts diff --git a/docs/content/1.guide/12.in-page-channel.md b/docs/content/1.guide/12.in-page-channel.md index b03ed67de..7b73b4d61 100644 --- a/docs/content/1.guide/12.in-page-channel.md +++ b/docs/content/1.guide/12.in-page-channel.md @@ -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. 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. +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 is available to coding agents through MCP; the field implicitly enables strict JSON serialization (an explicit `jsonSerializable: false` conflicts). `defineChannelFunction` retains the named definition shape for lower-level authoring. `call()` accepts names from `functions`, including actions returning `void` or `Promise`: 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. diff --git a/docs/content/6.errors/DF0080.md b/docs/content/6.errors/DF0080.md index 24e9cdfb5..4168e132d 100644 --- a/docs/content/6.errors/DF0080.md +++ b/docs/content/6.errors/DF0080.md @@ -1,15 +1,15 @@ --- title: 'DF0080: Agent In-Page Function Not JSON-Serializable' -description: 'An in-page channel function sets `agent` but does not set `jsonSerializable: true`.' +description: 'An in-page channel function sets `agent` but `jsonSerializable` is `false`.' --- ## Message -> In-page channel function "{name}" has `agent` set but `jsonSerializable` is not `true`; MCP requires JSON-serializable data. +> In-page channel function "{name}" has `agent` set but `jsonSerializable` is `false`; 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. +The `agent` field exposes an in-page function over the browser-to-node agent bridge and MCP, which only consumes JSON-shaped data, so it implicitly enables strict JSON serialization. An explicit `jsonSerializable: false` conflicts with that contract. ## Example @@ -21,7 +21,8 @@ createPageScriptChannel({ functions: { addTodo: { agent: { description: 'Add a todo item.' }, - handler: (text: string) => ({ added: text }), // ✗ `agent` without `jsonSerializable: true` + jsonSerializable: false, // ✗ throws DF0080 + handler: (text: string) => ({ added: text }), }, }, }) @@ -29,16 +30,15 @@ createPageScriptChannel({ ## Fix -Set `jsonSerializable: true` when the payload is JSON-safe, or remove `agent` to keep the function channel-only. +Remove `jsonSerializable: false` to use the implicit JSON contract, or remove `agent` to keep the function channel-only. ```ts const addTodo = { - agent: { description: 'Add a todo item.' }, - jsonSerializable: true, // ✓ + agent: { description: 'Add a todo item.' }, // jsonSerializable is inferred 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`. +- [`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 combines `agent` with `jsonSerializable: false`, and infers `jsonSerializable: true` otherwise. diff --git a/packages/devframe/src/agent/safety.ts b/packages/devframe/src/agent/safety.ts new file mode 100644 index 000000000..4b4d20ad7 --- /dev/null +++ b/packages/devframe/src/agent/safety.ts @@ -0,0 +1,15 @@ +import type { RpcFunctionAgentOptions } from '../rpc/types' + +/** + * An agent tool's safety classification: the explicit `agent.safety`, else + * inferred from the function `type` (`static`/`query` are read-only, the rest + * mutate). Shared by every agent surface (MCP host, WebMCP, in-page bridge). + */ +export function resolveAgentSafety( + type: string | undefined, + agent: RpcFunctionAgentOptions, +): 'read' | 'action' | 'destructive' { + if (agent.safety) + return agent.safety + return type === 'static' || type === 'query' || type == null ? 'read' : 'action' +} diff --git a/packages/devframe/src/client/browser-agent.ts b/packages/devframe/src/client/browser-agent.ts index d01c6677a..bbccf986f 100644 --- a/packages/devframe/src/client/browser-agent.ts +++ b/packages/devframe/src/client/browser-agent.ts @@ -1,5 +1,3 @@ -import type { RpcFunctionAgentOptions } from 'devframe/rpc' - export interface BrowserAgentToolManifest { id: string title?: string @@ -41,24 +39,10 @@ export function registerBrowserAgentTool(tool: BrowserAgentTool): () => void { } export function listBrowserAgentTools(): BrowserAgentTool[] { - const unique = new Map() - for (const tool of tools.values()) { - if (!unique.has(tool.id)) - unique.set(tool.id, tool) - } - return [...unique.values()] + return [...tools.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' -} diff --git a/packages/devframe/src/client/webmcp.ts b/packages/devframe/src/client/webmcp.ts index ecd3c1563..1692d4d69 100644 --- a/packages/devframe/src/client/webmcp.ts +++ b/packages/devframe/src/client/webmcp.ts @@ -1,9 +1,10 @@ -import type { RpcFunctionAgentOptions, RpcFunctionDefinitionAnyWithContext, RpcFunctionsCollector, RpcFunctionType } from 'devframe/rpc' +import type { RpcFunctionAgentOptions, RpcFunctionDefinitionAnyWithContext, RpcFunctionsCollector } from 'devframe/rpc' import { getRpcHandler } from 'devframe/rpc' import { toAgentToolName } from 'devframe/utils/agent-tool-name' // Pure, browser-safe projections shared with the node-side MCP adapter // (`@devframes/agentic/mcp`, via `devframe/internal`), so the WebMCP surface // cannot drift from the MCP one. +import { resolveAgentSafety } from '../agent/safety' import { argsToJsonSchema } from '../agent/to-json-schema' import { toolInputToRpcArgs } from '../tool-input' @@ -132,14 +133,15 @@ export function registerWebMcpTools( } const controller = new AbortController() + const safety = resolveAgentSafety(def.type, agent) const result = modelContext.registerTool({ name, description: agent.description, inputSchema: argsToJsonSchema(def.args), annotations: { title: agent.title ?? def.name, - readOnlyHint: resolveSafety(def, agent) === 'read', - destructiveHint: resolveSafety(def, agent) === 'destructive', + readOnlyHint: safety === 'read', + destructiveHint: safety === 'destructive', }, execute: args => executeRpcTool(def, clientRpc.context, args), }, { signal: controller.signal }) @@ -180,16 +182,6 @@ export function registerWebMcpTools( } } -function resolveSafety( - def: RpcFunctionDefinitionAnyWithContext, - agent: RpcFunctionAgentOptions, -): 'read' | 'action' | 'destructive' { - if (agent.safety) - return agent.safety - const type: RpcFunctionType = def.type ?? 'query' - return type === 'static' || type === 'query' ? 'read' : 'action' -} - async function executeRpcTool( def: RpcFunctionDefinitionAnyWithContext, context: SetupContext, diff --git a/packages/devframe/src/in-page-channel/agent.test.ts b/packages/devframe/src/in-page-channel/agent.test.ts index f57f5be4d..b62bfce87 100644 --- a/packages/devframe/src/in-page-channel/agent.test.ts +++ b/packages/devframe/src/in-page-channel/agent.test.ts @@ -62,13 +62,31 @@ describe('in-page channel agent tools', () => { expect(listBrowserAgentTools().some(tool => tool.id === 'devframes:test:add')).toBe(false) }) - it('rejects agent exposure without strict JSON serialization', () => { + it('infers strict JSON serialization when agent is set', () => { + const channel = createPageScriptChannel({ + name: 'devframes:inferred', + window: false, + heartbeat: false, + functions: { + add: { + agent: { description: 'Add two numbers.' }, + handler: (a, b) => ({ sum: a + b }), + }, + hidden: { handler: () => 'internal' }, + }, + }) + expect(listBrowserAgentTools().some(tool => tool.id === 'devframes:inferred:add')).toBe(true) + channel.close() + }) + + it('rejects agent exposure with explicit jsonSerializable: false', () => { expect(() => createPageScriptChannel({ name: 'devframes:invalid', window: false, functions: { add: { agent: { description: 'Add two numbers.' }, + jsonSerializable: false, handler: (a, b) => ({ sum: a + b }), }, hidden: { handler: () => 'internal' }, diff --git a/packages/devframe/src/in-page-channel/diagnostics.ts b/packages/devframe/src/in-page-channel/diagnostics.ts index dd2686f22..c52a77f19 100644 --- a/packages/devframe/src/in-page-channel/diagnostics.ts +++ b/packages/devframe/src/in-page-channel/diagnostics.ts @@ -9,8 +9,8 @@ export const diagnostics = /* #__PURE__ */ defineDiagnostics({ }, DF0080: { why: (p: { name: string }) => - `In-page channel function "${p.name}" has \`agent\` set but \`jsonSerializable\` is not \`true\`; MCP requires JSON-serializable data.`, - fix: 'Set `jsonSerializable: true` if the payload is JSON-safe, or remove `agent` to keep it channel-only.', + `In-page channel function "${p.name}" has \`agent\` set but \`jsonSerializable\` is \`false\`; MCP requires JSON-serializable data.`, + fix: 'Remove `jsonSerializable: false`, or remove `agent` to keep it channel-only.', }, }, }) diff --git a/packages/devframe/src/in-page-channel/internal.ts b/packages/devframe/src/in-page-channel/internal.ts index bc46f12d5..662a0df4c 100644 --- a/packages/devframe/src/in-page-channel/internal.ts +++ b/packages/devframe/src/in-page-channel/internal.ts @@ -4,8 +4,9 @@ import type { RpcArgsSchema } from '../rpc/types' import type { InPageChannelControlFrame } from './protocol' import type { InPageFunctionDefinitionAny, InPageFunctionType } from './types' import { createBirpc } from 'birpc' +import { resolveAgentSafety } from '../agent/safety' import { argsToJsonSchema } from '../agent/to-json-schema' -import { registerBrowserAgentTool, resolveBrowserAgentSafety } from '../client/browser-agent' +import { registerBrowserAgentTool } from '../client/browser-agent' import { toolInputToRpcArgs } from '../tool-input' import { diagnostics } from './diagnostics' import { isControlFrame } from './protocol' @@ -189,8 +190,13 @@ export function createLocalFunctionRegistry(codec: InPageChannelSerialization): return { definitions, register(definition) { - if ('agent' in definition && definition.agent && definition.jsonSerializable !== true) - throw diagnostics.DF0080({ name: definition.name }) + // `agent` implies strict JSON serialization, since MCP consumes + // JSON-shaped data; an explicit `jsonSerializable: false` conflicts. + if ('agent' in definition && definition.agent) { + if (definition.jsonSerializable === false) + throw diagnostics.DF0080({ name: definition.name }) + definition.jsonSerializable = true + } definitions.set(channelMethod(definition.type, definition.name), definition) }, // The shared-state layer keys its handlers by their own fully-qualified @@ -276,7 +282,7 @@ export function registerInPageAgentTools( id: `${channelName}:${definition.name}`, title: agent.title ?? definition.name, description: agent.description, - safety: resolveBrowserAgentSafety(definition.type, agent), + safety: resolveAgentSafety(definition.type, agent), tags: agent.tags, inputSchema: argsToJsonSchema(definition.args), invoke: (args) => { diff --git a/packages/devframe/src/node/host-agent.ts b/packages/devframe/src/node/host-agent.ts index 0c5c65d9d..0a1223a07 100644 --- a/packages/devframe/src/node/host-agent.ts +++ b/packages/devframe/src/node/host-agent.ts @@ -1,4 +1,4 @@ -import type { RpcFunctionDefinitionAnyWithContext, RpcFunctionType } from 'devframe/rpc' +import type { RpcFunctionDefinitionAnyWithContext } from 'devframe/rpc' import type { AgentHandle, AgentManifest, @@ -16,6 +16,7 @@ import type { RpcFunctionAgentOptions, } from 'devframe/types' import { createEventEmitter } from 'devframe/utils/events' +import { resolveAgentSafety } from '../agent/safety' import { DEVFRAME_EVENTS } from '../events' import { toolInputToRpcArgs } from '../tool-input' import { diagnostics } from './diagnostics' @@ -261,8 +262,7 @@ export class DevframeAgentHost implements DevframeAgentHostType { if (!agent.description || typeof agent.description !== 'string') throw diagnostics.DF0014({ name }) - const type: RpcFunctionType = def.type ?? 'query' - const safety = agent.safety ?? inferSafety(type) + const safety = resolveAgentSafety(def.type, agent) out.push({ id: name, kind: 'rpc', @@ -287,9 +287,3 @@ export class DevframeAgentHost implements DevframeAgentHostType { return undefined } } - -function inferSafety(type: RpcFunctionType): 'read' | 'action' | 'destructive' { - if (type === 'static' || type === 'query') - return 'read' - return 'action' -} From 08122d8b91c5ee4b20b793a7862caffa78762edd Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 16 Sep 2026 01:46:22 +0000 Subject: [PATCH 3/4] feat: give each browser client a stable id reported to node Groundwork for #394. Each browser tab mints a stable client id (`sessionStorage`-backed nanoid that survives reloads and RPC reconnects) and tags its `devframe:agent:sync-client-tools` payload with it, so the node-side client-agent registry records which tab each session belongs to. This is the identifier only: no per-tab listing or routing yet. Tab duplication copies `sessionStorage`, so duplicated tabs can briefly share an id until the wider tab-metadata work adds disambiguation. Co-authored-by: agent --- .../src/client/browser-agent-rpc.test.ts | 4 ++- .../devframe/src/client/browser-agent-rpc.ts | 4 ++- packages/devframe/src/client/client-id.ts | 31 +++++++++++++++++++ .../src/node/__tests__/client-agent.test.ts | 2 +- packages/devframe/src/node/client-agent.ts | 5 ++- .../src/node/rpc/agent-sync-client-tools.ts | 4 +-- packages/devframe/src/node/rpc/index.ts | 2 +- packages/devframe/src/types/rpc-augments.ts | 4 +-- .../tsnapi/devframe/index.snapshot.d.ts | 2 +- 9 files changed, 48 insertions(+), 10 deletions(-) create mode 100644 packages/devframe/src/client/client-id.ts diff --git a/packages/devframe/src/client/browser-agent-rpc.test.ts b/packages/devframe/src/client/browser-agent-rpc.test.ts index 66faea589..d27fceff1 100644 --- a/packages/devframe/src/client/browser-agent-rpc.test.ts +++ b/packages/devframe/src/client/browser-agent-rpc.test.ts @@ -19,9 +19,10 @@ describe('browser agent RPC bridge', () => { }, callOptional( method: 'devframe:agent:sync-client-tools', + clientId: string, tools: BrowserAgentToolManifest[], ) { - return callOptional(method, tools) + return callOptional(method, clientId, tools) }, events: { on: () => () => {} }, } @@ -36,6 +37,7 @@ describe('browser agent RPC bridge', () => { disposals.push(setupBrowserAgentRpcBridge(rpc)) await vi.waitFor(() => expect(callOptional).toHaveBeenCalledWith( 'devframe:agent:sync-client-tools', + expect.any(String), [{ id: 'todos:add', description: 'Add a todo.', diff --git a/packages/devframe/src/client/browser-agent-rpc.ts b/packages/devframe/src/client/browser-agent-rpc.ts index 514f67e4f..205259e25 100644 --- a/packages/devframe/src/client/browser-agent-rpc.ts +++ b/packages/devframe/src/client/browser-agent-rpc.ts @@ -4,6 +4,7 @@ import { listBrowserAgentTools, onBrowserAgentToolsChanged, } from './browser-agent' +import { resolveClientId } from './client-id' export interface BrowserAgentInvocationDefinition { name: 'devframe:agent:invoke-client-tool' @@ -16,6 +17,7 @@ interface BrowserAgentRpcClient { client: { register: (definition: BrowserAgentInvocationDefinition) => void } callOptional: ( method: 'devframe:agent:sync-client-tools', + clientId: string, tools: BrowserAgentToolManifest[], ) => Promise events: { @@ -57,7 +59,7 @@ export function setupBrowserAgentRpcBridge(rpc: BrowserAgentRpcClient): () => vo if (manifests.length === 0 && lastSyncedCount === 0) return lastSyncedCount = manifests.length - await rpc.callOptional('devframe:agent:sync-client-tools', manifests).catch(() => {}) + await rpc.callOptional('devframe:agent:sync-client-tools', resolveClientId(), manifests).catch(() => {}) }) } diff --git a/packages/devframe/src/client/client-id.ts b/packages/devframe/src/client/client-id.ts new file mode 100644 index 000000000..0874bc086 --- /dev/null +++ b/packages/devframe/src/client/client-id.ts @@ -0,0 +1,31 @@ +import { nanoid } from 'devframe/utils/nanoid' + +const CLIENT_ID_STORAGE_KEY = 'devframe:client-id' +let memoryClientId: string | undefined + +/** + * This browser tab's stable client id: one nanoid per tab, persisted in + * `sessionStorage` so it survives page reloads and RPC reconnects. The node + * side uses it to tell connected tabs apart across reconnects (see #394). + * + * Tab duplication copies `sessionStorage`, so two tabs can briefly share an id + * until per-tab disambiguation lands with the wider tab-metadata work. + */ +export function resolveClientId(win: Window | undefined = globalThis.window): string { + try { + const storage = win?.sessionStorage + if (storage) { + let id = storage.getItem(CLIENT_ID_STORAGE_KEY) + if (!id) { + id = nanoid() + storage.setItem(CLIENT_ID_STORAGE_KEY, id) + } + return id + } + } + catch { + // Storage unavailable (sandboxed iframe, disabled cookies); fall through. + } + memoryClientId ??= nanoid() + return memoryClientId +} diff --git a/packages/devframe/src/node/__tests__/client-agent.test.ts b/packages/devframe/src/node/__tests__/client-agent.test.ts index e7f3638ff..bd0912f3d 100644 --- a/packages/devframe/src/node/__tests__/client-agent.test.ts +++ b/packages/devframe/src/node/__tests__/client-agent.test.ts @@ -20,7 +20,7 @@ describe('client agent tools', () => { rpc: { $callRaw: callRaw }, } - syncClientAgentTools(context, session, [{ + syncClientAgentTools(context, session, 'tab-abc', [{ id: 'pinia-colada:refetch', description: 'Refetch matching queries.', safety: 'action', diff --git a/packages/devframe/src/node/client-agent.ts b/packages/devframe/src/node/client-agent.ts index 1ce06c5d1..2843ce5dc 100644 --- a/packages/devframe/src/node/client-agent.ts +++ b/packages/devframe/src/node/client-agent.ts @@ -15,6 +15,8 @@ interface ClientAgentSession { interface ClientAgentState { sessions: Map notifyChanged: () => void @@ -53,10 +55,11 @@ function getState(context: ClientAgentContext): ClientAgentState { export function syncClientAgentTools( context: ClientAgentContext, session: ClientAgentSession, + clientId: string, tools: BrowserAgentToolManifest[], ): void { const state = getState(context) - state.sessions.set(session.meta, { session, tools }) + state.sessions.set(session.meta, { session, clientId, tools }) state.notifyChanged() } diff --git a/packages/devframe/src/node/rpc/agent-sync-client-tools.ts b/packages/devframe/src/node/rpc/agent-sync-client-tools.ts index 39b9a3dd2..ff39662d0 100644 --- a/packages/devframe/src/node/rpc/agent-sync-client-tools.ts +++ b/packages/devframe/src/node/rpc/agent-sync-client-tools.ts @@ -7,10 +7,10 @@ export const agentSyncClientTools = defineRpcFunction({ type: 'action', jsonSerializable: true, setup: context => ({ - handler(tools: BrowserAgentToolManifest[]): void { + handler(clientId: string, tools: BrowserAgentToolManifest[]): void { const session = context.rpc.getCurrentRpcSession() if (session) - syncClientAgentTools(context, session, tools) + syncClientAgentTools(context, session, clientId, tools) }, }), }) diff --git a/packages/devframe/src/node/rpc/index.ts b/packages/devframe/src/node/rpc/index.ts index 9a09dc2bb..a48a58175 100644 --- a/packages/devframe/src/node/rpc/index.ts +++ b/packages/devframe/src/node/rpc/index.ts @@ -23,6 +23,6 @@ declare module 'devframe/types' { 'devframe:agent:invoke-tool': (id: string, args: unknown) => Promise 'devframe:agent:list-resources': () => Promise 'devframe:agent:read-resource': (id: string) => Promise - 'devframe:agent:sync-client-tools': (tools: import('../../client/browser-agent').BrowserAgentToolManifest[]) => Promise + 'devframe:agent:sync-client-tools': (clientId: string, tools: import('../../client/browser-agent').BrowserAgentToolManifest[]) => Promise } } diff --git a/packages/devframe/src/types/rpc-augments.ts b/packages/devframe/src/types/rpc-augments.ts index 1a910c611..05a5d2cf2 100644 --- a/packages/devframe/src/types/rpc-augments.ts +++ b/packages/devframe/src/types/rpc-augments.ts @@ -53,8 +53,8 @@ export interface DevframeRpcClientFunctions { * To be extended */ export interface DevframeRpcServerFunctions { - /** Replace this connection's browser-agent tool manifest. @internal */ - 'devframe:agent:sync-client-tools': (tools: import('../client/browser-agent').BrowserAgentToolManifest[]) => Promise + /** Replace this connection's browser-agent tool manifest, tagged with the calling tab's stable client id. @internal */ + 'devframe:agent:sync-client-tools': (clientId: string, tools: import('../client/browser-agent').BrowserAgentToolManifest[]) => Promise /** * Authenticate a connection with a previously-issued bearer token; resolves * whether the connection is now trusted. The interactive handler is provided diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index 35adf361e..f2bda7638 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -255,7 +255,7 @@ export interface DevframeRpcOptions { snapshot?: DevframeSnapshotRpcEntry[]; } export interface DevframeRpcServerFunctions { - 'devframe:agent:sync-client-tools': (_: BrowserAgentToolManifest[]) => Promise; + 'devframe:agent:sync-client-tools': (_: string, _: BrowserAgentToolManifest[]) => Promise; 'anonymous:devframe:auth': (_: { authToken: string; ua: string; From 38fd4368b55f58b50c30bb85bc16921612d22f89 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 16 Sep 2026 02:07:42 +0000 Subject: [PATCH 4/4] perf: load the browser-agent bridge lazily, only when MCP is enabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client-tool bridge only matters when the node exposes an MCP endpoint, so gate it on `connectionMeta.mcp` (already forwarded in `__connection.json`) and pull it in through a dynamic `import()`. Its code — the bridge, the browser tool registry, and the client-id helper — now lands in its own chunk that a non-MCP connection never downloads or runs. Co-authored-by: agent --- .../devframe/src/client/rpc-auth-gate.test.ts | 25 +++++++++++++++++++ packages/devframe/src/client/rpc.ts | 15 +++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/devframe/src/client/rpc-auth-gate.test.ts b/packages/devframe/src/client/rpc-auth-gate.test.ts index f295fca55..c7e948b55 100644 --- a/packages/devframe/src/client/rpc-auth-gate.test.ts +++ b/packages/devframe/src/client/rpc-auth-gate.test.ts @@ -145,4 +145,29 @@ describe('getDevframeRpcClient: auth bootstrap gates outbound calls', () => { // `close?:` exists to keep working. expect(() => rpc.close?.()).not.toThrow() }) + + const INVOKE_CLIENT_TOOL = 'devframe:agent:invoke-client-tool' + + it('loads the browser-agent bridge only when the node advertises MCP', async () => { + const { getDevframeRpcClient } = await import('./rpc') + const rpc = await getDevframeRpcClient({ + connectionMeta: { ...connectionMeta, mcp: { path: '__mcp' } }, + otpParam: false, + simpleAuth: false, + }) + // The bridge lives in its own chunk and registers this handler once loaded. + await vi.waitFor(() => expect(rpc.client.definitions.has(INVOKE_CLIENT_TOOL)).toBe(true)) + }) + + it('leaves the browser-agent bridge unloaded without MCP', async () => { + const { getDevframeRpcClient } = await import('./rpc') + const rpc = await getDevframeRpcClient({ + connectionMeta, + otpParam: false, + simpleAuth: false, + }) + // Give any stray dynamic import time to resolve; it must not. + await new Promise(resolve => setTimeout(resolve, 20)) + expect(rpc.client.definitions.has(INVOKE_CLIENT_TOOL)).toBe(false) + }) }) diff --git a/packages/devframe/src/client/rpc.ts b/packages/devframe/src/client/rpc.ts index a80bd41ff..4f09a287d 100644 --- a/packages/devframe/src/client/rpc.ts +++ b/packages/devframe/src/client/rpc.ts @@ -11,7 +11,6 @@ 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' @@ -358,6 +357,7 @@ export async function getDevframeRpcClient( // No-op when the browser provides no WebMCP model context. const disposeWebMcp = options.webmcp === false ? undefined : registerWebMcpTools(clientRpc) let disposeBrowserAgentBridge: (() => void) | undefined + let closed = false async function fetchJsonFromBases(path: string): Promise { const candidates = [ @@ -449,6 +449,7 @@ export async function getDevframeRpcClient( /** Release authentication and transport resources even if another disposer fails. */ function closeRpcClient(): void { + closed = true try { disposeBrowserAgentBridge?.() disposeWebMcp?.() @@ -599,7 +600,17 @@ export async function getDevframeRpcClient( () => { bootstrapAuthSettled = true }, ) - disposeBrowserAgentBridge = setupBrowserAgentRpcBridge(rpc) + // Only when the node advertises an MCP endpoint (`connectionMeta.mcp`) is the + // browser-agent bridge useful, so load it from its own chunk on demand and + // keep it out of the main client bundle for every non-MCP connection. + if (connectionMeta.mcp) { + void import('./browser-agent-rpc') + .then(({ setupBrowserAgentRpcBridge }) => { + if (!closed) + disposeBrowserAgentBridge = setupBrowserAgentRpcBridge(rpc) + }) + .catch(() => {}) + } // Listen for auth updates from other tabs (e.g., the auth page, or another // tab that just completed a code exchange).