diff --git a/.changeset/mcp-web-request-adapter.md b/.changeset/mcp-web-request-adapter.md new file mode 100644 index 000000000..adb5d9d0a --- /dev/null +++ b/.changeset/mcp-web-request-adapter.md @@ -0,0 +1,17 @@ +--- +'@objectstack/runtime': patch +--- + +fix(runtime): adapt node/Hono req → Web Request for the MCP transport (ADR-0036) + +The MCP Streamable HTTP transport needs a Web-standard `Request`, but the +runtime HTTP adapter hands the dispatcher a node/Hono-style req (plain `headers` +object, path-only `url`). `handleMcp` rejected it with 400 ("MCP transport +requires a standard HTTP request") — so the live endpoint was unusable even +once routed + registered. Unit tests passed a real `Request`, hiding it; caught +in staging e2e on `initialize`. + +`handleMcp` now reconstructs a Web `Request` (method, absolute URL from +host+path, normalised headers, JSON body from the parsed body) when the inbound +req isn't already Web-standard. Regression tests cover a POST and a GET +node-style req. diff --git a/packages/runtime/src/http-dispatcher.mcp.test.ts b/packages/runtime/src/http-dispatcher.mcp.test.ts index c16550d7f..143e22199 100644 --- a/packages/runtime/src/http-dispatcher.mcp.test.ts +++ b/packages/runtime/src/http-dispatcher.mcp.test.ts @@ -47,8 +47,10 @@ function makeKernel(opts: { withMcp?: boolean; recordedContexts?: any[] } = {}) // The fake MCP service exercises the bridge so we can assert principal binding. const mcpService: any = { lastOpts: undefined, + lastReq: undefined, handleHttpRequest: async (_req: Request, o: any) => { mcpService.lastOpts = o; + mcpService.lastReq = _req; const created = await o.bridge.create('task', { title: 'x' }); return new Response(JSON.stringify({ ok: true, created }), { status: 200, @@ -92,6 +94,42 @@ describe('HttpDispatcher.handleMcp', () => { expect(res.response.status).toBe(501); }); + it('normalises a node/Hono-style req into a Web Request for the transport', async () => { + // Regression: production hands the dispatcher a node/Hono req (plain + // headers object, path-only url) — NOT a Web Request. handleMcp must + // reconstruct one so the transport's headers.get()/new URL(url) work. + const { kernel, mcpService } = makeKernel({ withMcp: true }); + const d = new HttpDispatcher(kernel, undefined, { enforceProjectMembership: false }); + const nodeReq = { + method: 'POST', + url: '/api/v1/mcp', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + host: 'env.objectos.app', + 'x-api-key': 'osk_demo', + }, + }; + const res = await d.handleMcp({ jsonrpc: '2.0', id: 1, method: 'tools/list' }, makeContext({ request: nodeReq })); + expect(res.response.status).toBe(200); + const req = mcpService.lastReq as Request; + expect(typeof req.headers.get).toBe('function'); + expect(req.method).toBe('POST'); + expect(req.url).toBe('https://env.objectos.app/api/v1/mcp'); + expect(req.headers.get('x-api-key')).toBe('osk_demo'); + expect(req.headers.get('accept')).toContain('text/event-stream'); + }); + + it('normalises a GET node req without a body', async () => { + const { kernel, mcpService } = makeKernel({ withMcp: true }); + const d = new HttpDispatcher(kernel, undefined, { enforceProjectMembership: false }); + const nodeReq = { method: 'GET', url: '/api/v1/mcp', headers: { host: 'env.objectos.app', accept: 'text/event-stream' } }; + await d.handleMcp(undefined, makeContext({ request: nodeReq })); + const req = mcpService.lastReq as Request; + expect(req.method).toBe('GET'); + expect(req.url).toBe('https://env.objectos.app/api/v1/mcp'); + }); + it('returns 401 for an anonymous request (fail-closed auth)', async () => { const { kernel } = makeKernel({ withMcp: true }); const d = new HttpDispatcher(kernel, undefined, { enforceProjectMembership: false }); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index cdea16a97..d389885c3 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -296,16 +296,18 @@ export class HttpDispatcher { return { handled: true, response: this.error('Unauthorized: a valid API key is required', 401) }; } - const request = context.request; - if (!request || typeof (request as any).headers?.get !== 'function') { - // The MCP transport needs a Web-standard Request (headers/method/url). + // The MCP transport needs a Web-standard Request. The runtime HTTP + // adapter may hand us a node/Hono-style req (plain `headers` object, + // path-only `url`), so normalise it. + const webRequest = this.toMcpWebRequest(context.request, body); + if (!webRequest) { return { handled: true, response: this.error('MCP transport requires a standard HTTP request', 400) }; } const bridge = this.buildMcpBridge(context); let webRes: Response; try { - webRes = await mcp.handleHttpRequest(request, { bridge, parsedBody: body }); + webRes = await mcp.handleHttpRequest(webRequest, { bridge, parsedBody: body }); } catch (err: any) { return { handled: true, response: this.error(err?.message ?? 'MCP request failed', 500) }; } @@ -332,6 +334,56 @@ export class HttpDispatcher { return typeof process !== 'undefined' && process.env?.OS_MCP_SERVER_ENABLED === 'true'; } + /** + * Normalise the inbound request into a Web-standard `Request` for the MCP + * transport. Accepts an already-Web `Request`, or a node/Hono-style req + * (plain `headers` object, path-only `url`). Returns undefined only if the + * shape is unusable. The body is carried separately via `parsedBody`, so a + * GET/DELETE (no body) and a POST (JSON-RPC) both normalise cleanly. + */ + private toMcpWebRequest(raw: any, parsedBody: any): Request | undefined { + if (!raw) return undefined; + // Already a Web Request. + if (typeof raw.headers?.get === 'function' && typeof raw.url === 'string' && typeof raw.method === 'string') { + return raw as Request; + } + try { + const method = String(raw.method ?? 'POST').toUpperCase(); + + // Normalise headers (plain object or Headers-like). + const headers = new Headers(); + const h = raw.headers; + if (h) { + if (typeof h.forEach === 'function') { + h.forEach((v: any, k: any) => { if (v != null) headers.set(String(k), String(v)); }); + } else { + for (const k of Object.keys(h)) { + const v = (h as any)[k]; + if (v != null) headers.set(k, Array.isArray(v) ? v.join(',') : String(v)); + } + } + } + + // Build an absolute URL (node req.url is path-only). + let url: string; + try { + url = new URL(String(raw.url)).toString(); + } catch { + const host = headers.get('host') || 'mcp.local'; + const path = typeof raw.url === 'string' && raw.url ? raw.url : '/api/v1/mcp'; + url = `https://${host}${path.startsWith('/') ? path : `/${path}`}`; + } + + const init: { method: string; headers: Headers; body?: string } = { method, headers }; + if (method !== 'GET' && method !== 'HEAD' && method !== 'DELETE') { + init.body = typeof parsedBody === 'string' ? parsedBody : JSON.stringify(parsedBody ?? {}); + } + return new Request(url, init); + } catch { + return undefined; + } + } + /** * Build a principal-bound {@link McpDataBridge}: every method runs AS the * request's ExecutionContext through {@link callData} (RLS/permissions) and