diff --git a/.changeset/25759-mcp-stateless-get-405.md b/.changeset/25759-mcp-stateless-get-405.md new file mode 100644 index 00000000000..8a1e8dbd763 --- /dev/null +++ b/.changeset/25759-mcp-stateless-get-405.md @@ -0,0 +1,21 @@ +--- +'@objectstack/mcp': patch +'@objectstack/runtime': patch +--- + +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 + + + +**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` 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` (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. 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..3e128359cd2 100644 --- a/packages/mcp/src/mcp-server-runtime.ts +++ b/packages/mcp/src/mcp-server-runtime.ts @@ -974,6 +974,35 @@ 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( + // Mirrors `handleUnsupportedRequest` in the SDK's + // `server/webStandardStreamableHttp.js`, `Allow` aside. + 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 +1653,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 +1691,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..7d376865354 --- /dev/null +++ b/packages/qa/http-conformance/src/hono-mcp-stateless-verbs.conformance.test.ts @@ -0,0 +1,178 @@ +// 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). + * 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`, `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`, `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 + * `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)']; + /** 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 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(); + + 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'); + }); + + 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(); + }); + } + } + + 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) => { + 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.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 c832a721de5..2f3ebcf1702 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) => { @@ -1344,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 @@ -2139,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` ' 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