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