Skip to content
Closed
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
1 change: 1 addition & 0 deletions alias.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const alias = {
'devframe/utils/shared-state': r('devframe/src/utils/shared-state.ts'),
'devframe/utils/streaming-channel': r('devframe/src/utils/streaming-channel.ts'),
'devframe/utils/structured-clone': r('devframe/src/utils/structured-clone.ts'),
'devframe/utils/valibot-json-schema': r('devframe/src/utils/valibot-json-schema.ts'),
'devframe/utils/when': r('devframe/src/utils/when.ts'),
'devframe/adapters/cac': r('devframe/src/adapters/cac.ts'),
'devframe/adapters/cli': r('devframe/src/adapters/cli.ts'),
Expand Down
27 changes: 27 additions & 0 deletions docs/adapters/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,31 @@ defineDevframe({
})
```

### Hosted bridges

Both hosted bridges forward the same option to their side-car dev server and advertise the endpoint (with its port) in the `__connection.json` they serve:

```ts
// Vite
viteDevBridge(devframe, { devMiddleware: true, mcp: true })

// Next.js (@devframes/next)
createDevframeNextHandler(devframe, { mcp: true })
```

## Custom hosts

`createMcpFetchHandler(ctx, options)` returns the endpoint as a web-standard `Request → Response` handler plus a `dispose()` for session teardown — mount it on any fetch-shaped server (a Next.js App Router route, a custom Node server). The h3 `mountMcpHttp` used by the dev server is a thin wrapper over it.

```ts
import { createMcpFetchHandler } from 'devframe/adapters/mcp'

const mcp = createMcpFetchHandler(ctx, {
serverName: 'my-tool (devframe)',
serverVersion: '1.0.0',
exposeSharedState: true,
})
// route every method on /__mcp to mcp.fetch(request)
```

See the [Agent-Native](/guide/agent-native) page for the full API, safety model, and Claude Desktop integration example.
48 changes: 48 additions & 0 deletions docs/errors/DF8404.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
outline: deep
---

# DF8404: Agent Exposure Without Handler

## Message

> Command "`{id}`" declares agent exposure but has no handler

## Cause

`ctx.commands.register(command)` or a command handle `update()` received a command carrying an `agent` field but no `handler`. Agent-exposed commands are projected into `ctx.agent` as callable tools (reaching MCP clients through the devframe MCP adapter), so they must be executable server-side — a handler-less command is a palette group and cannot run.

## Example

```ts
// ✗ Bad: group-only command opting into the agent surface
ctx.commands.register({
id: 'my-tool:group',
title: 'My tool',
agent: { description: 'Run my tool.' },
children: [/* … */],
})

// ✓ Good: the executable child carries the agent field
ctx.commands.register({
id: 'my-tool:group',
title: 'My tool',
children: [
{
id: 'my-tool:reload',
title: 'Reload',
agent: { description: 'Reload my tool\'s state. Call after changing its config.' },
handler: () => reload(),
},
],
})
```

## Fix

- Add a `handler` to the command carrying the `agent` field.
- Or move the `agent` field to an executable child command.

## Source

- [`packages/hub/src/node/host-commands.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/host-commands.ts) — `DevframeCommandsHost.register()` and command handle `update()` validate agent exposure across the command tree.
2 changes: 2 additions & 0 deletions docs/guide/agent-native.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ ctx.agent.registerResource({

Every `ctx.rpc.sharedState` key is also automatically exposed to MCP as `devframe://state/<key>`. Pass `exposeSharedState: false` (or a filter function) to `createMcpServer` to opt out.

Shared state is additionally reachable through the built-in **`read_state` tool** — call it without arguments for the key list, with a `key` for that value — since many MCP clients only consume tools. It honors the same `exposeSharedState` filter as the resource projection.

## Starting the MCP server

The simplest path is the CLI:
Expand Down
18 changes: 18 additions & 0 deletions docs/guide/hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,24 @@ Every hub context auto-registers these RPC functions so framework kits don't rei

Host-specific capabilities (open in editor, reveal in finder, …) ship as kit-registered RPC functions rather than as part of the hub surface.

## Commands as agent tools

A server command opts into the [agent surface](./agent-native) with an `agent` field — the same default-deny convention as `defineRpcFunction`. Agent-flagged, handler-bearing commands are projected into `ctx.agent` as callable tools and reach MCP clients through the devframe MCP adapter:

```ts
ctx.commands.register({
id: 'app:build',
title: 'Run build',
agent: {
description: 'Run the production build. Call after config or dependency changes to verify the app still builds.',
args: [v.object({ configFile: v.optional(v.string()) })],
},
handler: (opts?: { configFile?: string }) => runBuild(opts),
})
```

`args` takes positional valibot schemas (a single `v.object(...)` is unwrapped into the tool's input object); omit it for a zero-argument tool. `safety` defaults to `'action'`. `when` clauses evaluate client-side only and are not enforced for agent calls — opt in a `when`-gated command only if running it outside its UI context is safe.

## Cross-iframe dock activation

The viewer's active dock is client-local state — which dock is on screen lives in the shell page, not in shared state. A mounted devframe runs in its own iframe on its own RPC client, so it can't reach that selection directly. `hub:docks:activate` bridges the gap: any connected client asks the hub to switch the active dock, and the hub relays the request to the shell.
Expand Down
1 change: 1 addition & 0 deletions packages/devframe/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"./utils/shared-state": "./dist/utils/shared-state.mjs",
"./utils/streaming-channel": "./dist/utils/streaming-channel.mjs",
"./utils/structured-clone": "./dist/utils/structured-clone.mjs",
"./utils/valibot-json-schema": "./dist/utils/valibot-json-schema.mjs",
"./utils/when": "./dist/utils/when.mjs",
"./package.json": "./package.json"
},
Expand Down
80 changes: 80 additions & 0 deletions packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,4 +176,84 @@ describe('mcp adapter (in-memory)', () => {
await cleanup()
}
})

it('exposes shared state through the built-in read_state tool', async () => {
const { ctx, client, cleanup } = await bootPair()
try {
await ctx.rpc.sharedState.get('my-plugin:counter', {
initialValue: { count: 7 },
})

const listed = await client.listTools()
const tool = listed.tools.find(t => t.name === 'read_state')
expect(tool).toBeDefined()
expect(tool!.annotations?.readOnlyHint).toBe(true)

// No key → key list.
const keys = await client.callTool({ name: 'read_state', arguments: {} })
expect(keys.structuredContent).toEqual({ keys: ['my-plugin:counter'] })

// With key → the value.
const value = await client.callTool({ name: 'read_state', arguments: { key: 'my-plugin:counter' } })
expect(value.structuredContent).toEqual({ key: 'my-plugin:counter', value: { count: 7 } })

// Unknown key → agent-actionable error.
const missing = await client.callTool({ name: 'read_state', arguments: { key: 'nope' } })
expect(missing.isError).toBe(true)
const content = missing.content as Array<{ text: string }>
expect(content[0]!.text).toContain('unknown shared-state key')
}
finally {
await cleanup()
}
})

it('hides read_state when shared-state exposure is disabled', async () => {
const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: nullHost() })
const { server, dispose } = buildMcpServerFromContext(ctx, {
serverName: 'test',
serverVersion: '0.0.0-test',
exposeSharedState: false,
})
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
await server.connect(serverTransport)
const client = new Client({ name: 'test-client', version: '0.0.0' })
await client.connect(clientTransport)
try {
const listed = await client.listTools()
expect(listed.tools.map(t => t.name)).not.toContain('read_state')
}
finally {
dispose()
await client.close()
await server.close()
}
})

it('respects the shared-state filter in read_state', async () => {
const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: nullHost() })
await ctx.rpc.sharedState.get('visible:key', { initialValue: { n: 1 } })
await ctx.rpc.sharedState.get('hidden:key', { initialValue: { n: 2 } })
const { server, dispose } = buildMcpServerFromContext(ctx, {
serverName: 'test',
serverVersion: '0.0.0-test',
exposeSharedState: key => key.startsWith('visible:'),
})
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
await server.connect(serverTransport)
const client = new Client({ name: 'test-client', version: '0.0.0' })
await client.connect(clientTransport)
try {
const keys = await client.callTool({ name: 'read_state', arguments: {} })
expect(keys.structuredContent).toEqual({ keys: ['visible:key'] })

const hidden = await client.callTool({ name: 'read_state', arguments: { key: 'hidden:key' } })
expect(hidden.isError).toBe(true)
}
finally {
dispose()
await client.close()
await server.close()
}
})
})
74 changes: 72 additions & 2 deletions packages/devframe/src/adapters/mcp/build-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export function buildMcpServerFromContext(
},
)

registerToolHandlers(server, ctx)
registerToolHandlers(server, ctx, options.exposeSharedState)
registerResourceHandlers(server, ctx, options.exposeSharedState)

const notify = (method: string): void => {
Expand Down Expand Up @@ -151,15 +151,85 @@ export async function createMcpServer(
}
}

function registerToolHandlers(server: Server, ctx: DevframeNodeContext): void {
/**
* Name of the built-in shared-state read tool. Tool-shaped access matters
* because many MCP clients only consume tools — the parallel
* `devframe://state/<key>` resource projection stays for the clients that do
* read resources.
*/
const READ_STATE_TOOL = 'read_state'

function sharedStateFilter(exposeSharedState: boolean | ((key: string) => boolean)): ((key: string) => boolean) | undefined {
if (exposeSharedState === false)
return undefined
return typeof exposeSharedState === 'function' ? exposeSharedState : () => true
}

function readStateToolProjection(): Record<string, unknown> {
return {
name: READ_STATE_TOOL,
title: 'Read shared state',
description: 'Read this devtool\'s live shared state. Call without arguments to list the available keys, then with a key to get that value as JSON. Safe to call freely.',
inputSchema: {
type: 'object',
properties: {
key: {
type: 'string',
description: 'A shared-state key from the key list. Omit to list all keys.',
},
},
},
annotations: {
title: 'Read shared state',
readOnlyHint: true,
destructiveHint: false,
},
}
}

async function readStateResult(
ctx: DevframeNodeContext,
filter: (key: string) => boolean,
key: string | undefined,
): Promise<unknown> {
const keys = ctx.rpc.sharedState.keys().filter(filter)
if (key === undefined)
return { keys }
if (!keys.includes(key))
throw new Error(`unknown shared-state key "${key}" — call ${READ_STATE_TOOL} without arguments to list the available keys`)
const state = await ctx.rpc.sharedState.get(key)
return { key, value: state.value() }
}

function registerToolHandlers(
server: Server,
ctx: DevframeNodeContext,
exposeSharedState: boolean | ((key: string) => boolean),
): void {
const stateFilter = sharedStateFilter(exposeSharedState)

server.setRequestHandler(ListToolsRequestSchema, async () => {
const tools = ctx.agent.list().tools.map(tool => projectTool(tool, ctx))
// A registered agent tool of the same name wins over the built-in.
if (stateFilter && !ctx.agent.getTool(READ_STATE_TOOL))
tools.push(readStateToolProjection())
return { tools }
})

server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params
try {
// Built-in shared-state read. A registered agent tool of the same
// name wins (mirroring the list projection above); plugin tools keep
// namespaced ids (`<plugin>:<tool>`), so collisions are deliberate.
if (stateFilter && name === READ_STATE_TOOL && !ctx.agent.getTool(READ_STATE_TOOL)) {
const key = (args as { key?: string } | undefined)?.key
const result = await readStateResult(ctx, stateFilter, key)
return {
content: [{ type: 'text', text: stringifyForMcp(result) }],
structuredContent: result as Record<string, unknown>,
}
}
const tool = ctx.agent.getTool(name)
const outputSchema = tool
? tool.outputSchema ?? computeOutputSchema(tool, ctx)
Expand Down
Loading