Repository navigation
fix(mcp,runtime)!: the stateless /api/v1/mcp answers GET and DELETE with 405 Allow: POST - #25774
Conversation
…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 <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 34 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 08587f698cccf21b324ded233b1ff4fb9cadc591 && git checkout 08587f698cccf21b324ded233b1ff4fb9cadc591
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5612dd931be8f982847ac83b021ce56daa0b23d9 6c2890d0cf24106855dfb1b2e644897b772b99d0 && git checkout -B drift-repro 5612dd931be8f982847ac83b021ce56daa0b23d9 && git merge --no-ff 6c2890d0cf24106855dfb1b2e644897b772b99d0
node scripts/docs-audit/affected-docs.mjs --json 5612dd931be8f982847ac83b021ce56daa0b23d9
|
…ain, 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 <noreply@anthropic.com>
…in the changeset title Claude-Session: https://claude.ai/code/session_6eb9ec2e-0e3f-410b-9123-9dab7a337b9e Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewContract review (CONTRACT_REVIEW_TIER): round 1 CHANGES REQUIRED (the standalone door's unmounted verbs answered the adapter 405 with
Recorded at 2026-10-25T11:26Z on behalf of the coordinator's contract review; the nits commit changes only the changeset text. Generated by Claude Code |
Clause-②: yes (narrowing)
Metadata-protocol: none
Closes #25759
Dispatched directly by the maintainer through the cloud ops seat: 「objectstack#25759 你直接派发处理」 (claim: #25759 (comment)).
What changes
MCPServerRuntime.handleHttpRequest(@objectstack/mcp), the one function every host calls for/api/v1/mcp, now answers every verb butPOSTwith405,Allow: POSTand the MCP SDK transport's own JSON-RPC 405 body ({"jsonrpc":"2.0","error":{"code":-32000,"message":"Method not allowed."},"id":null}). It answers before a server is built, so a refused verb pays for no tool registration.POSTis untouched.sessionIdGenerator: undefined,enableJsonResponse: true) and closes its per-request server infinally. A GET reached the SDK'shandleGetRequest, which answered200 text/event-streamand lost the stream when the server closed. Measured on5874d25302throughhandleHttpRequest:200,text/event-stream, empty body, ~13 ms; through both host doors:200. The Streamable HTTP transport says a server that offers no GET stream answers405, and the SDK client (client/streamableHttp.js,_startOrAuthSse) returns quietly on a405and does not retry.405. This server never issues a session id, the SDK client sends DELETE only for an issued one (terminateSessionreturns early without it, and accepts405), and the old answer was200for ending nothing. Same treatment, soAllow: POSTis true.405from the SDK transport, with anAllow: GET, POST, DELETEthat named two verbs this surface cannot serve. NowAllow: POST.createHonoApphost's catch-all hands every verb on/mcptodispatch(), and cloud's MCP ceiling proxieshandleHttpRequest, so both reach this function after the dispatcher's anonymous401with no change. The standalone server needed one change (@objectstack/runtime,dispatcher-plugin.ts, contract review round 1): it mounts/mcpfor GET, POST and DELETE only and did not declare the path withdomainDecidesEveryVerb(runtime(dispatcher): on the standalone server an anonymous non-POST to /api/v1/actions gets the adapter's 405 instead of the v18 401 floor — registerActionRoutes mounts POST only (the #23909 / #24024 gap, for /actions) #24228), so onHonoServerPluginaPUT, aPATCHor anOPTIONSwith CORS off matched no route and the adapter answered it itself (405,Allow: DELETE, GET, HEAD, POST, aMETHOD_NOT_ALLOWEDenvelope), to an anonymous caller too, before the401(ADR-0138 D2)./mcpis now declared like/mcp/skilland/keys, so every unrouted verb goes todispatch(): the401first, then the transport's405 Allow: POST, as on the hosted door. GET and DELETE stay mounted. The resolver-absence warn now names/mcp, and the stale mount comment (GET as SSE, DELETE as a session end) is corrected.Tests
packages/mcp/src/mcp-server-runtime.http-verbs.test.ts(new, 5 tests): GET with claude-code's headers answers405Allow: POSTwith the JSON-RPC body and reads no bridge member; DELETE (with and without a session id) the same; PUT/PATCH/OPTIONS/HEAD the same;POST initialize→notifications/initialized(202) →tools/listround trip is200with no session id; and the SDK's ownClient+StreamableHTTPClientTransport, connected over the surface with a 5 ms reconnect delay, opens the GET once in 250 ms and raises no error, whilelistToolsworks.packages/qa/http-conformance/src/hono-mcp-stateless-verbs.conformance.test.ts(new, 29 tests): the REALMCPServerRuntimeas themcpservice on both doors (createHonoAppandHonoServerPlugin+ dispatcher). Signed in, GET, DELETE, PUT, PATCH and OPTIONS answer405Allow: POSTwith the body intact on each door; signed-inPOST initializeandtools/listanswer200; anonymous GET, PUT, PATCH and OPTIONS answer401with noAllow; the parity loop holds the two doors to the same status andAllowfor every one of those rows, including the verbs the standalone router does not mount. The package takes@objectstack/mcpas a devDependency and a source alias for it (check:test-source-alias).Ablation. Each fix disabled by a reversible edit and restored the same way,
git status --porcelainback to 0 lines, tests green again. (1) The transport's early return,if (false && …). (2) ThedomainDecidesEveryVerb('POST', `${prefix}/mcp`)line, commented out, runtime rebuilt (the conformance package reads@objectstack/runtimefromdist).mcp-server-runtime.http-verbs.test.tstext/event-stream, DELETE200,AllowGET, POST, DELETE; the SDK client opened the GET 41 times in 250 mshono-mcp-stateless-verbs.conformance.test.ts200on both doors)405withAllow: DELETE, GET, HEAD, POST, anonymous PUT/PATCH/OPTIONS405instead of401, and the 6 parity rows for those verbsLocal runs on
2f378a68b0@objectstack/mcp: test 43 files / 538 tests pass; typecheck (incl.check:test-typecheck) pass.@objectstack/http-conformance: test 20 files / 275 tests pass; typecheck pass.@objectstack/runtime: test 444 files / 7095 tests pass (24 skipped); typecheck pass. Includesdispatcher-plugin.routes.test.ts,dispatcher-plugin.fallback-absence-warn.test.tsand theactions/keys/notifications/mcp-skillevery-verb integration suites.@objectstack/dogfood:showcase-mcp-http-identity.dogfood.test.ts6/6. Full suite ond88a6ae069: 295 files passed, 3 skipped; 2520 tests passed, 18 skipped; exit 0. Full suite on2f378a68b0: 295 files passed, 3 skipped; 2520 tests passed, 18 skipped; exit 0.node scripts/pm/dispatch-gates.mjs --commands --changedfor the new diff: 81 commands derived, 81 exit 0 (incl.check-adr-0087-registration --base origin/main,check-changeset-no-major,check:test-source-alias,check:error-status-conformance,check:route-envelope,check:dispatcher-error-vocabulary).For the contract review
200(empty stream) →405, DELETE200(no-op) →405, every verb'sAllow→POST. Is DELETE in scope? The card names GET; DELETE is included because otherwiseAllow: POSTis false and the spec gives the same answer for a server with no sessions.buildApiErrorenvelope thatdomains/*.ts405s use: it is the transport answering, like its other non-2xx answers (406, 400 parse error), and it is byte-for-byte what the SDK answered for PUT before.401before the verb answer). Round 1 found that this was false for unmounted verbs on the standalone door; fixed in2f378a68b0as described under Hosts.@objectstack/runtimeas well as@objectstack/mcp.Generated by Claude Code