Skip to content

Commit 2537e28

Browse files
xuyushun441-sysos-zhuangclaude
authored
fix(runtime): adapt node/Hono req → Web Request for MCP transport (ADR-0036) (#1632)
The MCP transport needs a Web-standard Request, but the runtime HTTP adapter passes a node/Hono-style req (plain headers object, path-only url). handleMcp 400'd it ("MCP transport requires a standard HTTP request") → live endpoint unusable even after routing+registration. 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) when the inbound req isn't already Web-standard. +2 regression tests (node-style POST + GET). Full runtime suite 381 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 0ec7717 commit 2537e28

3 files changed

Lines changed: 111 additions & 4 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): adapt node/Hono req → Web Request for the MCP transport (ADR-0036)
6+
7+
The MCP Streamable HTTP transport needs a Web-standard `Request`, but the
8+
runtime HTTP adapter hands the dispatcher a node/Hono-style req (plain `headers`
9+
object, path-only `url`). `handleMcp` rejected it with 400 ("MCP transport
10+
requires a standard HTTP request") — so the live endpoint was unusable even
11+
once routed + registered. Unit tests passed a real `Request`, hiding it; caught
12+
in staging e2e on `initialize`.
13+
14+
`handleMcp` now reconstructs a Web `Request` (method, absolute URL from
15+
host+path, normalised headers, JSON body from the parsed body) when the inbound
16+
req isn't already Web-standard. Regression tests cover a POST and a GET
17+
node-style req.

packages/runtime/src/http-dispatcher.mcp.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,10 @@ function makeKernel(opts: { withMcp?: boolean; recordedContexts?: any[] } = {})
4747
// The fake MCP service exercises the bridge so we can assert principal binding.
4848
const mcpService: any = {
4949
lastOpts: undefined,
50+
lastReq: undefined,
5051
handleHttpRequest: async (_req: Request, o: any) => {
5152
mcpService.lastOpts = o;
53+
mcpService.lastReq = _req;
5254
const created = await o.bridge.create('task', { title: 'x' });
5355
return new Response(JSON.stringify({ ok: true, created }), {
5456
status: 200,
@@ -92,6 +94,42 @@ describe('HttpDispatcher.handleMcp', () => {
9294
expect(res.response.status).toBe(501);
9395
});
9496

97+
it('normalises a node/Hono-style req into a Web Request for the transport', async () => {
98+
// Regression: production hands the dispatcher a node/Hono req (plain
99+
// headers object, path-only url) — NOT a Web Request. handleMcp must
100+
// reconstruct one so the transport's headers.get()/new URL(url) work.
101+
const { kernel, mcpService } = makeKernel({ withMcp: true });
102+
const d = new HttpDispatcher(kernel, undefined, { enforceProjectMembership: false });
103+
const nodeReq = {
104+
method: 'POST',
105+
url: '/api/v1/mcp',
106+
headers: {
107+
'content-type': 'application/json',
108+
accept: 'application/json, text/event-stream',
109+
host: 'env.objectos.app',
110+
'x-api-key': 'osk_demo',
111+
},
112+
};
113+
const res = await d.handleMcp({ jsonrpc: '2.0', id: 1, method: 'tools/list' }, makeContext({ request: nodeReq }));
114+
expect(res.response.status).toBe(200);
115+
const req = mcpService.lastReq as Request;
116+
expect(typeof req.headers.get).toBe('function');
117+
expect(req.method).toBe('POST');
118+
expect(req.url).toBe('https://env.objectos.app/api/v1/mcp');
119+
expect(req.headers.get('x-api-key')).toBe('osk_demo');
120+
expect(req.headers.get('accept')).toContain('text/event-stream');
121+
});
122+
123+
it('normalises a GET node req without a body', async () => {
124+
const { kernel, mcpService } = makeKernel({ withMcp: true });
125+
const d = new HttpDispatcher(kernel, undefined, { enforceProjectMembership: false });
126+
const nodeReq = { method: 'GET', url: '/api/v1/mcp', headers: { host: 'env.objectos.app', accept: 'text/event-stream' } };
127+
await d.handleMcp(undefined, makeContext({ request: nodeReq }));
128+
const req = mcpService.lastReq as Request;
129+
expect(req.method).toBe('GET');
130+
expect(req.url).toBe('https://env.objectos.app/api/v1/mcp');
131+
});
132+
95133
it('returns 401 for an anonymous request (fail-closed auth)', async () => {
96134
const { kernel } = makeKernel({ withMcp: true });
97135
const d = new HttpDispatcher(kernel, undefined, { enforceProjectMembership: false });

packages/runtime/src/http-dispatcher.ts

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -296,16 +296,18 @@ export class HttpDispatcher {
296296
return { handled: true, response: this.error('Unauthorized: a valid API key is required', 401) };
297297
}
298298

299-
const request = context.request;
300-
if (!request || typeof (request as any).headers?.get !== 'function') {
301-
// The MCP transport needs a Web-standard Request (headers/method/url).
299+
// The MCP transport needs a Web-standard Request. The runtime HTTP
300+
// adapter may hand us a node/Hono-style req (plain `headers` object,
301+
// path-only `url`), so normalise it.
302+
const webRequest = this.toMcpWebRequest(context.request, body);
303+
if (!webRequest) {
302304
return { handled: true, response: this.error('MCP transport requires a standard HTTP request', 400) };
303305
}
304306

305307
const bridge = this.buildMcpBridge(context);
306308
let webRes: Response;
307309
try {
308-
webRes = await mcp.handleHttpRequest(request, { bridge, parsedBody: body });
310+
webRes = await mcp.handleHttpRequest(webRequest, { bridge, parsedBody: body });
309311
} catch (err: any) {
310312
return { handled: true, response: this.error(err?.message ?? 'MCP request failed', 500) };
311313
}
@@ -332,6 +334,56 @@ export class HttpDispatcher {
332334
return typeof process !== 'undefined' && process.env?.OS_MCP_SERVER_ENABLED === 'true';
333335
}
334336

337+
/**
338+
* Normalise the inbound request into a Web-standard `Request` for the MCP
339+
* transport. Accepts an already-Web `Request`, or a node/Hono-style req
340+
* (plain `headers` object, path-only `url`). Returns undefined only if the
341+
* shape is unusable. The body is carried separately via `parsedBody`, so a
342+
* GET/DELETE (no body) and a POST (JSON-RPC) both normalise cleanly.
343+
*/
344+
private toMcpWebRequest(raw: any, parsedBody: any): Request | undefined {
345+
if (!raw) return undefined;
346+
// Already a Web Request.
347+
if (typeof raw.headers?.get === 'function' && typeof raw.url === 'string' && typeof raw.method === 'string') {
348+
return raw as Request;
349+
}
350+
try {
351+
const method = String(raw.method ?? 'POST').toUpperCase();
352+
353+
// Normalise headers (plain object or Headers-like).
354+
const headers = new Headers();
355+
const h = raw.headers;
356+
if (h) {
357+
if (typeof h.forEach === 'function') {
358+
h.forEach((v: any, k: any) => { if (v != null) headers.set(String(k), String(v)); });
359+
} else {
360+
for (const k of Object.keys(h)) {
361+
const v = (h as any)[k];
362+
if (v != null) headers.set(k, Array.isArray(v) ? v.join(',') : String(v));
363+
}
364+
}
365+
}
366+
367+
// Build an absolute URL (node req.url is path-only).
368+
let url: string;
369+
try {
370+
url = new URL(String(raw.url)).toString();
371+
} catch {
372+
const host = headers.get('host') || 'mcp.local';
373+
const path = typeof raw.url === 'string' && raw.url ? raw.url : '/api/v1/mcp';
374+
url = `https://${host}${path.startsWith('/') ? path : `/${path}`}`;
375+
}
376+
377+
const init: { method: string; headers: Headers; body?: string } = { method, headers };
378+
if (method !== 'GET' && method !== 'HEAD' && method !== 'DELETE') {
379+
init.body = typeof parsedBody === 'string' ? parsedBody : JSON.stringify(parsedBody ?? {});
380+
}
381+
return new Request(url, init);
382+
} catch {
383+
return undefined;
384+
}
385+
}
386+
335387
/**
336388
* Build a principal-bound {@link McpDataBridge}: every method runs AS the
337389
* request's ExecutionContext through {@link callData} (RLS/permissions) and

0 commit comments

Comments
 (0)