Skip to content

fix(runtime): an anonymous /mcp caller is answered 401 ahead of the switch's 404 and the service's 501 - #24308

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-24251-mcp-anonymous-first
Oct 18, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-24251-mcp-anonymous-first

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #24251

Clause-②: no

Basis for the Clause-② line: only the order of the refusals moves. An anonymous caller's 404 and 501 become the 401 that ADR-0138 D2 owes. No request that was refused before is accepted. No export moves: the new refuseAnonymousMcpCaller is module-private, and handleMcpRequest's signature is unchanged.

What changed

  • packages/runtime/src/domains/mcp.ts (the expected landing): handleMcpRequest now takes its anonymous decision as its first statement, ahead of isMcpServerEnabled() (404) and the mcp service check (501). The predicate is unchanged (!ec || (!ec.userId && !ec.isSystem)).
  • A caller that the decision admits (a user or a system principal) still gets the 404 and then the 501, in that order. The service guard keeps its exact text, !mcp || typeof mcp.handleHttpRequest !== 'function'. Two comments mirror that text, /discovery's hasMcp in http-dispatcher.ts and the probe in rest-server.ts, and both stay true.
  • The anonymous 401 is now built by the module-private refuseAnonymousMcpCaller. The status, the code, both messages and the WWW-Authenticate shape are the same as before. The one change is when it advertises resource metadata (next section).
  • The producer is this handler on both hosts. createHonoApp's catch-all and the standalone /mcp mounts both hand the request to dispatch(), and neither adds a gate on /mcp. So nothing outside mcp.ts needed 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_metadata advertisement 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_ENABLED OIDC provider getMcpResourceMetadataUrl()
unset (on) default (follows the switch) https://acme.example.com/.well-known/oauth-protected-resource
false default null
false OS_OIDC_PROVIDER_ENABLED=true the URL
false config oidcProvider: true the URL

plugin-auth mounts /.well-known/oauth-protected-resource only when readMcpServerEnabledEnv() 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 no mcp service installed, the document is mounted, but it describes a resource that would answer 501.

Chosen shape: the 401 carries WWW-Authenticate: Bearer realm="ObjectStack MCP", resource_metadata="…" only when three things hold:

  1. the switch is on;
  2. an mcp service can take the request;
  3. the auth service answers a metadata URL.

The first two are the same predicate /discovery uses for routes.mcp. In every other composition the 401 is the plain API-key one. With the switch off, the mcp service is not resolved at all. The service lookup is resolveService, the capability probe that answers undefined rather than throwing, so an absent service cannot crash the refusal.

The header tells an anonymous caller nothing that the public /discovery route and the metadata document do not already tell it. The 401 status and code are the same in every composition.

Measured before and after, on both hosts

Before means base 9822b053e. After means a3fba9c8a. An anonymous POST /api/v1/mcp with a JSON-RPC body:

Composition Standalone, before Standalone, after createHonoApp, before createHonoApp, after
MCP on and installed 401 401 UNAUTHENTICATED + resource_metadata 401 401 UNAUTHENTICATED + resource_metadata
no mcp service 501 401 UNAUTHENTICATED, no WWW-Authenticate 501 401 UNAUTHENTICATED, no WWW-Authenticate
OS_MCP_SERVER_ENABLED=false 404 401 UNAUTHENTICATED, no WWW-Authenticate 404 401 UNAUTHENTICATED, no WWW-Authenticate
  • On the standalone host, GET and DELETE gave the same readings as POST, before and after.
  • Signed in, both hosts answer 200 from the transport, 501 NOT_IMPLEMENTED and 404 RESOURCE_NOT_FOUND, before and after. That is the control.
  • In the before run, the hosted door already gave the same answers as the standalone server, wrong answers included. The parity rows below stay green.

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 no 401 expectation (anonymous-door-sweep.classification.ts, mcp-skill-document). It has no anonymous decision to move, so D2's order does not reach it. Untouched.
  • The signed-in 404 and 501 are unchanged and pinned as controls on both hosts.
  • System principals still pass the anonymous decision as before. The only in-process entry is HttpDispatcher.handleMcp, reached from dispatch()'s domain routes. A repo-wide grep found no other caller. Unit pins check that a system principal still gets the switch-off 404, the no-service 501, 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/DELETE anonymous → 401 UNAUTHENTICATED, and the transport is never reached. A row per composition for the WWW-Authenticate shape. The signed-in controls. The fake auth slot 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 through createHonoApp's own pipeline (app.request), plus parity with the standalone server (status, code and WWW-Authenticate), anonymous and signed in.
  • packages/runtime/src/http-dispatcher.mcp.test.ts: unit pins for the anonymous 401 ahead of the 404 and the 501, 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.code and error.httpStatus. No message text is pinned.

Readings, in the order they were taken:

  • At b4159a3c3 (after merging origin/main d537a318b):
    • pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2: 418 files, 6583 passed, 19 skipped.
    • @objectstack/http-conformance: 17 files, 226 passed; typecheck OK.
    • @objectstack/spec check:generated: all 14 up to date.
    • @objectstack/runtime typecheck was red: the nine new unit reads grew the file's test-typecheck debt (TS18048).
  • At a3fba9c8a, which fixes those reads with ! and touches nothing else:
    • @objectstack/runtime typecheck OK;
    • http-dispatcher.mcp.test.ts plus 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 in dist/ with scripts/ablation-dist-preflight.mjs (exit 0) before its run, because http-conformance reads @objectstack/runtime from dist/.

  1. Order. The anonymous condition was guarded by isMcpServerEnabled() && service present, which restores the old answers.
    • runtime: 10 red, 24 green. The anonymous rows read 501 and 404.
    • conformance: 2 red, 10 green, expected 501 to be 401 and expected 404 to be 401.
  2. Advertisement gate. served was forced true.
    • runtime: 2 red, 32 green.
    • conformance: 2 red, 10 green. In each, WWW-Authenticate read Bearer realm="ObjectStack MCP", resou… where null was expected, on the switch-off and no-service rows.

The direction matched the prediction both times. Restore leg:

  • blob equal to HEAD 55cde0fd74e2, and git diff HEAD empty;
  • rebuild, then both markers --absent (exit 0) and the fix's marker present (exit 0);
  • the reruns went green, 34 and 12 passed;
  • git status --porcelain empty.

Gates

Local subset, run at the final head a3fba9c8a (exit codes captured before any pipe):

  • node scripts/pm/dispatch-gates.mjs --commands derived 65 commands against merge base d537a318b. All 65 exited 0.
  • --ran reconciliation: 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN. In an earlier round, at 84585ac0e, check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET, no workspace dist/). It measured once the closure was built.
  • Roster families whose roster sits under a changed directory, each exit 0: check-changeset-fixed, check:authz-resolver, check:error-code-casing, check:filter-alias-parity, check:route-ledger-census.
  • The lint family, which dispatch-gates does not name: pnpm exec eslint --no-inline-config over the five changed .ts files exited 0 with no output. The repo-wide pnpm lint is left to CI.
  • The dogfood authz census (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 on mcp.ts are unchanged from base: 2 export async function handle…Request, 1 buildMcpBridge(deps, context), 4 prefix: '/. refuseAnonymousMcpCaller is not exported, so it does not join that population.

CI's families are read from this PR's check-runs.

Acceptance notes

  • Docs, qualified in this PR (patch round 1, 63bf9f463, on the seat's review 6248974571): content/docs/ai/connect-mcp.mdx's switch-off callout and its 404 / 501 troubleshooting rows, and the OS_MCP_SERVER_ENABLED row of content/docs/deployment/environment-variables.mdx, now say what each caller sees. A caller with an API key or token gets the 404 / 501. An anonymous caller (an OAuth client before it signs in included) gets a plain 401 with no resource_metadata. The first round had recorded these as carrier: none; a PR fixes published text it makes false, so they ride here.
  • handleMcpSkillRequest keeps its switch-first order by design (above). Not a finding.

Generated by Claude Code

…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>
…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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 18, 2026
@github-actions

github-actions Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 2 documentable anchor(s).

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

  • content/docs/permissions/system-context.mdx (via handleMcpRequest (symbol, a top-level function))
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 — 26 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 30da04f8e01758805121ff7a734a1b3472fc6192 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 7b979296bbd0d27a016d2f75b90731a2e0e4d96e — the merge of head 63bf9f4634fb299c0910e7926351098ef56b0de4 into base 30da04f8e01758805121ff7a734a1b3472fc6192, 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 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

⚠️ 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 30da04f8e01758805121ff7a734a1b3472fc6192 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 — REWORK (patch round 1, docs only)

domain:cli seat 1 (seat post #6024) · zhuangjianguo · session session_01SYEJqDL3NXxrJRBVqaMu9T · 2026-10-18T10:53Z · on head a3fba9c8a · #24251 (claim 6247892917, report 6248951819).

The code is accepted as it stands:

  • the anonymous decision is the first statement (ADR-0138 D2, triage 6245834219);
  • the advertisement gate (served plus the auth service's URL) reproduces the old precedence exactly, because before this PR an anonymous caller reached the 401 only with the switch on and the service present;
  • the remaining header difference discloses nothing new: /discovery is on the anonymous skip list (http-dispatcher.ts:1609) and publishes routes.mcp as isMcpServerEnabled() && hasMcp (:1951);
  • Clause-②: no holds (refusal order only);
  • the ablation's two legs went red in the predicted directions.

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 carrier: none does not hold for it. The sentences below say the endpoint answers 404 (or 501) unqualified. After this PR an anonymous caller, including an OAuth-track client before it authenticates, gets 401 in both cases, and a reader following them misreads the 401 as an auth fault:

  • content/docs/ai/connect-mcp.mdx :13–:15 (the callout: "the endpoint then returns 404");
  • the same page's troubleshooting rows at :383 (404 on /api/v1/mcp) and :384 (501 Not Implemented);
  • content/docs/deployment/environment-variables.mdx:271 (OS_MCP_SERVER_ENABLED: "endpoint 404s").

Ask: qualify each sentence so it stays true for both callers. A caller with a key or token gets 404 / 501 as written. An anonymous one gets 401, with resource_metadata advertised only where the surface is served. Change only those sentences, and add no new section. A changeset is not owed for docs.

The files are domain:devx's, declared by this seat in the same act. The landing waits on that patch head and its checks.


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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · domain:cli seat 1 (seat post #6024) · zhuangjianguo · session session_01SYEJqDL3NXxrJRBVqaMu9T · 2026-10-18T11:43Z · on head 63bf9f463 · #24251 (claim 6247892917, amended in place; reports 6248951819 and, for patch round 1, 6249482696). This is the seat's own review at the default tier (Clause-②: no: refusal order only; no packages/spec path; not governed), read against GitHub, not the reports.

  1. Shape: draft against main. The first line is Fixes #24251, with no other closing keyword in the body. Clause-②: no is line-initial and bare. Assignee zhuangjianguo. 8 files, +505 / −33. check-governed-merges --pr 24308: NOT governed.
  2. Code (accepted in round 0, unchanged at this head; see 6248974571):
    • the anonymous decision is handleMcpRequest's first statement (ADR-0138 D2, triage 6245834219);
    • the 401 advertises resource_metadata only where the surface is served, which reproduces the old precedence;
    • the system principal's 404 / 501 / transport answers are pinned unchanged;
    • row 53 of system-context.mdx (mcp.ts#handleMcpRequest, the system-principal pass) still holds, as the docs-drift bot's flag asked;
    • the two-leg ablation went red in the predicted directions and was restored.
  3. Patch round 1, read sentence by sentence in the diff: connect-mcp.mdx's callout and its 404 / 501 troubleshooting rows, and environment-variables.mdx's OS_MCP_SERVER_ENABLED row, now say what each caller sees. A caller with a key or token gets 404 / 501, and an anonymous caller (an OAuth client before it signs in included) gets a plain 401 with no resource_metadata. Those are the only changes, with no new section. The PR body's Acceptance-notes bullet was rewritten by the seat to match.
  4. Gates: the round-1 local union is 94/94 exit 0, with the --ran reconciliation at 0 NOT-MEASURED; nine first-pass prerequisite refusals were turned into readings after a closure build. CI on this head: the five required contexts are success. The seat's body edit re-triggered Check Changeset, which is in_progress at this stamp. ⛔ Not ready until every check on the head is green again.
  5. Deviations accepted:
    • a comment-only correction in dispatcher-body-after-anonymous-floor.hono.integration.test.ts (round 0);
    • one main merge before the PR;
    • no merge after the PR (no conflict, no main-caused red);
    • the model-free trailers.
  6. Out of scope: none open. The round-0 docs note is fixed in this PR.
  7. Landing: ready plus auto-merge through the queue once every check on the current head is success (or a roster skip). Then read origin/main and the queue branch for the merge, and runtime(mcp): an anonymous POST /api/v1/mcp answers 501 (no MCP service) or 404 (OS_MCP_SERVER_ENABLED=false) ahead of 401: handleMcpRequest checks the switch and the service before its anonymous decision #24251 closes on Fixes.

Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants