Skip to content

Commit f6d652d

Browse files
koala73e
andauthored
Support MCP streamable HTTP replay (#4242)
* feat(mcp): support resumable streamable HTTP * fix(mcp): tighten SSE replay error semantics --------- Co-authored-by: e <e@e.co>
1 parent 5bb591b commit f6d652d

14 files changed

Lines changed: 646 additions & 44 deletions

File tree

api/_cors.js

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,26 @@ const ALLOWED_ORIGIN_PATTERNS = [
1212
]),
1313
];
1414

15+
const ALLOWED_HEADERS = [
16+
'Content-Type',
17+
'Authorization',
18+
'X-WorldMonitor-Key',
19+
'X-Api-Key',
20+
'X-Widget-Key',
21+
'X-Pro-Key',
22+
'X-WorldMonitor-Desktop-Timestamp',
23+
'X-WorldMonitor-Desktop-Signature',
24+
'Mcp-Session-Id',
25+
'MCP-Protocol-Version',
26+
'Last-Event-ID',
27+
].join(', ');
28+
29+
const EXPOSED_HEADERS = [
30+
'Mcp-Session-Id',
31+
'WWW-Authenticate',
32+
'Retry-After',
33+
].join(', ');
34+
1535
function isAllowedOrigin(origin) {
1636
return Boolean(origin) && ALLOWED_ORIGIN_PATTERNS.some((pattern) => pattern.test(origin));
1737
}
@@ -23,7 +43,8 @@ export function getCorsHeaders(req, methods = 'GET, OPTIONS') {
2343
'Access-Control-Allow-Origin': allowOrigin,
2444
'Access-Control-Allow-Credentials': 'true',
2545
'Access-Control-Allow-Methods': methods,
26-
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-WorldMonitor-Key, X-Api-Key, X-Widget-Key, X-Pro-Key, X-WorldMonitor-Desktop-Timestamp, X-WorldMonitor-Desktop-Signature',
46+
'Access-Control-Allow-Headers': ALLOWED_HEADERS,
47+
'Access-Control-Expose-Headers': EXPOSED_HEADERS,
2748
'Access-Control-Max-Age': '3600',
2849
'Vary': 'Origin',
2950
};
@@ -41,7 +62,8 @@ export function getPublicCorsHeaders(methods = 'GET, OPTIONS') {
4162
return {
4263
'Access-Control-Allow-Origin': '*',
4364
'Access-Control-Allow-Methods': methods,
44-
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-WorldMonitor-Key, X-Api-Key, X-Widget-Key, X-Pro-Key, X-WorldMonitor-Desktop-Timestamp, X-WorldMonitor-Desktop-Signature',
65+
'Access-Control-Allow-Headers': ALLOWED_HEADERS,
66+
'Access-Control-Expose-Headers': EXPOSED_HEADERS,
4567
'Access-Control-Max-Age': '3600',
4668
};
4769
}

api/_cors.test.mjs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { strict as assert } from 'node:assert';
22
import test from 'node:test';
3-
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
3+
import { getCorsHeaders, getPublicCorsHeaders, isDisallowedOrigin } from './_cors.js';
44

55
function makeRequest(origin) {
66
const headers = new Headers();
@@ -40,3 +40,20 @@ test('requests without origin remain allowed', () => {
4040
const req = makeRequest(null);
4141
assert.equal(isDisallowedOrigin(req), false);
4242
});
43+
44+
test('CORS allow headers include MCP transport headers', () => {
45+
const privateCors = getCorsHeaders(makeRequest('https://worldmonitor.app'));
46+
const publicCors = getPublicCorsHeaders('POST, GET, OPTIONS');
47+
48+
for (const cors of [privateCors, publicCors]) {
49+
const allowed = cors['Access-Control-Allow-Headers'];
50+
assert.match(allowed, /\bMcp-Session-Id\b/);
51+
assert.match(allowed, /\bMCP-Protocol-Version\b/);
52+
assert.match(allowed, /\bLast-Event-ID\b/);
53+
54+
const exposed = cors['Access-Control-Expose-Headers'];
55+
assert.match(exposed, /\bMcp-Session-Id\b/);
56+
assert.match(exposed, /\bWWW-Authenticate\b/);
57+
assert.match(exposed, /\bRetry-After\b/);
58+
}
59+
});

api/mcp/auth.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -240,21 +240,21 @@ export async function runProPreChecks(
240240
/** Per-minute rate limit. Both paths fail-OPEN on Upstash error (graceful);
241241
* the daily quota is the hard-cap fail-CLOSED gate. Returns null on success
242242
* or pass-through, a Response on a real 60/min limit hit. */
243-
export async function applyPerMinuteLimit(context: McpAuthContext): Promise<Response | null> {
243+
export async function applyPerMinuteLimit(context: McpAuthContext, headers: Record<string, string> = {}): Promise<Response | null> {
244244
if (context.kind === 'env_key') {
245245
const rl = getMcpRatelimit();
246246
if (!rl) return null;
247247
try {
248248
const { success } = await rl.limit(`key:${context.apiKey}`);
249-
if (!success) return rpcError(null, -32029, 'Rate limit exceeded. Max 60 requests per minute per API key.');
249+
if (!success) return rpcError(null, -32029, 'Rate limit exceeded. Max 60 requests per minute per API key.', headers);
250250
} catch { /* graceful degradation */ }
251251
return null;
252252
}
253253
const rl = getMcpProMinRatelimit();
254254
if (!rl) return null;
255255
try {
256256
const { success } = await rl.limit(`pro-user:${context.userId}`);
257-
if (!success) return rpcError(null, -32029, 'Rate limit exceeded. Max 60 requests per minute per Pro user.');
257+
if (!success) return rpcError(null, -32029, 'Rate limit exceeded. Max 60 requests per minute per Pro user.', headers);
258258
} catch { /* graceful degradation */ }
259259
return null;
260260
}

api/mcp/dispatch.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,11 @@ export async function dispatchToolsCall(
110110
const id = body.id ?? null;
111111
const p = body.params as { name?: string; arguments?: Record<string, unknown> } | null;
112112
if (!p || typeof p.name !== 'string') {
113-
return rpcError(id, -32602, 'Invalid params: missing tool name');
113+
return rpcError(id, -32602, 'Invalid params: missing tool name', corsHeaders);
114114
}
115115
const tool = TOOL_REGISTRY.find((t) => t.name === p.name);
116116
if (!tool) {
117-
return rpcError(id, -32602, `Unknown tool: ${p.name}`);
117+
return rpcError(id, -32602, `Unknown tool: ${p.name}`, corsHeaders);
118118
}
119119

120120
// Pro-only INCR-first reservation. Both cache-only AND RPC tools count
@@ -266,6 +266,6 @@ export async function dispatchToolsCall(
266266
error_kind: isClient4xx ? 'client_4xx' : 'server_error',
267267
budget_exceeded: false,
268268
});
269-
return rpcError(id, -32603, 'Internal error: data fetch failed');
269+
return rpcError(id, -32603, 'Internal error: data fetch failed', corsHeaders);
270270
}
271271
}

0 commit comments

Comments
 (0)