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
17 changes: 17 additions & 0 deletions .changeset/mcp-web-request-adapter.md
Original file line number Diff line number Diff line change
@@ -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.
38 changes: 38 additions & 0 deletions packages/runtime/src/http-dispatcher.mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 });
Expand Down
60 changes: 56 additions & 4 deletions packages/runtime/src/http-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) };
}
Expand All @@ -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
Expand Down
Loading