Skip to content

Commit b53a08d

Browse files
committed
feat(agent): agent-native wave phase 2 — fetch handler, read_state, commands bridge, git agent tools
- createMcpFetchHandler: framework-agnostic web-standard MCP endpoint extracted from mountMcpHttp (now a thin h3 wrapper); exported from devframe/adapters/mcp for custom hosts (Next App Router, etc.) - built-in read_state(key?) MCP tool over shared state, honoring the exposeSharedState filter alongside the resource projection - hub commands gain opt-in agent exposure: agent field (description, safety, valibot args) projects handler-bearing commands into ctx.agent; DF8404 rejects agent exposure on group-only commands - valibot→JSON-Schema conversion moved to devframe/utils/valibot-json-schema (public) so SDK-free hosts can convert schemas - rpc: schema-typed handlers may be async — Thenable<InferReturnType<RS>> in the schema-typed definition branch - git plugin: status/log/show/branches/diff agent-flagged with valibot args/returns schemas (read-only surface; writes stay private) - docs: hub commands-as-tools, read_state, custom-host mounting; DF8404 page
1 parent 801177e commit b53a08d

39 files changed

Lines changed: 1063 additions & 332 deletions

‎alias.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export const alias = {
3131
'devframe/utils/shared-state': r('devframe/src/utils/shared-state.ts'),
3232
'devframe/utils/streaming-channel': r('devframe/src/utils/streaming-channel.ts'),
3333
'devframe/utils/structured-clone': r('devframe/src/utils/structured-clone.ts'),
34+
'devframe/utils/valibot-json-schema': r('devframe/src/utils/valibot-json-schema.ts'),
3435
'devframe/utils/when': r('devframe/src/utils/when.ts'),
3536
'devframe/adapters/cac': r('devframe/src/adapters/cac.ts'),
3637
'devframe/adapters/cli': r('devframe/src/adapters/cli.ts'),

‎docs/adapters/mcp.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,31 @@ defineDevframe({
4646
})
4747
```
4848

49+
### Hosted bridges
50+
51+
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:
52+
53+
```ts
54+
// Vite
55+
viteDevBridge(devframe, { devMiddleware: true, mcp: true })
56+
57+
// Next.js (@devframes/next)
58+
createDevframeNextHandler(devframe, { mcp: true })
59+
```
60+
61+
## Custom hosts
62+
63+
`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.
64+
65+
```ts
66+
import { createMcpFetchHandler } from 'devframe/adapters/mcp'
67+
68+
const mcp = createMcpFetchHandler(ctx, {
69+
serverName: 'my-tool (devframe)',
70+
serverVersion: '1.0.0',
71+
exposeSharedState: true,
72+
})
73+
// route every method on /__mcp to mcp.fetch(request)
74+
```
75+
4976
See the [Agent-Native](/guide/agent-native) page for the full API, safety model, and Claude Desktop integration example.

‎docs/errors/DF8404.md‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF8404: Agent Exposure Without Handler
6+
7+
## Message
8+
9+
> Command "`{id}`" declares agent exposure but has no handler
10+
11+
## Cause
12+
13+
`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.
14+
15+
## Example
16+
17+
```ts
18+
// ✗ Bad: group-only command opting into the agent surface
19+
ctx.commands.register({
20+
id: 'my-tool:group',
21+
title: 'My tool',
22+
agent: { description: 'Run my tool.' },
23+
children: [/* … */],
24+
})
25+
26+
// ✓ Good: the executable child carries the agent field
27+
ctx.commands.register({
28+
id: 'my-tool:group',
29+
title: 'My tool',
30+
children: [
31+
{
32+
id: 'my-tool:reload',
33+
title: 'Reload',
34+
agent: { description: 'Reload my tool\'s state. Call after changing its config.' },
35+
handler: () => reload(),
36+
},
37+
],
38+
})
39+
```
40+
41+
## Fix
42+
43+
- Add a `handler` to the command carrying the `agent` field.
44+
- Or move the `agent` field to an executable child command.
45+
46+
## Source
47+
48+
- [`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.

‎docs/guide/agent-native.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ ctx.agent.registerResource({
7979

8080
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.
8181

82+
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.
83+
8284
## Starting the MCP server
8385

8486
The simplest path is the CLI:

‎docs/guide/hub.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,24 @@ Every hub context auto-registers these RPC functions so framework kits don't rei
3030

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

33+
## Commands as agent tools
34+
35+
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:
36+
37+
```ts
38+
ctx.commands.register({
39+
id: 'app:build',
40+
title: 'Run build',
41+
agent: {
42+
description: 'Run the production build. Call after config or dependency changes to verify the app still builds.',
43+
args: [v.object({ configFile: v.optional(v.string()) })],
44+
},
45+
handler: (opts?: { configFile?: string }) => runBuild(opts),
46+
})
47+
```
48+
49+
`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.
50+
3351
## Cross-iframe dock activation
3452

3553
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.

‎packages/devframe/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"./utils/shared-state": "./dist/utils/shared-state.mjs",
5555
"./utils/streaming-channel": "./dist/utils/streaming-channel.mjs",
5656
"./utils/structured-clone": "./dist/utils/structured-clone.mjs",
57+
"./utils/valibot-json-schema": "./dist/utils/valibot-json-schema.mjs",
5758
"./utils/when": "./dist/utils/when.mjs",
5859
"./package.json": "./package.json"
5960
},

‎packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,4 +176,84 @@ describe('mcp adapter (in-memory)', () => {
176176
await cleanup()
177177
}
178178
})
179+
180+
it('exposes shared state through the built-in read_state tool', async () => {
181+
const { ctx, client, cleanup } = await bootPair()
182+
try {
183+
await ctx.rpc.sharedState.get('my-plugin:counter', {
184+
initialValue: { count: 7 },
185+
})
186+
187+
const listed = await client.listTools()
188+
const tool = listed.tools.find(t => t.name === 'read_state')
189+
expect(tool).toBeDefined()
190+
expect(tool!.annotations?.readOnlyHint).toBe(true)
191+
192+
// No key → key list.
193+
const keys = await client.callTool({ name: 'read_state', arguments: {} })
194+
expect(keys.structuredContent).toEqual({ keys: ['my-plugin:counter'] })
195+
196+
// With key → the value.
197+
const value = await client.callTool({ name: 'read_state', arguments: { key: 'my-plugin:counter' } })
198+
expect(value.structuredContent).toEqual({ key: 'my-plugin:counter', value: { count: 7 } })
199+
200+
// Unknown key → agent-actionable error.
201+
const missing = await client.callTool({ name: 'read_state', arguments: { key: 'nope' } })
202+
expect(missing.isError).toBe(true)
203+
const content = missing.content as Array<{ text: string }>
204+
expect(content[0]!.text).toContain('unknown shared-state key')
205+
}
206+
finally {
207+
await cleanup()
208+
}
209+
})
210+
211+
it('hides read_state when shared-state exposure is disabled', async () => {
212+
const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: nullHost() })
213+
const { server, dispose } = buildMcpServerFromContext(ctx, {
214+
serverName: 'test',
215+
serverVersion: '0.0.0-test',
216+
exposeSharedState: false,
217+
})
218+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
219+
await server.connect(serverTransport)
220+
const client = new Client({ name: 'test-client', version: '0.0.0' })
221+
await client.connect(clientTransport)
222+
try {
223+
const listed = await client.listTools()
224+
expect(listed.tools.map(t => t.name)).not.toContain('read_state')
225+
}
226+
finally {
227+
dispose()
228+
await client.close()
229+
await server.close()
230+
}
231+
})
232+
233+
it('respects the shared-state filter in read_state', async () => {
234+
const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: nullHost() })
235+
await ctx.rpc.sharedState.get('visible:key', { initialValue: { n: 1 } })
236+
await ctx.rpc.sharedState.get('hidden:key', { initialValue: { n: 2 } })
237+
const { server, dispose } = buildMcpServerFromContext(ctx, {
238+
serverName: 'test',
239+
serverVersion: '0.0.0-test',
240+
exposeSharedState: key => key.startsWith('visible:'),
241+
})
242+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
243+
await server.connect(serverTransport)
244+
const client = new Client({ name: 'test-client', version: '0.0.0' })
245+
await client.connect(clientTransport)
246+
try {
247+
const keys = await client.callTool({ name: 'read_state', arguments: {} })
248+
expect(keys.structuredContent).toEqual({ keys: ['visible:key'] })
249+
250+
const hidden = await client.callTool({ name: 'read_state', arguments: { key: 'hidden:key' } })
251+
expect(hidden.isError).toBe(true)
252+
}
253+
finally {
254+
dispose()
255+
await client.close()
256+
await server.close()
257+
}
258+
})
179259
})

‎packages/devframe/src/adapters/mcp/build-server.ts‎

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export function buildMcpServerFromContext(
6868
},
6969
)
7070

71-
registerToolHandlers(server, ctx)
71+
registerToolHandlers(server, ctx, options.exposeSharedState)
7272
registerResourceHandlers(server, ctx, options.exposeSharedState)
7373

7474
const notify = (method: string): void => {
@@ -151,15 +151,85 @@ export async function createMcpServer(
151151
}
152152
}
153153

154-
function registerToolHandlers(server: Server, ctx: DevframeNodeContext): void {
154+
/**
155+
* Name of the built-in shared-state read tool. Tool-shaped access matters
156+
* because many MCP clients only consume tools — the parallel
157+
* `devframe://state/<key>` resource projection stays for the clients that do
158+
* read resources.
159+
*/
160+
const READ_STATE_TOOL = 'read_state'
161+
162+
function sharedStateFilter(exposeSharedState: boolean | ((key: string) => boolean)): ((key: string) => boolean) | undefined {
163+
if (exposeSharedState === false)
164+
return undefined
165+
return typeof exposeSharedState === 'function' ? exposeSharedState : () => true
166+
}
167+
168+
function readStateToolProjection(): Record<string, unknown> {
169+
return {
170+
name: READ_STATE_TOOL,
171+
title: 'Read shared state',
172+
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.',
173+
inputSchema: {
174+
type: 'object',
175+
properties: {
176+
key: {
177+
type: 'string',
178+
description: 'A shared-state key from the key list. Omit to list all keys.',
179+
},
180+
},
181+
},
182+
annotations: {
183+
title: 'Read shared state',
184+
readOnlyHint: true,
185+
destructiveHint: false,
186+
},
187+
}
188+
}
189+
190+
async function readStateResult(
191+
ctx: DevframeNodeContext,
192+
filter: (key: string) => boolean,
193+
key: string | undefined,
194+
): Promise<unknown> {
195+
const keys = ctx.rpc.sharedState.keys().filter(filter)
196+
if (key === undefined)
197+
return { keys }
198+
if (!keys.includes(key))
199+
throw new Error(`unknown shared-state key "${key}" — call ${READ_STATE_TOOL} without arguments to list the available keys`)
200+
const state = await ctx.rpc.sharedState.get(key)
201+
return { key, value: state.value() }
202+
}
203+
204+
function registerToolHandlers(
205+
server: Server,
206+
ctx: DevframeNodeContext,
207+
exposeSharedState: boolean | ((key: string) => boolean),
208+
): void {
209+
const stateFilter = sharedStateFilter(exposeSharedState)
210+
155211
server.setRequestHandler(ListToolsRequestSchema, async () => {
156212
const tools = ctx.agent.list().tools.map(tool => projectTool(tool, ctx))
213+
// A registered agent tool of the same name wins over the built-in.
214+
if (stateFilter && !ctx.agent.getTool(READ_STATE_TOOL))
215+
tools.push(readStateToolProjection())
157216
return { tools }
158217
})
159218

160219
server.setRequestHandler(CallToolRequestSchema, async (request) => {
161220
const { name, arguments: args } = request.params
162221
try {
222+
// Built-in shared-state read. A registered agent tool of the same
223+
// name wins (mirroring the list projection above); plugin tools keep
224+
// namespaced ids (`<plugin>:<tool>`), so collisions are deliberate.
225+
if (stateFilter && name === READ_STATE_TOOL && !ctx.agent.getTool(READ_STATE_TOOL)) {
226+
const key = (args as { key?: string } | undefined)?.key
227+
const result = await readStateResult(ctx, stateFilter, key)
228+
return {
229+
content: [{ type: 'text', text: stringifyForMcp(result) }],
230+
structuredContent: result as Record<string, unknown>,
231+
}
232+
}
163233
const tool = ctx.agent.getTool(name)
164234
const outputSchema = tool
165235
? tool.outputSchema ?? computeOutputSchema(tool, ctx)

0 commit comments

Comments
 (0)