Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit caf6c5a

Browse files
authored
feat(devframe): route-based MCP server on the dev server (#85)
1 parent 3fa4ebd commit caf6c5a

15 files changed

Lines changed: 506 additions & 12 deletions

File tree

docs/adapters/mcp.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,34 @@ import devframe from './devframe'
1616
await createMcpServer(devframe, { transport: 'stdio' })
1717
```
1818

19-
`@modelcontextprotocol/sdk` is a peer dependency — install it when shipping MCP support. The current transport is `stdio`.
19+
`@modelcontextprotocol/sdk` is a peer dependency — install it when shipping MCP support. `createMcpServer` speaks the `stdio` transport, spawned per session by the client.
20+
21+
## Route-based server
22+
23+
The dev server can expose the same agent surface over HTTP, so an MCP client connects to the **running** server and sees live tool and resource changes. Enable it with `cli.mcp`:
24+
25+
```ts
26+
import { defineDevframe } from 'devframe'
27+
28+
export default defineDevframe({
29+
//
30+
cli: {
31+
mcp: true,
32+
},
33+
})
34+
```
35+
36+
The endpoint speaks the MCP Streamable-HTTP transport at `/__mcp` (relative to the base path — `/__<id>/__mcp` under a host), sharing the dev server's origin and port. The `--mcp` and `--no-mcp` flags override the definition per run. `__connection.json` advertises the route so in-browser tooling can discover it.
37+
38+
Each client session gets its own MCP server built from the live context, correlated by the `Mcp-Session-Id` header, so `tools/list_changed` and `resources/list_changed` notifications reach connected clients as the tool evolves. The endpoint binds to the same loopback host as the dev server and applies the shared loopback origin gate; widen it for a tunnel or LAN origin:
39+
40+
```ts
41+
defineDevframe({
42+
//
43+
cli: {
44+
mcp: { allowedOrigins: ['https://tunnel.example.com'] },
45+
},
46+
})
47+
```
2048

2149
See the [Agent-Native](/guide/agent-native) page for the full API, safety model, and Claude Desktop integration example.

docs/errors/DF0017.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,18 @@ The agent-native surface is experimental and may change without a major version
1414
1515
## Cause
1616

17-
`createMcpServer()` failed while initializing. Common reasons:
17+
The MCP server failed while initializing. Common reasons:
1818

1919
- `@modelcontextprotocol/sdk` is not installed. This is a peer dependency — add it to your devtool's dependencies.
20-
- The selected transport is not yet implemented (the first devframe MCP release ships with `stdio` only; `http` is planned).
21-
- The underlying transport threw during `connect()` (e.g. stdin/stdout is not available).
20+
- The stdio transport threw during `connect()` (e.g. stdin/stdout is not available).
21+
- The route-based MCP server (`cli.mcp`) could not load its transport module — usually the missing SDK peer dependency.
2222

2323
## Fix
2424

25-
- **Missing SDK**: `pnpm add @modelcontextprotocol/sdk` (or npm/yarn equivalent) in the package that imports `devframe/adapters/mcp`.
26-
- **Unsupported transport**: pass `{ transport: 'stdio' }` until HTTP support lands.
25+
- **Missing SDK**: `pnpm add @modelcontextprotocol/sdk` (or npm/yarn equivalent) in the package that imports `devframe/adapters/mcp` or enables `cli.mcp`.
2726
- **Transport init failure**: check the underlying error (attached as `cause`) for specifics.
2827

2928
## Source
3029

31-
- [`packages/devframe/src/adapters/mcp/build-server.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/adapters/mcp/build-server.ts)`createMcpServer()` throws `DF0017` when the requested transport is unsupported or when the underlying transport fails to `connect()`.
30+
- [`packages/devframe/src/adapters/mcp/build-server.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/adapters/mcp/build-server.ts)`createMcpServer()` throws `DF0017` when the stdio transport fails to `connect()`.
31+
- [`packages/devframe/src/adapters/dev.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/adapters/dev.ts)`createDevServer()` throws `DF0017` (transport `http`) when the route-based MCP server can't load its transport module.

packages/devframe/src/adapters/cli.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ export function createCli(d: DevframeDefinition, options: CreateCliOptions = {})
4949
.option('--host <host>', 'Host to bind to', { default: defaultHost })
5050
.option('--open', 'Open the browser on start')
5151
.option('--no-open', 'Do not open the browser')
52+
// Only `--mcp` is declared: CAC's `--no-*` auto-negation would inject a
53+
// `true` default, silently enabling MCP. Declaring just `--mcp` yields the
54+
// opt-in tri-state — absent → `undefined` (falls through to `cli.mcp`),
55+
// `--mcp` → `true`, `--no-mcp` → `false` (handled by CAC's `--no-` prefix).
56+
.option('--mcp', 'Expose an MCP server over HTTP at /__mcp (use --no-mcp to disable) [experimental]')
5257

5358
// Register typed flags from the definition ahead of `cli.configure`
5459
// so authors can still override or augment via the escape hatch.
@@ -69,10 +74,15 @@ export function createCli(d: DevframeDefinition, options: CreateCliOptions = {})
6974
const flags = resolveTypedFlags(d, rawFlags) as CliFlags
7075
const host = (flags.host as string | undefined) ?? defaultHost
7176
const port = (flags.port as number | undefined) ?? await resolveDevServerPort(d, { host, defaultPort })
77+
// `--mcp` / `--no-mcp` map to a boolean override; when neither is
78+
// passed CAC leaves `mcp` undefined so `createDevServer` falls through
79+
// to `def.cli?.mcp`.
80+
const mcp = flags.mcp as boolean | undefined
7281
await createDevServer(d, {
7382
host,
7483
port,
7584
flags,
85+
mcp,
7686
onReady: options.onReady,
7787
})
7888
})

packages/devframe/src/adapters/dev.ts

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
import type { StartedServer } from '../node/server'
22
import type { ConnectionMeta } from '../types/context'
3-
import type { DevframeDefinition, DevframeSetupInfo, DevframeWsOptions } from '../types/devframe'
3+
import type { DevframeDefinition, DevframeSetupInfo, DevframeWsOptions, McpRouteOptions } from '../types/devframe'
44
import process from 'node:process'
55
import { open } from 'devframe/utils/open'
66
import { mountStaticHandler } from 'devframe/utils/serve-static'
77
import { getPort } from 'get-port-please'
88
import { H3 } from 'h3'
99
import { resolve } from 'pathe'
1010
import { joinURL, withBase, withLeadingSlash, withoutLeadingSlash } from 'ufo'
11-
import { DEVFRAME_CONNECTION_META_FILENAME, DEVFRAME_WS_ROUTE } from '../constants'
11+
import { DEVFRAME_CONNECTION_META_FILENAME, DEVFRAME_MCP_ROUTE, DEVFRAME_WS_ROUTE } from '../constants'
1212
import { createHostContext } from '../node/context'
13+
import { diagnostics } from '../node/diagnostics'
1314
import { createH3DevframeHost } from '../node/host-h3'
1415
import { startHttpAndWs } from '../node/server'
1516
import { normalizeBasePath, resolveBasePath } from './_shared'
@@ -64,6 +65,13 @@ export interface CreateDevServerOptions {
6465
* sources; a string opens that relative path.
6566
*/
6667
openBrowser?: boolean | string
68+
/**
69+
* Expose a route-based MCP server on the dev server (Streamable-HTTP).
70+
* Overrides `def.cli?.mcp`; `undefined` falls through to it. `false`
71+
* disables the route regardless of the definition default. See
72+
* {@link McpRouteOptions}.
73+
*/
74+
mcp?: boolean | McpRouteOptions
6775
/**
6876
* Called once the WS server is bound. Devframe stays headless
6977
* otherwise — wire this if you want a startup banner.
@@ -149,6 +157,35 @@ export async function createDevServer(
149157
const setupInfo: DevframeSetupInfo = { flags }
150158
await def.setup(ctx, setupInfo)
151159

160+
// Route-based MCP server (opt-in via `cli.mcp` / the `mcp` option). Mounted
161+
// before the SPA static catch-all so the exact `/__mcp` route wins, and
162+
// advertised in `__connection.json` so in-browser tooling can discover it.
163+
// The MCP SDK is an optional peer dep, so its code is only pulled in
164+
// (dynamically) when the route is enabled.
165+
const mcpConfig = resolveMcpConfig(options.mcp ?? def.cli?.mcp)
166+
let mcpDispose: (() => Promise<void>) | undefined
167+
let mcpMeta: ConnectionMeta['mcp']
168+
if (mcpConfig) {
169+
const mcpRoute = withoutLeadingSlash(mcpConfig.path ?? DEVFRAME_MCP_ROUTE)
170+
const mcpPath = joinURL(basePath, mcpRoute)
171+
let mountMcpHttp: typeof import('./mcp/http').mountMcpHttp
172+
try {
173+
;({ mountMcpHttp } = await import('./mcp/http'))
174+
}
175+
catch (error) {
176+
const reason = error instanceof Error ? error.message : String(error)
177+
throw diagnostics.DF0017({ transport: 'http', reason, cause: error })
178+
}
179+
const mounted = mountMcpHttp(app, ctx, mcpPath, {
180+
serverName: `${def.id} (devframe)`,
181+
serverVersion: def.version ?? '0.0.0',
182+
exposeSharedState: true,
183+
allowedOrigins: mcpConfig.allowedOrigins,
184+
})
185+
mcpDispose = mounted.dispose
186+
mcpMeta = { path: mcpRoute }
187+
}
188+
152189
// Connection meta — the SPA fetches this to discover the RPC backend. How
153190
// the WS endpoint is bound and advertised follows the resolved ws config:
154191
// a same-origin route (default, proxy-safe), a dedicated port, or a remote
@@ -159,12 +196,13 @@ export async function createDevServer(
159196
app.use(connectionMetaPath, () => ({
160197
backend: 'websocket',
161198
websocket: meta,
199+
...(mcpMeta ? { mcp: mcpMeta } : {}),
162200
}))
163201

164202
if (distDir)
165203
mountStaticHandler(app, basePath, resolve(distDir))
166204

167-
return startHttpAndWs({
205+
const started = await startHttpAndWs({
168206
context: ctx,
169207
host,
170208
port,
@@ -177,6 +215,28 @@ export async function createDevServer(
177215
await maybeOpenBrowser(def, flags, `${info.origin}${basePath}`, options.openBrowser)
178216
},
179217
})
218+
219+
// Fold MCP session teardown into the server's close so callers get a single
220+
// graceful-shutdown handle.
221+
if (mcpDispose) {
222+
const closeServer = started.close
223+
started.close = async () => {
224+
await mcpDispose!()
225+
await closeServer()
226+
}
227+
}
228+
229+
return started
230+
}
231+
232+
/**
233+
* Normalize the `cli.mcp` / `mcp` option (`boolean | McpRouteOptions`) into
234+
* concrete options, or `undefined` when the MCP route is disabled.
235+
*/
236+
function resolveMcpConfig(mcp: boolean | McpRouteOptions | undefined): McpRouteOptions | undefined {
237+
if (!mcp)
238+
return undefined
239+
return mcp === true ? {} : mcp
180240
}
181241

182242
/**
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import type { StartedServer } from '../../../node/server'
2+
import type { DevframeDefinition } from '../../../types/devframe'
3+
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
4+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
5+
import { afterEach, describe, expect, it } from 'vitest'
6+
import { createDevServer } from '../../dev'
7+
8+
function defineTestDef(overrides?: Partial<DevframeDefinition>): DevframeDefinition {
9+
return {
10+
id: 'mcp-http-test',
11+
name: 'MCP HTTP Test',
12+
version: '1.2.3',
13+
packageName: '@devframe/mcp-http-test',
14+
homepage: 'https://example.com',
15+
description: 'Test fixture for the route-based MCP server.',
16+
setup(ctx) {
17+
ctx.agent.registerTool({
18+
id: 'greet',
19+
description: 'Say hello.',
20+
safety: 'read',
21+
handler: (args: { name?: string }) => ({ greeting: `hi ${args.name ?? 'there'}` }),
22+
})
23+
},
24+
...overrides,
25+
}
26+
}
27+
28+
describe('mcp adapter (streamable http route)', () => {
29+
let server: StartedServer | undefined
30+
31+
afterEach(async () => {
32+
await server?.close()
33+
server = undefined
34+
})
35+
36+
async function boot(def = defineTestDef()): Promise<StartedServer> {
37+
server = await createDevServer(def, { host: '127.0.0.1', mcp: true })
38+
return server
39+
}
40+
41+
it('advertises the mcp endpoint in __connection.json', async () => {
42+
const started = await boot()
43+
const res = await fetch(`${started.origin}/__connection.json`)
44+
const meta = await res.json() as { backend: string, mcp?: { path: string } }
45+
expect(meta.backend).toBe('websocket')
46+
expect(meta.mcp).toEqual({ path: '__mcp' })
47+
})
48+
49+
it('omits the mcp block when the route is disabled', async () => {
50+
server = await createDevServer(defineTestDef(), { host: '127.0.0.1', mcp: false })
51+
const res = await fetch(`${server.origin}/__connection.json`)
52+
const meta = await res.json() as { mcp?: unknown }
53+
expect(meta.mcp).toBeUndefined()
54+
})
55+
56+
it('establishes a stateful session and lists agent tools', async () => {
57+
const started = await boot()
58+
const transport = new StreamableHTTPClientTransport(new URL(`${started.origin}/__mcp`))
59+
const client = new Client({ name: 'test-client', version: '0.0.0' })
60+
try {
61+
await client.connect(transport)
62+
// Stateful mode issues an Mcp-Session-Id on initialize.
63+
expect(transport.sessionId).toBeTypeOf('string')
64+
expect(transport.sessionId!.length).toBeGreaterThan(0)
65+
66+
const tools = await client.listTools()
67+
expect(tools.tools.map(t => t.name)).toContain('greet')
68+
69+
const result = await client.callTool({ name: 'greet', arguments: { name: 'devframe' } })
70+
const content = result.content as Array<{ type: string, text: string }>
71+
expect(JSON.parse(content[0]!.text)).toEqual({ greeting: 'hi devframe' })
72+
}
73+
finally {
74+
await client.close()
75+
}
76+
})
77+
78+
it('tears the session down on DELETE and rejects reuse of the id', async () => {
79+
const started = await boot()
80+
const url = `${started.origin}/__mcp`
81+
82+
// Initialize over raw HTTP to capture the issued session id from the
83+
// response header (the body is an SSE stream we can discard).
84+
const init = await fetch(url, {
85+
method: 'POST',
86+
headers: {
87+
'content-type': 'application/json',
88+
'accept': 'application/json, text/event-stream',
89+
},
90+
body: JSON.stringify({
91+
jsonrpc: '2.0',
92+
id: 1,
93+
method: 'initialize',
94+
params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'x', version: '0' } },
95+
}),
96+
})
97+
const sessionId = init.headers.get('mcp-session-id')
98+
await init.body?.cancel()
99+
expect(sessionId).toBeTruthy()
100+
101+
// DELETE ends the session.
102+
const del = await fetch(url, {
103+
method: 'DELETE',
104+
headers: { 'mcp-session-id': sessionId! },
105+
})
106+
await del.body?.cancel()
107+
expect(del.status).toBeLessThan(300)
108+
109+
// Reusing the terminated id is no longer a known session — the server
110+
// answers 404 rather than falling through to the SPA static catch-all.
111+
const stale = await fetch(url, {
112+
method: 'POST',
113+
headers: {
114+
'content-type': 'application/json',
115+
'accept': 'application/json, text/event-stream',
116+
'mcp-session-id': sessionId!,
117+
},
118+
body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list' }),
119+
})
120+
await stale.body?.cancel()
121+
expect(stale.status).toBe(404)
122+
})
123+
124+
it('rejects a disallowed cross-origin request', async () => {
125+
const started = await boot()
126+
const res = await fetch(`${started.origin}/__mcp`, {
127+
method: 'POST',
128+
headers: {
129+
'content-type': 'application/json',
130+
'accept': 'application/json, text/event-stream',
131+
'origin': 'http://evil.example.com',
132+
},
133+
body: JSON.stringify({
134+
jsonrpc: '2.0',
135+
id: 1,
136+
method: 'initialize',
137+
params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'x', version: '0' } },
138+
}),
139+
})
140+
expect(res.status).toBe(403)
141+
})
142+
})

0 commit comments

Comments
 (0)