Skip to content

Commit 19a8cff

Browse files
authored
feat(agent): list connected browser tabs and route client tool calls per tab (#413)
1 parent 7889f1a commit 19a8cff

19 files changed

Lines changed: 661 additions & 74 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ Channel names are namespaced with the devframe id, like RPC ids. Function names
6464

6565
## The page script endpoint
6666

67-
The required `functions` option and optional `events` option declare every incoming name on the endpoint's protocol side; use `{}` for an empty direction. Functions require a `handler`. Events accept an optional `handler`, and `{}` registers an event for runtime subscriptions through `on()`. Handlers are contextually typed from the shared protocol and support Standard-Schema argument validation and `jsonSerializable` metadata. 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.
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 is forwarded to coding agents [over the node's MCP endpoint](/guide/agent-native#in-page-tools-over-mcp); the field implicitly enables strict JSON serialization (an explicit `jsonSerializable: false` conflicts). `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: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,27 @@ 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 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+
agent: { description: 'Return the node the user selected in the page. Call it before proposing an edit.' },
174+
handler: () => getSelectedNode(),
175+
},
176+
},
177+
})
178+
```
179+
180+
Every browser tab exposing such tools 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.
181+
182+
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 [DF0082](/errors/DF0082), 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.
183+
163184
## Writing descriptions agents act on
164185

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

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
title: 'DF0082: 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,7 +86,9 @@ 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 |
8990
| [DF0081](/errors/DF0081) | error | Invalid MCP Content Blocks |
91+
| [DF0082](/errors/DF0082) | error | Addressed Client Not Connected |
9092

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

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

Lines changed: 23 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, withLeadingSlash, withTrailingSlash } from 'devframe/utils/url'
@@ -82,11 +82,16 @@ interface IndexedInstance extends Omit<DevframeInstanceRecord, 'mcp'> {
8282
mcp: {
8383
url: string
8484
tools?: IndexedInstanceTools[]
85+
/** Browser tabs connected to the instance, when it forwards client tools. */
86+
clients?: ConnectedClient[]
8587
error?: string
8688
} | null
8789
hint?: string
8890
}
8991

92+
/** Wire name of the built-in tab-listing tool an instance exposes once a browser tab connects. */
93+
const LIST_CLIENTS_NAME = toAgentToolName(LIST_CLIENTS_TOOL)
94+
9095
// Gateway tool ids follow the `devframe:<area>:<fn>` convention; the wire
9196
// names are their sanitized forms (`devframe_connect_list-instances`, …).
9297
const INDEX_TOOL = toAgentToolName('devframe:connect:list-instances')
@@ -99,7 +104,7 @@ const GATEWAY_TOOLS: Tool[] = [
99104
{
100105
name: INDEX_TOOL,
101106
title: 'Discover running devframes',
102-
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.',
107+
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.',
103108
inputSchema: { type: 'object', properties: {} },
104109
annotations: { readOnlyHint: true, destructiveHint: false },
105110
},
@@ -112,7 +117,7 @@ const GATEWAY_TOOLS: Tool[] = [
112117
properties: {
113118
port: { type: 'number', description: 'The instance\'s port, from the list-instances tool.' },
114119
tool: { type: 'string', description: 'Tool name, from the instance\'s tool list.' },
115-
args: { type: 'object', description: 'Arguments object for the tool. Omit for zero-argument tools.' },
120+
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.' },
116121
},
117122
required: ['port', 'tool'],
118123
additionalProperties: false,
@@ -187,7 +192,7 @@ async function index(options: ConnectServerOptions): Promise<unknown> {
187192
}
188193
const url = `${record.origin}${mcp.path}`
189194
try {
190-
entry.mcp = { url, tools: await listInstanceTools(url, resolveAuthToken(options.authToken, record)) }
195+
entry.mcp = { url, ...await indexInstanceMcp(url, resolveAuthToken(options.authToken, record)) }
191196
}
192197
catch (error) {
193198
entry.mcp = { url, error: error instanceof Error ? error.message : String(error) }
@@ -228,8 +233,19 @@ export async function probePort(port: number, base = '/', timeoutMs?: number): P
228233
}
229234
}
230235

231-
async function listInstanceTools(url: string, token: string | undefined): Promise<IndexedInstanceTools[]> {
232-
return withInstanceClient(url, token, async client => (await client.listTools()).tools)
236+
async function indexInstanceMcp(
237+
url: string,
238+
token: string | undefined,
239+
): Promise<Pick<NonNullable<IndexedInstance['mcp']>, 'tools' | 'clients'>> {
240+
return withInstanceClient(url, token, async (client) => {
241+
const tools: IndexedInstanceTools[] = (await client.listTools()).tools
242+
if (!tools.some(tool => tool.name === LIST_CLIENTS_NAME))
243+
return { tools }
244+
const result = await client.callTool({ name: LIST_CLIENTS_NAME, arguments: {} })
245+
// `structuredContent` is untyped on the wire; the tool's outputSchema fixes this shape.
246+
const clients = (result.structuredContent as { clients?: ConnectedClient[] } | undefined)?.clients ?? []
247+
return { tools, clients }
248+
})
233249
}
234250

235251
async function call(
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import type { StartedServer } from 'devframe/internal'
2+
import type { DevframeDefinition, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from 'devframe/types'
3+
import { mkdtempSync, rmSync } from 'node:fs'
4+
import { tmpdir } from 'node:os'
5+
import { join } from 'node:path'
6+
import { fileURLToPath } from 'node:url'
7+
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
8+
import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/client/stdio'
9+
import { createDevServer } from 'devframe/adapters/dev'
10+
import { createRpcClient } from 'devframe/rpc/client'
11+
import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client'
12+
import { afterEach, describe, expect, it } from 'vitest'
13+
14+
const definition: DevframeDefinition = {
15+
id: 'client-tools-test',
16+
name: 'Client Tools Test',
17+
version: '0.0.0',
18+
packageName: '@devframe/client-tools-test',
19+
homepage: 'https://example.com',
20+
description: 'Fixture: a devframe whose only agent tools live in browser tabs.',
21+
setup() {},
22+
}
23+
24+
/** A browser tab: one RPC connection exposing `page:selection` and answering with its own id. */
25+
function connectTab(origin: string, client: { id: string, focused: boolean, visible?: boolean }) {
26+
const clientFunctions = {
27+
'devframe:agent:invoke-client-tool': async (id: string, args: Record<string, unknown>) => ({ tab: client.id, tool: id, args }),
28+
}
29+
const rpc = createRpcClient<DevframeRpcServerFunctions, DevframeRpcClientFunctions>(
30+
clientFunctions as any,
31+
{ channel: createWsRpcChannel({ url: `${origin.replace('http', 'ws')}/__ws` }) },
32+
)
33+
const sync = (focused = client.focused) => rpc.$call(
34+
'devframe:agent:sync-client-tools',
35+
client.id,
36+
[{ id: 'page:selection', description: 'Read the selection.', safety: 'read', inputSchema: { type: 'object', properties: {} } }],
37+
{ url: `http://app.local/${client.id}`, title: client.id, visible: client.visible ?? true, focused },
38+
)
39+
return { rpc, sync }
40+
}
41+
42+
describe('client tools over the MCP route', () => {
43+
let server: StartedServer | undefined
44+
afterEach(async () => {
45+
await server?.close()
46+
server = undefined
47+
})
48+
49+
it('lists connected tabs and routes calls per tab', async () => {
50+
server = await createDevServer(definition, { host: '127.0.0.1', port: 0, auth: false, mcp: true })
51+
const a = connectTab(server.origin, { id: 'tab-a', focused: false })
52+
const b = connectTab(server.origin, { id: 'tab-b', focused: true })
53+
await a.sync()
54+
await b.sync()
55+
56+
const mcp = new Client({ name: 'test', version: '0.0.0' }, { versionNegotiation: { mode: 'auto' } })
57+
await mcp.connect(new StreamableHTTPClientTransport(new URL(`${server.origin}/__mcp`), {
58+
requestInit: { headers: { origin: server.origin } },
59+
}))
60+
try {
61+
const { tools } = await mcp.listTools()
62+
const names = tools.map(t => t.name)
63+
expect(names).toContain('devframe_agent_list-clients')
64+
expect(names.filter(n => n === 'page_selection')).toHaveLength(1)
65+
expect((tools.find(t => t.name === 'page_selection')!.inputSchema as any).properties.client_id).toMatchObject({ type: 'string' })
66+
67+
const listed = await mcp.callTool({ name: 'devframe_agent_list-clients', arguments: {} })
68+
expect(listed.structuredContent).toEqual({
69+
clients: [
70+
expect.objectContaining({ id: 'tab-a', url: 'http://app.local/tab-a', focused: false, tools: ['page:selection'] }),
71+
expect.objectContaining({ id: 'tab-b', focused: true, tools: ['page:selection'] }),
72+
],
73+
})
74+
75+
const unaddressed = await mcp.callTool({ name: 'page_selection', arguments: {} })
76+
expect(JSON.parse((unaddressed.content as any)[0].text)).toMatchObject({ tab: 'tab-b', args: {} })
77+
78+
const addressed = await mcp.callTool({ name: 'page_selection', arguments: { client_id: 'tab-a' } })
79+
expect(JSON.parse((addressed.content as any)[0].text)).toMatchObject({ tab: 'tab-a', args: {} })
80+
81+
const missing = await mcp.callTool({ name: 'page_selection', arguments: { client_id: 'gone' } })
82+
expect(missing.isError).toBe(true)
83+
expect((missing.content as any)[0].text).toMatch(/DF0082.*tab-a, tab-b/s)
84+
85+
// Focus moves to a: unaddressed calls follow it.
86+
await a.sync(true)
87+
const refocused = await mcp.callTool({ name: 'page_selection', arguments: {} })
88+
expect(JSON.parse((refocused.content as any)[0].text)).toMatchObject({ tab: 'tab-a' })
89+
}
90+
finally {
91+
await mcp.close()
92+
}
93+
})
94+
95+
it('devframe connect nests the connected tabs under each instance', async () => {
96+
const instancesDir = mkdtempSync(join(tmpdir(), 'devframe-instances-'))
97+
const env = { ...process.env }
98+
process.env.DEVFRAME_INSTANCES_DIR = instancesDir
99+
process.env.DEVFRAME_DISABLE_INSTANCE_REGISTRY = '0'
100+
const gateway = new Client({ name: 'test', version: '0.0.0' })
101+
try {
102+
server = await createDevServer(definition, { host: '127.0.0.1', port: 0, auth: false, mcp: true })
103+
await connectTab(server.origin, { id: 'tab-a', focused: true }).sync()
104+
105+
await gateway.connect(new StdioClientTransport({
106+
command: process.execPath,
107+
args: [fileURLToPath(new URL('../../../../devframe/bin/devframe.mjs', import.meta.url)), 'connect'],
108+
env: { ...getDefaultEnvironment(), DEVFRAME_INSTANCES_DIR: instancesDir },
109+
}))
110+
const listed = await gateway.callTool({ name: 'devframe_connect_list-instances', arguments: {} })
111+
const { instances } = JSON.parse((listed.content as any)[0].text)
112+
expect(instances).toHaveLength(1)
113+
expect(instances[0].mcp.clients).toEqual([
114+
expect.objectContaining({ id: 'tab-a', url: 'http://app.local/tab-a', focused: true, tools: ['page:selection'] }),
115+
])
116+
}
117+
finally {
118+
await gateway.close().catch(() => {})
119+
process.env = env
120+
rmSync(instancesDir, { recursive: true, force: true })
121+
}
122+
})
123+
})

‎packages/devframe/src/client/browser-agent-rpc.test.ts‎

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { BrowserAgentToolManifest } from './browser-agent'
1+
import type { BrowserAgentClientInfo, BrowserAgentToolManifest } from './browser-agent'
22
import type { BrowserAgentInvocationDefinition } from './browser-agent-rpc'
33
import { afterEach, describe, expect, it, vi } from 'vitest'
44
import { registerBrowserAgentTool } from './browser-agent'
@@ -21,8 +21,9 @@ describe('browser agent RPC bridge', () => {
2121
method: 'devframe:agent:sync-client-tools',
2222
clientId: string,
2323
tools: BrowserAgentToolManifest[],
24+
info: BrowserAgentClientInfo,
2425
) {
25-
return callOptional(method, clientId, tools)
26+
return callOptional(method, clientId, tools, info)
2627
},
2728
events: { on: () => () => {} },
2829
}
@@ -44,11 +45,74 @@ describe('browser agent RPC bridge', () => {
4445
safety: 'action',
4546
inputSchema: { type: 'object' },
4647
}],
48+
{ url: expect.any(String), title: expect.any(String), visible: expect.any(Boolean), focused: expect.any(Boolean) },
4749
))
4850

4951
await expect(handlers.get('devframe:agent:invoke-client-tool')!(
5052
'todos:add',
5153
{ text: 'milk' },
5254
)).resolves.toEqual({ added: 'milk' })
5355
})
56+
57+
describe('in a browser document', () => {
58+
function stubDocument() {
59+
const doc = Object.assign(new EventTarget(), {
60+
title: 'Todos',
61+
location: { href: 'http://localhost:5173/todos' },
62+
visibilityState: 'visible' as DocumentVisibilityState,
63+
focused: false,
64+
hasFocus() {
65+
return this.focused
66+
},
67+
})
68+
const win = Object.assign(new EventTarget(), { document: doc })
69+
vi.stubGlobal('window', win)
70+
vi.stubGlobal('document', doc)
71+
return { win, doc }
72+
}
73+
afterEach(() => vi.unstubAllGlobals())
74+
75+
function bridge() {
76+
const callOptional = vi.fn().mockResolvedValue(undefined)
77+
disposals.push(registerBrowserAgentTool({
78+
id: 'todos:add',
79+
description: 'Add a todo.',
80+
safety: 'action',
81+
invoke: () => undefined,
82+
}))
83+
disposals.push(setupBrowserAgentRpcBridge({
84+
client: { register() {} },
85+
callOptional: (...args) => callOptional(...args),
86+
events: { on: () => () => {} },
87+
}))
88+
const lastInfo = () => callOptional.mock.lastCall?.[3]
89+
return { callOptional, lastInfo }
90+
}
91+
92+
it('reports the tab it runs in', async () => {
93+
stubDocument()
94+
const { lastInfo } = bridge()
95+
await vi.waitFor(() => expect(lastInfo()).toEqual({
96+
url: 'http://localhost:5173/todos',
97+
title: 'Todos',
98+
visible: true,
99+
focused: false,
100+
}))
101+
})
102+
103+
it('re-syncs when the tab gains focus or changes visibility', async () => {
104+
const { win, doc } = stubDocument()
105+
const { callOptional, lastInfo } = bridge()
106+
await vi.waitFor(() => expect(callOptional).toHaveBeenCalledTimes(1))
107+
108+
doc.focused = true
109+
win.dispatchEvent(new Event('focus'))
110+
await vi.waitFor(() => expect(lastInfo()).toMatchObject({ focused: true }))
111+
112+
doc.focused = false
113+
doc.visibilityState = 'hidden'
114+
doc.dispatchEvent(new Event('visibilitychange'))
115+
await vi.waitFor(() => expect(lastInfo()).toMatchObject({ focused: false, visible: false }))
116+
})
117+
})
54118
})

0 commit comments

Comments
 (0)