Skip to content

Commit 96a8040

Browse files
authored
feat(mcp): agent-native wave phase 1 — bridge MCP forwarding + structured diagnostic errors (#142)
1 parent 64236ac commit 96a8040

17 files changed

Lines changed: 461 additions & 12 deletions

File tree

docs/guide/agent-native.md

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Three building blocks:
1616

1717
1. **An `agent` field on `defineRpcFunction`.** Add `agent: { description, ... }` to opt a function in. Functions without the field stay private.
1818
2. **`ctx.agent`** — a host exposed on `DevframeNodeContext`. Plugins register tools that aren't backed by an RPC, and expose readable resources (e.g. a Markdown build summary).
19-
3. **The MCP adapter** (`devframe/adapters/mcp`) — translates the agent host into a [Model Context Protocol](https://modelcontextprotocol.io) server, currently over `stdio`.
19+
3. **The MCP adapter** (`devframe/adapters/mcp`) — translates the agent host into a [Model Context Protocol](https://modelcontextprotocol.io) server, over `stdio` (`devframe mcp`) or as a Streamable-HTTP route on the dev server (`--mcp`, advertised in `__connection.json`).
2020

2121
## Exposing an RPC function
2222

@@ -118,6 +118,52 @@ Add an entry to `claude_desktop_config.json`:
118118

119119
Restart Claude Desktop. The tools you flagged with `agent: { ... }` (plus any `registerTool` calls) show up in the MCP tool drawer. Resources are reachable as `devframe://resource/<id>` and `devframe://state/<key>` URIs.
120120

121+
## Writing descriptions agents act on
122+
123+
A tool description is a prompt, not documentation. The agent decides *when* to call your tool from the description alone, so tell it — state when to reach for the tool, not just what it returns:
124+
125+
<!-- eslint-skip -->
126+
127+
```ts
128+
// ✗ Bad: describes the mechanism
129+
agent: { description: 'Returns the session summary object.' }
130+
// ✓ Good: tells the agent when and why
131+
agent: { description: 'Summarize the current build session — durations, chunk counts, warnings. Call this before proposing any build-config change.' }
132+
```
133+
134+
Two conventions:
135+
136+
- **Lead with the action and the trigger.** "Call this before/after/when …" steers proactive use; a bare noun phrase gets ignored.
137+
- **State freshness and cost.** "Safe to call freely" / "expensive, call once per session" lets the agent budget calls.
138+
139+
## Gateway tools
140+
141+
A gateway tool returns *instructions and locations* instead of doing the work — the pattern for anything the agent can do better directly (reading bundled docs, running a CLI it has shell access to):
142+
143+
```ts
144+
ctx.agent.registerTool({
145+
id: 'my-plugin:docs',
146+
description: 'Locate the version-accurate docs for this tool. Call before answering questions about its config format.',
147+
safety: 'read',
148+
handler: () => ({
149+
docsPath: resolveInstalledDocsDir(),
150+
hint: 'Read the file matching your topic; do not rely on training-data knowledge of this config format.',
151+
}),
152+
})
153+
```
154+
155+
The agent gets a path and a next step; the actual reading happens with its own tools, which are faster and keep large content out of the MCP payload.
156+
157+
## Structured errors
158+
159+
A coded devframe diagnostic thrown from a tool handler crosses the MCP boundary as structured JSON rather than a flattened message:
160+
161+
```json
162+
{ "error": { "code": "DF0017", "message": "", "fix": "", "docs": "https://devfra.me/errors/df0017" } }
163+
```
164+
165+
Agents can act on `fix` directly and follow `docs` for detail — prefer throwing coded diagnostics from anything agent-reachable.
166+
121167
## Safety model
122168

123169
- **Opt-in exposure.** Functions opt in via the `agent` field; everything else stays private.

packages/devframe/src/adapters/dev.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,34 @@ function resolveMcpConfig(mcp: boolean | McpRouteOptions | undefined): McpRouteO
286286
return mcp === true ? {} : mcp
287287
}
288288

289+
/**
290+
* Resolve the `mcp` entry a `__connection.json` should advertise for a dev
291+
* server started with the given `mcp` option (falling back to `def.cli?.mcp`,
292+
* exactly like {@link createDevServer}), or `undefined` when the route is
293+
* disabled.
294+
*
295+
* Hosted bridges that hand-roll their connection meta (`viteDevBridge`,
296+
* `@devframes/next`'s handler) pass the side-car `port`: the advertised path
297+
* becomes absolute (the side-car mounts at `/`) and the client dials
298+
* `<page-host>:<port><path>`. Without `port` the path stays relative, resolved
299+
* against `__connection.json`'s own location (the same-server default).
300+
*
301+
* @experimental
302+
*/
303+
export function resolveMcpConnectionMeta(
304+
def: DevframeDefinition,
305+
mcp: boolean | McpRouteOptions | undefined,
306+
port?: number,
307+
): ConnectionMeta['mcp'] {
308+
const config = resolveMcpConfig(mcp ?? def.cli?.mcp)
309+
if (!config)
310+
return undefined
311+
const route = withoutLeadingSlash(config.path ?? DEVFRAME_MCP_ROUTE)
312+
return port != null
313+
? { path: withLeadingSlash(route), port }
314+
: { path: route }
315+
}
316+
289317
/**
290318
* Resolve the three WS connection scenarios from the definition / call-site
291319
* config into a concrete server bind path, optional dedicated port, and the

packages/devframe/src/adapters/mcp/__tests__/stringify.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Diagnostic } from 'nostics'
12
import { describe, expect, it } from 'vitest'
23
import { formatMcpError, stringifyForMcp } from '../stringify'
34

@@ -110,4 +111,28 @@ describe('formatMcpError', () => {
110111
const err = new Error('outer', { cause: 'bad input' })
111112
expect(formatMcpError(err)).toBe('Error: outer (cause: bad input)')
112113
})
114+
115+
it('emits structured JSON for a nostics Diagnostic', () => {
116+
const diagnostic = new Diagnostic({
117+
code: 'DF9999',
118+
why: 'Something coded went wrong',
119+
fix: 'Run the fixer.',
120+
docs: 'https://devfra.me/errors/df9999',
121+
})
122+
expect(JSON.parse(formatMcpError(diagnostic))).toEqual({
123+
error: {
124+
code: 'DF9999',
125+
message: 'Something coded went wrong',
126+
fix: 'Run the fixer.',
127+
docs: 'https://devfra.me/errors/df9999',
128+
},
129+
})
130+
})
131+
132+
it('omits absent fix/docs from the structured diagnostic payload', () => {
133+
const diagnostic = new Diagnostic({ code: 'DF9998', why: 'No extras' })
134+
expect(JSON.parse(formatMcpError(diagnostic))).toEqual({
135+
error: { code: 'DF9998', message: 'No extras' },
136+
})
137+
})
113138
})

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ import { valibotArgsToJsonSchema, valibotReturnToJsonSchema } from './to-json-sc
1818

1919
export interface CreateMcpServerOptions {
2020
/**
21-
* Transport to use. Only `'stdio'` is implemented today; HTTP support
22-
* is planned in a follow-up PR.
21+
* Transport to use. `createMcpServer` itself runs `'stdio'` (a standalone
22+
* process with its own host context); the Streamable-HTTP transport is
23+
* served route-based by the dev server instead — see `mountMcpHttp` and
24+
* the `mcp` option on `createDevServer` / `createCac`'s `--mcp` flag.
2325
*/
2426
transport?: 'stdio'
2527
/**

packages/devframe/src/adapters/mcp/stringify.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { Diagnostic } from 'nostics'
2+
13
/**
24
* JSON-coercing serializer for MCP text payloads.
35
*
@@ -55,11 +57,25 @@ export function stringifyForMcp(value: unknown): string {
5557
}
5658

5759
/**
58-
* Format a thrown value for an MCP `isError` text payload. Surfaces the
59-
* `Error.name`/`message`, and one level of `cause.message` so context
60-
* isn't dropped silently.
60+
* Format a thrown value for an MCP `isError` text payload.
61+
*
62+
* A nostics `Diagnostic` (every coded devframe error) becomes structured
63+
* JSON — `{ error: { code, message, fix?, docs? } }` — so an agent receives
64+
* the actionable next step (`fix`) and the docs URL instead of a bare
65+
* message string. Other errors surface `Error.name`/`message`, plus one
66+
* level of `cause.message` so context isn't dropped silently.
6167
*/
6268
export function formatMcpError(error: unknown): string {
69+
if (error instanceof Diagnostic) {
70+
return JSON.stringify({
71+
error: {
72+
code: error.code,
73+
message: error.message,
74+
...(error.fix ? { fix: error.fix } : {}),
75+
...(error.docs ? { docs: error.docs } : {}),
76+
},
77+
}, null, 2)
78+
}
6379
if (!(error instanceof Error))
6480
return String(error)
6581
const cause = (error as { cause?: unknown }).cause
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import type { DevframeDefinition } from '../../types/devframe'
2+
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
3+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
4+
import { getPort } from 'get-port-please'
5+
import { afterEach, describe, expect, it } from 'vitest'
6+
import { viteDevBridge } from '../vite'
7+
8+
function defineTestDef(): DevframeDefinition {
9+
return {
10+
id: 'vite-bridge-test',
11+
name: 'Vite Bridge Test',
12+
version: '0.0.0',
13+
packageName: '@devframe/vite-bridge-test',
14+
homepage: '',
15+
description: '',
16+
setup(ctx) {
17+
ctx.agent.registerTool({
18+
id: 'greet',
19+
description: 'Say hello.',
20+
safety: 'read',
21+
handler: () => ({ greeting: 'hi' }),
22+
})
23+
},
24+
}
25+
}
26+
27+
interface FakeViteServer {
28+
middlewares: { use: (path: string, handler: any) => void }
29+
httpServer: null
30+
routes: Map<string, any>
31+
}
32+
33+
function fakeViteServer(): FakeViteServer {
34+
const routes = new Map<string, any>()
35+
return {
36+
middlewares: { use: (path, handler) => routes.set(path, handler) },
37+
httpServer: null,
38+
routes,
39+
}
40+
}
41+
42+
/** Invoke a registered connect-style middleware and capture its JSON body. */
43+
async function readJsonMiddleware(handler: any): Promise<any> {
44+
return await new Promise((resolvePromise) => {
45+
handler(undefined, {
46+
setHeader: () => {},
47+
end: (body: string) => resolvePromise(JSON.parse(body)),
48+
})
49+
})
50+
}
51+
52+
describe('viteDevBridge (bridge mode mcp)', () => {
53+
let bridge: ReturnType<typeof viteDevBridge> | undefined
54+
55+
afterEach(async () => {
56+
await bridge?.closeBundle?.()
57+
bridge = undefined
58+
})
59+
60+
it('forwards the mcp option and advertises the side-car endpoint in the meta', async () => {
61+
const port = await getPort({ port: 19710, host: '127.0.0.1' })
62+
bridge = viteDevBridge(defineTestDef(), {
63+
devMiddleware: { port, host: '127.0.0.1' },
64+
mcp: true,
65+
})
66+
67+
const server = fakeViteServer()
68+
await bridge.configureServer(server)
69+
70+
const metaHandler = server.routes.get('/__vite-bridge-test/__connection.json')
71+
expect(metaHandler).toBeDefined()
72+
const meta = await readJsonMiddleware(metaHandler)
73+
expect(meta.backend).toBe('websocket')
74+
expect(meta.websocket).toEqual({ port, path: '/__devframe_ws' })
75+
expect(meta.mcp).toEqual({ port, path: '/__mcp' })
76+
77+
// The advertised endpoint is live: a real MCP client can connect and
78+
// list the agent tools on the side-car origin.
79+
const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/__mcp`))
80+
const client = new Client({ name: 'test-client', version: '0.0.0' })
81+
try {
82+
await client.connect(transport)
83+
const tools = await client.listTools()
84+
expect(tools.tools.map(t => t.name)).toContain('greet')
85+
}
86+
finally {
87+
await client.close()
88+
}
89+
})
90+
91+
it('omits the mcp block when the option is not set', async () => {
92+
const port = await getPort({ port: 19720, host: '127.0.0.1' })
93+
bridge = viteDevBridge(defineTestDef(), {
94+
devMiddleware: { port, host: '127.0.0.1' },
95+
})
96+
97+
const server = fakeViteServer()
98+
await bridge.configureServer(server)
99+
100+
const meta = await readJsonMiddleware(server.routes.get('/__vite-bridge-test/__connection.json'))
101+
expect(meta.mcp).toBeUndefined()
102+
})
103+
})

packages/devframe/src/helpers/vite.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import type { DevframeAuthHandler } from '../node/auth/handler'
2-
import type { DevframeDefinition } from '../types/devframe'
2+
import type { DevframeDefinition, McpRouteOptions } from '../types/devframe'
33
import { serveStaticNodeMiddleware } from 'devframe/utils/serve-static'
44
import { resolve } from 'pathe'
55
import { normalizeBasePath, resolveBasePath } from '../adapters/_shared'
6-
import { createDevServer, resolveDevServerPort } from '../adapters/dev'
6+
import { createDevServer, resolveDevServerPort, resolveMcpConnectionMeta } from '../adapters/dev'
77
import { DEVFRAME_CONNECTION_META_FILENAME, DEVFRAME_WS_ROUTE } from '../constants'
88
import { diagnostics } from '../node/diagnostics'
99

@@ -50,6 +50,19 @@ export interface ViteDevBridgeOptions {
5050
* @default false
5151
*/
5252
auth?: boolean | DevframeAuthHandler
53+
/**
54+
* Expose the side-car's route-based MCP server (Streamable-HTTP) and
55+
* advertise it in the bridge's `__connection.json`. Forwarded to
56+
* {@link createDevServer}: overrides `def.cli?.mcp`, `undefined` falls
57+
* through to it, `false` disables the route regardless. Only applies in
58+
* bridge mode (`devMiddleware`); the static-mount mode starts no server.
59+
*
60+
* The endpoint lives on the side-car's own port, so the advertised meta
61+
* carries `{ port, path }` — see `ConnectionMeta['mcp']`.
62+
*
63+
* @experimental
64+
*/
65+
mcp?: boolean | McpRouteOptions
5366
}
5467

5568
export interface DevframeVitePlugin {
@@ -126,6 +139,7 @@ export function viteDevBridge(d: DevframeDefinition, options: ViteDevBridgeOptio
126139
// Hosted adapter: the host owns auth, so the bridged devframe's own
127140
// gate stays off unless the caller explicitly opts back in.
128141
auth: options.auth ?? false,
142+
mcp: options.mcp,
129143
})
130144
}
131145
catch (e) {
@@ -136,13 +150,16 @@ export function viteDevBridge(d: DevframeDefinition, options: ViteDevBridgeOptio
136150
// The side-car listens on its own port, so the browser must target that
137151
// port explicitly (it can't reach the WS on Vite's origin). The route is
138152
// `/__devframe_ws` — the bridge `createDevServer` mounts the SPA at `/`, so its WS
139-
// upgrade handler is bound there.
153+
// upgrade handler is bound there. The MCP route (when enabled) lives on
154+
// the same side-car origin, advertised with the same explicit port.
155+
const mcpMeta = resolveMcpConnectionMeta(d, options.mcp, port)
140156
const metaPath = `${base}${DEVFRAME_CONNECTION_META_FILENAME}`
141157
server.middlewares.use(metaPath, (_req: unknown, res: any) => {
142158
res.setHeader('Content-Type', 'application/json')
143159
res.end(JSON.stringify({
144160
backend: 'websocket',
145161
websocket: { port, path: `/${DEVFRAME_WS_ROUTE}` },
162+
...(mcpMeta ? { mcp: mcpMeta } : {}),
146163
}))
147164
})
148165

packages/devframe/src/types/context.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,13 @@ export interface ConnectionMeta {
120120
* (`cli.mcp`). Advertises the MCP Streamable-HTTP route so in-browser
121121
* tooling (e.g. an MCP inspector) can discover it without guessing the
122122
* path. `path` is relative to `__connection.json`'s location, like the
123-
* WebSocket `path`.
123+
* WebSocket `path`. `port` is set when the endpoint lives on a side-car
124+
* server on its own port (bridge mode — `viteDevBridge`,
125+
* `@devframes/next`): the client combines the page hostname with `port`
126+
* and resolves `path` against that origin, mirroring
127+
* {@link ConnectionMetaWebsocket.port}.
124128
*/
125-
mcp?: { path: string }
129+
mcp?: { path: string, port?: number }
126130
/**
127131
* Names of RPC functions that have declared `jsonSerializable: true`.
128132
* Used by the WS / static client to dispatch the per-call wire

packages/next/src/handler.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { DevframeDefinition, DevframeStorageScope } from 'devframe/types'
44
import { homedir } from 'node:os'
55
import { join } from 'node:path'
66
import process from 'node:process'
7-
import { createDevServer, resolveDevServerPort } from 'devframe/adapters/dev'
7+
import { createDevServer, resolveDevServerPort, resolveMcpConnectionMeta } from 'devframe/adapters/dev'
88
import { DEVFRAME_WS_ROUTE } from 'devframe/constants'
99
import { createDevframeNextHost } from './host'
1010

@@ -31,6 +31,16 @@ export interface CreateDevframeNextHandlerOptions {
3131
resolveOrigin?: () => string
3232
/** Override where persisted devframe state lives (defaults under the cwd / home). */
3333
getStorageDir?: (scope: DevframeStorageScope) => string
34+
/**
35+
* Expose the side-car's route-based MCP server (Streamable-HTTP) and
36+
* advertise it in the handler's `__connection.json`. Forwarded to
37+
* `createDevServer`: overrides `def.cli?.mcp`, `undefined` falls through to
38+
* it, `false` disables the route regardless. The endpoint lives on the
39+
* side-car's own port, so the advertised meta carries `{ port, path }`.
40+
*
41+
* @experimental
42+
*/
43+
mcp?: CreateDevServerOptions['mcp']
3444
}
3545

3646
export interface DevframeNextHandler {
@@ -118,10 +128,13 @@ export function createDevframeNextHandler(
118128
flags: options.flags,
119129
openBrowser: false,
120130
auth: options.auth ?? false,
131+
mcp: options.mcp,
121132
})
133+
const mcpMeta = resolveMcpConnectionMeta(def, options.mcp, port)
122134
nextHost.setConnectionMeta({
123135
backend: 'websocket',
124136
websocket: { port, path: `/${DEVFRAME_WS_ROUTE}` },
137+
...(mcpMeta ? { mcp: mcpMeta } : {}),
125138
})
126139
})()
127140

0 commit comments

Comments
 (0)