Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/content/2.adapters/7.mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import devframe from './devframe'
await createMcpServer(devframe, { transport: 'stdio' })
```

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

## Route-based server

Expand All @@ -31,7 +31,7 @@ export default defineDevframe({

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

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'] } }`.
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'] } }`.

### Hosted bridges

Expand Down
41 changes: 41 additions & 0 deletions docs/content/7.migrations/1.migration-0.10.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
title: 'Migrating to 0.10'
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.'
---

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.

## The MCP endpoint is stateless

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.

- Every request stands alone and can reach any server instance — no session affinity.
- A `GET` or `DELETE` (the 2025 session operations) is answered `405 Method Not Allowed`.
- 2025-era clients still work: they are served through the SDK's stateless legacy path per request.
- `list_changed` events reach modern clients over the `subscriptions/listen` stream they open.

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.

## stdio is served through `serveStdio`

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

## `devframe connect` negotiates the modern era

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.

## Connecting your own MCP client

A client that connects to devframe's endpoint should negotiate the modern era to use the stateless protocol:

```ts
import { Client } from '@modelcontextprotocol/client'

const client = new Client(
{ name: 'my-client', version: '1.0.0' },
{ versionNegotiation: { mode: 'auto' } },
)
await client.connect(transport)
```

A client left on the default (2025-era) negotiation is still served through the stateless legacy path.
1 change: 1 addition & 0 deletions docs/content/7.migrations/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Upgrade guides for devframe and `@devframes/hub`, newest first. Each one lists e

| Version | What changed |
| ------- | ------------ |
| [Migrating to 0.10](/migrations/migration-0.10) | Moves the MCP surface to the stateless MCP 2026-07-28 protocol. |
| [Migrating to 0.9](/migrations/migration-0.9) | Removes the compatibility shims deprecated across the 0.7 series and trims the public API. |
| [Migrating to 0.8](/migrations/migration-0.8) | Makes RPC schemas validator-neutral and runtime-validated, and adds the agent-native MCP surface. |
| [Migrating to 0.7](/migrations/migration-0.7) | Makes `cac` an optional peer and moves json-render into an opt-in package. |
Expand Down
69 changes: 19 additions & 50 deletions packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,20 @@ describe('mcp adapter (streamable http route)', () => {
})
}

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

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

it('tears the session down on DELETE and rejects reuse of the id', async () => {
it('answers a bare GET with 405 (no session lifecycle)', async () => {
const started = await boot()
const url = `${started.origin}/__mcp`

// Initialize over raw HTTP to capture the issued session id from the
// response header (the body is an SSE stream we can discard).
const originHeader = { origin: started.origin }
const init = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
'accept': 'application/json, text/event-stream',
...originHeader,
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'x', version: '0' } },
}),
})
const sessionId = init.headers.get('mcp-session-id')
await init.body?.cancel()
expect(sessionId).toBeTruthy()

// DELETE ends the session.
const del = await fetch(url, {
method: 'DELETE',
headers: { 'mcp-session-id': sessionId!, ...originHeader },
})
await del.body?.cancel()
expect(del.status).toBeLessThan(300)

// Reusing the terminated id is no longer a known session — the server
// answers 404 rather than falling through to the SPA static catch-all.
const stale = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
'accept': 'application/json, text/event-stream',
'mcp-session-id': sessionId!,
...originHeader,
},
body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list' }),
// Stateless serving has no session stream to open — the SDK answers a
// GET (a 2025 session operation) with `405 Method Not Allowed` rather
// than falling through to the SPA static catch-all.
const res = await fetch(`${started.origin}/__mcp`, {
method: 'GET',
headers: { accept: 'text/event-stream', origin: started.origin },
})
await stale.body?.cancel()
expect(stale.status).toBe(404)
await res.body?.cancel()
expect(res.status).toBe(405)
})

it('rejects an Origin-less request', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ function nullHost(): DevframeHost {
async function bootPair() {
const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: nullHost() })

const { server, dispose } = buildMcpServerFromContext(ctx, {
const server = buildMcpServerFromContext(ctx, {
serverName: 'test',
serverVersion: '0.0.0-test',
exposeSharedState: true,
Expand All @@ -31,7 +31,6 @@ async function bootPair() {
ctx,
client,
cleanup: async () => {
dispose()
await client.close()
await server.close()
},
Expand Down Expand Up @@ -314,7 +313,7 @@ describe('mcp adapter (in-memory)', () => {

it('hides devframe:state:read when shared-state exposure is disabled', async () => {
const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: nullHost() })
const { server, dispose } = buildMcpServerFromContext(ctx, {
const server = buildMcpServerFromContext(ctx, {
serverName: 'test',
serverVersion: '0.0.0-test',
exposeSharedState: false,
Expand All @@ -328,7 +327,6 @@ describe('mcp adapter (in-memory)', () => {
expect(listed.tools.map(t => t.name)).not.toContain('devframe_state_read')
}
finally {
dispose()
await client.close()
await server.close()
}
Expand All @@ -338,7 +336,7 @@ describe('mcp adapter (in-memory)', () => {
const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: nullHost() })
await ctx.rpc.sharedState.get('visible:key', { initialValue: { n: 1 } })
await ctx.rpc.sharedState.get('hidden:key', { initialValue: { n: 2 } })
const { server, dispose } = buildMcpServerFromContext(ctx, {
const server = buildMcpServerFromContext(ctx, {
serverName: 'test',
serverVersion: '0.0.0-test',
exposeSharedState: key => key.startsWith('visible:'),
Expand All @@ -355,7 +353,6 @@ describe('mcp adapter (in-memory)', () => {
expect(hidden.isError).toBe(true)
}
finally {
dispose()
await client.close()
await server.close()
}
Expand Down
91 changes: 65 additions & 26 deletions packages/devframe/src/adapters/mcp/build-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,18 +40,28 @@ export interface McpServerHandle {
stop: () => Promise<void>
}

export interface BuildMcpServerOptions {
serverName: string
serverVersion: string
exposeSharedState: boolean | ((k: string) => boolean)
}

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

const notify = (method: string): void => {
server.notification({ method }).catch(() => { /* ignore transport errors */ })
}
return server
}

/**
* Publish devframe's `list_changed` events through a set of typed sinks:
* `tools()` for tool-list changes and `resources()` for resource-list
* changes (shared-state keys are surfaced as resources). Returns an
* unsubscribe function.
*
* The HTTP path passes the handler's `notify` bus sugar; the stdio path
* passes the pinned server's `send*ListChanged` methods, which `serveStdio`
* routes onto the connection's active `subscriptions/listen` streams.
*
* @internal
*/
export function bridgeListChanged(
ctx: DevframeNodeContext,
sinks: { tools: () => void, resources: () => void },
): () => void {
const offManifest = ctx.agent.events.on(DEVFRAME_EVENTS.bus.agentManifestChanged, () => {
notify('notifications/tools/list_changed')
notify('notifications/resources/list_changed')
sinks.tools()
sinks.resources()
})
const offKeyAdded = ctx.rpc.sharedState.onKeyAdded(() => {
notify('notifications/resources/list_changed')
sinks.resources()
})

return {
server,
dispose: () => {
offManifest()
offKeyAdded()
},
return () => {
offManifest()
offKeyAdded()
}
}

Expand Down Expand Up @@ -124,16 +146,34 @@ export async function createMcpServer(
await ctx.services.ready()
await definition.setup(ctx)

const { server, dispose } = buildMcpServerFromContext(ctx, {
const buildOptions: BuildMcpServerOptions = {
serverName: options.serverName ?? `${definition.id} (devframe)`,
serverVersion: options.serverVersion ?? definition.version ?? '0.0.0',
exposeSharedState: options.exposeSharedState ?? true,
})
}

const { startStdioTransport } = await import('./transports')
let stop: () => Promise<void>
// `serveStdio` owns the connection's era decision and pins ONE instance
// for its lifetime. Each pinned server sets up its own `list_changed`
// bridge over the connection's `send*ListChanged` calls (routed onto the
// active `subscriptions/listen` streams on a modern connection, sent
// unsolicited on a 2025-era one) and tears it down when that server
// closes.
let handle: import('@modelcontextprotocol/server/stdio').StdioServerHandle
try {
stop = await startStdioTransport(server)
const { serveStdio } = await import('@modelcontextprotocol/server/stdio')
handle = serveStdio(() => {
const server = buildMcpServerFromContext(ctx, buildOptions)
const unbridge = bridgeListChanged(ctx, {
tools: () => { void server.sendToolListChanged().catch(() => {}) },
resources: () => { void server.sendResourceListChanged().catch(() => {}) },
})
const priorOnClose = server.onclose
server.onclose = () => {
unbridge()
priorOnClose?.()
}
return server
})
}
catch (error) {
const reason = error instanceof Error ? error.message : String(error)
Expand All @@ -144,8 +184,7 @@ export async function createMcpServer(

return {
async stop() {
dispose()
await stop()
await handle.close()
},
}
}
Expand Down
Loading
Loading