From f44fce1346a01d2aa22ada244f1e1638d7cddf83 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 7 Jun 2026 07:26:36 +0800 Subject: [PATCH] =?UTF-8?q?fix(runtime):=20mount=20/mcp=20and=20/keys=20HT?= =?UTF-8?q?TP=20routes=20=E2=80=94=20were=20unreachable=20(ADR-0036)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dispatcher mounts routes explicitly (no catch-all). #1626 (MCP transport) and #1630 (key-gen) added dispatch() branches but never registered the HTTP routes, so /api/v1/mcp and /api/v1/keys 404'd at the HTTP layer before reaching the dispatcher. Unit tests called handlers directly, hiding it; caught in live staging e2e. - Register /mcp (GET/POST/DELETE) + /keys (POST) via dispatch() in the dispatcher plugin (transport reads the method from the request). - dispatcher-plugin.routes.test.ts asserts the registrations (the missing regression). Full runtime suite 379 green. Co-Authored-By: Claude Opus 4.8 --- .changeset/mount-mcp-keys-routes.md | 18 +++++ .../src/dispatcher-plugin.routes.test.ts | 75 +++++++++++++++++++ packages/runtime/src/dispatcher-plugin.ts | 32 ++++++++ 3 files changed, 125 insertions(+) create mode 100644 .changeset/mount-mcp-keys-routes.md create mode 100644 packages/runtime/src/dispatcher-plugin.routes.test.ts diff --git a/.changeset/mount-mcp-keys-routes.md b/.changeset/mount-mcp-keys-routes.md new file mode 100644 index 000000000..754db13df --- /dev/null +++ b/.changeset/mount-mcp-keys-routes.md @@ -0,0 +1,18 @@ +--- +'@objectstack/runtime': patch +--- + +fix(runtime): mount /mcp and /keys HTTP routes (ADR-0036) — were unreachable + +The dispatcher mounts routes EXPLICITLY on the HTTP server (no catch-all). The +MCP transport (#1626) and key-generation (#1630) added branches inside +`dispatch()` but never registered the corresponding `server.()` routes, so +`/api/v1/mcp` and `/api/v1/keys` 404'd at the HTTP layer before ever reaching +the dispatcher. Unit tests called the handlers directly, hiding the gap; it only +showed up in live staging e2e. + +- Register `/mcp` (GET/POST/DELETE → dispatch, transport reads the method) and + `/keys` (POST) in the dispatcher plugin, routed through `dispatch()` so the + host's project-aware kernel swap + executionContext resolution run first. +- Add `dispatcher-plugin.routes.test.ts` asserting the routes are registered + (the regression that would have caught this). diff --git a/packages/runtime/src/dispatcher-plugin.routes.test.ts b/packages/runtime/src/dispatcher-plugin.routes.test.ts new file mode 100644 index 000000000..6b0eb6167 --- /dev/null +++ b/packages/runtime/src/dispatcher-plugin.routes.test.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; + +import { createDispatcherPlugin } from './dispatcher-plugin.js'; + +/** + * Regression: the dispatcher mounts routes EXPLICITLY on the HTTP server (there + * is no catch-all). A dispatch() branch with no matching `server.()` + * registration is unreachable over HTTP and 404s before reaching the handler — + * which is exactly how /mcp and /keys shipped broken (unit tests called the + * handlers directly, hiding it). This test asserts the routes are registered. + */ + +function makeFakeServer() { + const routes: string[] = []; + const rec = (verb: string) => (path: string, _handler: unknown) => { + routes.push(`${verb} ${path}`); + }; + return { + routes, + server: { + get: rec('GET'), + post: rec('POST'), + put: rec('PUT'), + delete: rec('DELETE'), + patch: rec('PATCH'), + }, + }; +} + +function makeCtx(fakeServer: any) { + const kernel = { + getService: () => undefined, + getServiceAsync: async () => undefined, + }; + return { + getKernel: () => kernel, + getService: (name: string) => (name === 'http.server' ? fakeServer : undefined), + environmentId: undefined, + logger: { info() {}, warn() {}, error() {}, debug() {} }, + hook: () => {}, + on: () => {}, + } as any; +} + +describe('createDispatcherPlugin — HTTP route registration', () => { + it('mounts /mcp (GET/POST/DELETE) and /keys (POST) so they reach dispatch()', async () => { + const { server, routes } = makeFakeServer(); + const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false }); + await plugin.start?.(makeCtx(server)); + + expect(routes).toContain('POST /api/v1/mcp'); + expect(routes).toContain('GET /api/v1/mcp'); + expect(routes).toContain('DELETE /api/v1/mcp'); + expect(routes).toContain('POST /api/v1/keys'); + }); + + it('also mounts a known existing route (sanity that start() ran)', async () => { + const { server, routes } = makeFakeServer(); + const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false }); + await plugin.start?.(makeCtx(server)); + + expect(routes).toContain('POST /api/v1/analytics/query'); + }); + + it('honours a custom prefix', async () => { + const { server, routes } = makeFakeServer(); + const plugin = createDispatcherPlugin({ prefix: '/v2', securityHeaders: false }); + await plugin.start?.(makeCtx(server)); + + expect(routes).toContain('POST /v2/mcp'); + expect(routes).toContain('POST /v2/keys'); + }); +}); diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index 1bf133504..575452d03 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -541,6 +541,38 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu } }); + // ── MCP (Streamable HTTP) + API keys (ADR-0036) ───────────── + // Mounted explicitly (there is no catch-all) and routed through + // dispatch() so the host's project-aware kernel swap + execution + // context resolution run first. /mcp accepts POST (JSON-RPC), GET + // (SSE) and DELETE (session end) — the transport reads the method + // from the request, the dispatcher gates on OS_MCP_SERVER_ENABLED + // and the resolved principal. NOTE: the dispatch() branches alone + // are unreachable over HTTP without these registrations. + const mountMcp = (method: 'GET' | 'POST' | 'DELETE') => { + const register = method === 'GET' ? server.get : method === 'DELETE' ? server.delete : server.post; + register.call(server, `${prefix}/mcp`, async (req: any, res: any) => { + try { + const result = await dispatcher.dispatch(method, '/mcp', req.body, req.query, { request: req }); + sendResult(result, res); + } catch (err: any) { + errorResponse(err, res); + } + }); + }; + mountMcp('POST'); + mountMcp('GET'); + mountMcp('DELETE'); + + server.post(`${prefix}/keys`, async (req: any, res: any) => { + try { + const result = await dispatcher.dispatch('POST', '/keys', req.body, req.query, { request: req }); + sendResult(result, res); + } catch (err: any) { + errorResponse(err, res); + } + }); + // ── Packages ──────────────────────────────────────────────── server.get(`${prefix}/packages`, async (req: any, res: any) => { try {