Skip to content

Commit 0ec7717

Browse files
xuyushun441-sysos-zhuangclaude
authored
fix(runtime): mount /mcp and /keys HTTP routes — were unreachable (ADR-0036) (#1631)
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: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f68be58 commit 0ec7717

3 files changed

Lines changed: 125 additions & 0 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): mount /mcp and /keys HTTP routes (ADR-0036) — were unreachable
6+
7+
The dispatcher mounts routes EXPLICITLY on the HTTP server (no catch-all). The
8+
MCP transport (#1626) and key-generation (#1630) added branches inside
9+
`dispatch()` but never registered the corresponding `server.<verb>()` routes, so
10+
`/api/v1/mcp` and `/api/v1/keys` 404'd at the HTTP layer before ever reaching
11+
the dispatcher. Unit tests called the handlers directly, hiding the gap; it only
12+
showed up in live staging e2e.
13+
14+
- Register `/mcp` (GET/POST/DELETE → dispatch, transport reads the method) and
15+
`/keys` (POST) in the dispatcher plugin, routed through `dispatch()` so the
16+
host's project-aware kernel swap + executionContext resolution run first.
17+
- Add `dispatcher-plugin.routes.test.ts` asserting the routes are registered
18+
(the regression that would have caught this).
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
5+
import { createDispatcherPlugin } from './dispatcher-plugin.js';
6+
7+
/**
8+
* Regression: the dispatcher mounts routes EXPLICITLY on the HTTP server (there
9+
* is no catch-all). A dispatch() branch with no matching `server.<verb>()`
10+
* registration is unreachable over HTTP and 404s before reaching the handler —
11+
* which is exactly how /mcp and /keys shipped broken (unit tests called the
12+
* handlers directly, hiding it). This test asserts the routes are registered.
13+
*/
14+
15+
function makeFakeServer() {
16+
const routes: string[] = [];
17+
const rec = (verb: string) => (path: string, _handler: unknown) => {
18+
routes.push(`${verb} ${path}`);
19+
};
20+
return {
21+
routes,
22+
server: {
23+
get: rec('GET'),
24+
post: rec('POST'),
25+
put: rec('PUT'),
26+
delete: rec('DELETE'),
27+
patch: rec('PATCH'),
28+
},
29+
};
30+
}
31+
32+
function makeCtx(fakeServer: any) {
33+
const kernel = {
34+
getService: () => undefined,
35+
getServiceAsync: async () => undefined,
36+
};
37+
return {
38+
getKernel: () => kernel,
39+
getService: (name: string) => (name === 'http.server' ? fakeServer : undefined),
40+
environmentId: undefined,
41+
logger: { info() {}, warn() {}, error() {}, debug() {} },
42+
hook: () => {},
43+
on: () => {},
44+
} as any;
45+
}
46+
47+
describe('createDispatcherPlugin — HTTP route registration', () => {
48+
it('mounts /mcp (GET/POST/DELETE) and /keys (POST) so they reach dispatch()', async () => {
49+
const { server, routes } = makeFakeServer();
50+
const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false });
51+
await plugin.start?.(makeCtx(server));
52+
53+
expect(routes).toContain('POST /api/v1/mcp');
54+
expect(routes).toContain('GET /api/v1/mcp');
55+
expect(routes).toContain('DELETE /api/v1/mcp');
56+
expect(routes).toContain('POST /api/v1/keys');
57+
});
58+
59+
it('also mounts a known existing route (sanity that start() ran)', async () => {
60+
const { server, routes } = makeFakeServer();
61+
const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false });
62+
await plugin.start?.(makeCtx(server));
63+
64+
expect(routes).toContain('POST /api/v1/analytics/query');
65+
});
66+
67+
it('honours a custom prefix', async () => {
68+
const { server, routes } = makeFakeServer();
69+
const plugin = createDispatcherPlugin({ prefix: '/v2', securityHeaders: false });
70+
await plugin.start?.(makeCtx(server));
71+
72+
expect(routes).toContain('POST /v2/mcp');
73+
expect(routes).toContain('POST /v2/keys');
74+
});
75+
});

packages/runtime/src/dispatcher-plugin.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,38 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
541541
}
542542
});
543543

544+
// ── MCP (Streamable HTTP) + API keys (ADR-0036) ─────────────
545+
// Mounted explicitly (there is no catch-all) and routed through
546+
// dispatch() so the host's project-aware kernel swap + execution
547+
// context resolution run first. /mcp accepts POST (JSON-RPC), GET
548+
// (SSE) and DELETE (session end) — the transport reads the method
549+
// from the request, the dispatcher gates on OS_MCP_SERVER_ENABLED
550+
// and the resolved principal. NOTE: the dispatch() branches alone
551+
// are unreachable over HTTP without these registrations.
552+
const mountMcp = (method: 'GET' | 'POST' | 'DELETE') => {
553+
const register = method === 'GET' ? server.get : method === 'DELETE' ? server.delete : server.post;
554+
register.call(server, `${prefix}/mcp`, async (req: any, res: any) => {
555+
try {
556+
const result = await dispatcher.dispatch(method, '/mcp', req.body, req.query, { request: req });
557+
sendResult(result, res);
558+
} catch (err: any) {
559+
errorResponse(err, res);
560+
}
561+
});
562+
};
563+
mountMcp('POST');
564+
mountMcp('GET');
565+
mountMcp('DELETE');
566+
567+
server.post(`${prefix}/keys`, async (req: any, res: any) => {
568+
try {
569+
const result = await dispatcher.dispatch('POST', '/keys', req.body, req.query, { request: req });
570+
sendResult(result, res);
571+
} catch (err: any) {
572+
errorResponse(err, res);
573+
}
574+
});
575+
544576
// ── Packages ────────────────────────────────────────────────
545577
server.get(`${prefix}/packages`, async (req: any, res: any) => {
546578
try {

0 commit comments

Comments
 (0)