From d88a6ae0696c9ef81f4fb7263468c4c9d5e21c93 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:49:21 +0800 Subject: [PATCH 1/3] fix(mcp)!: the stateless /api/v1/mcp answers GET and DELETE with 405 Allow: POST MCPServerRuntime.handleHttpRequest serves a stateless transport (sessionIdGenerator undefined, enableJsonResponse true) and closes the per-request server in finally, so it can hold no server-initiated SSE stream and has no session to end. A GET still reached the SDK's handleGetRequest, which answered 200 text/event-stream and lost the stream when the server closed; the MCP client read that as a dropped stream and re-opened it 2-3 times a second. Every verb but POST now answers 405 with Allow: POST and the SDK transport's own JSON-RPC 405 body, before a server is built. The Streamable HTTP transport gives that answer for a server with no GET stream and no session termination, and the SDK client stops opening the stream on it. POST is unchanged. The hosts need no change: the standalone dispatcher, a createHonoApp host and cloud's ceiling proxy all call this one function after the anonymous 401. Tests: mcp-server-runtime.http-verbs.test.ts (verbs, POST round trip, the SDK client opens the GET once) and a two-door conformance test with the real runtime in @objectstack/http-conformance. Claude-Session: https://claude.ai/code/session_6eb9ec2e-0e3f-410b-9123-9dab7a337b9e Co-authored-by: Claude --- .changeset/25759-mcp-stateless-get-405.md | 19 ++ .../src/mcp-server-runtime.http-verbs.test.ts | 210 ++++++++++++++++++ packages/mcp/src/mcp-server-runtime.ts | 49 ++++ packages/qa/http-conformance/package.json | 1 + ...no-mcp-stateless-verbs.conformance.test.ts | 160 +++++++++++++ packages/qa/http-conformance/vitest.config.ts | 7 + packages/runtime/src/dispatcher-plugin.ts | 15 +- pnpm-lock.yaml | 3 + 8 files changed, 459 insertions(+), 5 deletions(-) create mode 100644 .changeset/25759-mcp-stateless-get-405.md create mode 100644 packages/mcp/src/mcp-server-runtime.http-verbs.test.ts create mode 100644 packages/qa/http-conformance/src/hono-mcp-stateless-verbs.conformance.test.ts diff --git a/.changeset/25759-mcp-stateless-get-405.md b/.changeset/25759-mcp-stateless-get-405.md new file mode 100644 index 00000000000..52fc9bd704e --- /dev/null +++ b/.changeset/25759-mcp-stateless-get-405.md @@ -0,0 +1,19 @@ +--- +'@objectstack/mcp': patch +--- + +fix(mcp)!: the stateless `/api/v1/mcp` surface answers `GET` and `DELETE` with `405` and `Allow: POST`, so an MCP client stops re-opening a GET stream that ends at once + +Clause-②: yes (narrowing) +Metadata-protocol: none + + + +**BREAKING** accept-set narrowing at the MCP HTTP transport (`MCPServerRuntime.handleHttpRequest`). + +- **Before.** The transport is stateless (no session id) and closes its per-request server when the request ends. A `GET` still reached the SDK's stream handler, which answered `200 text/event-stream` and then lost the stream when the server closed. An MCP client reads a GET stream that ends as a dropped connection and opens another one: claude-code re-opened it 2–3 times a second for as long as a session stayed connected. A `DELETE` answered `200` and ended nothing, because no session exists. Every other verb answered `405` with `Allow: GET, POST, DELETE`. +- **Now.** Every verb but `POST` answers `405` with `Allow: POST` and the JSON-RPC body `{"jsonrpc":"2.0","error":{"code":-32000,"message":"Method not allowed."},"id":null}`, before a server is built. This is the Streamable HTTP transport's answer for a server that offers no GET stream and does not end sessions. The MCP SDK client treats a `405` on GET as "no stream offered" and does not retry it. +- **Unchanged.** `POST` (JSON-RPC: `initialize`, `tools/list`, `tools/call`, …) answers exactly as before. An anonymous caller is still refused `401` first on every verb. +- **Who sees it.** A client that opens the GET stream on `/api/v1/mcp` now gets `405` instead of a `200` stream that ends at once. Nothing was ever sent on that stream. + +**The remedy.** None needed for an MCP client: it stops opening the stream. A probe or health check that read `GET /api/v1/mcp` as `200` should send a `POST initialize` instead. diff --git a/packages/mcp/src/mcp-server-runtime.http-verbs.test.ts b/packages/mcp/src/mcp-server-runtime.http-verbs.test.ts new file mode 100644 index 00000000000..b5e82ef9438 --- /dev/null +++ b/packages/mcp/src/mcp-server-runtime.http-verbs.test.ts @@ -0,0 +1,210 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25759] The stateless Streamable HTTP surface serves POST alone: GET and + * DELETE (and every other verb) answer `405` with `Allow: POST`. + * + * ## The defect + * + * `handleHttpRequest` builds a stateless transport (`sessionIdGenerator: + * undefined`, `enableJsonResponse: true`) and closes the per-request server in + * `finally`. Before this change a GET went on to the SDK's `handleGetRequest`, + * which answered `200 text/event-stream`, and the stream closed with the + * server. Measured on `5874d25302`: `200`, empty body, ~13 ms. The SDK client + * reads a GET stream that ends as a dropped connection and opens another one; + * production saw ~183 `GET /api/v1/mcp` a minute from one machine with Claude + * Code sessions open (cloud#3103). A DELETE answered `200` and ended nothing, + * because no session exists. + * + * ## What is pinned + * + * 1. GET, DELETE and the other verbs answer `405`, `Allow: POST`, with the + * SDK transport's own JSON-RPC 405 body, and no bridge member is read. + * 2. POST is unchanged: `initialize` and `tools/list` round trips answer + * `200` with no session id. + * 3. The SDK's own client, connected over this surface, opens the GET stream + * once and never again, and its tool calls still work. This is the + * reconnect loop itself: with the 200 stream, the same client re-opens the + * GET every time it closes. + */ + +import { describe, it, expect } from 'vitest'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; + +import { MCPServerRuntime } from './mcp-server-runtime.js'; +import type { McpDataBridge } from './mcp-http-tools.js'; + +const URL_ = 'http://localhost/api/v1/mcp'; + +/** A bridge that records every member read, so a refused verb can be shown to read none. */ +function makeBridge(): McpDataBridge & { calls: string[] } { + const calls: string[] = []; + return { + calls, + async listObjects() { + calls.push('listObjects'); + return [{ name: 'task', label: 'Task', fieldCount: 1 }]; + }, + async describeObject() { + calls.push('describeObject'); + return { name: 'task', fields: [{ name: 'title', type: 'text' }] }; + }, + async query(object: string) { + calls.push('query'); + return { object, records: [], total: 0 }; + }, + async get(_object: string, id: string) { + calls.push('get'); + return { id }; + }, + async create(object: string, data: any) { + calls.push('create'); + return { object, id: 'n1', record: data }; + }, + async update(object: string, id: string, data: any) { + calls.push('update'); + return { object, id, record: data }; + }, + async remove(object: string, id: string) { + calls.push('remove'); + return { object, id, success: true }; + }, + }; +} + +/** The headers a claude-code client sends on its GET (cloud#3103's reading). */ +const CLIENT_GET_HEADERS = { + accept: 'text/event-stream', + 'mcp-protocol-version': '2025-06-18', + 'x-api-key': 'osk_test', +}; + +const INIT = { + jsonrpc: '2.0', + id: 0, + method: 'initialize', + params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 't', version: '1' } }, +}; + +async function post(runtime: MCPServerRuntime, body: unknown, bridge?: McpDataBridge): Promise { + return runtime.handleHttpRequest( + new Request(URL_, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify(body), + }), + { bridge, parsedBody: body }, + ); +} + +async function expectMethodNotAllowed(res: Response): Promise { + expect(res.status).toBe(405); + expect(res.headers.get('allow')).toBe('POST'); + expect(res.headers.get('content-type')).toContain('application/json'); + expect(res.headers.get('mcp-session-id')).toBeNull(); + expect(await res.json()).toEqual({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Method not allowed.' }, + id: null, + }); +} + +describe('[#25759] the stateless /api/v1/mcp surface answers every verb but POST with 405', () => { + it('GET answers 405 with Allow: POST, not an SSE stream that ends at once', async () => { + const runtime = new MCPServerRuntime({ name: 'objectstack-test', version: '9.9.9' }); + const bridge = makeBridge(); + const res = await runtime.handleHttpRequest(new Request(URL_, { method: 'GET', headers: CLIENT_GET_HEADERS }), { + bridge, + }); + + expect(res.headers.get('content-type')).not.toContain('text/event-stream'); + await expectMethodNotAllowed(res); + expect(bridge.calls, 'a refused verb reads no bridge member').toEqual([]); + }); + + it('DELETE answers 405 with Allow: POST: there is no session to end', async () => { + const runtime = new MCPServerRuntime({ name: 'objectstack-test', version: '9.9.9' }); + for (const headers of [{}, { 'mcp-session-id': 'never-issued', 'mcp-protocol-version': '2025-06-18' }]) { + const res = await runtime.handleHttpRequest(new Request(URL_, { method: 'DELETE', headers }), { + bridge: makeBridge(), + }); + await expectMethodNotAllowed(res); + } + }); + + it('every other verb answers 405 with Allow: POST (the SDK used to name GET and DELETE in Allow)', async () => { + const runtime = new MCPServerRuntime({ name: 'objectstack-test', version: '9.9.9' }); + for (const method of ['PUT', 'PATCH', 'OPTIONS']) { + const res = await runtime.handleHttpRequest(new Request(URL_, { method }), { bridge: makeBridge() }); + await expectMethodNotAllowed(res); + } + const head = await runtime.handleHttpRequest(new Request(URL_, { method: 'HEAD' }), { bridge: makeBridge() }); + expect(head.status).toBe(405); + expect(head.headers.get('allow')).toBe('POST'); + }); + + it('POST is unchanged: initialize and tools/list round trips answer 200 with no session id', async () => { + const runtime = new MCPServerRuntime({ name: 'objectstack-test', version: '9.9.9' }); + const bridge = makeBridge(); + + const init = await post(runtime, INIT, bridge); + expect(init.status).toBe(200); + expect(init.headers.get('mcp-session-id')).toBeNull(); + const initJson: any = await init.json(); + expect(initJson.result.serverInfo).toEqual({ name: 'objectstack-test', version: '9.9.9' }); + expect(initJson.result.capabilities.tools).toBeDefined(); + + const initialized = await post(runtime, { jsonrpc: '2.0', method: 'notifications/initialized' }, bridge); + expect(initialized.status).toBe(202); + + const list = await post(runtime, { jsonrpc: '2.0', id: 1, method: 'tools/list' }, bridge); + expect(list.status).toBe(200); + const names = ((await list.json()) as any).result.tools.map((t: any) => t.name); + expect(names).toContain('query_records'); + expect(names).toContain('list_objects'); + }); + + it('the SDK client opens the GET stream once and never again, and its calls still work', async () => { + const runtime = new MCPServerRuntime({ name: 'objectstack-test', version: '9.9.9' }); + const bridge = makeBridge(); + const seen: string[] = []; + + // Route the client's fetch straight into the surface, as a host's dispatcher does. + const fetchIntoSurface = async (input: string | URL | Request, init?: RequestInit): Promise => { + const request = new Request(input, init); + seen.push(request.method); + const text = request.method === 'POST' ? await request.text() : ''; + return runtime.handleHttpRequest(request, { bridge, parsedBody: text ? JSON.parse(text) : undefined }); + }; + + const transport = new StreamableHTTPClientTransport(new URL(URL_), { + fetch: fetchIntoSurface as typeof fetch, + // A reconnect as fast as the loop can go, so a dropped GET stream shows + // up as many GETs inside the window below rather than one per second. + reconnectionOptions: { + initialReconnectionDelay: 5, + maxReconnectionDelay: 5, + reconnectionDelayGrowFactor: 1, + maxRetries: 1000, + }, + }); + const client = new Client({ name: 'verb-test', version: '1.0.0' }); + const errors: unknown[] = []; + client.onerror = (err) => errors.push(err); + + try { + await client.connect(transport); + const tools = await client.listTools(); + expect(tools.tools.map((t) => t.name)).toContain('query_records'); + + await new Promise((resolve) => setTimeout(resolve, 250)); + + const gets = seen.filter((m) => m === 'GET').length; + expect(gets, `the client opened the GET stream ${gets} times: ${seen.join(' ')}`).toBe(1); + expect(errors, 'a 405 on GET is the "no stream offered" answer, not a client error').toEqual([]); + } finally { + await client.close(); + } + }); +}); diff --git a/packages/mcp/src/mcp-server-runtime.ts b/packages/mcp/src/mcp-server-runtime.ts index 8f432968ac6..48b9d6a4b9f 100644 --- a/packages/mcp/src/mcp-server-runtime.ts +++ b/packages/mcp/src/mcp-server-runtime.ts @@ -974,6 +974,33 @@ export async function buildObjectListResource( }; } +/** + * The verbs the stateless Streamable HTTP surface serves: POST alone. Sent as + * the `Allow` header of {@link methodNotAllowedResponse}. + */ +const MCP_HTTP_ALLOWED_METHODS = 'POST'; + +/** + * The stateless transport's answer to any verb but POST (#25759): `405` with + * `Allow: POST`. The body is the SDK transport's own 405 body + * (`handleUnsupportedRequest`), so a client reads the same JSON-RPC error it + * read for an unsupported verb before; only the status of GET and DELETE and + * the `Allow` value change. + */ +function methodNotAllowedResponse(): Response { + return new Response( + JSON.stringify({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Method not allowed.' }, + id: null, + }), + { + status: 405, + headers: { Allow: MCP_HTTP_ALLOWED_METHODS, 'Content-Type': 'application/json' }, + }, + ); +} + /** * MCPServerRuntime — Bridges ObjectStack kernel services to the Model Context Protocol. * @@ -1624,6 +1651,8 @@ export class MCPServerRuntime { * Stateless by design: a fresh {@link McpServer} + transport is built per * request (the SDK-recommended pattern for stateless HTTP — it avoids any * cross-request session/request-id collision and keeps each call isolated). + * So it serves POST alone: GET (a server-initiated SSE stream), DELETE (a + * session end) and every other verb answer `405` with `Allow: POST` (#25759). * The tool set is the object-CRUD bridge plus — when the bridge can resolve * the framework's action mechanism — the business-action tools * (`list_actions` / `run_action`, and `resume_run` when the bridge can @@ -1660,6 +1689,26 @@ export class MCPServerRuntime { toolOptions?: RegisterObjectToolsOptions & RegisterActionToolsOptions; } = {}, ): Promise { + // [#25759] This transport answers POST and nothing else. It is stateless + // (no session id) and the per-request server is closed in `finally`, so it + // can hold no server-initiated SSE stream and has no session to end: + // - GET used to reach the SDK's `handleGetRequest`, which answered `200 + // text/event-stream` and then lost the stream when `finally` closed the + // server. The client read a dropped stream and opened another one, 2–3 + // times a second for as long as a session stayed connected. The Streamable + // HTTP transport gives a server with no GET stream one answer: `405`, and + // the SDK client then stops opening one (`client/streamableHttp.js` + // treats 405 on GET as "this server offers no stream"). + // - DELETE ends a session. The SDK client sends it only for a session id + // the server issued, and this server never issues one; the transport + // lets a server that does not end sessions answer `405`. + // Every other verb was a 405 already, with an `Allow` that named GET and + // DELETE. All of them are answered here, before a server is built, so a + // refused verb pays for no tool registration. + if (request.method !== 'POST') { + return methodNotAllowedResponse(); + } + // The prompt surface is wired by capability, like the action tools: a // bridge that cannot read skill metadata gets no `prompts` capability and // no handlers, rather than a capability that answers nothing. diff --git a/packages/qa/http-conformance/package.json b/packages/qa/http-conformance/package.json index 808cf0e4e4c..73c22de379d 100644 --- a/packages/qa/http-conformance/package.json +++ b/packages/qa/http-conformance/package.json @@ -17,6 +17,7 @@ "devDependencies": { "@objectstack/driver-sqlite-wasm": "workspace:*", "@objectstack/hono": "workspace:*", + "@objectstack/mcp": "workspace:*", "@objectstack/objectql": "workspace:*", "@objectstack/plugin-approvals": "workspace:*", "@objectstack/plugin-auth": "workspace:*", diff --git a/packages/qa/http-conformance/src/hono-mcp-stateless-verbs.conformance.test.ts b/packages/qa/http-conformance/src/hono-mcp-stateless-verbs.conformance.test.ts new file mode 100644 index 00000000000..d525584dba2 --- /dev/null +++ b/packages/qa/http-conformance/src/hono-mcp-stateless-verbs.conformance.test.ts @@ -0,0 +1,160 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25759] `/api/v1/mcp` answers `GET` and `DELETE` with `405` and + * `Allow: POST` on both doors that mount it, and `POST` is unchanged. + * + * The verb decision lives in `MCPServerRuntime.handleHttpRequest` + * (`@objectstack/mcp`): the transport is stateless and closes its per-request + * server in `finally`, so it can hold no GET stream and has no session to end. + * Before the change a GET answered `200 text/event-stream` with a stream that + * ended at once, and claude-code re-opened it 2–3 times a second (cloud#3103). + * The hosts add no verb logic of their own on `/mcp`: the standalone server + * mounts GET, POST and DELETE and hands each to `dispatch()`, and a + * `createHonoApp` host's catch-all does the same. What this file pins is that + * the transport's answer reaches the wire on both, `Allow` header included. + * + * 1. Signed in, `GET` and `DELETE` answer `405`, `Allow: POST`, on each door. + * 2. Signed in, `POST initialize` and `POST tools/list` answer `200`. + * 3. Anonymous, `GET` is still `401`: the anonymous decision comes first + * (ADR-0138 D2), ahead of the transport's verb answer. + * 4. The two doors answer each row with the same status and `Allow`. + * + * `LiteKernel` with a fake `auth` slot (a session only for a request carrying + * `x-test-session`) and the REAL `MCPServerRuntime` as the `mcp` service. No + * data service: nothing here calls a tool. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { LiteKernel } from '@objectstack/core'; +import type { Plugin, PluginContext } from '@objectstack/core'; +import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; +import { createDispatcherPlugin } from '@objectstack/runtime'; +import { createHonoApp } from '@objectstack/hono'; +import { MCPServerRuntime } from '@objectstack/mcp'; +import type { IHttpServer } from '@objectstack/spec/contracts'; + +const PREFIX = '/api/v1'; +const SESSION_HEADER = 'x-test-session'; + +function servicesPlugin(): Plugin { + return { + name: 'com.objectstack.test.mcp-stateless-verbs', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.registerService('auth', { + api: { + getSession: async ({ headers }: { headers?: Headers }) => + (headers?.get?.(SESSION_HEADER) ? { user: { id: 'u_mcp_stateless_verbs' } } : undefined), + }, + }); + ctx.registerService('mcp', new MCPServerRuntime({ name: 'objectstack-conformance', version: '9.9.9' })); + }, + }; +} + +type Door = (init: RequestInit) => Promise; + +function request(method: string, signedIn: boolean, body?: unknown): RequestInit { + return { + method, + headers: { + ...(method === 'GET' + ? { accept: 'text/event-stream', 'mcp-protocol-version': '2025-06-18' } + : { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }), + ...(signedIn ? { [SESSION_HEADER]: '1' } : {}), + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }; +} + +const INIT = { + jsonrpc: '2.0', + id: 0, + method: 'initialize', + params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'conformance', version: '1' } }, +}; + +describe('[#25759] the stateless /api/v1/mcp answers GET and DELETE with 405 Allow: POST on both doors (conformance)', () => { + const kernels: LiteKernel[] = []; + const doors = new Map(); + const prevSwitch = process.env.OS_MCP_SERVER_ENABLED; + + beforeAll(async () => { + process.env.OS_MCP_SERVER_ENABLED = 'true'; + + const hostedKernel = new LiteKernel(); + hostedKernel.use(servicesPlugin()); + await hostedKernel.bootstrap(); + kernels.push(hostedKernel); + const app = createHonoApp({ kernel: hostedKernel as any, prefix: PREFIX, cors: false }); + doors.set('hosted (createHonoApp)', async (init) => app.request(`http://hosted.test${PREFIX}/mcp`, init)); + + const standaloneKernel = new LiteKernel(); + standaloneKernel.use(servicesPlugin()); + standaloneKernel.use(new HonoServerPlugin({ port: 0, cors: false })); + standaloneKernel.use(createDispatcherPlugin({ prefix: PREFIX, securityHeaders: false })); + await standaloneKernel.bootstrap(); + kernels.push(standaloneKernel); + const base = `http://127.0.0.1:${standaloneKernel.getService('http.server').getPort!()}`; + doors.set('standalone (HonoServerPlugin + dispatcher)', (init) => fetch(`${base}${PREFIX}/mcp`, init)); + }, 30_000); + + afterAll(async () => { + if (prevSwitch === undefined) delete process.env.OS_MCP_SERVER_ENABLED; + else process.env.OS_MCP_SERVER_ENABLED = prevSwitch; + for (const kernel of kernels) { + await Promise.race([kernel.shutdown(), new Promise((resolve) => setTimeout(resolve, 10_000))]); + } + }, 30_000); + + const DOORS = ['hosted (createHonoApp)', 'standalone (HonoServerPlugin + dispatcher)']; + + for (const door of DOORS) { + for (const method of ['GET', 'DELETE']) { + it(`${door}: signed-in ${method} /mcp → 405, Allow: POST, never an event stream`, async () => { + const res = await doors.get(door)!(request(method, true)); + const body = await res.text(); + + expect(res.status, `${method} answered ${res.status}: ${body}`).toBe(405); + expect(res.headers.get('allow')).toBe('POST'); + expect(res.headers.get('content-type') ?? '').not.toContain('text/event-stream'); + expect(JSON.parse(body)).toEqual({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Method not allowed.' }, + id: null, + }); + }); + } + + it(`${door}: signed-in POST initialize and tools/list → 200, unchanged`, async () => { + const init = await doors.get(door)!(request('POST', true, INIT)); + expect(init.status).toBe(200); + const initJson: any = await init.json(); + expect(initJson.result.serverInfo.name).toBe('objectstack-conformance'); + + const list = await doors.get(door)!(request('POST', true, { jsonrpc: '2.0', id: 1, method: 'tools/list' })); + expect(list.status).toBe(200); + const listJson: any = await list.json(); + expect(listJson.result.tools.map((t: any) => t.name)).toContain('query_records'); + }); + + it(`${door}: anonymous GET /mcp is still 401 — the anonymous decision comes before the verb`, async () => { + const res = await doors.get(door)!(request('GET', false)); + expect(res.status).toBe(401); + }); + } + + for (const [method, signedIn] of [['GET', true], ['DELETE', true], ['GET', false]] as const) { + it(`both doors answer ${signedIn ? 'signed-in' : 'anonymous'} ${method} /mcp the same`, async () => { + const readings = await Promise.all( + DOORS.map(async (door) => { + const res = await doors.get(door)!(request(method, signedIn)); + await res.arrayBuffer(); + return { status: res.status, allow: res.headers.get('allow') }; + }), + ); + expect(readings[0]).toEqual(readings[1]); + }); + } +}); diff --git a/packages/qa/http-conformance/vitest.config.ts b/packages/qa/http-conformance/vitest.config.ts index fa281bdc519..3846c8b1a29 100644 --- a/packages/qa/http-conformance/vitest.config.ts +++ b/packages/qa/http-conformance/vitest.config.ts @@ -54,6 +54,13 @@ export default defineConfig({ find: /^@objectstack\/plugin-auth$/, replacement: path.resolve(__dirname, '../../plugins/plugin-auth/src/index.ts'), }, + // `hono-mcp-stateless-verbs.conformance.test.ts` serves `/api/v1/mcp` + // from the REAL `MCPServerRuntime` on both doors, so its verdict on which + // verbs the transport answers is about this checkout's mcp source. + { + find: /^@objectstack\/mcp$/, + replacement: path.resolve(__dirname, '../../mcp/src/index.ts'), + }, ], }, test: { diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index c832a721de5..3efbbba7b93 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -1325,11 +1325,16 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu // ── MCP (Streamable HTTP) + API keys (ADR-0036) ───────────── // Mounted explicitly (there is no catch-all) and routed through // dispatch() so the host's project-aware kernel swap + execution - // context resolution run first. /mcp accepts POST (JSON-RPC), GET - // (SSE) and DELETE (session end) — the transport reads the method - // from the request, the dispatcher gates on OS_MCP_SERVER_ENABLED - // and the resolved principal. NOTE: the dispatch() branches alone - // are unreachable over HTTP without these registrations. + // context resolution run first. /mcp is mounted for POST, GET and + // DELETE and the transport reads the method from the request: it + // is stateless, so it serves POST (JSON-RPC) and answers GET and + // DELETE `405 Allow: POST` itself (#25759). GET and DELETE stay + // mounted so that answer comes from the transport, after the + // dispatcher's anonymous refusal, and not from the adapter's + // unmatched-route handler. The dispatcher gates on + // OS_MCP_SERVER_ENABLED and the resolved principal. NOTE: the + // dispatch() branches alone are unreachable over HTTP without these + // registrations. const mountMcp = (method: 'GET' | 'POST' | 'DELETE') => { const register = method === 'GET' ? server.get : method === 'DELETE' ? server.delete : server.post; register.call(server, `${prefix}/mcp`, async (req: any, res: any) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa6a224c2a1..efc7eba2a58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2253,6 +2253,9 @@ importers: '@objectstack/hono': specifier: workspace:* version: link:../../adapters/hono + '@objectstack/mcp': + specifier: workspace:* + version: link:../../mcp '@objectstack/objectql': specifier: workspace:* version: link:../../objectql From 2f378a68b01d5f33ac8330d1b2642d13856eebb4 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:32:56 +0800 Subject: [PATCH 2/3] fix(runtime)!: the standalone /api/v1/mcp hands every verb to the domain, so PUT, PATCH and OPTIONS answer 401 or 405 Allow: POST createDispatcherPlugin mounts /mcp for GET, POST and DELETE only and did not declare it with domainDecidesEveryVerb, so on HonoServerPlugin a PUT, a PATCH or an OPTIONS with CORS off matched no route and the adapter answered it itself: 405, Allow: DELETE, GET, HEAD, POST, to an anonymous caller too (the route's shape before the 401, ADR-0138 D2). The hosted createHonoApp door already handed every verb to dispatch(). Declare /mcp as a domain-decided path, as /mcp/skill and /keys are. Every unrouted verb now reaches the /mcp domain: the anonymous 401 first, then the transport's 405 Allow: POST, the same as the hosted door. The resolver-absence warn names /mcp too. The conformance test gains PUT, PATCH and OPTIONS rows, signed in and anonymous, on both doors and in the parity loop. The changeset now names @objectstack/runtime and the standalone-host change. Claude-Session: https://claude.ai/code/session_6eb9ec2e-0e3f-410b-9123-9dab7a337b9e Co-authored-by: Claude --- .changeset/25759-mcp-stateless-get-405.md | 8 ++-- packages/mcp/src/mcp-server-runtime.ts | 2 + ...no-mcp-stateless-verbs.conformance.test.ts | 44 +++++++++++++------ ...tcher-plugin.fallback-absence-warn.test.ts | 2 +- packages/runtime/src/dispatcher-plugin.ts | 9 +++- 5 files changed, 47 insertions(+), 18 deletions(-) diff --git a/.changeset/25759-mcp-stateless-get-405.md b/.changeset/25759-mcp-stateless-get-405.md index 52fc9bd704e..86676de606e 100644 --- a/.changeset/25759-mcp-stateless-get-405.md +++ b/.changeset/25759-mcp-stateless-get-405.md @@ -1,5 +1,6 @@ --- '@objectstack/mcp': patch +'@objectstack/runtime': patch --- fix(mcp)!: the stateless `/api/v1/mcp` surface answers `GET` and `DELETE` with `405` and `Allow: POST`, so an MCP client stops re-opening a GET stream that ends at once @@ -9,11 +10,12 @@ Metadata-protocol: none -**BREAKING** accept-set narrowing at the MCP HTTP transport (`MCPServerRuntime.handleHttpRequest`). +**BREAKING** accept-set narrowing at the MCP HTTP transport (`MCPServerRuntime.handleHttpRequest`, `@objectstack/mcp`), and on the standalone server's `/api/v1/mcp` route (`createDispatcherPlugin`, `@objectstack/runtime`). - **Before.** The transport is stateless (no session id) and closes its per-request server when the request ends. A `GET` still reached the SDK's stream handler, which answered `200 text/event-stream` and then lost the stream when the server closed. An MCP client reads a GET stream that ends as a dropped connection and opens another one: claude-code re-opened it 2–3 times a second for as long as a session stayed connected. A `DELETE` answered `200` and ended nothing, because no session exists. Every other verb answered `405` with `Allow: GET, POST, DELETE`. - **Now.** Every verb but `POST` answers `405` with `Allow: POST` and the JSON-RPC body `{"jsonrpc":"2.0","error":{"code":-32000,"message":"Method not allowed."},"id":null}`, before a server is built. This is the Streamable HTTP transport's answer for a server that offers no GET stream and does not end sessions. The MCP SDK client treats a `405` on GET as "no stream offered" and does not retry it. -- **Unchanged.** `POST` (JSON-RPC: `initialize`, `tools/list`, `tools/call`, …) answers exactly as before. An anonymous caller is still refused `401` first on every verb. -- **Who sees it.** A client that opens the GET stream on `/api/v1/mcp` now gets `405` instead of a `200` stream that ends at once. Nothing was ever sent on that stream. +- **The standalone server.** `createDispatcherPlugin` mounts `/api/v1/mcp` for `GET`, `POST` and `DELETE`, and now also declares it a path whose domain decides every verb (the declaration `/mcp/skill` and `/keys` already carry). Before, a `PUT`, a `PATCH` or an `OPTIONS` with CORS off matched no route there and the HTTP adapter answered it itself: `405` with `Allow: DELETE, GET, HEAD, POST` and a `METHOD_NOT_ALLOWED` envelope, to an anonymous caller too. Now those verbs reach the dispatcher like the mounted ones: an anonymous caller gets `401`, a signed-in one the transport's `405` with `Allow: POST`. A `createHonoApp` host already handed every verb to the dispatcher, so both doors now answer every verb alike. +- **Unchanged.** `POST` (JSON-RPC: `initialize`, `tools/list`, `tools/call`, …) answers exactly as before. An anonymous caller is refused `401` before any verb answer, on every verb and on both doors. +- **Who sees it.** A client that opens the GET stream on `/api/v1/mcp` now gets `405` instead of a `200` stream that ends at once. Nothing was ever sent on that stream. On the standalone server, an anonymous `PUT`, `PATCH` or `OPTIONS` on `/api/v1/mcp` now gets `401` instead of `405`. **The remedy.** None needed for an MCP client: it stops opening the stream. A probe or health check that read `GET /api/v1/mcp` as `200` should send a `POST initialize` instead. diff --git a/packages/mcp/src/mcp-server-runtime.ts b/packages/mcp/src/mcp-server-runtime.ts index 48b9d6a4b9f..3e128359cd2 100644 --- a/packages/mcp/src/mcp-server-runtime.ts +++ b/packages/mcp/src/mcp-server-runtime.ts @@ -989,6 +989,8 @@ const MCP_HTTP_ALLOWED_METHODS = 'POST'; */ function methodNotAllowedResponse(): Response { return new Response( + // Mirrors `handleUnsupportedRequest` in the SDK's + // `server/webStandardStreamableHttp.js`, `Allow` aside. JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: 'Method not allowed.' }, diff --git a/packages/qa/http-conformance/src/hono-mcp-stateless-verbs.conformance.test.ts b/packages/qa/http-conformance/src/hono-mcp-stateless-verbs.conformance.test.ts index d525584dba2..7d376865354 100644 --- a/packages/qa/http-conformance/src/hono-mcp-stateless-verbs.conformance.test.ts +++ b/packages/qa/http-conformance/src/hono-mcp-stateless-verbs.conformance.test.ts @@ -9,15 +9,21 @@ * server in `finally`, so it can hold no GET stream and has no session to end. * Before the change a GET answered `200 text/event-stream` with a stream that * ended at once, and claude-code re-opened it 2–3 times a second (cloud#3103). - * The hosts add no verb logic of their own on `/mcp`: the standalone server - * mounts GET, POST and DELETE and hands each to `dispatch()`, and a - * `createHonoApp` host's catch-all does the same. What this file pins is that - * the transport's answer reaches the wire on both, `Allow` header included. + * A `createHonoApp` host's catch-all hands every verb on `/mcp` to + * `dispatch()`. The standalone server mounts GET, POST and DELETE, and declares + * `/mcp` with `domainDecidesEveryVerb` (#24228) so the verbs it does not mount + * (`PUT`, `PATCH`, `OPTIONS` with CORS off) reach `dispatch()` too. Without that + * declaration the adapter answered them itself: `405` with the mounted verbs + * in `Allow`, to an anonymous caller as well. What this file pins is that the + * transport's answer reaches the wire on both doors, `Allow` header included. * - * 1. Signed in, `GET` and `DELETE` answer `405`, `Allow: POST`, on each door. + * 1. Signed in, `GET`, `DELETE`, `PUT`, `PATCH` and `OPTIONS` answer `405`, + * `Allow: POST`, on each door. * 2. Signed in, `POST initialize` and `POST tools/list` answer `200`. - * 3. Anonymous, `GET` is still `401`: the anonymous decision comes first - * (ADR-0138 D2), ahead of the transport's verb answer. + * 3. Anonymous, `GET`, `PUT`, `PATCH` and `OPTIONS` are still `401`: the + * anonymous decision comes first (ADR-0138 D2), ahead of the transport's + * verb answer, on a verb the standalone router mounts and on ones it does + * not. * 4. The two doors answer each row with the same status and `Allow`. * * `LiteKernel` with a fake `auth` slot (a session only for a request carrying @@ -109,9 +115,13 @@ describe('[#25759] the stateless /api/v1/mcp answers GET and DELETE with 405 All }, 30_000); const DOORS = ['hosted (createHonoApp)', 'standalone (HonoServerPlugin + dispatcher)']; + /** Every verb but POST. GET and DELETE are mounted on the standalone router; the rest are not. */ + const REFUSED = ['GET', 'DELETE', 'PUT', 'PATCH', 'OPTIONS']; + /** An anonymous caller is refused 401 on a mounted verb and on unmounted ones. */ + const ANONYMOUS = ['GET', 'PUT', 'PATCH', 'OPTIONS']; for (const door of DOORS) { - for (const method of ['GET', 'DELETE']) { + for (const method of REFUSED) { it(`${door}: signed-in ${method} /mcp → 405, Allow: POST, never an event stream`, async () => { const res = await doors.get(door)!(request(method, true)); const body = await res.text(); @@ -139,13 +149,21 @@ describe('[#25759] the stateless /api/v1/mcp answers GET and DELETE with 405 All expect(listJson.result.tools.map((t: any) => t.name)).toContain('query_records'); }); - it(`${door}: anonymous GET /mcp is still 401 — the anonymous decision comes before the verb`, async () => { - const res = await doors.get(door)!(request('GET', false)); - expect(res.status).toBe(401); - }); + for (const method of ANONYMOUS) { + it(`${door}: anonymous ${method} /mcp is still 401 — the anonymous decision comes before the verb`, async () => { + const res = await doors.get(door)!(request(method, false)); + await res.arrayBuffer(); + expect(res.status).toBe(401); + expect(res.headers.get('allow'), 'an anonymous caller is not told the route shape').toBeNull(); + }); + } } - for (const [method, signedIn] of [['GET', true], ['DELETE', true], ['GET', false]] as const) { + const PARITY: Array<[string, boolean]> = [ + ...REFUSED.map((m): [string, boolean] => [m, true]), + ...ANONYMOUS.map((m): [string, boolean] => [m, false]), + ]; + for (const [method, signedIn] of PARITY) { it(`both doors answer ${signedIn ? 'signed-in' : 'anonymous'} ${method} /mcp the same`, async () => { const readings = await Promise.all( DOORS.map(async (door) => { diff --git a/packages/runtime/src/dispatcher-plugin.fallback-absence-warn.test.ts b/packages/runtime/src/dispatcher-plugin.fallback-absence-warn.test.ts index 888bd59553d..404a7ab1fe2 100644 --- a/packages/runtime/src/dispatcher-plugin.fallback-absence-warn.test.ts +++ b/packages/runtime/src/dispatcher-plugin.fallback-absence-warn.test.ts @@ -169,7 +169,7 @@ describe('dispatcher declarative-endpoint seam — absence is loud (#5400)', () /** * [#24228] The fallback seam also hands a domain that decides every verb - * (`/actions`, `/keys`, `/notifications`, `/mcp/skill`) the requests its + * (`/actions`, `/keys`, `/notifications`, `/mcp`, `/mcp/skill`) the requests its * router could not route on that domain's paths. Which paths those are is the * router's verdict, asked through `IHttpServer.resolveMountedRoute` — OPTIONAL * on the contract like the seam itself. A transport that has the seam but not diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index 3efbbba7b93..2f3ebcf1702 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -1349,6 +1349,13 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu mountMcp('POST'); mountMcp('GET'); mountMcp('DELETE'); + // [#25759] `PUT`, `PATCH` and every verb the transport cannot route + // reach the same `dispatch()` through the fallback seam (#24228): + // the anonymous 401 first, then the transport's own `405 Allow: + // POST`, as on a `createHonoApp` host. Without it the adapter + // answered them `405` with the mounted verbs in `Allow`, before + // the domain could refuse an anonymous caller. + domainDecidesEveryVerb('POST', `${prefix}/mcp`); // Public SKILL.md download (env-customized portable Agent Skill). // Separate registration: `/mcp` above is an exact-path mount, so @@ -2144,7 +2151,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu if (!resolveMountedRoute) { ctx.logger.warn( '[dispatcher] http.server exposes no `resolveMountedRoute`: on /actions, /keys, ' - + '/notifications and /mcp/skill a verb the transport cannot route is answered by the ' + + '/notifications, /mcp and /mcp/skill a verb the transport cannot route is answered by the ' + 'transport\'s own 405, not by the domain — an anonymous caller is told the route\'s ' + 'shape instead of 401, and `Allow` names verbs mounted only to be refused. ' + 'Fix: compose an HTTP adapter that implements `resolveMountedRoute` ' From 6c2890d0cf24106855dfb1b2e644897b772b99d0 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:06:56 +0800 Subject: [PATCH 3/3] docs(changeset): qualify the OPTIONS answer by CORS and name runtime in the changeset title Claude-Session: https://claude.ai/code/session_6eb9ec2e-0e3f-410b-9123-9dab7a337b9e Co-authored-by: Claude --- .changeset/25759-mcp-stateless-get-405.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/25759-mcp-stateless-get-405.md b/.changeset/25759-mcp-stateless-get-405.md index 86676de606e..8a1e8dbd763 100644 --- a/.changeset/25759-mcp-stateless-get-405.md +++ b/.changeset/25759-mcp-stateless-get-405.md @@ -3,7 +3,7 @@ '@objectstack/runtime': patch --- -fix(mcp)!: the stateless `/api/v1/mcp` surface answers `GET` and `DELETE` with `405` and `Allow: POST`, so an MCP client stops re-opening a GET stream that ends at once +fix(mcp,runtime)!: the stateless `/api/v1/mcp` surface answers `GET` and `DELETE` with `405` and `Allow: POST`, so an MCP client stops re-opening a GET stream that ends at once Clause-②: yes (narrowing) Metadata-protocol: none @@ -13,9 +13,9 @@ Metadata-protocol: none **BREAKING** accept-set narrowing at the MCP HTTP transport (`MCPServerRuntime.handleHttpRequest`, `@objectstack/mcp`), and on the standalone server's `/api/v1/mcp` route (`createDispatcherPlugin`, `@objectstack/runtime`). - **Before.** The transport is stateless (no session id) and closes its per-request server when the request ends. A `GET` still reached the SDK's stream handler, which answered `200 text/event-stream` and then lost the stream when the server closed. An MCP client reads a GET stream that ends as a dropped connection and opens another one: claude-code re-opened it 2–3 times a second for as long as a session stayed connected. A `DELETE` answered `200` and ended nothing, because no session exists. Every other verb answered `405` with `Allow: GET, POST, DELETE`. -- **Now.** Every verb but `POST` answers `405` with `Allow: POST` and the JSON-RPC body `{"jsonrpc":"2.0","error":{"code":-32000,"message":"Method not allowed."},"id":null}`, before a server is built. This is the Streamable HTTP transport's answer for a server that offers no GET stream and does not end sessions. The MCP SDK client treats a `405` on GET as "no stream offered" and does not retry it. +- **Now.** Every verb but `POST` that reaches the transport (`OPTIONS` with CORS off; with CORS on the CORS middleware's `204` answers first on both doors) answers `405` with `Allow: POST` and the JSON-RPC body `{"jsonrpc":"2.0","error":{"code":-32000,"message":"Method not allowed."},"id":null}`, before a server is built. This is the Streamable HTTP transport's answer for a server that offers no GET stream and does not end sessions. The MCP SDK client treats a `405` on GET as "no stream offered" and does not retry it. - **The standalone server.** `createDispatcherPlugin` mounts `/api/v1/mcp` for `GET`, `POST` and `DELETE`, and now also declares it a path whose domain decides every verb (the declaration `/mcp/skill` and `/keys` already carry). Before, a `PUT`, a `PATCH` or an `OPTIONS` with CORS off matched no route there and the HTTP adapter answered it itself: `405` with `Allow: DELETE, GET, HEAD, POST` and a `METHOD_NOT_ALLOWED` envelope, to an anonymous caller too. Now those verbs reach the dispatcher like the mounted ones: an anonymous caller gets `401`, a signed-in one the transport's `405` with `Allow: POST`. A `createHonoApp` host already handed every verb to the dispatcher, so both doors now answer every verb alike. - **Unchanged.** `POST` (JSON-RPC: `initialize`, `tools/list`, `tools/call`, …) answers exactly as before. An anonymous caller is refused `401` before any verb answer, on every verb and on both doors. -- **Who sees it.** A client that opens the GET stream on `/api/v1/mcp` now gets `405` instead of a `200` stream that ends at once. Nothing was ever sent on that stream. On the standalone server, an anonymous `PUT`, `PATCH` or `OPTIONS` on `/api/v1/mcp` now gets `401` instead of `405`. +- **Who sees it.** A client that opens the GET stream on `/api/v1/mcp` now gets `405` instead of a `200` stream that ends at once. Nothing was ever sent on that stream. On the standalone server, an anonymous `PUT`, `PATCH` or `OPTIONS` (CORS off; with CORS on the CORS middleware's `204` answers `OPTIONS` first on both doors) on `/api/v1/mcp` now gets `401` instead of `405`. **The remedy.** None needed for an MCP client: it stops opening the stream. A probe or health check that read `GET /api/v1/mcp` as `200` should send a `POST initialize` instead.