Repository navigation
fix(runtime): an anonymous /mcp caller is answered 401 ahead of the switch's 404 and the service's 501 - #24308
Conversation
…ead of the switch and the service WIP: the pins first, red on the base until handleMcpRequest takes its anonymous decision as its first statement (ADR-0138 D2). Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
…witch's 404 and the service's 501 ADR-0138 D2 takes the anonymous decision once per domain, as the domain's first statement. handleMcpRequest checked OS_MCP_SERVER_ENABLED (404) and the mcp service (501) before it, so an anonymous caller learned whether the deployment runs MCP before it was refused. The anonymous decision is now the handler's first statement. A caller it admits (a user or a system principal) keeps the 404 and the 501 in their order. The 401 advertises RFC 9728 resource metadata only where the resource is served (switch on, an mcp service that can take the request) and the OAuth track is live, so it never points a client at metadata for a resource the deployment does not serve. Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
…ugh a non-null assertion `res.response` is optional on HttpDispatcherResult, so the nine new reads grew the file's test-typecheck debt (TS18048). The pre-existing reads in the file keep their ledgered spelling. Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 26 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 7b979296bbd0d27a016d2f75b90731a2e0e4d96e && git checkout 7b979296bbd0d27a016d2f75b90731a2e0e4d96e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 30da04f8e01758805121ff7a734a1b3472fc6192 63bf9f4634fb299c0910e7926351098ef56b0de4 && git checkout -B drift-repro 30da04f8e01758805121ff7a734a1b3472fc6192 && git merge --no-ff 63bf9f4634fb299c0910e7926351098ef56b0de4
node scripts/docs-audit/affected-docs.mjs --json 30da04f8e01758805121ff7a734a1b3472fc6192
|
席位核对 — REWORK (patch round 1, docs only)
The code is accepted as it stands:
One item, owed in this PR. The change makes published text false, so it is fixed here rather than noted. The out-of-scope note's
Ask: qualify each sentence so it stays true for both callers. A caller with a key or token gets The files are Generated by Claude Code |
…r with a key or token sees; an anonymous caller gets 401 first handleMcpRequest now takes its anonymous decision first (ADR-0138 D2), so three published sentences that said the endpoint answers 404 (switch off) or 501 (no service) were false for an anonymous caller, an OAuth-track client before it signs in included. Each now says what both callers see. Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
席位核对ACCEPT ·
Generated by Claude Code |
Fixes #24251
Clause-②: no
Basis for the Clause-② line: only the order of the refusals moves. An anonymous caller's
404and501become the401that ADR-0138 D2 owes. No request that was refused before is accepted. No export moves: the newrefuseAnonymousMcpCalleris module-private, andhandleMcpRequest's signature is unchanged.What changed
packages/runtime/src/domains/mcp.ts(the expected landing):handleMcpRequestnow takes its anonymous decision as its first statement, ahead ofisMcpServerEnabled()(404) and themcpservice check (501). The predicate is unchanged (!ec || (!ec.userId && !ec.isSystem)).404and then the501, in that order. The service guard keeps its exact text,!mcp || typeof mcp.handleHttpRequest !== 'function'. Two comments mirror that text,/discovery'shasMcpinhttp-dispatcher.tsand the probe inrest-server.ts, and both stay true.401is now built by the module-privaterefuseAnonymousMcpCaller. The status, the code, both messages and theWWW-Authenticateshape are the same as before. The one change is when it advertises resource metadata (next section).createHonoApp's catch-all and the standalone/mcpmounts both hand the request todispatch(), and neither adds a gate on/mcp. So nothing outsidemcp.tsneeded to change.What the 401 advertises, now that it comes first
Before this change, the anonymous branch was reached only when the switch was on and a service was installed. Now it is reached in every composition, so its RFC 9728
resource_metadataadvertisement needed a rule for the other two.Measured on the real
AuthManager(plugin-auth,baseUrl: https://acme.example.com). This was a throwaway probe, deleted after the run.OS_MCP_SERVER_ENABLEDgetMcpResourceMetadataUrl()https://acme.example.com/.well-known/oauth-protected-resourcefalsenullfalseOS_OIDC_PROVIDER_ENABLED=truefalseoidcProvider: trueplugin-auth mounts
/.well-known/oauth-protected-resourceonly whenreadMcpServerEnabledEnv()is true (auth-plugin.ts). With the switch off and OIDC forced on, the auth service therefore answers a URL that the deployment does not serve. With nomcpservice installed, the document is mounted, but it describes a resource that would answer501.Chosen shape: the
401carriesWWW-Authenticate: Bearer realm="ObjectStack MCP", resource_metadata="…"only when three things hold:mcpservice can take the request;The first two are the same predicate
/discoveryuses forroutes.mcp. In every other composition the401is the plain API-key one. With the switch off, themcpservice is not resolved at all. The service lookup isresolveService, the capability probe that answersundefinedrather than throwing, so an absent service cannot crash the refusal.The header tells an anonymous caller nothing that the public
/discoveryroute and the metadata document do not already tell it. The401status andcodeare the same in every composition.Measured before and after, on both hosts
Before means base
9822b053e. After meansa3fba9c8a. An anonymousPOST /api/v1/mcpwith a JSON-RPC body:createHonoApp, beforecreateHonoApp, after401401 UNAUTHENTICATED+resource_metadata401401 UNAUTHENTICATED+resource_metadatamcpservice501401 UNAUTHENTICATED, noWWW-Authenticate501401 UNAUTHENTICATED, noWWW-AuthenticateOS_MCP_SERVER_ENABLED=false404401 UNAUTHENTICATED, noWWW-Authenticate404401 UNAUTHENTICATED, noWWW-AuthenticateGETandDELETEgave the same readings asPOST, before and after.200from the transport,501 NOT_IMPLEMENTEDand404 RESOURCE_NOT_FOUND, before and after. That is the control.Hold-offs confirmed, not widened
handleMcpSkillRequest(/mcp/skill) still checks the switch first. It is served public on purpose, like/discovery. The anonymous-door sweep classifies it as an outside family with no401expectation (anonymous-door-sweep.classification.ts,mcp-skill-document). It has no anonymous decision to move, so D2's order does not reach it. Untouched.404and501are unchanged and pinned as controls on both hosts.HttpDispatcher.handleMcp, reached fromdispatch()'s domain routes. A repo-wide grep found no other caller. Unit pins check that a system principal still gets the switch-off404, the no-service501, and the transport.Tests
packages/runtime/src/mcp-anonymous-first.hono.integration.test.ts(new): the standalone composition (LiteKernel+HonoServerPlugin+ the dispatcher plugin) over a real socket. Three compositions ×POST/GET/DELETEanonymous →401 UNAUTHENTICATED, and the transport is never reached. A row per composition for theWWW-Authenticateshape. The signed-in controls. The fakeauthslot answers a metadata URL in every composition, as plugin-auth does with OIDC forced on.packages/qa/http-conformance/src/hono-mcp-anonymous-first.conformance.test.ts(new): the same rows throughcreateHonoApp's own pipeline (app.request), plus parity with the standalone server (status,codeandWWW-Authenticate), anonymous and signed in.packages/runtime/src/http-dispatcher.mcp.test.ts: unit pins for the anonymous401ahead of the404and the501, and the three system-principal controls.packages/runtime/src/dispatcher-body-after-anonymous-floor.hono.integration.test.ts: a header comment said the anonymous decision followed the enabled and available checks. This change makes that comment false, so it is corrected. Comment only.Every refusal pin asserts
status,error.codeanderror.httpStatus. No message text is pinned.Readings, in the order they were taken:
b4159a3c3(after mergingorigin/maind537a318b):pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2: 418 files, 6583 passed, 19 skipped.@objectstack/http-conformance: 17 files, 226 passed;typecheckOK.@objectstack/spec check:generated: all 14 up to date.@objectstack/runtimetypecheckwas red: the nine new unit reads grew the file's test-typecheck debt (TS18048).a3fba9c8a, which fixes those reads with!and touches nothing else:@objectstack/runtimetypecheckOK;http-dispatcher.mcp.test.tsplus the new integration file: 34 passed.Ablation (one-time, not kept)
Both mutations were landed with
scripts/ablation-replace.mjs: the anchor hit 1 → 0 and the blob changed. Each was rebuilt and proven indist/withscripts/ablation-dist-preflight.mjs(exit 0) before its run, becausehttp-conformancereads@objectstack/runtimefromdist/.isMcpServerEnabled() && service present, which restores the old answers.501and404.expected 501 to be 401andexpected 404 to be 401.servedwas forced true.WWW-AuthenticatereadBearer realm="ObjectStack MCP", resou…wherenullwas expected, on the switch-off and no-service rows.The direction matched the prediction both times. Restore leg:
55cde0fd74e2, andgit diff HEADempty;--absent(exit 0) and the fix's marker present (exit 0);git status --porcelainempty.Gates
Local subset, run at the final head
a3fba9c8a(exit codes captured before any pipe):node scripts/pm/dispatch-gates.mjs --commandsderived 65 commands against merge based537a318b. All 65 exited 0.--ranreconciliation: 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN. In an earlier round, at84585ac0e,check:dual-build-cjs-loadsexited 3 (PREREQUISITE NOT MET, no workspacedist/). It measured once the closure was built.check-changeset-fixed,check:authz-resolver,check:error-code-casing,check:filter-alias-parity,check:route-ledger-census.dispatch-gatesdoes not name:pnpm exec eslint --no-inline-configover the five changed.tsfiles exited 0 with no output. The repo-widepnpm lintis left to CI.authz-probe-blind-spot.test.ts,authz-conformance.test.ts), because the Dogfood gate does not run on a PR: 2 files, 108 passed. The census controls onmcp.tsare unchanged from base: 2export async function handle…Request, 1buildMcpBridge(deps, context), 4prefix: '/.refuseAnonymousMcpCalleris not exported, so it does not join that population.CI's families are read from this PR's check-runs.
Acceptance notes
63bf9f463, on the seat's review6248974571):content/docs/ai/connect-mcp.mdx's switch-off callout and its404/501troubleshooting rows, and theOS_MCP_SERVER_ENABLEDrow ofcontent/docs/deployment/environment-variables.mdx, now say what each caller sees. A caller with an API key or token gets the404/501. An anonymous caller (an OAuth client before it signs in included) gets a plain401with noresource_metadata. The first round had recorded these ascarrier: none; a PR fixes published text it makes false, so they ride here.handleMcpSkillRequestkeeps its switch-first order by design (above). Not a finding.Generated by Claude Code