diff --git a/docs/content/2.adapters/7.mcp.md b/docs/content/2.adapters/7.mcp.md index b2f56ffa..a1630c76 100644 --- a/docs/content/2.adapters/7.mcp.md +++ b/docs/content/2.adapters/7.mcp.md @@ -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 @@ -31,7 +31,7 @@ export default defineDevframe({ The endpoint speaks Streamable-HTTP at `/__mcp` (`/__/__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 diff --git a/docs/content/7.migrations/1.migration-0.9.md b/docs/content/7.migrations/1.migration-0.9.md index fa9376a2..be1b6d16 100644 --- a/docs/content/7.migrations/1.migration-0.9.md +++ b/docs/content/7.migrations/1.migration-0.9.md @@ -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 @@ -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) +``` diff --git a/docs/content/7.migrations/index.md b/docs/content/7.migrations/index.md index 2d405d42..bcee572e 100644 --- a/docs/content/7.migrations/index.md +++ b/docs/content/7.migrations/index.md @@ -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. | diff --git a/packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts b/packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts index 15fb7385..4f415083 100644 --- a/packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts +++ b/packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts @@ -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') @@ -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 () => { diff --git a/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts b/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts index fddaa92d..cf9f7b4b 100644 --- a/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts +++ b/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts @@ -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, @@ -31,7 +31,6 @@ async function bootPair() { ctx, client, cleanup: async () => { - dispose() await client.close() await server.close() }, @@ -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, @@ -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() } @@ -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:'), @@ -355,7 +353,6 @@ describe('mcp adapter (in-memory)', () => { expect(hidden.isError).toBe(true) } finally { - dispose() await client.close() await server.close() } diff --git a/packages/devframe/src/adapters/mcp/build-server.ts b/packages/devframe/src/adapters/mcp/build-server.ts index 69f26a39..237c8fb6 100644 --- a/packages/devframe/src/adapters/mcp/build-server.ts +++ b/packages/devframe/src/adapters/mcp/build-server.ts @@ -40,18 +40,28 @@ export interface McpServerHandle { stop: () => Promise } +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, @@ -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() } } @@ -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 + // `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) @@ -144,8 +184,7 @@ export async function createMcpServer( return { async stop() { - dispose() - await stop() + await handle.close() }, } } diff --git a/packages/devframe/src/adapters/mcp/fetch.ts b/packages/devframe/src/adapters/mcp/fetch.ts index 32a8a482..384e6def 100644 --- a/packages/devframe/src/adapters/mcp/fetch.ts +++ b/packages/devframe/src/adapters/mcp/fetch.ts @@ -1,8 +1,7 @@ import type { DevframeNodeContext } from 'devframe/types' -import { randomUUID } from 'node:crypto' -import { isInitializeRequest, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server' +import { createMcpHandler } from '@modelcontextprotocol/server' import { isAllowedOrigin } from 'devframe/rpc/transports/ws-server' -import { buildMcpServerFromContext } from './build-server' +import { bridgeListChanged, buildMcpServerFromContext } from './build-server' export interface CreateMcpFetchHandlerOptions { /** Name reported in the MCP handshake. */ @@ -25,88 +24,53 @@ export interface CreateMcpFetchHandlerOptions { export interface McpFetchHandler { /** - * WHATWG-`fetch` handler for the MCP Streamable-HTTP endpoint. Hand every - * method (POST/GET/DELETE) on the endpoint's path to it — routing by path - * is the host's job. + * WHATWG-`fetch` handler for the MCP endpoint. Hand every method + * (POST/GET/DELETE) on the endpoint's path to it — routing by path is the + * host's job. */ fetch: (request: Request) => Promise - /** Tear down every live MCP session (closes servers, drops subscriptions). */ - dispose: () => Promise -} - -interface McpSession { - transport: WebStandardStreamableHTTPServerTransport + /** Tear down the handler (aborts in-flight exchanges, drops the change bridge). */ dispose: () => Promise } /** - * Build a framework-agnostic MCP Streamable-HTTP endpoint over a devframe - * context: a web-standard `Request → Response` handler any host can mount — - * h3 (see `mountMcpHttp`), a Next.js App Router route, or any other - * fetch-shaped server. + * Build a framework-agnostic MCP endpoint over a devframe context: a + * web-standard `Request → Response` handler any host can mount — h3 (see + * `mountMcpHttp`), a Next.js App Router route, or any other fetch-shaped + * server. + * + * The endpoint is **stateless**: it serves the 2026-07-28 revision per request + * through the SDK's {@link createMcpHandler}, which builds a fresh MCP server + * (from the shared, live `ctx` via `buildMcpServerFromContext`) for each + * request — no `Mcp-Session-Id` registry, no session-local routing, no + * GET/DELETE teardown protocol. 2025-era clients are still served through the + * SDK's default stateless legacy path. `list_changed` events reach modern + * `subscriptions/listen` streams through the handler's `notify` bus. * - * Each MCP session gets its own {@link WebStandardStreamableHTTPServerTransport} - * and MCP server (built from the shared, live `ctx` via - * `buildMcpServerFromContext`), correlated by the `Mcp-Session-Id` header: an - * `initialize` POST spins up a session; later requests route to it; a `DELETE` - * (or client disconnect) tears it down. The origin gate guards every request: - * loopback-default DNS-rebinding protection that — unlike the WS upgrade's - * `isAllowedOrigin` — also rejects `Origin`-less requests, so a route-based - * endpoint isn't reachable by an arbitrary local process. + * The origin gate guards every request: loopback-default DNS-rebinding + * protection that — unlike the WS upgrade's `isAllowedOrigin` — also rejects + * `Origin`-less requests, so a route-based endpoint isn't reachable by an + * arbitrary local process. */ export function createMcpFetchHandler( ctx: DevframeNodeContext, options: CreateMcpFetchHandlerOptions, ): McpFetchHandler { - const sessions = new Map() const allowedOrigins = options.allowedOrigins - function drop(sessionId: string): void { - const session = sessions.get(sessionId) - if (!session) - return - sessions.delete(sessionId) - void session.dispose() - } - - async function createSession(): Promise { - // Declared up front so the transport's session callbacks can capture it; - // it's assigned before any of them can fire (they run during - // `handleRequest`, after `connect` below). - let session!: McpSession - - const transport = new WebStandardStreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: (id) => { - sessions.set(id, session) - }, - onsessionclosed: (id) => { - drop(id) - }, - }) - - const { server, dispose } = buildMcpServerFromContext(ctx, { - serverName: options.serverName, - serverVersion: options.serverVersion, - exposeSharedState: options.exposeSharedState, - }) + const handler = createMcpHandler(() => buildMcpServerFromContext(ctx, { + serverName: options.serverName, + serverVersion: options.serverVersion, + exposeSharedState: options.exposeSharedState, + })) - session = { - transport, - dispose: async () => { - dispose() - await server.close() - }, - } - - transport.onclose = () => { - if (transport.sessionId) - drop(transport.sessionId) - } - - await server.connect(transport) - return session - } + // A single, long-lived bridge from devframe's change events onto the + // handler's `subscriptions/listen` bus — published once for the endpoint, + // not per (ephemeral, per-request) server instance. + const unbridge = bridgeListChanged(ctx, { + tools: () => { handler.notify.toolsChanged() }, + resources: () => { handler.notify.resourcesChanged() }, + }) async function handle(req: Request): Promise { // Origin gate — the endpoint's DNS-rebinding protection and its guard @@ -118,56 +82,14 @@ export function createMcpFetchHandler( if (allowedOrigins !== false && (origin === undefined || !isAllowedOrigin(origin, allowedOrigins ?? []))) return new Response('Forbidden: origin required', { status: 403 }) - const sessionId = req.headers.get('mcp-session-id') ?? undefined - let session = sessionId ? sessions.get(sessionId) : undefined - - // A POST may carry an `initialize` request that opens a brand-new - // session. Parse the body once and hand it to the transport as - // `parsedBody` (the web Request body can only be consumed once). - if (!session && req.method === 'POST') { - let body: unknown - try { - body = await req.json() - } - catch { - body = undefined - } - - if (!sessionId && isInitializeRequest(body)) { - session = await createSession() - } - else { - return new Response( - sessionId - ? 'Not Found: unknown MCP session' - : 'Bad Request: no valid session ID and not an initialize request', - { status: sessionId ? 404 : 400 }, - ) - } - - return session.transport.handleRequest(req, { parsedBody: body }) - } - - if (!session) { - // GET (open the SSE stream) / DELETE (end the session) require a - // known session id. - return new Response( - sessionId - ? 'Not Found: unknown MCP session' - : 'Bad Request: missing MCP session ID', - { status: sessionId ? 404 : 400 }, - ) - } - - return session.transport.handleRequest(req) + return handler.fetch(req) } return { fetch: handle, dispose: async () => { - const live = [...sessions.values()] - sessions.clear() - await Promise.all(live.map(session => session.dispose())) + unbridge() + await handler.close() }, } } diff --git a/packages/devframe/src/adapters/mcp/http.ts b/packages/devframe/src/adapters/mcp/http.ts index 48d2ea12..125ddddf 100644 --- a/packages/devframe/src/adapters/mcp/http.ts +++ b/packages/devframe/src/adapters/mcp/http.ts @@ -7,22 +7,22 @@ import { createMcpFetchHandler } from './fetch' export interface MountMcpHttpOptions extends CreateMcpFetchHandlerOptions {} export interface MountedMcpHttp { - /** Tear down every live MCP session (closes servers, drops subscriptions). */ + /** Tear down the MCP handler (aborts in-flight exchanges, drops the change bridge). */ dispose: () => Promise } /** - * Mount an MCP Streamable-HTTP endpoint on an h3 app at `path` — the h3 - * binding over {@link createMcpFetchHandler}, which owns the sessions, the + * Mount a stateless MCP endpoint on an h3 app at `path` — the h3 binding over + * {@link createMcpFetchHandler}, which owns the per-request serving, the * origin gate, and the transport plumbing. * * The handler is web-standard — it takes the h3 event's web `Request` and - * returns a web `Response` (an SSE `ReadableStream` body for the - * server→client stream). We copy that response onto `event.res` and return - * its body rather than returning the `Response` object directly, so a - * legitimate MCP 404 (unknown session) isn't swallowed by h3's - * "Response-with-404 falls through to the next handler" rule (which would - * otherwise hand the request to the SPA static catch-all). + * returns a web `Response` (an SSE `ReadableStream` body for a + * `subscriptions/listen` stream). We copy that response onto `event.res` and + * return its body rather than returning the `Response` object directly, so an + * MCP error response (e.g. a 4xx) isn't swallowed by h3's "Response-with-404 + * falls through to the next handler" rule (which would otherwise hand the + * request to the SPA static catch-all). */ export function mountMcpHttp( app: H3, diff --git a/packages/devframe/src/adapters/mcp/transports.ts b/packages/devframe/src/adapters/mcp/transports.ts deleted file mode 100644 index afe559d0..00000000 --- a/packages/devframe/src/adapters/mcp/transports.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Server } from '@modelcontextprotocol/server' -import { StdioServerTransport } from '@modelcontextprotocol/server/stdio' - -/** - * Start the MCP server on stdio. Returns a stop function. - * @internal - */ -export async function startStdioTransport(server: Server): Promise<() => Promise> { - const transport = new StdioServerTransport() - await server.connect(transport) - return async () => { - await server.close() - } -} diff --git a/packages/devframe/src/cli/connect.ts b/packages/devframe/src/cli/connect.ts index a42b4405..83296e54 100644 --- a/packages/devframe/src/cli/connect.ts +++ b/packages/devframe/src/cli/connect.ts @@ -255,7 +255,14 @@ async function withInstanceClient( new URL(url), { requestInit: { headers: { origin } } }, ) - const client = new sdk.Client({ name: 'devframe-connect', version: '0.0.0' }) + // Negotiate the era with `server/discover`, falling back to the 2025 + // `initialize` handshake for a 2025-only instance. Devframe's own route is + // stateless 2026-07-28, but a mixed fleet (older instances, third-party + // MCP servers reached by port) may still be 2025-era. + const client = new sdk.Client( + { name: 'devframe-connect', version: '0.0.0' }, + { versionNegotiation: { mode: 'auto' } }, + ) await client.connect(transport) try { return await fn(client) diff --git a/packages/hub/src/node/__tests__/initiate.test.ts b/packages/hub/src/node/__tests__/initiate.test.ts index 4649b21d..e1dd1032 100644 --- a/packages/hub/src/node/__tests__/initiate.test.ts +++ b/packages/hub/src/node/__tests__/initiate.test.ts @@ -297,49 +297,20 @@ describe('initHub', () => { await hub.ready expect(hub.connectionMeta().mcp).toEqual({ path: '__mcp' }) + // The endpoint is stateless: a single `tools/list` POST is answered per + // request, with no `initialize` handshake and no `Mcp-Session-Id`. const origin = 'http://localhost:3000' - const init = await hub.handler(new Request(`${origin}/__devframes/__mcp`, { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'accept': 'application/json, text/event-stream', - origin, - }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'x', version: '0' } }, - }), - })) - expect(init.status).toBe(200) - const sessionId = init.headers.get('mcp-session-id') - expect(sessionId).toBeTruthy() - await init.body?.cancel() - - const initialized = await hub.handler(new Request(`${origin}/__devframes/__mcp`, { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'accept': 'application/json, text/event-stream', - 'mcp-session-id': sessionId!, - origin, - }, - body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }), - })) - await initialized.body?.cancel() - const list = await hub.handler(new Request(`${origin}/__devframes/__mcp`, { method: 'POST', headers: { 'content-type': 'application/json', 'accept': 'application/json, text/event-stream', - 'mcp-session-id': sessionId!, origin, }, body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list' }), })) expect(list.status).toBe(200) + expect(list.headers.get('mcp-session-id')).toBeNull() const raw = await list.text() // Tools from both frames surface through the one aggregate endpoint. expect(raw).toContain('alpha-tool') diff --git a/packages/hub/src/node/initiate.ts b/packages/hub/src/node/initiate.ts index c220f404..1905ec37 100644 --- a/packages/hub/src/node/initiate.ts +++ b/packages/hub/src/node/initiate.ts @@ -331,7 +331,7 @@ export interface HubInstance { context: Promise /** The `ConnectionMeta` served at `__connection.json` (and every frame base). */ connectionMeta: () => ConnectionMeta - /** Tear down: WS transport/side-car, MCP sessions. */ + /** Tear down: WS transport/side-car, MCP handler. */ close: () => Promise } diff --git a/packages/next/test/handler.test.ts b/packages/next/test/handler.test.ts index 437a1d25..c3c3198d 100644 --- a/packages/next/test/handler.test.ts +++ b/packages/next/test/handler.test.ts @@ -98,7 +98,9 @@ describe('createDevframeNextHandler', () => { // The advertised endpoint answers MCP initialize through the route // handler when a loopback Origin (required by the route's gate) is - // presented. + // presented. A 2025-era `initialize` is served statelessly through the + // SDK's default legacy path — answered per request with no + // `Mcp-Session-Id`. const origin = 'http://localhost:3000' const initBody = JSON.stringify({ jsonrpc: '2.0', @@ -116,7 +118,7 @@ describe('createDevframeNextHandler', () => { body: initBody, })) expect(init.status).toBe(200) - expect(init.headers.get('mcp-session-id')).toBeTruthy() + expect(init.headers.get('mcp-session-id')).toBeNull() await init.body?.cancel() // Without an Origin header the same request is rejected. diff --git a/tests/optional-mcp-bundles.test.ts b/tests/optional-mcp-bundles.test.ts index 1a1cb771..e2a9c1ce 100644 --- a/tests/optional-mcp-bundles.test.ts +++ b/tests/optional-mcp-bundles.test.ts @@ -91,8 +91,10 @@ describe('optional MCP peers in consumer bundles', () => { }), })) + // A 2025-era `initialize` is served statelessly through the SDK's + // default legacy path — answered per request with no `Mcp-Session-Id`. expect(response.status).toBe(200) - expect(response.headers.get('mcp-session-id')).toBeTruthy() + expect(response.headers.get('mcp-session-id')).toBeNull() await response.body?.cancel() } finally {