Skip to content

Commit 889bf12

Browse files
fix(runtime): an anonymous /mcp caller is answered 401 ahead of the switch's 404 and the service's 501 (#24308)
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](https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 85be049 commit 889bf12

8 files changed

Lines changed: 505 additions & 33 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): an anonymous caller of `/api/v1/mcp` is answered `401 UNAUTHENTICATED` whatever `OS_MCP_SERVER_ENABLED` or the `mcp` service says
6+
7+
Clause-②: no
8+
9+
ADR-0138 D2 takes the anonymous decision once per domain, as the domain's first statement. The `/mcp` domain checked the `OS_MCP_SERVER_ENABLED` switch and the `mcp` service before it, so an anonymous caller was answered `404 RESOURCE_NOT_FOUND` with the switch off and `501 NOT_IMPLEMENTED` with no MCP service installed, and learned whether the deployment runs MCP before it was refused. Measured on both hosts, the standalone server (`HonoServerPlugin` with the dispatcher plugin) and a `createHonoApp` host.
10+
11+
- An anonymous request on `/api/v1/mcp` (any verb) is now refused `401 UNAUTHENTICATED` first, in every composition. Only the order of the refusals moves: no request that was refused before is accepted.
12+
- A signed-in caller, or a system principal, keeps the switch's `404` and the service's `501`, in that order.
13+
- The `401` advertises RFC 9728 resource metadata (`WWW-Authenticate: Bearer … resource_metadata="…"`) only where the resource is served: the switch on, an `mcp` service that can take the request, and an auth service that answers a metadata URL. With the switch off or no service the `401` is the plain API-key one, so it never points a client at a protected resource the deployment does not serve.

‎content/docs/ai/connect-mcp.mdx‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ about the only thing left to do: **connecting a client and proving it works**.
1212

1313
<Callout type="info">
1414
To turn the surface off, set `OS_MCP_SERVER_ENABLED=false` — the endpoint then
15-
returns 404 and the **Connect an Agent** page disappears with it, along with
16-
both its Setup and Account navigation entries. See
15+
returns 404 to a caller with an API key or token (an anonymous caller still gets
16+
401 first, with no OAuth metadata advertised), and the **Connect an Agent** page
17+
disappears with it, along with both its Setup and Account navigation entries. See
1718
[environment variables](/docs/deployment/environment-variables#mcp-server).
1819
</Callout>
1920

@@ -380,8 +381,8 @@ skill and a guided `/objectstack:connect` command.
380381
| **✘ Failed to connect** / `ConnectionRefused` on a server that used to work | The app is not running. Start it — the client reconnects on its own, no need to re-register |
381382
| The server is missing from `/mcp` in a running session | Sessions read MCP config at start — open a new one. If it is still missing, you registered at **local** scope from another directory; re-add with `-s user` |
382383
| `Object 'x' not found` on `query_records` / `get_record` | Wrong object name — pass the API name from `list_objects` in **`objectName`** (`crm_opportunity`, not `opportunity`) |
383-
| `404` on `/api/v1/mcp` | The HTTP surface is disabled — unset `OS_MCP_SERVER_ENABLED` (default is on) |
384-
| `501 Not Implemented` | The MCP plugin isn't part of this build — check your stack's plugins |
384+
| `404` on `/api/v1/mcp` | The HTTP surface is disabled — unset `OS_MCP_SERVER_ENABLED` (default is on). A client with an API key or token sees the `404`; an anonymous client, including an OAuth client before it signs in, sees a plain `401` with no `resource_metadata` in `WWW-Authenticate` |
385+
| `501 Not Implemented` | The MCP plugin isn't part of this build — check your stack's plugins. A client with an API key or token sees the `501`; an anonymous client sees a plain `401` with no `resource_metadata` |
385386
| `mcp` missing from `GET /api/v1/discovery` and no Connect-an-Agent card, but `OS_MCP_SERVER_ENABLED` is on | The same cause as the `501` above, seen from the other side: the surface is *enabled* but not *serveable*, so discovery declines to advertise a route that would 501 rather than over-promising it (`declared === enforced`). Load the MCP plugin — `os serve` / `os dev` do it for you; a host that embeds `@objectstack/rest` directly must add `@objectstack/mcp` itself |
386387
| stdio won't start / boot fails closed | `OS_MCP_STDIO_ENABLED=true` but `OS_MCP_STDIO_API_KEY` is missing, unknown, revoked, or expired — fail-closed by design (ADR-0101). Set a valid `osk_` key; there is no unscoped or `system` fallback |
387388
| `401` on every call | Anonymous or invalid credentials. Interactive clients: complete the browser login (the `WWW-Authenticate` header advertises the OAuth metadata). Headless: check the `osk_` key and header spelling |

‎content/docs/deployment/environment-variables.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ OS_MCP_STDIO_ENABLED=true OS_MCP_STDIO_API_KEY=osk_... os start # start the lo
268268

269269
| Variable | Type | Default | Description |
270270
|:---|:---|:---|:---|
271-
| `OS_MCP_SERVER_ENABLED` | boolean | `true` | The MCP **HTTP** surface (`/api/v1/mcp`) is a core capability and defaults **on**. Set `false` to disable it (endpoint 404s, the Connect-an-Agent page disappears). |
271+
| `OS_MCP_SERVER_ENABLED` | boolean | `true` | The MCP **HTTP** surface (`/api/v1/mcp`) is a core capability and defaults **on**. Set `false` to disable it (the endpoint answers 404 to a caller with an API key or token and 401 to an anonymous one, and the Connect-an-Agent page disappears). |
272272
| `OS_MCP_STDIO_ENABLED` | boolean | `false` | Auto-start the long-lived **stdio** transport at boot. Opt-in and **stricter** than the HTTP surface. **Requires `OS_MCP_STDIO_API_KEY`** — stdio runs as that key's identity with RLS/FLS/tenant applied; if the key is missing or invalid, boot **fails closed** (stdio refuses to start). See ADR-0101. |
273273
| `OS_MCP_STDIO_API_KEY` | string | — | The `osk_...` API key the **stdio** transport runs as. Resolved through the same verify chain as the HTTP/REST surfaces, so reads are scoped to that identity's permissions. Mint one from the **Connect an Agent** page — **Account → Developer** for any signed-in user, **Setup → Connect an Agent** for platform admins — or `POST /api/v1/keys`. For full authority, mint a key on a platform-admin or dedicated **service** identity — there is deliberately no `system`/unscoped bypass. |
274274
| `OS_MCP_SERVER_NAME` | string | `objectstack` | Server name advertised to MCP clients. |
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#24251] On a `createHonoApp` host, an anonymous caller of `/api/v1/mcp` is
5+
* answered `401 UNAUTHENTICATED` whatever the MCP switch or the `mcp` service
6+
* says — through the host's own pipeline, and the same as the standalone
7+
* server answers it.
8+
*
9+
* ## The defect
10+
*
11+
* ADR-0138 D2 takes the anonymous decision "once per domain, as the domain's
12+
* first statement". `handleMcpRequest` (`@objectstack/runtime`,
13+
* `domains/mcp.ts`) checked `OS_MCP_SERVER_ENABLED` (`404`) and the `mcp`
14+
* service (`501`) ahead of it, so an anonymous caller learned whether the
15+
* deployment runs MCP before it was refused. Measured on `9822b053e` through
16+
* this file's hosted door: anonymous `POST /api/v1/mcp` answered `404
17+
* RESOURCE_NOT_FOUND` with the switch off and `501 NOT_IMPLEMENTED` with no
18+
* service. The host adds no gate of its own on `/mcp`: its catch-all hands the
19+
* request to `dispatch()`, so the domain's order was the host's answer.
20+
*
21+
* ## What is pinned, per composition
22+
*
23+
* 1. Hosted (`app.request` into the Hono app `createHonoApp` returns): an
24+
* anonymous `POST` is `401 UNAUTHENTICATED` and never reaches the
25+
* transport; the `401` advertises RFC 9728 resource metadata only where
26+
* the resource is served (switch on, `mcp` installed). The fake `auth`
27+
* slot answers a metadata URL in every composition, as plugin-auth does
28+
* with the switch off when `OS_OIDC_PROVIDER_ENABLED=true`.
29+
* 2. Hosted, signed in: the domain's own answer is unchanged — `200` from the
30+
* transport, `501 NOT_IMPLEMENTED`, `404 RESOURCE_NOT_FOUND`.
31+
* 3. The hosted door answers the standalone server's status, `code` and
32+
* `WWW-Authenticate`, anonymous and signed in.
33+
*
34+
* `LiteKernel`, fake services only (no storage): the `auth` slot answers a
35+
* session only for a request carrying `x-test-session`. The switch is read per
36+
* request, so each row sets it for its own requests.
37+
*/
38+
39+
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
40+
import { LiteKernel } from '@objectstack/core';
41+
import type { Plugin, PluginContext } from '@objectstack/core';
42+
import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
43+
import { createDispatcherPlugin } from '@objectstack/runtime';
44+
import { createHonoApp } from '@objectstack/hono';
45+
import type { IHttpServer } from '@objectstack/spec/contracts';
46+
47+
const PREFIX = '/api/v1';
48+
const SESSION_HEADER = 'x-test-session';
49+
const METADATA_URL = 'https://mcp-anonymous-first.test/.well-known/oauth-protected-resource';
50+
const JSON_RPC = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' });
51+
52+
/** How many requests reached a fake `/mcp` transport, on either door. */
53+
let transportCalls = 0;
54+
55+
function fakeServicesPlugin(withMcp: boolean): Plugin {
56+
return {
57+
name: `com.objectstack.test.fake-services-hono-mcp-anonymous-first-${withMcp ? 'mcp' : 'no-mcp'}`,
58+
version: '1.0.0',
59+
init: async (ctx: PluginContext) => {
60+
ctx.registerService('auth', {
61+
api: {
62+
getSession: async ({ headers }: { headers?: Headers }) =>
63+
(headers?.get?.(SESSION_HEADER) ? { user: { id: 'u_hono_mcp_anonymous_first' } } : undefined),
64+
},
65+
getMcpResourceMetadataUrl: () => METADATA_URL,
66+
});
67+
if (withMcp) {
68+
ctx.registerService('mcp', {
69+
handleHttpRequest: async () => {
70+
transportCalls += 1;
71+
return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });
72+
},
73+
renderSkill: () => '# skill',
74+
});
75+
}
76+
},
77+
};
78+
}
79+
80+
interface Composition {
81+
name: string;
82+
withMcp: boolean;
83+
switchOff: boolean;
84+
/** The anonymous 401 carries `resource_metadata`. */
85+
advertises: boolean;
86+
/** What a signed-in `POST` is answered: the domain's own answer. */
87+
signedIn: { status: number; code?: string };
88+
}
89+
90+
const COMPOSITIONS: Composition[] = [
91+
{ name: 'MCP on and installed', withMcp: true, switchOff: false, advertises: true, signedIn: { status: 200 } },
92+
{ name: 'no mcp service installed', withMcp: false, switchOff: false, advertises: false, signedIn: { status: 501, code: 'NOT_IMPLEMENTED' } },
93+
{ name: 'OS_MCP_SERVER_ENABLED=false', withMcp: true, switchOff: true, advertises: false, signedIn: { status: 404, code: 'RESOURCE_NOT_FOUND' } },
94+
];
95+
96+
interface Reading { status: number; code: string | undefined; httpStatus: number | undefined; www: string | null }
97+
async function read(res: Response): Promise<Reading> {
98+
const json = (await res.json()) as { error?: { code?: string; httpStatus?: number } };
99+
return { status: res.status, code: json.error?.code, httpStatus: json.error?.httpStatus, www: res.headers.get('www-authenticate') };
100+
}
101+
102+
describe('[#24251] an anonymous /mcp caller is answered 401 first on a createHonoApp host (conformance)', () => {
103+
const kernels: LiteKernel[] = [];
104+
const hosted = new Map<boolean, (init: RequestInit) => Promise<Response>>();
105+
const standalone = new Map<boolean, (init: RequestInit) => Promise<Response>>();
106+
const prevSwitch = process.env.OS_MCP_SERVER_ENABLED;
107+
108+
beforeAll(async () => {
109+
for (const withMcp of [true, false]) {
110+
const hostedKernel = new LiteKernel();
111+
hostedKernel.use(fakeServicesPlugin(withMcp));
112+
await hostedKernel.bootstrap();
113+
kernels.push(hostedKernel);
114+
const app = createHonoApp({ kernel: hostedKernel as any, prefix: PREFIX, cors: false });
115+
hosted.set(withMcp, async (init) => app.request(`http://hosted.test${PREFIX}/mcp`, init));
116+
117+
const standaloneKernel = new LiteKernel();
118+
standaloneKernel.use(fakeServicesPlugin(withMcp));
119+
standaloneKernel.use(new HonoServerPlugin({ port: 0, cors: false }));
120+
standaloneKernel.use(createDispatcherPlugin({ prefix: PREFIX, securityHeaders: false }));
121+
await standaloneKernel.bootstrap();
122+
kernels.push(standaloneKernel);
123+
const base = `http://127.0.0.1:${standaloneKernel.getService<IHttpServer>('http.server').getPort!()}`;
124+
standalone.set(withMcp, (init) => fetch(`${base}${PREFIX}/mcp`, init));
125+
}
126+
}, 30_000);
127+
128+
afterAll(async () => {
129+
for (const kernel of kernels) {
130+
await Promise.race([kernel.shutdown(), new Promise<void>((resolve) => setTimeout(resolve, 10_000))]);
131+
}
132+
}, 30_000);
133+
134+
afterEach(() => {
135+
if (prevSwitch === undefined) delete process.env.OS_MCP_SERVER_ENABLED;
136+
else process.env.OS_MCP_SERVER_ENABLED = prevSwitch;
137+
});
138+
139+
function post(c: Composition, signedIn: boolean): RequestInit {
140+
process.env.OS_MCP_SERVER_ENABLED = c.switchOff ? 'false' : 'true';
141+
return {
142+
method: 'POST',
143+
headers: {
144+
'content-type': 'application/json',
145+
accept: 'application/json, text/event-stream',
146+
...(signedIn ? { [SESSION_HEADER]: '1' } : {}),
147+
},
148+
body: JSON_RPC,
149+
};
150+
}
151+
152+
for (const c of COMPOSITIONS) {
153+
it(`${c.name}: hosted anonymous POST /mcp → 401 UNAUTHENTICATED, the transport never reached`, async () => {
154+
const before = transportCalls;
155+
const r = await read(await hosted.get(c.withMcp)!(post(c, false)));
156+
157+
expect(r.status).toBe(401);
158+
expect(r.code).toBe('UNAUTHENTICATED');
159+
expect(r.httpStatus).toBe(401);
160+
expect(transportCalls, 'an anonymous caller must never reach the MCP transport').toBe(before);
161+
if (c.advertises) {
162+
expect(r.www).toContain(`resource_metadata="${METADATA_URL}"`);
163+
} else {
164+
expect(r.www).toBeNull();
165+
}
166+
});
167+
168+
it(`${c.name}: hosted signed-in POST /mcp keeps the domain's own ${c.signedIn.status}${c.signedIn.code ? ` ${c.signedIn.code}` : ''}`, async () => {
169+
const res = await hosted.get(c.withMcp)!(post(c, true));
170+
expect(res.status).toBe(c.signedIn.status);
171+
if (c.signedIn.code) {
172+
const r = await read(res);
173+
expect(r.code).toBe(c.signedIn.code);
174+
expect(r.httpStatus).toBe(c.signedIn.status);
175+
}
176+
});
177+
178+
for (const signedIn of [false, true]) {
179+
it(`${c.name}: the hosted door answers the standalone server's ${signedIn ? 'signed-in' : 'anonymous'} POST /mcp`, async () => {
180+
const viaHost = await hosted.get(c.withMcp)!(post(c, signedIn));
181+
const viaStandalone = await standalone.get(c.withMcp)!(post(c, signedIn));
182+
const a = signedIn && !c.signedIn.code ? { status: viaHost.status } : await read(viaHost);
183+
const b = signedIn && !c.signedIn.code ? { status: viaStandalone.status } : await read(viaStandalone);
184+
185+
expect(a).toEqual(b);
186+
});
187+
}
188+
}
189+
});

‎packages/runtime/src/dispatcher-body-after-anonymous-floor.hono.integration.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ import { createDispatcherPlugin } from './dispatcher-plugin.js';
4444
* every answer here comes before a domain touches storage. The `auth` slot
4545
* answers a session only when the request carries `x-test-session`. A stub
4646
* `analytics` slot makes the standalone server mount `/analytics`, and a stub
47-
* `mcp` slot lets `/mcp` reach its anonymous decision (which follows its
48-
* enabled and available checks).
47+
* `mcp` slot lets a signed-in `/mcp` caller past the domain's available check
48+
* to the body read (its anonymous decision comes first, #24251).
4949
*/
5050

5151
const SESSION_HEADER = 'x-test-session';

0 commit comments

Comments
 (0)