diff --git a/.changeset/24251-mcp-anonymous-first.md b/.changeset/24251-mcp-anonymous-first.md
new file mode 100644
index 00000000000..3d0a75c4105
--- /dev/null
+++ b/.changeset/24251-mcp-anonymous-first.md
@@ -0,0 +1,13 @@
+---
+'@objectstack/runtime': patch
+---
+
+fix(runtime): an anonymous caller of `/api/v1/mcp` is answered `401 UNAUTHENTICATED` whatever `OS_MCP_SERVER_ENABLED` or the `mcp` service says
+
+Clause-②: no
+
+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.
+
+- 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.
+- A signed-in caller, or a system principal, keeps the switch's `404` and the service's `501`, in that order.
+- 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.
diff --git a/content/docs/ai/connect-mcp.mdx b/content/docs/ai/connect-mcp.mdx
index 2dda31aedd3..53ec2fbe196 100644
--- a/content/docs/ai/connect-mcp.mdx
+++ b/content/docs/ai/connect-mcp.mdx
@@ -12,8 +12,9 @@ about the only thing left to do: **connecting a client and proving it works**.
To turn the surface off, set `OS_MCP_SERVER_ENABLED=false` — the endpoint then
-returns 404 and the **Connect an Agent** page disappears with it, along with
-both its Setup and Account navigation entries. See
+returns 404 to a caller with an API key or token (an anonymous caller still gets
+401 first, with no OAuth metadata advertised), and the **Connect an Agent** page
+disappears with it, along with both its Setup and Account navigation entries. See
[environment variables](/docs/deployment/environment-variables#mcp-server).
@@ -380,8 +381,8 @@ skill and a guided `/objectstack:connect` command.
| **✘ 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 |
| 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` |
| `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`) |
-| `404` on `/api/v1/mcp` | The HTTP surface is disabled — unset `OS_MCP_SERVER_ENABLED` (default is on) |
-| `501 Not Implemented` | The MCP plugin isn't part of this build — check your stack's plugins |
+| `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` |
+| `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` |
| `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 |
| 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 |
| `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 |
diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx
index 02f93925ed5..08831aef155 100644
--- a/content/docs/deployment/environment-variables.mdx
+++ b/content/docs/deployment/environment-variables.mdx
@@ -268,7 +268,7 @@ OS_MCP_STDIO_ENABLED=true OS_MCP_STDIO_API_KEY=osk_... os start # start the lo
| Variable | Type | Default | Description |
|:---|:---|:---|:---|
-| `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). |
+| `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). |
| `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. |
| `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. |
| `OS_MCP_SERVER_NAME` | string | `objectstack` | Server name advertised to MCP clients. |
diff --git a/packages/qa/http-conformance/src/hono-mcp-anonymous-first.conformance.test.ts b/packages/qa/http-conformance/src/hono-mcp-anonymous-first.conformance.test.ts
new file mode 100644
index 00000000000..11dc92a1850
--- /dev/null
+++ b/packages/qa/http-conformance/src/hono-mcp-anonymous-first.conformance.test.ts
@@ -0,0 +1,189 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * [#24251] On a `createHonoApp` host, an anonymous caller of `/api/v1/mcp` is
+ * answered `401 UNAUTHENTICATED` whatever the MCP switch or the `mcp` service
+ * says — through the host's own pipeline, and the same as the standalone
+ * server answers it.
+ *
+ * ## The defect
+ *
+ * ADR-0138 D2 takes the anonymous decision "once per domain, as the domain's
+ * first statement". `handleMcpRequest` (`@objectstack/runtime`,
+ * `domains/mcp.ts`) checked `OS_MCP_SERVER_ENABLED` (`404`) and the `mcp`
+ * service (`501`) ahead of it, so an anonymous caller learned whether the
+ * deployment runs MCP before it was refused. Measured on `9822b053e` through
+ * this file's hosted door: anonymous `POST /api/v1/mcp` answered `404
+ * RESOURCE_NOT_FOUND` with the switch off and `501 NOT_IMPLEMENTED` with no
+ * service. The host adds no gate of its own on `/mcp`: its catch-all hands the
+ * request to `dispatch()`, so the domain's order was the host's answer.
+ *
+ * ## What is pinned, per composition
+ *
+ * 1. Hosted (`app.request` into the Hono app `createHonoApp` returns): an
+ * anonymous `POST` is `401 UNAUTHENTICATED` and never reaches the
+ * transport; the `401` advertises RFC 9728 resource metadata only where
+ * the resource is served (switch on, `mcp` installed). The fake `auth`
+ * slot answers a metadata URL in every composition, as plugin-auth does
+ * with the switch off when `OS_OIDC_PROVIDER_ENABLED=true`.
+ * 2. Hosted, signed in: the domain's own answer is unchanged — `200` from the
+ * transport, `501 NOT_IMPLEMENTED`, `404 RESOURCE_NOT_FOUND`.
+ * 3. The hosted door answers the standalone server's status, `code` and
+ * `WWW-Authenticate`, anonymous and signed in.
+ *
+ * `LiteKernel`, fake services only (no storage): the `auth` slot answers a
+ * session only for a request carrying `x-test-session`. The switch is read per
+ * request, so each row sets it for its own requests.
+ */
+
+import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
+import { LiteKernel } from '@objectstack/core';
+import type { Plugin, PluginContext } from '@objectstack/core';
+import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
+import { createDispatcherPlugin } from '@objectstack/runtime';
+import { createHonoApp } from '@objectstack/hono';
+import type { IHttpServer } from '@objectstack/spec/contracts';
+
+const PREFIX = '/api/v1';
+const SESSION_HEADER = 'x-test-session';
+const METADATA_URL = 'https://mcp-anonymous-first.test/.well-known/oauth-protected-resource';
+const JSON_RPC = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' });
+
+/** How many requests reached a fake `/mcp` transport, on either door. */
+let transportCalls = 0;
+
+function fakeServicesPlugin(withMcp: boolean): Plugin {
+ return {
+ name: `com.objectstack.test.fake-services-hono-mcp-anonymous-first-${withMcp ? 'mcp' : 'no-mcp'}`,
+ version: '1.0.0',
+ init: async (ctx: PluginContext) => {
+ ctx.registerService('auth', {
+ api: {
+ getSession: async ({ headers }: { headers?: Headers }) =>
+ (headers?.get?.(SESSION_HEADER) ? { user: { id: 'u_hono_mcp_anonymous_first' } } : undefined),
+ },
+ getMcpResourceMetadataUrl: () => METADATA_URL,
+ });
+ if (withMcp) {
+ ctx.registerService('mcp', {
+ handleHttpRequest: async () => {
+ transportCalls += 1;
+ return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });
+ },
+ renderSkill: () => '# skill',
+ });
+ }
+ },
+ };
+}
+
+interface Composition {
+ name: string;
+ withMcp: boolean;
+ switchOff: boolean;
+ /** The anonymous 401 carries `resource_metadata`. */
+ advertises: boolean;
+ /** What a signed-in `POST` is answered: the domain's own answer. */
+ signedIn: { status: number; code?: string };
+}
+
+const COMPOSITIONS: Composition[] = [
+ { name: 'MCP on and installed', withMcp: true, switchOff: false, advertises: true, signedIn: { status: 200 } },
+ { name: 'no mcp service installed', withMcp: false, switchOff: false, advertises: false, signedIn: { status: 501, code: 'NOT_IMPLEMENTED' } },
+ { name: 'OS_MCP_SERVER_ENABLED=false', withMcp: true, switchOff: true, advertises: false, signedIn: { status: 404, code: 'RESOURCE_NOT_FOUND' } },
+];
+
+interface Reading { status: number; code: string | undefined; httpStatus: number | undefined; www: string | null }
+async function read(res: Response): Promise {
+ const json = (await res.json()) as { error?: { code?: string; httpStatus?: number } };
+ return { status: res.status, code: json.error?.code, httpStatus: json.error?.httpStatus, www: res.headers.get('www-authenticate') };
+}
+
+describe('[#24251] an anonymous /mcp caller is answered 401 first on a createHonoApp host (conformance)', () => {
+ const kernels: LiteKernel[] = [];
+ const hosted = new Map Promise>();
+ const standalone = new Map Promise>();
+ const prevSwitch = process.env.OS_MCP_SERVER_ENABLED;
+
+ beforeAll(async () => {
+ for (const withMcp of [true, false]) {
+ const hostedKernel = new LiteKernel();
+ hostedKernel.use(fakeServicesPlugin(withMcp));
+ await hostedKernel.bootstrap();
+ kernels.push(hostedKernel);
+ const app = createHonoApp({ kernel: hostedKernel as any, prefix: PREFIX, cors: false });
+ hosted.set(withMcp, async (init) => app.request(`http://hosted.test${PREFIX}/mcp`, init));
+
+ const standaloneKernel = new LiteKernel();
+ standaloneKernel.use(fakeServicesPlugin(withMcp));
+ standaloneKernel.use(new HonoServerPlugin({ port: 0, cors: false }));
+ standaloneKernel.use(createDispatcherPlugin({ prefix: PREFIX, securityHeaders: false }));
+ await standaloneKernel.bootstrap();
+ kernels.push(standaloneKernel);
+ const base = `http://127.0.0.1:${standaloneKernel.getService('http.server').getPort!()}`;
+ standalone.set(withMcp, (init) => fetch(`${base}${PREFIX}/mcp`, init));
+ }
+ }, 30_000);
+
+ afterAll(async () => {
+ for (const kernel of kernels) {
+ await Promise.race([kernel.shutdown(), new Promise((resolve) => setTimeout(resolve, 10_000))]);
+ }
+ }, 30_000);
+
+ afterEach(() => {
+ if (prevSwitch === undefined) delete process.env.OS_MCP_SERVER_ENABLED;
+ else process.env.OS_MCP_SERVER_ENABLED = prevSwitch;
+ });
+
+ function post(c: Composition, signedIn: boolean): RequestInit {
+ process.env.OS_MCP_SERVER_ENABLED = c.switchOff ? 'false' : 'true';
+ return {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ accept: 'application/json, text/event-stream',
+ ...(signedIn ? { [SESSION_HEADER]: '1' } : {}),
+ },
+ body: JSON_RPC,
+ };
+ }
+
+ for (const c of COMPOSITIONS) {
+ it(`${c.name}: hosted anonymous POST /mcp → 401 UNAUTHENTICATED, the transport never reached`, async () => {
+ const before = transportCalls;
+ const r = await read(await hosted.get(c.withMcp)!(post(c, false)));
+
+ expect(r.status).toBe(401);
+ expect(r.code).toBe('UNAUTHENTICATED');
+ expect(r.httpStatus).toBe(401);
+ expect(transportCalls, 'an anonymous caller must never reach the MCP transport').toBe(before);
+ if (c.advertises) {
+ expect(r.www).toContain(`resource_metadata="${METADATA_URL}"`);
+ } else {
+ expect(r.www).toBeNull();
+ }
+ });
+
+ it(`${c.name}: hosted signed-in POST /mcp keeps the domain's own ${c.signedIn.status}${c.signedIn.code ? ` ${c.signedIn.code}` : ''}`, async () => {
+ const res = await hosted.get(c.withMcp)!(post(c, true));
+ expect(res.status).toBe(c.signedIn.status);
+ if (c.signedIn.code) {
+ const r = await read(res);
+ expect(r.code).toBe(c.signedIn.code);
+ expect(r.httpStatus).toBe(c.signedIn.status);
+ }
+ });
+
+ for (const signedIn of [false, true]) {
+ it(`${c.name}: the hosted door answers the standalone server's ${signedIn ? 'signed-in' : 'anonymous'} POST /mcp`, async () => {
+ const viaHost = await hosted.get(c.withMcp)!(post(c, signedIn));
+ const viaStandalone = await standalone.get(c.withMcp)!(post(c, signedIn));
+ const a = signedIn && !c.signedIn.code ? { status: viaHost.status } : await read(viaHost);
+ const b = signedIn && !c.signedIn.code ? { status: viaStandalone.status } : await read(viaStandalone);
+
+ expect(a).toEqual(b);
+ });
+ }
+ }
+});
diff --git a/packages/runtime/src/dispatcher-body-after-anonymous-floor.hono.integration.test.ts b/packages/runtime/src/dispatcher-body-after-anonymous-floor.hono.integration.test.ts
index c05422cf40e..f00a1bc8585 100644
--- a/packages/runtime/src/dispatcher-body-after-anonymous-floor.hono.integration.test.ts
+++ b/packages/runtime/src/dispatcher-body-after-anonymous-floor.hono.integration.test.ts
@@ -44,8 +44,8 @@ import { createDispatcherPlugin } from './dispatcher-plugin.js';
* every answer here comes before a domain touches storage. The `auth` slot
* answers a session only when the request carries `x-test-session`. A stub
* `analytics` slot makes the standalone server mount `/analytics`, and a stub
- * `mcp` slot lets `/mcp` reach its anonymous decision (which follows its
- * enabled and available checks).
+ * `mcp` slot lets a signed-in `/mcp` caller past the domain's available check
+ * to the body read (its anonymous decision comes first, #24251).
*/
const SESSION_HEADER = 'x-test-session';
diff --git a/packages/runtime/src/domains/mcp.ts b/packages/runtime/src/domains/mcp.ts
index 2f3cc854566..55cde0fd74e 100644
--- a/packages/runtime/src/domains/mcp.ts
+++ b/packages/runtime/src/domains/mcp.ts
@@ -46,14 +46,17 @@ export function createMcpDomains(deps: DomainHandlerDeps): DomainRoute[] {
/**
* Handle an MCP request over the Streamable HTTP transport (`/mcp`).
*
- * Gating + auth (fail-closed):
+ * Gating + auth (fail-closed), in this order:
+ * - **auth first**: requires a principal already resolved by
+ * `resolveExecutionContext` (the `sys_api_key` Bearer/header path, an
+ * OAuth access token or a session). Anonymous → 401, whatever the two
+ * checks below would answer: ADR-0138 D2 takes the anonymous decision as
+ * the domain's first statement ({@link refuseAnonymousMcpCaller}).
* - **default-on**: served unless `OS_MCP_SERVER_ENABLED=false` (single-env
* runtime; MCP is a core platform capability). Multi-tenant cloud
* overrides this gate per env. When opted out we return 404 so the
* surface isn't advertised.
- * - **auth**: requires a principal already resolved by
- * `resolveExecutionContext` (the `sys_api_key` Bearer/header path or a
- * session). Anonymous → 401.
+ * - **serveable**: 501 when no `mcp` service can take the request.
*
* Execution: the MCP runtime builds a stateless per-request server whose
* object-CRUD tools run through {@link callData} bound to THIS request's
@@ -61,6 +64,15 @@ export function createMcpDomains(deps: DomainHandlerDeps): DomainRoute[] {
* uses. An external agent can never exceed the key's authority.
*/
export async function handleMcpRequest(deps: DomainHandlerDeps, body: any, context: HttpProtocolContext): Promise {
+ // [#24251] ADR-0138 D2: the anonymous decision is this domain's first
+ // statement. The switch's 404 and the service's 501 below used to come
+ // first, so an anonymous caller learned whether this deployment runs MCP
+ // before it was refused.
+ const ec = context.executionContext;
+ if (!ec || (!ec.userId && !ec.isSystem)) {
+ return { handled: true, response: await refuseAnonymousMcpCaller(deps, context) };
+ }
+
if (!isMcpServerEnabled()) {
return { handled: true, response: deps.error('MCP server is not enabled for this environment', 404) };
}
@@ -69,28 +81,6 @@ export async function handleMcpRequest(deps: DomainHandlerDeps, body: any, conte
return { handled: true, response: deps.error('MCP server is not available', 501) };
}
- const ec = context.executionContext;
- if (!ec || (!ec.userId && !ec.isSystem)) {
- // Per the MCP authorization spec (RFC 9728 §5.1), a 401 from the
- // protected resource advertises where its metadata lives so an
- // OAuth-capable client can bootstrap discovery → DCR → PKCE.
- // Only advertised when the OAuth track is actually live (AS on +
- // TLS rule satisfied); API-key-only deployments return a plain 401.
- const resourceMetadataUrl = await getMcpResourceMetadataUrl(deps, context);
- const response = deps.error(
- resourceMetadataUrl
- ? 'Unauthorized: a valid OAuth access token or API key is required'
- : 'Unauthorized: a valid API key is required',
- 401,
- ) as { status: number; body: any; headers?: Record };
- if (resourceMetadataUrl) {
- response.headers = {
- 'WWW-Authenticate':
- `Bearer realm="ObjectStack MCP", resource_metadata="${resourceMetadataUrl}"`,
- };
- }
- return { handled: true, response };
- }
// [#24151] The body is read once the anonymous decision above has admitted
// the caller, never ahead of it (`../deferred-request-body.ts`).
body = readDispatchedBody(body);
@@ -254,6 +244,51 @@ export async function handleMcpSkillRequest(deps: DomainHandlerDeps, method: str
} as any;
}
+/**
+ * [#24251] The anonymous `401` of `/mcp` (ADR-0138 D2): the same status and
+ * code whatever `OS_MCP_SERVER_ENABLED` or the `mcp` service says.
+ *
+ * Per the MCP authorization spec (RFC 9728 §5.1), a 401 from the protected
+ * resource advertises where its metadata lives so an OAuth-capable client can
+ * bootstrap discovery → DCR → PKCE. It is advertised only when both halves
+ * hold, and API-key-only deployments get a plain 401:
+ *
+ * - **the resource is served**: the switch is on and an `mcp` service can take
+ * the request. That is the predicate `handleMcpRequest` applies to an
+ * admitted caller (its 404 and 501) and `/discovery` applies to
+ * `routes.mcp`. Without it there is nothing behind the metadata to
+ * authorize. The auth service's answer alone does not settle it: with
+ * `OS_OIDC_PROVIDER_ENABLED=true` (or the `oidcProvider` config key)
+ * plugin-auth answers a metadata URL while the switch is off, and its
+ * `/.well-known/oauth-protected-resource` mount follows the switch, so
+ * that URL is not served.
+ * - **the OAuth track is live**: the auth service answers a metadata URL
+ * (AS on + TLS rule satisfied).
+ *
+ * The header tells an anonymous caller nothing the public `/discovery` route
+ * and the metadata document itself do not already answer anonymously.
+ */
+async function refuseAnonymousMcpCaller(deps: DomainHandlerDeps, context: HttpProtocolContext) {
+ const mcp: any = isMcpServerEnabled()
+ ? await deps.resolveService(context, 'mcp', context.environmentId)
+ : undefined;
+ const served = !!mcp && typeof mcp.handleHttpRequest === 'function';
+ const resourceMetadataUrl = served ? await getMcpResourceMetadataUrl(deps, context) : null;
+ const response = deps.error(
+ resourceMetadataUrl
+ ? 'Unauthorized: a valid OAuth access token or API key is required'
+ : 'Unauthorized: a valid API key is required',
+ 401,
+ ) as { status: number; body: any; headers?: Record };
+ if (resourceMetadataUrl) {
+ response.headers = {
+ 'WWW-Authenticate':
+ `Bearer realm="ObjectStack MCP", resource_metadata="${resourceMetadataUrl}"`,
+ };
+ }
+ return response;
+}
+
/**
* Absolute URL of the RFC 9728 protected-resource metadata for the MCP
* endpoint, advertised via `WWW-Authenticate` (#2698). `null` when the
diff --git a/packages/runtime/src/http-dispatcher.mcp.test.ts b/packages/runtime/src/http-dispatcher.mcp.test.ts
index 7c11fa0c3c7..8892bc946e5 100644
--- a/packages/runtime/src/http-dispatcher.mcp.test.ts
+++ b/packages/runtime/src/http-dispatcher.mcp.test.ts
@@ -191,6 +191,61 @@ describe('HttpDispatcher.handleMcp', () => {
expect(recorded[0]?.isSystem).toBe(false);
});
});
+
+ // [#24251] ADR-0138 D2: the anonymous decision is the domain's first
+ // statement, so an anonymous caller is answered 401 whatever the switch or
+ // the service says. A caller the decision admits — a user or a system
+ // principal — keeps the switch's 404 and the service's 501, in that order.
+ // Over sockets, on both hosts: mcp-anonymous-first.hono.integration.test.ts
+ // and http-conformance's hono-mcp-anonymous-first.conformance.test.ts.
+ describe('[#24251] the anonymous decision comes first (ADR-0138 D2)', () => {
+ const SYSTEM = { userId: undefined, isSystem: true, positions: [], permissions: [] };
+
+ it('anonymous → 401 UNAUTHENTICATED with the switch off, not 404', async () => {
+ process.env.OS_MCP_SERVER_ENABLED = 'false';
+ const { kernel } = makeKernel({ withMcp: true });
+ const d = new HttpDispatcher(kernel, undefined, { enforceProjectMembership: false });
+ const res = await d.handleMcp({}, makeContext({ executionContext: undefined }));
+ expect(res.response!.status).toBe(401);
+ expect(res.response!.body.error.code).toBe('UNAUTHENTICATED');
+ });
+
+ it('anonymous → 401 UNAUTHENTICATED with no mcp service, not 501', async () => {
+ process.env.OS_MCP_SERVER_ENABLED = 'true';
+ const { kernel } = makeKernel({ withMcp: false });
+ const d = new HttpDispatcher(kernel, undefined, { enforceProjectMembership: false });
+ const res = await d.handleMcp({}, makeContext({ executionContext: undefined }));
+ expect(res.response!.status).toBe(401);
+ expect(res.response!.body.error.code).toBe('UNAUTHENTICATED');
+ });
+
+ it('a system principal is still admitted: the switch-off 404 answers it', async () => {
+ process.env.OS_MCP_SERVER_ENABLED = 'false';
+ const { kernel } = makeKernel({ withMcp: true });
+ const d = new HttpDispatcher(kernel, undefined, { enforceProjectMembership: false });
+ const res = await d.handleMcp({}, makeContext({ executionContext: SYSTEM }));
+ expect(res.response!.status).toBe(404);
+ expect(res.response!.body.error.code).toBe('RESOURCE_NOT_FOUND');
+ });
+
+ it('a system principal is still admitted: the no-service 501 answers it', async () => {
+ process.env.OS_MCP_SERVER_ENABLED = 'true';
+ const { kernel } = makeKernel({ withMcp: false });
+ const d = new HttpDispatcher(kernel, undefined, { enforceProjectMembership: false });
+ const res = await d.handleMcp({}, makeContext({ executionContext: SYSTEM }));
+ expect(res.response!.status).toBe(501);
+ expect(res.response!.body.error.code).toBe('NOT_IMPLEMENTED');
+ });
+
+ it('a system principal is still admitted: MCP on and installed reaches the transport', async () => {
+ process.env.OS_MCP_SERVER_ENABLED = 'true';
+ const { kernel, mcpService } = makeKernel({ withMcp: true });
+ const d = new HttpDispatcher(kernel, undefined, { enforceProjectMembership: false });
+ const res = await d.handleMcp({ jsonrpc: '2.0', id: 1, method: 'tools/list' }, makeContext({ executionContext: SYSTEM }));
+ expect(res.response!.status).toBe(200);
+ expect(mcpService.lastOpts).toBeDefined();
+ });
+ });
});
describe('HttpDispatcher.handleMcpSkill (GET /mcp/skill)', () => {
diff --git a/packages/runtime/src/mcp-anonymous-first.hono.integration.test.ts b/packages/runtime/src/mcp-anonymous-first.hono.integration.test.ts
new file mode 100644
index 00000000000..9d4df0dd996
--- /dev/null
+++ b/packages/runtime/src/mcp-anonymous-first.hono.integration.test.ts
@@ -0,0 +1,179 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
+import { LiteKernel } from '@objectstack/core';
+import type { Plugin, PluginContext } from '@objectstack/core';
+import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
+import type { IHttpServer } from '@objectstack/spec/contracts';
+
+import { createDispatcherPlugin } from './dispatcher-plugin.js';
+
+/**
+ * [#24251] On the standalone server (`HonoServerPlugin` + the dispatcher
+ * plugin), an anonymous caller of `/api/v1/mcp` is answered `401
+ * UNAUTHENTICATED` whatever the MCP switch or the `mcp` service says, over a
+ * real Hono socket.
+ *
+ * ADR-0138 D2 takes the anonymous decision "once per domain, as the domain's
+ * first statement". `handleMcpRequest` (`domains/mcp.ts`) checked
+ * `OS_MCP_SERVER_ENABLED` and the `mcp` service ahead of it, so an anonymous
+ * caller was told whether this deployment runs MCP before it was refused.
+ * Measured on `9822b053e`, this file's anonymous rows: `404
+ * RESOURCE_NOT_FOUND` with the switch off, `501 NOT_IMPLEMENTED` with no
+ * service, `401 UNAUTHENTICATED` only with MCP on and installed.
+ *
+ * What is pinned, per composition:
+ *
+ * 1. anonymous, every verb the standalone server mounts on `/mcp` → `401
+ * UNAUTHENTICATED`, and the transport is never reached;
+ * 2. the `401` advertises RFC 9728 resource metadata only where the resource
+ * is served: the switch on, an `mcp` service installed, and the auth
+ * service answering a metadata URL. The fake `auth` slot below answers one
+ * in every composition, as plugin-auth does with the switch off when
+ * `OS_OIDC_PROVIDER_ENABLED=true`, so the switch-off and no-service rows
+ * are the ones that would advertise a resource nobody serves;
+ * 3. signed in, the domain's own answer is unchanged: `200` from the
+ * transport, `501 NOT_IMPLEMENTED`, `404 RESOURCE_NOT_FOUND`.
+ *
+ * `LiteKernel`, fake services only (no storage). The `auth` slot answers a
+ * session only for a request carrying `x-test-session`. The switch is read
+ * per request, so each row sets it for its own requests.
+ */
+
+const PREFIX = '/api/v1';
+const SESSION_HEADER = 'x-test-session';
+const METADATA_URL = 'https://mcp-anonymous-first.test/.well-known/oauth-protected-resource';
+const JSON_RPC = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' });
+
+/** How many requests reached the fake `/mcp` transport. */
+let transportCalls = 0;
+
+function fakeServicesPlugin(withMcp: boolean): Plugin {
+ return {
+ name: `com.objectstack.test.fake-services-mcp-anonymous-first-${withMcp ? 'mcp' : 'no-mcp'}`,
+ version: '1.0.0',
+ init: async (ctx: PluginContext) => {
+ ctx.registerService('auth', {
+ api: {
+ getSession: async ({ headers }: { headers?: Headers }) =>
+ (headers?.get?.(SESSION_HEADER) ? { user: { id: 'u_mcp_anonymous_first' } } : undefined),
+ },
+ getMcpResourceMetadataUrl: () => METADATA_URL,
+ });
+ if (withMcp) {
+ ctx.registerService('mcp', {
+ handleHttpRequest: async () => {
+ transportCalls += 1;
+ return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });
+ },
+ renderSkill: () => '# skill',
+ });
+ }
+ },
+ };
+}
+
+interface Composition {
+ name: string;
+ withMcp: boolean;
+ switchOff: boolean;
+ /** The 401 carries `resource_metadata`. */
+ advertises: boolean;
+ /** What a signed-in `POST` is answered: the domain's own answer. */
+ signedIn: { status: number; code?: string };
+}
+
+const COMPOSITIONS: Composition[] = [
+ { name: 'MCP on and installed', withMcp: true, switchOff: false, advertises: true, signedIn: { status: 200 } },
+ { name: 'no mcp service installed', withMcp: false, switchOff: false, advertises: false, signedIn: { status: 501, code: 'NOT_IMPLEMENTED' } },
+ { name: 'OS_MCP_SERVER_ENABLED=false', withMcp: true, switchOff: true, advertises: false, signedIn: { status: 404, code: 'RESOURCE_NOT_FOUND' } },
+];
+
+const VERBS = ['POST', 'GET', 'DELETE'] as const;
+
+describe('[#24251] an anonymous /mcp caller is answered 401 first on the standalone server (integration)', () => {
+ const kernels: LiteKernel[] = [];
+ const bases = new Map();
+ const prevSwitch = process.env.OS_MCP_SERVER_ENABLED;
+
+ beforeAll(async () => {
+ for (const withMcp of [true, false]) {
+ const kernel = new LiteKernel();
+ kernel.use(fakeServicesPlugin(withMcp));
+ // port 0 → an OS-assigned free port, read back through getPort().
+ kernel.use(new HonoServerPlugin({ port: 0, cors: false }));
+ kernel.use(createDispatcherPlugin({ prefix: PREFIX, securityHeaders: false }));
+ await kernel.bootstrap();
+ kernels.push(kernel);
+ bases.set(withMcp, `http://127.0.0.1:${kernel.getService('http.server').getPort!()}`);
+ }
+ }, 30_000);
+
+ afterAll(async () => {
+ for (const kernel of kernels) {
+ await Promise.race([kernel.shutdown(), new Promise((resolve) => setTimeout(resolve, 10_000))]);
+ }
+ }, 30_000);
+
+ afterEach(() => {
+ if (prevSwitch === undefined) delete process.env.OS_MCP_SERVER_ENABLED;
+ else process.env.OS_MCP_SERVER_ENABLED = prevSwitch;
+ });
+
+ async function call(c: Composition, method: string, signedIn: boolean): Promise {
+ process.env.OS_MCP_SERVER_ENABLED = c.switchOff ? 'false' : 'true';
+ return fetch(`${bases.get(c.withMcp)}${PREFIX}/mcp`, {
+ method,
+ headers: {
+ 'content-type': 'application/json',
+ accept: 'application/json, text/event-stream',
+ ...(signedIn ? { [SESSION_HEADER]: '1' } : {}),
+ },
+ ...(method === 'GET' ? {} : { body: JSON_RPC }),
+ });
+ }
+
+ for (const c of COMPOSITIONS) {
+ for (const method of VERBS) {
+ it(`${c.name}: anonymous ${method} /mcp → 401 UNAUTHENTICATED, the transport never reached`, async () => {
+ const before = transportCalls;
+ const res = await call(c, method, false);
+ const json = (await res.json()) as { success?: boolean; error?: { code?: string; httpStatus?: number } };
+
+ expect(res.status).toBe(401);
+ expect(json.success).toBe(false);
+ expect(json.error?.code).toBe('UNAUTHENTICATED');
+ expect(json.error?.httpStatus).toBe(401);
+ expect(transportCalls, 'an anonymous caller must never reach the MCP transport').toBe(before);
+ });
+ }
+
+ it(`${c.name}: the 401 ${c.advertises ? 'advertises' : 'does not advertise'} RFC 9728 resource metadata`, async () => {
+ const res = await call(c, 'POST', false);
+ expect(res.status).toBe(401);
+ const www = res.headers.get('www-authenticate');
+ if (c.advertises) {
+ expect(www).toContain('Bearer');
+ expect(www).toContain(`resource_metadata="${METADATA_URL}"`);
+ } else {
+ expect(www).toBeNull();
+ }
+ });
+
+ it(`${c.name}: signed-in POST /mcp keeps the domain's own ${c.signedIn.status}${c.signedIn.code ? ` ${c.signedIn.code}` : ''}`, async () => {
+ const before = transportCalls;
+ const res = await call(c, 'POST', true);
+ const json = (await res.json()) as { success?: boolean; error?: { code?: string; httpStatus?: number } };
+
+ expect(res.status).toBe(c.signedIn.status);
+ if (c.signedIn.code) {
+ expect(json.success).toBe(false);
+ expect(json.error?.code).toBe(c.signedIn.code);
+ expect(json.error?.httpStatus).toBe(c.signedIn.status);
+ expect(transportCalls).toBe(before);
+ } else {
+ expect(transportCalls).toBe(before + 1);
+ }
+ });
+ }
+});