Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/mount-mcp-keys-routes.md
Original file line number Diff line number Diff line change
@@ -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.<verb>()` 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).
75 changes: 75 additions & 0 deletions packages/runtime/src/dispatcher-plugin.routes.test.ts
Original file line number Diff line number Diff line change
@@ -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.<verb>()`
* 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');
});
});
32 changes: 32 additions & 0 deletions packages/runtime/src/dispatcher-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading