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); + } + }); + } +});