Skip to content
Draft
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. `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<void>`: callers can await completion and catch errors or timeouts. `emit()`, its deprecated alias `callEvent()`, and `on()` use the names declared in `events`. Function and event names have separate namespaces.

Expand Down
2 changes: 2 additions & 0 deletions docs/content/1.guide/17.client-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ A client-only dock can also carry `type: 'json-render'` with an inline [JSON-ren

A client script is a `ClientScriptEntry`: `{ importFrom, importName? }` (`importName` defaults `'default'`). The field varies by entry kind: an `action` entry's `action` runs when the dock button is activated, a `custom-render` entry's `renderer` renders its panel, and an `iframe` entry's optional `clientScript` runs alongside the iframe panel inside the host page ([Hub API reference](/references/hub-api#dock-client-script-fields)).

An iframe entry normally runs its client script on first activation. Set `clientScript.eager: true` to run it once as soon as the client context and dock entry are available.

The exported function (`DockClientScriptContext`) receives the client context and two dock-scoped extras:

- **`current`** holds this entry's state: `entryMeta`, `isActive`, `domElements`, `events` (`entry:activated`, `entry:deactivated`, `entry:updated`, `dom:panel:mounted`, `dom:iframe:mounted`).
Expand Down
4 changes: 3 additions & 1 deletion docs/content/8.references/6.hub-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,13 @@ The properties of `DevframeClientContext`: [The client context](/guide/client-co

Which `ClientScriptEntry` field carries an entry's client script, and when it runs: [Dock client scripts](/guide/client-context#dock-client-scripts).

`ClientScriptEntry` requires `importFrom` and accepts `importName` (default: `default`).

| Entry kind | Field | Runs |
|---|---|---|
| `action` | `action` | when the dock button is activated |
| `custom-render` | `renderer` | to render the entry's panel |
| `iframe` | `clientScript` (optional) | alongside the iframe panel, inside the host page |
| `iframe` | `clientScript` (optional) | on first activation, or when the client context and dock entry become available with `clientScript.eager: true` |

## Frame-nav messages

Expand Down
17 changes: 7 additions & 10 deletions packages/devframe/src/cli/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,12 @@ export interface ConnectServerHandle {
}

/** One discovered instance in the `list-instances` payload: the registry record plus its probed MCP surface. */
interface IndexedInstanceTools extends Pick<Tool, 'name' | 'title' | 'description' | 'inputSchema' | 'outputSchema' | 'annotations'> {}

interface IndexedInstance extends Omit<DevframeInstanceRecord, 'mcp'> {
mcp: {
url: string
tools?: { name: string, description?: string }[]
tools?: IndexedInstanceTools[]
error?: string
} | null
hint?: string
Expand Down Expand Up @@ -243,14 +245,8 @@ async function probePort(port: number, timeoutMs?: number): Promise<DevframeInst
}
}

async function listInstanceTools(sdk: ConnectSdk, url: string, token: string | undefined): Promise<{ name: string, description?: string }[]> {
return withInstanceClient(sdk, 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(sdk: ConnectSdk, url: string, token: string | undefined): Promise<IndexedInstanceTools[]> {
return withInstanceClient(sdk, url, token, async client => (await client.listTools()).tools)
}

async function call(
Expand All @@ -265,7 +261,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)
Expand Down
52 changes: 52 additions & 0 deletions packages/devframe/src/client/browser-agent-rpc.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, (...args: any[]) => 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' })
})
})
68 changes: 68 additions & 0 deletions packages/devframe/src/client/browser-agent-rpc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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<string, unknown>) => Promise<unknown>
}

interface BrowserAgentRpcClient {
client: { register: (definition: BrowserAgentInvocationDefinition) => void }
callOptional: (
method: 'devframe:agent:sync-client-tools',
tools: BrowserAgentToolManifest[],
) => Promise<unknown>
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<string, unknown>) => {
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
const sync = (): void => {
if (queued || disposed)
return
queued = true
queueMicrotask(async () => {
queued = false
const manifests = listBrowserAgentTools().map(({ invoke: _, ...manifest }) => manifest)
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()
}
}
64 changes: 64 additions & 0 deletions packages/devframe/src/client/browser-agent.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => unknown | Promise<unknown>
}

interface BrowserAgentRegistryState {
tools: Map<symbol, BrowserAgentTool>
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<string, BrowserAgentTool>()
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'
}
5 changes: 5 additions & 0 deletions packages/devframe/src/client/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 'ufo'
import { setupBrowserAgentRpcBridge } from './browser-agent-rpc'
import { setupDevframeConnection } from './connection'
import { storeAuthToken } from './connection-storage'
import { authenticateWithUrlOtp } from './otp'
Expand Down Expand Up @@ -356,6 +357,7 @@ export async function getDevframeRpcClient(
const clientRpc: DevframeClientRpcHost = new RpcFunctionsCollectorBase<DevframeRpcClientFunctions, DevframeRpcContext>(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<any> {
const candidates = [
Expand Down Expand Up @@ -496,6 +498,7 @@ export async function getDevframeRpcClient(
cacheManager,
scope: undefined!,
close: () => {
disposeBrowserAgentBridge?.()
disposeWebMcp?.()
mode.close?.()
},
Expand Down Expand Up @@ -584,6 +587,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) {
Expand Down
75 changes: 75 additions & 0 deletions packages/devframe/src/in-page-channel/agent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { StandardSchemaV1 } from '@standard-schema/spec'
import { describe, expect, it } from 'vitest'
import { listBrowserAgentTools } from '../client/browser-agent'
import { createPageScriptChannel } from './page-script'

interface TestProtocol {
pageScript: {
add: (a: number, b: number) => { sum: number }
hidden: () => string
}
}

function schema<T>(json: Record<string, unknown>): StandardSchemaV1<T> {
return {
'~standard': {
version: 1,
vendor: 'test',
validate: (value: unknown) => ({ value: value as T }),
jsonSchema: {
input: () => json,
output: () => json,
},
} as StandardSchemaV1<T>['~standard'],
}
}

describe('in-page channel agent tools', () => {
it('registers the original handler for browser-to-node agent transport', async () => {
const channel = createPageScriptChannel<TestProtocol>({
name: 'devframes:test',
window: false,
heartbeat: false,
functions: {
add: {
jsonSerializable: true,
agent: { description: 'Add two numbers.' },
args: [schema<number>({ type: 'number' }), schema<number>({ 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<TestProtocol>({
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/)
})
})
5 changes: 5 additions & 0 deletions packages/devframe/src/in-page-channel/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
},
DF0078: {
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.',
},
},
})
Loading
Loading