From 6c81fc7797f50fd76ca6193b67c3902d9514d3e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 08:57:59 +0000 Subject: [PATCH 1/4] test(runtime,http-conformance): pin an anonymous /mcp caller's 401 ahead 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 --- ...no-mcp-anonymous-first.conformance.test.ts | 189 ++++++++++++++++++ .../runtime/src/http-dispatcher.mcp.test.ts | 55 +++++ ...p-anonymous-first.hono.integration.test.ts | 179 +++++++++++++++++ 3 files changed, 423 insertions(+) create mode 100644 packages/qa/http-conformance/src/hono-mcp-anonymous-first.conformance.test.ts create mode 100644 packages/runtime/src/mcp-anonymous-first.hono.integration.test.ts 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/http-dispatcher.mcp.test.ts b/packages/runtime/src/http-dispatcher.mcp.test.ts index 7c11fa0c3c7..7d2d7e4f45a 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); + } + }); + } +}); From 84585ac0e4f6da50103c488242ecf244bb313fc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 09:07:45 +0000 Subject: [PATCH 2/4] fix(runtime): an anonymous /mcp caller is answered 401 ahead of the switch'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 --- .changeset/24251-mcp-anonymous-first.md | 13 +++ ...r-anonymous-floor.hono.integration.test.ts | 4 +- packages/runtime/src/domains/mcp.ts | 87 +++++++++++++------ 3 files changed, 76 insertions(+), 28 deletions(-) create mode 100644 .changeset/24251-mcp-anonymous-first.md 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/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 From a3fba9c8a14f61ca736895f2cd889bedcfb62a5a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 10:13:58 +0000 Subject: [PATCH 3/4] test(runtime): the new /mcp unit pins read the dispatcher result through 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 --- .../runtime/src/http-dispatcher.mcp.test.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/runtime/src/http-dispatcher.mcp.test.ts b/packages/runtime/src/http-dispatcher.mcp.test.ts index 7d2d7e4f45a..8892bc946e5 100644 --- a/packages/runtime/src/http-dispatcher.mcp.test.ts +++ b/packages/runtime/src/http-dispatcher.mcp.test.ts @@ -206,8 +206,8 @@ describe('HttpDispatcher.handleMcp', () => { 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'); + 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 () => { @@ -215,8 +215,8 @@ describe('HttpDispatcher.handleMcp', () => { 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'); + 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 () => { @@ -224,8 +224,8 @@ describe('HttpDispatcher.handleMcp', () => { 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'); + 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 () => { @@ -233,8 +233,8 @@ describe('HttpDispatcher.handleMcp', () => { 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'); + 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 () => { @@ -242,7 +242,7 @@ describe('HttpDispatcher.handleMcp', () => { 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(res.response!.status).toBe(200); expect(mcpService.lastOpts).toBeDefined(); }); }); From 63bf9f4634fb299c0910e7926351098ef56b0de4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 10:56:06 +0000 Subject: [PATCH 4/4] docs(mcp): the switch-off 404 and the no-service 501 are what a caller 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 --- content/docs/ai/connect-mcp.mdx | 9 +++++---- content/docs/deployment/environment-variables.mdx | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) 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. |