Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 MCP 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 framework), sharing its origin/port. `--mcp` / `--no-mcp` override; `__connection.json` advertises it.

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'] } }`.
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
24 changes: 22 additions & 2 deletions docs/content/7.migrations/1.migration-0.9.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
---
title: 'Migrating to 0.9'
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.'
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.'
---

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

## `devframe/adapters/cli` is removed

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

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

## The MCP endpoints are stateless

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.

- **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.
- **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).
- **`devframe connect`** probes each instance with `server/discover` and negotiates the modern era, falling back to the 2025 handshake for a 2025-only instance.

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:

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

const client = new Client(
{ name: 'my-client', version: '1.0.0' },
{ versionNegotiation: { mode: 'auto' } },
)
await client.connect(transport)
```
2 changes: 1 addition & 1 deletion docs/content/7.migrations/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Upgrade guides for devframe and `@devframes/hub`, newest first. Each one lists e

| Version | What changed |
| ------- | ------------ |
| [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.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. |
| [Migrating to 0.8](/migrations/migration-0.8) | Makes RPC schemas validator-neutral and runtime-validated, and adds the agent-native MCP API. |
| [Migrating to 0.7](/migrations/migration-0.7) | Makes `cac` an optional peer and moves json-render into an opt-in package. |
| [Migrating to 0.6](/migrations/migration-0.6) | Tightens `defineDevframe`'s metadata, replaces the terminal and WebSocket transports, and adds enforced auth. |
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