Skip to content

Commit fc6a624

Browse files
committed
feat(agent): list connected browser tabs and route client tool calls per tab
Forward agent-flagged in-page channel functions to the node MCP endpoint (ports the bridge from #376 onto 0.10), and make each connected browser tab visible and addressable: a built-in devframe:agent:list-clients tool, a reserved client_id argument on forwarded tools, and focus-aware default routing so an unaddressed call follows the tab the user looked at last. devframe connect nests the tab list under mcp.clients per instance. Closes #394
1 parent 4ecaff9 commit fc6a624

29 files changed

Lines changed: 970 additions & 20 deletions

‎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 forwarded to coding agents [over the node's MCP endpoint](/guide/agent-native#in-page-tools-over-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/1.guide/15.agent-native.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,28 @@ rpc.client.register({
160160
> [!WARNING]
161161
> WebMCP is an experimental proposal; `registerWebMcpTools` tracks the current draft (`AbortSignal`-based unregistration) and earlier handle-returning drafts, but the browser API may still change.
162162
163+
## In-page tools over MCP
164+
165+
An [in-page channel](/guide/in-page-channel) function carrying an `agent` field (and `jsonSerializable: true`) is forwarded to the node side over the page's RPC connection and served from the same MCP endpoint as node-side tools, under the id `<channel name>:<function name>`. The page keeps executing the handler; the node relays the call and the result.
166+
167+
```ts
168+
const channel = createPageScriptChannel({
169+
name: 'my-plugin',
170+
functions: {
171+
'selected-node': {
172+
type: 'query',
173+
jsonSerializable: true,
174+
agent: { description: 'Return the node the user selected in the page. Call it before proposing an edit.' },
175+
handler: () => getSelectedNode(),
176+
},
177+
},
178+
})
179+
```
180+
181+
Every connected browser tab syncs its own manifest, so the same app open in several tabs exposes each tool once, plus a built-in `devframe:agent:list-clients` tool (wire name `devframe_agent_list-clients`) listing the connected tabs: a per-tab `id` (stable across reloads), `url`, `title`, `visible`/`focused`, `connectedAt`, and the tool ids that tab exposes. `devframe connect` nests the same list under `mcp.clients` for each instance.
182+
183+
A forwarded tool accepts a reserved `client_id` argument to run on one tab. Without it, the call goes to the most recently focused visible tab, falling back to the tab that synced last; an unknown `client_id` fails with [DF0081](/errors/DF0081), which lists the live ids. Tabs re-sync on focus and visibility changes, so an unaddressed call follows the tab the user looked at last.
184+
163185
## Writing descriptions agents act on
164186

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

‎docs/content/6.errors/DF0080.md‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
title: 'DF0080: In-Page Channel Agent Function Not JSON-Serializable'
3+
description: 'In-page channel function "{name}" has `agent` set but `jsonSerializable` is not `true`; MCP requires JSON-serializable data.'
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+
An in-page channel function declares `agent` metadata, so it is forwarded to coding agents over the node's MCP endpoint, but it did not opt into strict JSON serialization. MCP carries arguments and results as JSON, so a function whose payload may hold non-JSON values (class instances, `Map`s, functions) cannot be exposed safely.
13+
14+
## Example
15+
16+
```ts
17+
import { createPageScriptChannel } from 'devframe/in-page-channel'
18+
19+
createPageScriptChannel({
20+
name: 'my-plugin',
21+
functions: {
22+
'selected-node': {
23+
agent: { description: 'Return the selected node.' }, // ✗ no `jsonSerializable: true`
24+
handler: () => getSelectedNode(),
25+
},
26+
},
27+
})
28+
```
29+
30+
## Fix
31+
32+
Set `jsonSerializable: true` when the payload is JSON-safe, or drop the `agent` field to keep the function channel-only.
33+
34+
## Source
35+
36+
- [`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 an `agent`-flagged definition lacks `jsonSerializable: true`.

‎docs/content/6.errors/DF0081.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
title: 'DF0081: Addressed Client Not Connected'
3+
description: 'Tool "{tool}" was addressed to client "{clientId}", but no connected browser tab has that id and the tool.'
4+
---
5+
6+
## Message
7+
8+
> Tool "`{tool}`" was addressed to client "`{clientId}`", but no connected browser tab has that id and the tool. Connected clients: `{live}`.
9+
10+
## Cause
11+
12+
A forwarded in-page tool was called with a `client_id` that matches none of the browser tabs currently connected to this devframe (or the tab with that id does not expose the tool). Tab ids survive reloads but not closing the tab, so an id an agent listed earlier may have gone away since.
13+
14+
## Fix
15+
16+
Call `devframe:agent:list-clients` (wire name `devframe_agent_list-clients`; `mcp.clients` in `devframe connect`'s `list-instances`) to get the live ids and retry with one of them, or omit `client_id` to target the most recently focused tab.
17+
18+
## Source
19+
20+
- [`packages/devframe/src/node/client-agent.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/client-agent.ts): the forwarded tool's handler throws this when no connected session matches the requested `client_id`.

‎docs/content/6.errors/index.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ Emitted by `devframe`: the framework-neutral host, RPC, streaming, assets, servi
8686
| [DF0077](/errors/DF0077) | error | In-Page Channel Function Not Registered |
8787
| [DF0078](/errors/DF0078) | warn | Agent Surface Without @devframes/agentic |
8888
| [DF0079](/errors/DF0079) | error | MCP Enabled Without @devframes/agentic |
89+
| [DF0080](/errors/DF0080) | error | In-Page Channel Agent Function Not JSON-Serializable |
90+
| [DF0081](/errors/DF0081) | error | Addressed Client Not Connected |
8991

9092
## Hub: context & lifecycle (DF80xx)
9193

‎packages/agentic/src/connect/index.ts‎

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import type { Tool } from '@modelcontextprotocol/server'
2-
import type { DevframeInstanceRecord } from 'devframe/internal'
2+
import type { ConnectedClient, DevframeInstanceRecord } from 'devframe/internal'
33
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
44
import { Server } from '@modelcontextprotocol/server'
55
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'
6-
import { diagnostics, listLiveDevframeInstances, probeDevframeOrigin } from 'devframe/internal'
6+
import { diagnostics, LIST_CLIENTS_TOOL, listLiveDevframeInstances, probeDevframeOrigin } from 'devframe/internal'
77
import { toAgentToolName } from 'devframe/utils/agent-tool-name'
88
import { Diagnostic } from 'devframe/utils/nostics'
99
import { joinURL } from 'devframe/utils/url'
@@ -71,11 +71,16 @@ interface IndexedInstance extends Omit<DevframeInstanceRecord, 'mcp'> {
7171
mcp: {
7272
url: string
7373
tools?: { name: string, description?: string }[]
74+
/** Browser tabs connected to the instance, when it forwards client tools. */
75+
clients?: ConnectedClient[]
7476
error?: string
7577
} | null
7678
hint?: string
7779
}
7880

81+
/** Wire name of the built-in tab-listing tool an instance exposes once a browser tab connects. */
82+
const LIST_CLIENTS_NAME = toAgentToolName(LIST_CLIENTS_TOOL)
83+
7984
// Gateway tool ids follow the `devframe:<area>:<fn>` convention; the wire
8085
// names are their sanitized forms (`devframe_connect_list-instances`, …).
8186
const INDEX_TOOL = toAgentToolName('devframe:connect:list-instances')
@@ -88,7 +93,7 @@ const GATEWAY_TOOLS: Tool[] = [
8893
{
8994
name: INDEX_TOOL,
9095
title: 'Discover running devframes',
91-
description: 'Discover every running devframe dev server on this machine and list each one\'s MCP tools. Call this FIRST, before assuming which devtools are available; the result names the instance (id, project root, origin) and the port to pass to the call tool. Safe to call freely.',
96+
description: 'Discover every running devframe dev server on this machine and list each one\'s MCP tools, plus the browser tabs connected to it (`mcp.clients`). Call this FIRST, before assuming which devtools are available; the result names the instance (id, project root, origin) and the port to pass to the call tool. Safe to call freely.',
9297
inputSchema: { type: 'object', properties: {} },
9398
annotations: { readOnlyHint: true, destructiveHint: false },
9499
},
@@ -101,7 +106,7 @@ const GATEWAY_TOOLS: Tool[] = [
101106
properties: {
102107
port: { type: 'number', description: 'The instance\'s port, from the list-instances tool.' },
103108
tool: { type: 'string', description: 'Tool name, from the instance\'s tool list.' },
104-
args: { type: 'object', description: 'Arguments object for the tool. Omit for zero-argument tools.' },
109+
args: { type: 'object', description: 'Arguments object for the tool. Omit for zero-argument tools. Tools forwarded from a browser tab accept `client_id` (from `mcp.clients` in list-instances) to target one tab; omitted, the most recently focused tab runs it.' },
105110
},
106111
required: ['port', 'tool'],
107112
additionalProperties: false,
@@ -176,7 +181,7 @@ async function index(options: ConnectServerOptions): Promise<unknown> {
176181
}
177182
const url = `${record.origin}${mcp.path}`
178183
try {
179-
entry.mcp = { url, tools: await listInstanceTools(url, resolveAuthToken(options.authToken, record)) }
184+
entry.mcp = { url, ...await indexInstanceMcp(url, resolveAuthToken(options.authToken, record)) }
180185
}
181186
catch (error) {
182187
entry.mcp = { url, error: error instanceof Error ? error.message : String(error) }
@@ -214,13 +219,22 @@ async function probePort(port: number, timeoutMs?: number): Promise<DevframeInst
214219
}
215220
}
216221

217-
async function listInstanceTools(url: string, token: string | undefined): Promise<{ name: string, description?: string }[]> {
222+
async function indexInstanceMcp(
223+
url: string,
224+
token: string | undefined,
225+
): Promise<Pick<NonNullable<IndexedInstance['mcp']>, 'tools' | 'clients'>> {
218226
return withInstanceClient(url, token, async (client) => {
219227
const listed = await client.listTools()
220-
return listed.tools.map((tool: { name: string, description?: string }) => ({
228+
const tools = listed.tools.map((tool: { name: string, description?: string }) => ({
221229
name: tool.name,
222230
description: tool.description,
223231
}))
232+
if (!tools.some(tool => tool.name === LIST_CLIENTS_NAME))
233+
return { tools }
234+
const result = await client.callTool({ name: LIST_CLIENTS_NAME, arguments: {} })
235+
// `structuredContent` is untyped on the wire; the tool's outputSchema fixes this shape.
236+
const clients = (result.structuredContent as { clients?: ConnectedClient[] } | undefined)?.clients ?? []
237+
return { tools, clients }
224238
})
225239
}
226240

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import type { StartedServer } from 'devframe/internal'
2+
import type { DevframeDefinition, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from 'devframe/types'
3+
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
4+
import { createDevServer } from 'devframe/adapters/dev'
5+
import { createRpcClient } from 'devframe/rpc/client'
6+
import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client'
7+
import { afterEach, describe, expect, it } from 'vitest'
8+
9+
const definition: DevframeDefinition = {
10+
id: 'client-tools-test',
11+
name: 'Client Tools Test',
12+
version: '0.0.0',
13+
packageName: '@devframe/client-tools-test',
14+
homepage: 'https://example.com',
15+
description: 'Fixture: a devframe whose only agent tools live in browser tabs.',
16+
setup() {},
17+
}
18+
19+
/** A browser tab: one RPC connection exposing `page:selection` and answering with its own id. */
20+
function connectTab(origin: string, client: { id: string, focused: boolean, visible?: boolean }) {
21+
const clientFunctions = {
22+
'devframe:agent:invoke-client-tool': async (id: string, args: Record<string, unknown>) => ({ tab: client.id, tool: id, args }),
23+
}
24+
const rpc = createRpcClient<DevframeRpcServerFunctions, DevframeRpcClientFunctions>(
25+
clientFunctions as any,
26+
{ channel: createWsRpcChannel({ url: `${origin.replace('http', 'ws')}/__ws` }) },
27+
)
28+
const sync = (focused = client.focused) => rpc.$call('devframe:agent:sync-client-tools', {
29+
client: { id: client.id, url: `http://app.local/${client.id}`, title: client.id, visible: client.visible ?? true, focused },
30+
tools: [{ id: 'page:selection', description: 'Read the selection.', safety: 'read', inputSchema: { type: 'object', properties: {} } }],
31+
})
32+
return { rpc, sync }
33+
}
34+
35+
describe('client tools over the MCP route', () => {
36+
let server: StartedServer | undefined
37+
afterEach(async () => {
38+
await server?.close()
39+
server = undefined
40+
})
41+
42+
it('lists connected tabs and routes calls per tab', async () => {
43+
server = await createDevServer(definition, { host: '127.0.0.1', port: 0, auth: false, mcp: true })
44+
const a = connectTab(server.origin, { id: 'tab-a', focused: false })
45+
const b = connectTab(server.origin, { id: 'tab-b', focused: true })
46+
await a.sync()
47+
await b.sync()
48+
49+
const mcp = new Client({ name: 'test', version: '0.0.0' }, { versionNegotiation: { mode: 'auto' } })
50+
await mcp.connect(new StreamableHTTPClientTransport(new URL(`${server.origin}/__mcp`), {
51+
requestInit: { headers: { origin: server.origin } },
52+
}))
53+
try {
54+
const { tools } = await mcp.listTools()
55+
const names = tools.map(t => t.name)
56+
expect(names).toContain('devframe_agent_list-clients')
57+
expect(names.filter(n => n === 'page_selection')).toHaveLength(1)
58+
expect((tools.find(t => t.name === 'page_selection')!.inputSchema as any).properties.client_id).toMatchObject({ type: 'string' })
59+
60+
const listed = await mcp.callTool({ name: 'devframe_agent_list-clients', arguments: {} })
61+
expect(listed.structuredContent).toEqual({
62+
clients: [
63+
expect.objectContaining({ id: 'tab-a', url: 'http://app.local/tab-a', focused: false, tools: ['page:selection'] }),
64+
expect.objectContaining({ id: 'tab-b', focused: true, tools: ['page:selection'] }),
65+
],
66+
})
67+
68+
const unaddressed = await mcp.callTool({ name: 'page_selection', arguments: {} })
69+
expect(JSON.parse((unaddressed.content as any)[0].text)).toMatchObject({ tab: 'tab-b', args: {} })
70+
71+
const addressed = await mcp.callTool({ name: 'page_selection', arguments: { client_id: 'tab-a' } })
72+
expect(JSON.parse((addressed.content as any)[0].text)).toMatchObject({ tab: 'tab-a', args: {} })
73+
74+
const missing = await mcp.callTool({ name: 'page_selection', arguments: { client_id: 'gone' } })
75+
expect(missing.isError).toBe(true)
76+
expect((missing.content as any)[0].text).toMatch(/DF0081.*tab-a, tab-b/s)
77+
78+
// Focus moves to a: unaddressed calls follow it.
79+
await a.sync(true)
80+
const refocused = await mcp.callTool({ name: 'page_selection', arguments: {} })
81+
expect(JSON.parse((refocused.content as any)[0].text)).toMatchObject({ tab: 'tab-a' })
82+
}
83+
finally {
84+
await mcp.close()
85+
}
86+
})
87+
})
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type { BrowserAgentSync } 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 with a tab snapshot 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(method: 'devframe:agent:sync-client-tools', sync: BrowserAgentSync) {
21+
return callOptional(method, sync)
22+
},
23+
events: { on: () => () => {} },
24+
}
25+
26+
disposals.push(registerBrowserAgentTool({
27+
id: 'todos:add',
28+
description: 'Add a todo.',
29+
safety: 'action',
30+
inputSchema: { type: 'object' },
31+
invoke: args => ({ added: args.text }),
32+
}))
33+
disposals.push(setupBrowserAgentRpcBridge(rpc))
34+
await vi.waitFor(() => expect(callOptional).toHaveBeenCalledWith(
35+
'devframe:agent:sync-client-tools',
36+
{
37+
client: {
38+
id: expect.any(String),
39+
url: expect.any(String),
40+
title: expect.any(String),
41+
visible: expect.any(Boolean),
42+
focused: expect.any(Boolean),
43+
},
44+
tools: [{
45+
id: 'todos:add',
46+
description: 'Add a todo.',
47+
safety: 'action',
48+
inputSchema: { type: 'object' },
49+
}],
50+
},
51+
))
52+
53+
await expect(handlers.get('devframe:agent:invoke-client-tool')!(
54+
'todos:add',
55+
{ text: 'milk' },
56+
)).resolves.toEqual({ added: 'milk' })
57+
})
58+
})

0 commit comments

Comments
 (0)