Skip to content

Commit ff554f0

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 5130ccc commit ff554f0

19 files changed

Lines changed: 515 additions & 73 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 [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.
183+
163184
## Writing descriptions agents act on
164185

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

‎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: 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: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
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(
29+
'devframe:agent:sync-client-tools',
30+
client.id,
31+
[{ id: 'page:selection', description: 'Read the selection.', safety: 'read', inputSchema: { type: 'object', properties: {} } }],
32+
{ url: `http://app.local/${client.id}`, title: client.id, visible: client.visible ?? true, focused },
33+
)
34+
return { rpc, sync }
35+
}
36+
37+
describe('client tools over the MCP route', () => {
38+
let server: StartedServer | undefined
39+
afterEach(async () => {
40+
await server?.close()
41+
server = undefined
42+
})
43+
44+
it('lists connected tabs and routes calls per tab', async () => {
45+
server = await createDevServer(definition, { host: '127.0.0.1', port: 0, auth: false, mcp: true })
46+
const a = connectTab(server.origin, { id: 'tab-a', focused: false })
47+
const b = connectTab(server.origin, { id: 'tab-b', focused: true })
48+
await a.sync()
49+
await b.sync()
50+
51+
const mcp = new Client({ name: 'test', version: '0.0.0' }, { versionNegotiation: { mode: 'auto' } })
52+
await mcp.connect(new StreamableHTTPClientTransport(new URL(`${server.origin}/__mcp`), {
53+
requestInit: { headers: { origin: server.origin } },
54+
}))
55+
try {
56+
const { tools } = await mcp.listTools()
57+
const names = tools.map(t => t.name)
58+
expect(names).toContain('devframe_agent_list-clients')
59+
expect(names.filter(n => n === 'page_selection')).toHaveLength(1)
60+
expect((tools.find(t => t.name === 'page_selection')!.inputSchema as any).properties.client_id).toMatchObject({ type: 'string' })
61+
62+
const listed = await mcp.callTool({ name: 'devframe_agent_list-clients', arguments: {} })
63+
expect(listed.structuredContent).toEqual({
64+
clients: [
65+
expect.objectContaining({ id: 'tab-a', url: 'http://app.local/tab-a', focused: false, tools: ['page:selection'] }),
66+
expect.objectContaining({ id: 'tab-b', focused: true, tools: ['page:selection'] }),
67+
],
68+
})
69+
70+
const unaddressed = await mcp.callTool({ name: 'page_selection', arguments: {} })
71+
expect(JSON.parse((unaddressed.content as any)[0].text)).toMatchObject({ tab: 'tab-b', args: {} })
72+
73+
const addressed = await mcp.callTool({ name: 'page_selection', arguments: { client_id: 'tab-a' } })
74+
expect(JSON.parse((addressed.content as any)[0].text)).toMatchObject({ tab: 'tab-a', args: {} })
75+
76+
const missing = await mcp.callTool({ name: 'page_selection', arguments: { client_id: 'gone' } })
77+
expect(missing.isError).toBe(true)
78+
expect((missing.content as any)[0].text).toMatch(/DF0081.*tab-a, tab-b/s)
79+
80+
// Focus moves to a: unaddressed calls follow it.
81+
await a.sync(true)
82+
const refocused = await mcp.callTool({ name: 'page_selection', arguments: {} })
83+
expect(JSON.parse((refocused.content as any)[0].text)).toMatchObject({ tab: 'tab-a' })
84+
}
85+
finally {
86+
await mcp.close()
87+
}
88+
})
89+
})

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

Lines changed: 4 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,6 +45,7 @@ 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')!(

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

Lines changed: 27 additions & 3 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 { DevframeConnectionStatus } from './connection'
33
import {
44
listBrowserAgentTools,
@@ -19,6 +19,7 @@ interface BrowserAgentRpcClient {
1919
method: 'devframe:agent:sync-client-tools',
2020
clientId: string,
2121
tools: BrowserAgentToolManifest[],
22+
info: BrowserAgentClientInfo,
2223
) => Promise<unknown>
2324
events: {
2425
on: (
@@ -28,7 +29,23 @@ interface BrowserAgentRpcClient {
2829
}
2930
}
3031

31-
/** Mirror this document's browser-agent registry over its existing RPC connection. */
32+
/** Snapshot of this document as seen by a coding agent picking a tab. */
33+
function describeBrowserAgentClient(): BrowserAgentClientInfo {
34+
const doc = typeof document === 'undefined' ? undefined : document
35+
return {
36+
url: doc?.location?.href ?? '',
37+
title: doc?.title ?? '',
38+
visible: doc ? doc.visibilityState === 'visible' : true,
39+
focused: doc?.hasFocus() ?? true,
40+
}
41+
}
42+
43+
/**
44+
* Mirror this document's browser-agent registry over its existing RPC
45+
* connection. Re-syncs on tool changes, reconnects, and focus/visibility
46+
* changes so the node can route unaddressed calls to the tab the user
47+
* looked at last.
48+
*/
3249
export function setupBrowserAgentRpcBridge(rpc: BrowserAgentRpcClient): () => void {
3350
rpc.client.register({
3451
name: 'devframe:agent:invoke-client-tool',
@@ -59,7 +76,7 @@ export function setupBrowserAgentRpcBridge(rpc: BrowserAgentRpcClient): () => vo
5976
if (manifests.length === 0 && lastSyncedCount === 0)
6077
return
6178
lastSyncedCount = manifests.length
62-
await rpc.callOptional('devframe:agent:sync-client-tools', resolveClientId(), manifests).catch(() => {})
79+
await rpc.callOptional('devframe:agent:sync-client-tools', resolveClientId(), manifests, describeBrowserAgentClient()).catch(() => {})
6380
})
6481
}
6582

@@ -68,11 +85,18 @@ export function setupBrowserAgentRpcBridge(rpc: BrowserAgentRpcClient): () => vo
6885
if (status === 'connected')
6986
sync()
7087
})
88+
const win = typeof window === 'undefined' ? undefined : window
89+
win?.addEventListener('focus', sync)
90+
win?.addEventListener('blur', sync)
91+
win?.document.addEventListener('visibilitychange', sync)
7192
sync()
7293

7394
return () => {
7495
disposed = true
7596
stopTools()
7697
stopConnection()
98+
win?.removeEventListener('focus', sync)
99+
win?.removeEventListener('blur', sync)
100+
win?.document.removeEventListener('visibilitychange', sync)
77101
}
78102
}

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ export interface BrowserAgentToolManifest {
77
inputSchema?: unknown
88
}
99

10+
/** What a connected document reports about itself alongside its tool manifest. */
11+
export interface BrowserAgentClientInfo {
12+
url: string
13+
title: string
14+
visible: boolean
15+
focused: boolean
16+
}
17+
1018
export interface BrowserAgentTool extends BrowserAgentToolManifest {
1119
invoke: (args: Record<string, unknown>) => unknown | Promise<unknown>
1220
}

‎packages/devframe/src/internal/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ export { formatMcpError, stringifyForMcp } from '../agent/stringify'
4747
export { argsToJsonSchema, returnToJsonSchema } from '../agent/to-json-schema'
4848
export { importAgenticMcp } from '../node/agentic'
4949
export type { AgenticMcpModule, MountedMcpHttp, MountMcpHttpOptions } from '../node/agentic'
50+
export { LIST_CLIENTS_TOOL } from '../node/client-agent'
51+
export type { ConnectedClient } from '../node/client-agent'
5052
export { diagnostics } from '../node/diagnostics'
5153
export { DevframeAgentHost } from '../node/host-agent'
5254
export * from '../node/host-h3'

0 commit comments

Comments
 (0)