Skip to content

fix(mcp,runtime)!: the stateless /api/v1/mcp answers GET and DELETE with 405 Allow: POST - #25774

Merged
hotlong merged 3 commits into
mainfrom
claude/issue-25759-mcp-get-405
Oct 25, 2026
Merged

hotlong merged 3 commits into
mainfrom
claude/issue-25759-mcp-get-405

Conversation

@hotlong

@hotlong hotlong commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

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 but POST with 405, Allow: POST and 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. POST is untouched.

  • GET. The transport is stateless (sessionIdGenerator: undefined, enableJsonResponse: true) and closes its per-request server in finally. A GET reached the SDK's handleGetRequest, which answered 200 text/event-stream and lost the stream when the server closed. Measured on 5874d25302 through handleHttpRequest: 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 answers 405, and the SDK client (client/streamableHttp.js, _startOrAuthSse) returns quietly on a 405 and does not retry.
  • DELETE. The spec lets a server that does not end sessions answer 405. This server never issues a session id, the SDK client sends DELETE only for an issued one (terminateSession returns early without it, and accepts 405), and the old answer was 200 for ending nothing. Same treatment, so Allow: POST is true.
  • Every other verb was already 405 from the SDK transport, with an Allow: GET, POST, DELETE that named two verbs this surface cannot serve. Now Allow: POST.
  • Hosts. A createHonoApp host's catch-all hands every verb on /mcp to dispatch(), and cloud's MCP ceiling proxies handleHttpRequest, so both reach this function after the dispatcher's anonymous 401 with no change. The standalone server needed one change (@objectstack/runtime, dispatcher-plugin.ts, contract review round 1): it mounts /mcp for GET, POST and DELETE only and did not declare the path with domainDecidesEveryVerb (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 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, a METHOD_NOT_ALLOWED envelope), to an anonymous caller too, before the 401 (ADR-0138 D2). /mcp is now declared like /mcp/skill and /keys, so every unrouted verb goes to dispatch(): the 401 first, then the transport's 405 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.
  • Known clients that need the GET stream: none. The stateless server never sends a server-initiated message, so nothing was ever delivered on that stream.

Tests

  • packages/mcp/src/mcp-server-runtime.http-verbs.test.ts (new, 5 tests): GET with claude-code's headers answers 405 Allow: POST with 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/list round trip is 200 with no session id; and the SDK's own Client + StreamableHTTPClientTransport, connected over the surface with a 5 ms reconnect delay, opens the GET once in 250 ms and raises no error, while listTools works.
  • packages/qa/http-conformance/src/hono-mcp-stateless-verbs.conformance.test.ts (new, 29 tests): the REAL MCPServerRuntime as the mcp service on both doors (createHonoApp and HonoServerPlugin + dispatcher). Signed in, GET, DELETE, PUT, PATCH and OPTIONS answer 405 Allow: POST with the body intact on each door; signed-in POST initialize and tools/list answer 200; anonymous GET, PUT, PATCH and OPTIONS answer 401 with no Allow; the parity loop holds the two doors to the same status and Allow for every one of those rows, including the verbs the standalone router does not mount. The package takes @objectstack/mcp as 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 --porcelain back to 0 lines, tests green again. (1) The transport's early return, if (false && …). (2) The domainDecidesEveryVerb('POST', `${prefix}/mcp`) line, commented out, runtime rebuilt (the conformance package reads @objectstack/runtime from dist).

suite both fixes in (1) out (2) out
mcp-server-runtime.http-verbs.test.ts 5/5 1/5 — the POST control passes; GET is text/event-stream, DELETE 200, Allow GET, POST, DELETE; the SDK client opened the GET 41 times in 250 ms n/a (runtime not involved)
hono-mcp-stateless-verbs.conformance.test.ts 29/29 7/11 at the first head (GET and DELETE answered 200 on both doors) 17/29 — all 12 failures on the standalone door: signed-in PUT/PATCH/OPTIONS 405 with Allow: DELETE, GET, HEAD, POST, anonymous PUT/PATCH/OPTIONS 405 instead of 401, and the 6 parity rows for those verbs

Local 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. Includes dispatcher-plugin.routes.test.ts, dispatcher-plugin.fallback-absence-warn.test.ts and the actions/keys/notifications/mcp-skill every-verb integration suites.
  • @objectstack/dogfood: showcase-mcp-http-identity.dogfood.test.ts 6/6. Full suite on d88a6ae069: 295 files passed, 3 skipped; 2520 tests passed, 18 skipped; exit 0. Full suite on 2f378a68b0: 295 files passed, 3 skipped; 2520 tests passed, 18 skipped; exit 0.
  • ESLint on the touched files: clean.
  • node scripts/pm/dispatch-gates.mjs --commands --changed for 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

  • The narrowing: GET 200 (empty stream) → 405, DELETE 200 (no-op) → 405, every verb's Allow → POST. Is DELETE in scope? The card names GET; DELETE is included because otherwise Allow: POST is false and the spec gives the same answer for a server with no sessions.
  • The body is the SDK's JSON-RPC 405 shape, not the dispatcher's buildApiError envelope that domains/*.ts 405s 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.
  • The anonymous decision comes first on both doors for every verb (401 before the verb answer). Round 1 found that this was false for unmounted verbs on the standalone door; fixed in 2f378a68b0 as described under Hosts.
  • The changeset now names @objectstack/runtime as well as @objectstack/mcp.

Generated by Claude Code

…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>
@github-actions github-actions Bot added size/m dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Oct 25, 2026
@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/mcp, @objectstack/http-conformance, @objectstack/runtime, touching 6 documentable anchor(s).

14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via /api/v1/mcp (route, a path literal in a comment on a changed line))
  • content/docs/ai/connect-mcp.mdx (via /api/v1/mcp (route, a path literal in a comment on a changed line))
  • content/docs/ai/index.mdx (via /api/v1/mcp (route, a path literal in a comment on a changed line))
  • content/docs/ai/natural-language-queries.mdx (via /api/v1/mcp (route, a path literal in a comment on a changed line))
  • content/docs/api/index.mdx (via /api/v1/mcp (route, a path literal in a comment on a changed line))
  • content/docs/capabilities/ai.mdx (via /api/v1/mcp (route, a path literal in a comment on a changed line))
  • content/docs/deployment/cli.mdx (via /api/v1/mcp (route, a path literal in a comment on a changed line))
  • content/docs/deployment/environment-variables.mdx (via /api/v1/mcp (route, a path literal in a comment on a changed line))
  • content/docs/deployment/index.mdx (via /api/v1/mcp (route, a path literal in a comment on a changed line))
  • content/docs/deployment/production-readiness.mdx (via createDispatcherPlugin (symbol, a top-level function))
  • content/docs/deployment/self-hosting.mdx (via /api/v1/mcp (route, a path literal in a comment on a changed line))
  • content/docs/getting-started/build-with-claude-code.mdx (via /api/v1/mcp (route, a path literal in a comment on a changed line))
  • content/docs/permissions/authorization.mdx (via /api/v1/mcp (route, a path literal in a comment on a changed line))
  • content/docs/plugins/packages.mdx (via createDispatcherPlugin (symbol, a top-level function))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v13.mdx (via /api/v1/mcp (route, a path literal in a comment on a changed line))
  • content/docs/releases/v16.mdx (via /api/v1/mcp (route, a path literal in a comment on a changed line))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 34 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5612dd931be8f982847ac83b021ce56daa0b23d9 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 08587f698cccf21b324ded233b1ff4fb9cadc591 — the merge of head 6c2890d0cf24106855dfb1b2e644897b772b99d0 into base 5612dd931be8f982847ac83b021ce56daa0b23d9, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 5612dd931be8f982847ac83b021ce56daa0b23d9 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

hotlong and others added 2 commits October 25, 2026 18:32
…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>
@hotlong hotlong changed the title fix(mcp)!: the stateless /api/v1/mcp answers GET and DELETE with 405 Allow: POST fix(mcp,runtime)!: the stateless /api/v1/mcp answers GET and DELETE with 405 Allow: POST Oct 25, 2026
@hotlong

hotlong commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

Contract review

Contract review (CONTRACT_REVIEW_TIER): round 1 CHANGES REQUIRED (the standalone door's unmounted verbs answered the adapter 405 with Allow: DELETE, GET, HEAD, POST before the 401) → fixed in 2f378a68b0 → delta PASS with nits; nits applied in 6c2890d0cf.

  • Matrix: 48 cells measured (both doors × CORS on/off × the verbs × anonymous/signed-in), all agree: anonymous 401 first, signed-in 405 Allow: POST for every non-POST verb that reaches the transport, POST unchanged.
  • CORS preflight is unaffected: with CORS on (the default on both doors) the CORS middleware answers OPTIONS with 204 before the route, on both doors; the changeset says so.

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

@hotlong
hotlong marked this pull request as ready for review October 25, 2026 11:26
@hotlong
hotlong enabled auto-merge October 25, 2026 11:27
@hotlong
hotlong added this pull request to the merge queue Oct 25, 2026
Merged via the queue into main with commit bb85f5f Oct 25, 2026
53 of 56 checks passed
@hotlong
hotlong deleted the claude/issue-25759-mcp-get-405 branch October 25, 2026 11:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

1 participant