Skip to content

Commit 15b75be

Browse files
committed
feat(mcp): migrate to the stateless MCP 2026-07-28 protocol
Serve the MCP surface through the SDK's stateless 2026-07-28 entry points, retiring devframe's custom Mcp-Session-Id lifecycle: - HTTP moves to `createMcpHandler`, building a fresh server per request (no session registry, no session-local routing, GET/DELETE -> 405). 2025-era clients are still served through the SDK's stateless legacy path. - stdio moves to `serveStdio`, pinning one server per connection. - `devframe connect` negotiates the modern era via `server/discover`, falling back to the 2025 handshake for a 2025-only instance. - `list_changed` events bridge onto modern `subscriptions/listen` streams (HTTP via the handler's notify bus; stdio via the pinned server's send*ListChanged calls). The devframe API surface (createMcpServer, createMcpFetchHandler, mountMcpHttp, cli.mcp) and the origin gate are unchanged. Closes #293
1 parent 5421c3e commit 15b75be

16 files changed

Lines changed: 189 additions & 225 deletions

File tree

docs/content/2.adapters/7.mcp.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import devframe from './devframe'
1212
await createMcpServer(devframe, { transport: 'stdio' })
1313
```
1414

15-
`@modelcontextprotocol/server` is a peer dependency; `createMcpServer` speaks `stdio`, spawned per session.
15+
`@modelcontextprotocol/server` is a peer dependency; `createMcpServer` serves `stdio` through the SDK's `serveStdio`, pinning one server instance per connection.
1616

1717
## Route-based server
1818

@@ -31,7 +31,7 @@ export default defineDevframe({
3131

3232
The endpoint speaks Streamable-HTTP at `/__mcp` (`/__<id>/__mcp` under a host), sharing its origin/port. `--mcp` / `--no-mcp` override; `__connection.json` advertises it.
3333

34-
Each session gets its own MCP server, keyed by `Mcp-Session-Id`. An origin gate requires `Origin` be loopback (or allow-listed) and rejects `Origin`-less requests. Widen for a tunnel/LAN origin with `cli: { mcp: { allowedOrigins: ['https://tunnel.example.com'] } }`.
34+
The endpoint is **stateless**: it serves the [2026-07-28 revision](https://modelcontextprotocol.io/specification/2026-07-28) per request through the SDK's `createMcpHandler`, building a fresh MCP server for each request — every HTTP request stands alone, with no `Mcp-Session-Id` to correlate. 2025-era clients are still served through the SDK's stateless legacy path. An origin gate requires `Origin` be loopback (or allow-listed) and rejects `Origin`-less requests. Widen for a tunnel/LAN origin with `cli: { mcp: { allowedOrigins: ['https://tunnel.example.com'] } }`.
3535

3636
### Hosted bridges
3737

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
title: 'Migrating to 0.10'
3+
description: '0.10 moves the MCP surface to the stateless MCP 2026-07-28 protocol. The public devframe API is unchanged; the change is in how the MCP endpoints serve requests on the wire.'
4+
---
5+
6+
0.10 moves devframe's [MCP](/adapters/mcp) surface to the stateless [MCP 2026-07-28 protocol](https://modelcontextprotocol.io/specification/2026-07-28). The devframe API you author against — `createMcpServer`, `createMcpFetchHandler`, `mountMcpHttp`, `cli.mcp`, and the agent host — is unchanged. What changes is how the endpoints serve requests on the wire.
7+
8+
## The MCP endpoint is stateless
9+
10+
The HTTP endpoint serves the 2026-07-28 revision per request through the SDK's `createMcpHandler`, building a fresh MCP server for each request. There is no `Mcp-Session-Id` registry, no session-local routing, and no `initialize` handshake to open a session.
11+
12+
- Every request stands alone and can reach any server instance — no session affinity.
13+
- A `GET` or `DELETE` (the 2025 session operations) is answered `405 Method Not Allowed`.
14+
- 2025-era clients still work: they are served through the SDK's stateless legacy path per request.
15+
- `list_changed` events reach modern clients over the `subscriptions/listen` stream they open.
16+
17+
The origin gate is unchanged: an `Origin` must be loopback (or on `allowedOrigins`), and `Origin`-less requests are rejected. `createMcpFetchHandler` keeps its `{ fetch, dispose }` shape, so custom hosts and the Vite/Next bridges need no code changes.
18+
19+
## stdio is served through `serveStdio`
20+
21+
`createMcpServer(def, { transport: 'stdio' })` now serves the connection through the SDK's `serveStdio`, which pins one server instance per connection and owns the era decision (2026-07-28, falling back to the 2025 handshake for a 2025-era opening). The `createMcpServer` API and its `stop()` handle are unchanged.
22+
23+
## `devframe connect` negotiates the modern era
24+
25+
The `devframe connect` connector's client probes each instance with `server/discover` and negotiates the 2026-07-28 era, falling back to the 2025 `initialize` handshake for a 2025-only instance. Discovery, the two gateway tools, and the instance registry are unchanged.
26+
27+
## Connecting your own MCP client
28+
29+
A client that connects to devframe's endpoint should negotiate the modern era to use the stateless protocol:
30+
31+
```ts
32+
import { Client } from '@modelcontextprotocol/client'
33+
34+
const client = new Client(
35+
{ name: 'my-client', version: '1.0.0' },
36+
{ versionNegotiation: { mode: 'auto' } },
37+
)
38+
await client.connect(transport)
39+
```
40+
41+
A client left on the default (2025-era) negotiation is still served through the stateless legacy path.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.

docs/content/7.migrations/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Upgrade guides for devframe and `@devframes/hub`, newest first. Each one lists e
77

88
| Version | What changed |
99
| ------- | ------------ |
10+
| [Migrating to 0.10](/migrations/migration-0.10) | Moves the MCP surface to the stateless MCP 2026-07-28 protocol. |
1011
| [Migrating to 0.9](/migrations/migration-0.9) | Removes the compatibility shims deprecated across the 0.7 series and trims the public API. |
1112
| [Migrating to 0.8](/migrations/migration-0.8) | Makes RPC schemas validator-neutral and runtime-validated, and adds the agent-native MCP surface. |
1213
| [Migrating to 0.7](/migrations/migration-0.7) | Makes `cac` an optional peer and moves json-render into an opt-in package. |

packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts

Lines changed: 19 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -68,15 +68,20 @@ describe('mcp adapter (streamable http route)', () => {
6868
})
6969
}
7070

71-
it('establishes a stateful session and lists agent tools', async () => {
71+
it('serves the modern era statelessly and lists agent tools', async () => {
7272
const started = await boot()
7373
const transport = originTransport(started)
74-
const client = new Client({ name: 'test-client', version: '0.0.0' })
74+
// Negotiate the 2026-07-28 era via `server/discover`.
75+
const client = new Client(
76+
{ name: 'test-client', version: '0.0.0' },
77+
{ versionNegotiation: { mode: 'auto' } },
78+
)
7579
try {
7680
await client.connect(transport)
77-
// Stateful mode issues an Mcp-Session-Id on initialize.
78-
expect(transport.sessionId).toBeTypeOf('string')
79-
expect(transport.sessionId!.length).toBeGreaterThan(0)
81+
// Stateless per-request serving: the modern era negotiates no
82+
// `Mcp-Session-Id` — there is no session to key state on.
83+
expect(client.getProtocolEra()).toBe('modern')
84+
expect(transport.sessionId).toBeUndefined()
8085

8186
const tools = await client.listTools()
8287
expect(tools.tools.map(t => t.name)).toContain('greet')
@@ -90,53 +95,17 @@ describe('mcp adapter (streamable http route)', () => {
9095
}
9196
})
9297

93-
it('tears the session down on DELETE and rejects reuse of the id', async () => {
98+
it('answers a bare GET with 405 (no session lifecycle)', async () => {
9499
const started = await boot()
95-
const url = `${started.origin}/__mcp`
96-
97-
// Initialize over raw HTTP to capture the issued session id from the
98-
// response header (the body is an SSE stream we can discard).
99-
const originHeader = { origin: started.origin }
100-
const init = await fetch(url, {
101-
method: 'POST',
102-
headers: {
103-
'content-type': 'application/json',
104-
'accept': 'application/json, text/event-stream',
105-
...originHeader,
106-
},
107-
body: JSON.stringify({
108-
jsonrpc: '2.0',
109-
id: 1,
110-
method: 'initialize',
111-
params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'x', version: '0' } },
112-
}),
113-
})
114-
const sessionId = init.headers.get('mcp-session-id')
115-
await init.body?.cancel()
116-
expect(sessionId).toBeTruthy()
117-
118-
// DELETE ends the session.
119-
const del = await fetch(url, {
120-
method: 'DELETE',
121-
headers: { 'mcp-session-id': sessionId!, ...originHeader },
122-
})
123-
await del.body?.cancel()
124-
expect(del.status).toBeLessThan(300)
125-
126-
// Reusing the terminated id is no longer a known session — the server
127-
// answers 404 rather than falling through to the SPA static catch-all.
128-
const stale = await fetch(url, {
129-
method: 'POST',
130-
headers: {
131-
'content-type': 'application/json',
132-
'accept': 'application/json, text/event-stream',
133-
'mcp-session-id': sessionId!,
134-
...originHeader,
135-
},
136-
body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list' }),
100+
// Stateless serving has no session stream to open — the SDK answers a
101+
// GET (a 2025 session operation) with `405 Method Not Allowed` rather
102+
// than falling through to the SPA static catch-all.
103+
const res = await fetch(`${started.origin}/__mcp`, {
104+
method: 'GET',
105+
headers: { accept: 'text/event-stream', origin: started.origin },
137106
})
138-
await stale.body?.cancel()
139-
expect(stale.status).toBe(404)
107+
await res.body?.cancel()
108+
expect(res.status).toBe(405)
140109
})
141110

142111
it('rejects an Origin-less request', async () => {

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

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ function nullHost(): DevframeHost {
1515
async function bootPair() {
1616
const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: nullHost() })
1717

18-
const { server, dispose } = buildMcpServerFromContext(ctx, {
18+
const server = buildMcpServerFromContext(ctx, {
1919
serverName: 'test',
2020
serverVersion: '0.0.0-test',
2121
exposeSharedState: true,
@@ -31,7 +31,6 @@ async function bootPair() {
3131
ctx,
3232
client,
3333
cleanup: async () => {
34-
dispose()
3534
await client.close()
3635
await server.close()
3736
},
@@ -314,7 +313,7 @@ describe('mcp adapter (in-memory)', () => {
314313

315314
it('hides devframe:state:read when shared-state exposure is disabled', async () => {
316315
const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: nullHost() })
317-
const { server, dispose } = buildMcpServerFromContext(ctx, {
316+
const server = buildMcpServerFromContext(ctx, {
318317
serverName: 'test',
319318
serverVersion: '0.0.0-test',
320319
exposeSharedState: false,
@@ -328,7 +327,6 @@ describe('mcp adapter (in-memory)', () => {
328327
expect(listed.tools.map(t => t.name)).not.toContain('devframe_state_read')
329328
}
330329
finally {
331-
dispose()
332330
await client.close()
333331
await server.close()
334332
}
@@ -338,7 +336,7 @@ describe('mcp adapter (in-memory)', () => {
338336
const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: nullHost() })
339337
await ctx.rpc.sharedState.get('visible:key', { initialValue: { n: 1 } })
340338
await ctx.rpc.sharedState.get('hidden:key', { initialValue: { n: 2 } })
341-
const { server, dispose } = buildMcpServerFromContext(ctx, {
339+
const server = buildMcpServerFromContext(ctx, {
342340
serverName: 'test',
343341
serverVersion: '0.0.0-test',
344342
exposeSharedState: key => key.startsWith('visible:'),
@@ -355,7 +353,6 @@ describe('mcp adapter (in-memory)', () => {
355353
expect(hidden.isError).toBe(true)
356354
}
357355
finally {
358-
dispose()
359356
await client.close()
360357
await server.close()
361358
}

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

Lines changed: 65 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -40,18 +40,28 @@ export interface McpServerHandle {
4040
stop: () => Promise<void>
4141
}
4242

43+
export interface BuildMcpServerOptions {
44+
serverName: string
45+
serverVersion: string
46+
exposeSharedState: boolean | ((k: string) => boolean)
47+
}
48+
4349
/**
44-
* Wire an MCP {@link Server} to a devframe context. Returns the server
45-
* plus a disposal function for the subscriptions it sets up. The
46-
* transport is the caller's responsibility — `createMcpServer` connects
47-
* stdio; tests can connect an {@link InMemoryTransport} instead.
50+
* Build a fresh MCP {@link Server} over a devframe context, registering its
51+
* tool and resource handlers. This is a pure factory — it sets up no
52+
* long-lived subscriptions and holds no per-connection state, so it is safe
53+
* to call once per request under `createMcpHandler` or once per connection
54+
* under `serveStdio`. Change notifications are published separately: over
55+
* HTTP through the handler's `notify` bus (see `createMcpFetchHandler`), and
56+
* on stdio through the connection's own `send*ListChanged` calls (see
57+
* {@link bridgeListChanged}, wired by `serveStdio`).
4858
*
4959
* @internal
5060
*/
5161
export function buildMcpServerFromContext(
5262
ctx: DevframeNodeContext,
53-
options: { serverName: string, serverVersion: string, exposeSharedState: boolean | ((k: string) => boolean) },
54-
): { server: Server, dispose: () => void } {
63+
options: BuildMcpServerOptions,
64+
): Server {
5565
const server = new Server(
5666
{
5767
name: options.serverName,
@@ -68,23 +78,35 @@ export function buildMcpServerFromContext(
6878
registerToolHandlers(server, ctx, options.exposeSharedState)
6979
registerResourceHandlers(server, ctx, options.exposeSharedState)
7080

71-
const notify = (method: string): void => {
72-
server.notification({ method }).catch(() => { /* ignore transport errors */ })
73-
}
81+
return server
82+
}
83+
84+
/**
85+
* Publish devframe's `list_changed` events through a set of typed sinks:
86+
* `tools()` for tool-list changes and `resources()` for resource-list
87+
* changes (shared-state keys are surfaced as resources). Returns an
88+
* unsubscribe function.
89+
*
90+
* The HTTP path passes the handler's `notify` bus sugar; the stdio path
91+
* passes the pinned server's `send*ListChanged` methods, which `serveStdio`
92+
* routes onto the connection's active `subscriptions/listen` streams.
93+
*
94+
* @internal
95+
*/
96+
export function bridgeListChanged(
97+
ctx: DevframeNodeContext,
98+
sinks: { tools: () => void, resources: () => void },
99+
): () => void {
74100
const offManifest = ctx.agent.events.on(DEVFRAME_EVENTS.bus.agentManifestChanged, () => {
75-
notify('notifications/tools/list_changed')
76-
notify('notifications/resources/list_changed')
101+
sinks.tools()
102+
sinks.resources()
77103
})
78104
const offKeyAdded = ctx.rpc.sharedState.onKeyAdded(() => {
79-
notify('notifications/resources/list_changed')
105+
sinks.resources()
80106
})
81-
82-
return {
83-
server,
84-
dispose: () => {
85-
offManifest()
86-
offKeyAdded()
87-
},
107+
return () => {
108+
offManifest()
109+
offKeyAdded()
88110
}
89111
}
90112

@@ -124,16 +146,34 @@ export async function createMcpServer(
124146
await ctx.services.ready()
125147
await definition.setup(ctx)
126148

127-
const { server, dispose } = buildMcpServerFromContext(ctx, {
149+
const buildOptions: BuildMcpServerOptions = {
128150
serverName: options.serverName ?? `${definition.id} (devframe)`,
129151
serverVersion: options.serverVersion ?? definition.version ?? '0.0.0',
130152
exposeSharedState: options.exposeSharedState ?? true,
131-
})
153+
}
132154

133-
const { startStdioTransport } = await import('./transports')
134-
let stop: () => Promise<void>
155+
// `serveStdio` owns the connection's era decision and pins ONE instance
156+
// for its lifetime. Each pinned server sets up its own `list_changed`
157+
// bridge over the connection's `send*ListChanged` calls (routed onto the
158+
// active `subscriptions/listen` streams on a modern connection, sent
159+
// unsolicited on a 2025-era one) and tears it down when that server
160+
// closes.
161+
let handle: import('@modelcontextprotocol/server/stdio').StdioServerHandle
135162
try {
136-
stop = await startStdioTransport(server)
163+
const { serveStdio } = await import('@modelcontextprotocol/server/stdio')
164+
handle = serveStdio(() => {
165+
const server = buildMcpServerFromContext(ctx, buildOptions)
166+
const unbridge = bridgeListChanged(ctx, {
167+
tools: () => { void server.sendToolListChanged().catch(() => {}) },
168+
resources: () => { void server.sendResourceListChanged().catch(() => {}) },
169+
})
170+
const priorOnClose = server.onclose
171+
server.onclose = () => {
172+
unbridge()
173+
priorOnClose?.()
174+
}
175+
return server
176+
})
137177
}
138178
catch (error) {
139179
const reason = error instanceof Error ? error.message : String(error)
@@ -144,8 +184,7 @@ export async function createMcpServer(
144184

145185
return {
146186
async stop() {
147-
dispose()
148-
await stop()
187+
await handle.close()
149188
},
150189
}
151190
}

0 commit comments

Comments
 (0)