Skip to content

Commit 8d58eb5

Browse files
authored
feat(mcp): migrate to the stateless MCP 2026-07-28 protocol (#297)
1 parent 78ce8d7 commit 8d58eb5

14 files changed

Lines changed: 177 additions & 262 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 myDevframe from './my-tool'
1212
await createMcpServer(myDevframe, { transport: 'stdio' })
1313
```
1414

15-
`@modelcontextprotocol/server` is a peer dependency; `createMcpServer` speaks `stdio`, spawned per MCP 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 framework), sharing its origin/port. `--mcp` / `--no-mcp` override; `__connection.json` advertises it.
3333

34-
Each MCP 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

docs/content/7.migrations/1.migration-0.9.md

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
---
22
title: 'Migrating to 0.9'
3-
description: '0.9 removes the compatibility shims deprecated across the 0.7 series and trims the public API of devframe and @devframes/hub. Each change has a drop-in replacement.'
3+
description: '0.9 removes the compatibility shims deprecated across the 0.7 series, trims the public API of devframe and @devframes/hub, and moves the MCP surface to the stateless MCP 2026-07-28 protocol.'
44
---
55

6-
0.9 removes the compatibility shims deprecated across the 0.7 series and trims the public API of `devframe` and `@devframes/hub`. Each change has a drop-in replacement.
6+
0.9 removes the compatibility shims deprecated across the 0.7 series and trims the public API of `devframe` and `@devframes/hub`. Each change has a drop-in replacement. It also moves the [MCP](/adapters/mcp) surface to the stateless [MCP 2026-07-28 protocol](https://modelcontextprotocol.io/specification/2026-07-28) — the devframe API is unchanged; see [The MCP endpoints are stateless](#the-mcp-endpoints-are-stateless).
77

88
## `devframe/adapters/cli` is removed
99

@@ -332,3 +332,23 @@ export const DELETE = (req: Request) => hub.handler(req)
332332
```
333333

334334
`@devframes/vite/hub` and `@devframes/nuxt/hub` recommend the native [Vite DevTools](https://devtools.vite.dev) / [Nuxt DevTools](https://devtools.nuxt.com) once (silence with `{ quiet: true }`); `@devframes/next/hub` stays quiet.
335+
336+
## The MCP endpoints are stateless
337+
338+
The [MCP](/adapters/mcp) surface serves the stateless [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; the change is in how the endpoints serve requests on the wire.
339+
340+
- **HTTP** serves each request through the SDK's `createMcpHandler`, building a fresh server per request. There is no `Mcp-Session-Id` and no `initialize` handshake to open a session, so a request reaches any server instance without affinity. A `GET` or `DELETE` (the 2025 session operations) is answered `405`. 2025-era clients keep listing and calling tools and resources through the SDK's stateless legacy path; the live server-push channel for `list_changed` notifications is available to modern clients over the `subscriptions/listen` stream they open.
341+
- **stdio** serves the connection through the SDK's `serveStdio`, pinning one server instance per connection and negotiating the 2026-07-28 era (falling back to the 2025 handshake for a 2025-era opening).
342+
- **`devframe connect`** probes each instance with `server/discover` and negotiates the modern era, falling back to the 2025 handshake for a 2025-only instance.
343+
344+
A client that connects to devframe's HTTP endpoint should negotiate the modern era to use the stateless protocol; one left on the default (2025-era) negotiation is still served through the stateless legacy path:
345+
346+
```ts
347+
import { Client } from '@modelcontextprotocol/client'
348+
349+
const client = new Client(
350+
{ name: 'my-client', version: '1.0.0' },
351+
{ versionNegotiation: { mode: 'auto' } },
352+
)
353+
await client.connect(transport)
354+
```

docs/content/7.migrations/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +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.9](/migrations/migration-0.9) | Removes the compatibility shims deprecated across the 0.7 series and trims the public API. |
10+
| [Migrating to 0.9](/migrations/migration-0.9) | Removes the compatibility shims deprecated across the 0.7 series, trims the public API, and moves the MCP surface to the stateless MCP 2026-07-28 protocol. |
1111
| [Migrating to 0.8](/migrations/migration-0.8) | Makes RPC schemas validator-neutral and runtime-validated, and adds the agent-native MCP API. |
1212
| [Migrating to 0.7](/migrations/migration-0.7) | Makes `cac` an optional peer and moves json-render into an opt-in package. |
1313
| [Migrating to 0.6](/migrations/migration-0.6) | Tightens `defineDevframe`'s metadata, replaces the terminal and WebSocket transports, and adds enforced auth. |

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)