Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/content/1.guide/12.in-page-channel.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ Channel names are namespaced with the devframe id, like RPC ids. Function names

## The page script endpoint

The required `functions` option and optional `events` option declare every incoming name on the endpoint's protocol side; use `{}` for an empty direction. Functions require a `handler`. Events accept an optional `handler`, and `{}` registers an event for runtime subscriptions through `on()`. Handlers are contextually typed from the shared protocol and support Standard-Schema argument validation and `jsonSerializable` metadata. 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.
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.

`call()` accepts names from `functions`, including actions returning `void` or `Promise<void>`: callers can await completion and catch errors or timeouts. `emit()` and `on()` use the names declared in `events`. Function and event names have separate namespaces.

Expand Down
21 changes: 21 additions & 0 deletions docs/content/1.guide/15.agent-native.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,27 @@ rpc.client.register({
> [!WARNING]
> 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.

## In-page tools over MCP

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.

```ts
const channel = createPageScriptChannel({
name: 'my-plugin',
functions: {
'selected-node': {
type: 'query',
agent: { description: 'Return the node the user selected in the page. Call it before proposing an edit.' },
handler: () => getSelectedNode(),
},
},
})
```

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.

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.

## Writing descriptions agents act on

Describe *when* to use a tool, not just its return:
Expand Down
20 changes: 20 additions & 0 deletions docs/content/6.errors/DF0081.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
title: 'DF0081: Addressed Client Not Connected'
description: 'Tool "{tool}" was addressed to client "{clientId}", but no connected browser tab has that id and the tool.'
---

## Message

> Tool "`{tool}`" was addressed to client "`{clientId}`", but no connected browser tab has that id and the tool. Connected clients: `{live}`.

## Cause

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.

## Fix

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.

## Source

- [`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`.
2 changes: 2 additions & 0 deletions docs/content/6.errors/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ Emitted by `devframe`: the framework-neutral host, RPC, streaming, assets, servi
| [DF0077](/errors/DF0077) | error | In-Page Channel Function Not Registered |
| [DF0078](/errors/DF0078) | warn | Agent Surface Without @devframes/agentic |
| [DF0079](/errors/DF0079) | error | MCP Enabled Without @devframes/agentic |
| [DF0080](/errors/DF0080) | error | In-Page Channel Agent Function Not JSON-Serializable |
| [DF0081](/errors/DF0081) | error | Addressed Client Not Connected |

## Hub: context & lifecycle (DF80xx)

Expand Down
30 changes: 23 additions & 7 deletions packages/agentic/src/connect/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { Tool } from '@modelcontextprotocol/server'
import type { DevframeInstanceRecord } from 'devframe/internal'
import type { ConnectedClient, DevframeInstanceRecord } from 'devframe/internal'
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
import { Server } from '@modelcontextprotocol/server'
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'
import { diagnostics, listLiveDevframeInstances, probeDevframeOrigin } from 'devframe/internal'
import { diagnostics, LIST_CLIENTS_TOOL, listLiveDevframeInstances, probeDevframeOrigin } from 'devframe/internal'
import { toAgentToolName } from 'devframe/utils/agent-tool-name'
import { Diagnostic } from 'devframe/utils/nostics'
import { joinURL, withLeadingSlash, withTrailingSlash } from 'devframe/utils/url'
Expand Down Expand Up @@ -82,11 +82,16 @@ interface IndexedInstance extends Omit<DevframeInstanceRecord, 'mcp'> {
mcp: {
url: string
tools?: IndexedInstanceTools[]
/** Browser tabs connected to the instance, when it forwards client tools. */
clients?: ConnectedClient[]
error?: string
} | null
hint?: string
}

/** Wire name of the built-in tab-listing tool an instance exposes once a browser tab connects. */
const LIST_CLIENTS_NAME = toAgentToolName(LIST_CLIENTS_TOOL)

// Gateway tool ids follow the `devframe:<area>:<fn>` convention; the wire
// names are their sanitized forms (`devframe_connect_list-instances`, …).
const INDEX_TOOL = toAgentToolName('devframe:connect:list-instances')
Expand All @@ -99,7 +104,7 @@ const GATEWAY_TOOLS: Tool[] = [
{
name: INDEX_TOOL,
title: 'Discover running devframes',
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.',
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.',
inputSchema: { type: 'object', properties: {} },
annotations: { readOnlyHint: true, destructiveHint: false },
},
Expand All @@ -112,7 +117,7 @@ const GATEWAY_TOOLS: Tool[] = [
properties: {
port: { type: 'number', description: 'The instance\'s port, from the list-instances tool.' },
tool: { type: 'string', description: 'Tool name, from the instance\'s tool list.' },
args: { type: 'object', description: 'Arguments object for the tool. Omit for zero-argument tools.' },
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.' },
},
required: ['port', 'tool'],
additionalProperties: false,
Expand Down Expand Up @@ -187,7 +192,7 @@ async function index(options: ConnectServerOptions): Promise<unknown> {
}
const url = `${record.origin}${mcp.path}`
try {
entry.mcp = { url, tools: await listInstanceTools(url, resolveAuthToken(options.authToken, record)) }
entry.mcp = { url, ...await indexInstanceMcp(url, resolveAuthToken(options.authToken, record)) }
}
catch (error) {
entry.mcp = { url, error: error instanceof Error ? error.message : String(error) }
Expand Down Expand Up @@ -228,8 +233,19 @@ export async function probePort(port: number, base = '/', timeoutMs?: number): P
}
}

async function listInstanceTools(url: string, token: string | undefined): Promise<IndexedInstanceTools[]> {
return withInstanceClient(url, token, async client => (await client.listTools()).tools)
async function indexInstanceMcp(
url: string,
token: string | undefined,
): Promise<Pick<NonNullable<IndexedInstance['mcp']>, 'tools' | 'clients'>> {
return withInstanceClient(url, token, async (client) => {
const tools: IndexedInstanceTools[] = (await client.listTools()).tools
if (!tools.some(tool => tool.name === LIST_CLIENTS_NAME))
return { tools }
const result = await client.callTool({ name: LIST_CLIENTS_NAME, arguments: {} })
// `structuredContent` is untyped on the wire; the tool's outputSchema fixes this shape.
const clients = (result.structuredContent as { clients?: ConnectedClient[] } | undefined)?.clients ?? []
return { tools, clients }
})
}

async function call(
Expand Down
89 changes: 89 additions & 0 deletions packages/agentic/src/mcp/__tests__/mcp-client-tools.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { StartedServer } from 'devframe/internal'
import type { DevframeDefinition, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from 'devframe/types'
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
import { createDevServer } from 'devframe/adapters/dev'
import { createRpcClient } from 'devframe/rpc/client'
import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client'
import { afterEach, describe, expect, it } from 'vitest'

const definition: DevframeDefinition = {
id: 'client-tools-test',
name: 'Client Tools Test',
version: '0.0.0',
packageName: '@devframe/client-tools-test',
homepage: 'https://example.com',
description: 'Fixture: a devframe whose only agent tools live in browser tabs.',
setup() {},
}

/** A browser tab: one RPC connection exposing `page:selection` and answering with its own id. */
function connectTab(origin: string, client: { id: string, focused: boolean, visible?: boolean }) {
const clientFunctions = {
'devframe:agent:invoke-client-tool': async (id: string, args: Record<string, unknown>) => ({ tab: client.id, tool: id, args }),
}
const rpc = createRpcClient<DevframeRpcServerFunctions, DevframeRpcClientFunctions>(
clientFunctions as any,
{ channel: createWsRpcChannel({ url: `${origin.replace('http', 'ws')}/__ws` }) },
)
const sync = (focused = client.focused) => rpc.$call(
'devframe:agent:sync-client-tools',
client.id,
[{ id: 'page:selection', description: 'Read the selection.', safety: 'read', inputSchema: { type: 'object', properties: {} } }],
{ url: `http://app.local/${client.id}`, title: client.id, visible: client.visible ?? true, focused },
)
return { rpc, sync }
}

describe('client tools over the MCP route', () => {
let server: StartedServer | undefined
afterEach(async () => {
await server?.close()
server = undefined
})

it('lists connected tabs and routes calls per tab', async () => {
server = await createDevServer(definition, { host: '127.0.0.1', port: 0, auth: false, mcp: true })
const a = connectTab(server.origin, { id: 'tab-a', focused: false })
const b = connectTab(server.origin, { id: 'tab-b', focused: true })
await a.sync()
await b.sync()

const mcp = new Client({ name: 'test', version: '0.0.0' }, { versionNegotiation: { mode: 'auto' } })
await mcp.connect(new StreamableHTTPClientTransport(new URL(`${server.origin}/__mcp`), {
requestInit: { headers: { origin: server.origin } },
}))
try {
const { tools } = await mcp.listTools()
const names = tools.map(t => t.name)
expect(names).toContain('devframe_agent_list-clients')
expect(names.filter(n => n === 'page_selection')).toHaveLength(1)
expect((tools.find(t => t.name === 'page_selection')!.inputSchema as any).properties.client_id).toMatchObject({ type: 'string' })

const listed = await mcp.callTool({ name: 'devframe_agent_list-clients', arguments: {} })
expect(listed.structuredContent).toEqual({
clients: [
expect.objectContaining({ id: 'tab-a', url: 'http://app.local/tab-a', focused: false, tools: ['page:selection'] }),
expect.objectContaining({ id: 'tab-b', focused: true, tools: ['page:selection'] }),
],
})

const unaddressed = await mcp.callTool({ name: 'page_selection', arguments: {} })
expect(JSON.parse((unaddressed.content as any)[0].text)).toMatchObject({ tab: 'tab-b', args: {} })

const addressed = await mcp.callTool({ name: 'page_selection', arguments: { client_id: 'tab-a' } })
expect(JSON.parse((addressed.content as any)[0].text)).toMatchObject({ tab: 'tab-a', args: {} })

const missing = await mcp.callTool({ name: 'page_selection', arguments: { client_id: 'gone' } })
expect(missing.isError).toBe(true)
expect((missing.content as any)[0].text).toMatch(/DF0081.*tab-a, tab-b/s)

// Focus moves to a: unaddressed calls follow it.
await a.sync(true)
const refocused = await mcp.callTool({ name: 'page_selection', arguments: {} })
expect(JSON.parse((refocused.content as any)[0].text)).toMatchObject({ tab: 'tab-a' })
}
finally {
await mcp.close()
}
})
})
6 changes: 4 additions & 2 deletions packages/devframe/src/client/browser-agent-rpc.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { BrowserAgentToolManifest } from './browser-agent'
import type { BrowserAgentClientInfo, BrowserAgentToolManifest } from './browser-agent'
import type { BrowserAgentInvocationDefinition } from './browser-agent-rpc'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { registerBrowserAgentTool } from './browser-agent'
Expand All @@ -21,8 +21,9 @@ describe('browser agent RPC bridge', () => {
method: 'devframe:agent:sync-client-tools',
clientId: string,
tools: BrowserAgentToolManifest[],
info: BrowserAgentClientInfo,
) {
return callOptional(method, clientId, tools)
return callOptional(method, clientId, tools, info)
},
events: { on: () => () => {} },
}
Expand All @@ -44,6 +45,7 @@ describe('browser agent RPC bridge', () => {
safety: 'action',
inputSchema: { type: 'object' },
}],
{ url: expect.any(String), title: expect.any(String), visible: expect.any(Boolean), focused: expect.any(Boolean) },
))

await expect(handlers.get('devframe:agent:invoke-client-tool')!(
Expand Down
30 changes: 27 additions & 3 deletions packages/devframe/src/client/browser-agent-rpc.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { BrowserAgentToolManifest } from './browser-agent'
import type { BrowserAgentClientInfo, BrowserAgentToolManifest } from './browser-agent'
import type { DevframeConnectionStatus } from './connection'
import {
listBrowserAgentTools,
Expand All @@ -19,6 +19,7 @@ interface BrowserAgentRpcClient {
method: 'devframe:agent:sync-client-tools',
clientId: string,
tools: BrowserAgentToolManifest[],
info: BrowserAgentClientInfo,
) => Promise<unknown>
events: {
on: (
Expand All @@ -28,7 +29,23 @@ interface BrowserAgentRpcClient {
}
}

/** Mirror this document's browser-agent registry over its existing RPC connection. */
/** Snapshot of this document as seen by a coding agent picking a tab. */
function describeBrowserAgentClient(): BrowserAgentClientInfo {
const doc = typeof document === 'undefined' ? undefined : document
return {
url: doc?.location?.href ?? '',
title: doc?.title ?? '',
visible: doc ? doc.visibilityState === 'visible' : true,
focused: doc?.hasFocus() ?? true,
}
}

/**
* Mirror this document's browser-agent registry over its existing RPC
* connection. Re-syncs on tool changes, reconnects, and focus/visibility
* changes so the node can route unaddressed calls to the tab the user
* looked at last.
*/
export function setupBrowserAgentRpcBridge(rpc: BrowserAgentRpcClient): () => void {
rpc.client.register({
name: 'devframe:agent:invoke-client-tool',
Expand Down Expand Up @@ -59,7 +76,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', resolveClientId(), manifests).catch(() => {})
await rpc.callOptional('devframe:agent:sync-client-tools', resolveClientId(), manifests, describeBrowserAgentClient()).catch(() => {})
})
}

Expand All @@ -68,11 +85,18 @@ export function setupBrowserAgentRpcBridge(rpc: BrowserAgentRpcClient): () => vo
if (status === 'connected')
sync()
})
const win = typeof window === 'undefined' ? undefined : window
win?.addEventListener('focus', sync)
win?.addEventListener('blur', sync)
win?.document.addEventListener('visibilitychange', sync)
sync()

return () => {
disposed = true
stopTools()
stopConnection()
win?.removeEventListener('focus', sync)
win?.removeEventListener('blur', sync)
win?.document.removeEventListener('visibilitychange', sync)
}
}
8 changes: 8 additions & 0 deletions packages/devframe/src/client/browser-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ export interface BrowserAgentToolManifest {
inputSchema?: unknown
}

/** What a connected document reports about itself alongside its tool manifest. */
export interface BrowserAgentClientInfo {
url: string
title: string
visible: boolean
focused: boolean
}

export interface BrowserAgentTool extends BrowserAgentToolManifest {
invoke: (args: Record<string, unknown>) => unknown | Promise<unknown>
}
Expand Down
2 changes: 2 additions & 0 deletions packages/devframe/src/internal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export { formatMcpError, stringifyForMcp } from '../agent/stringify'
export { argsToJsonSchema, returnToJsonSchema } from '../agent/to-json-schema'
export { importAgenticMcp } from '../node/agentic'
export type { AgenticMcpModule, MountedMcpHttp, MountMcpHttpOptions } from '../node/agentic'
export { LIST_CLIENTS_TOOL } from '../node/client-agent'
export type { ConnectedClient } from '../node/client-agent'
export { diagnostics } from '../node/diagnostics'
export { DevframeAgentHost } from '../node/host-agent'
export * from '../node/host-h3'
Expand Down
Loading
Loading