fix(gateway): support Streamable HTTP MCP transport (GET/SSE + DELETE)

The MCP loopback server generates config with `"type": "http"` for
Claude Code, but only handled POST requests. Claude Code's Streamable
HTTP client sends GET to open an SSE notification channel before
completing initialization. The 405 rejection on GET caused Claude Code
to hang at "still connecting" indefinitely.

- Accept GET /mcp with bearer auth, return text/event-stream (idle SSE)
- Accept DELETE /mcp for session termination (spec compliance)
- Add Mcp-Session-Id header to POST responses (spec requirement)
- Update 405 Allow header to reflect supported methods
This commit is contained in:
Cameron Beeley
2026-06-09 16:05:14 +01:00
committed by Shakker
parent 7cdec28706
commit 7f69fe009a
2 changed files with 36 additions and 2 deletions
+31 -1
View File
@@ -139,13 +139,43 @@ export function validateMcpLoopbackRequest(params: {
return null;
}
if (params.req.method === "GET") {
const authHeader = getHeader(params.req, "authorization") ?? "";
const ownerTokenMatched = safeEqualSecret(authHeader, `Bearer ${params.ownerToken}`);
const nonOwnerTokenMatched = safeEqualSecret(authHeader, `Bearer ${params.nonOwnerToken}`);
if (!ownerTokenMatched && !nonOwnerTokenMatched) {
params.res.writeHead(401, { "Content-Type": "application/json" });
params.res.end(JSON.stringify({ error: "unauthorized" }));
return null;
}
logMcpLoopbackHttp("sse-open", { method: "GET", path: url.pathname });
params.res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
params.req.on("close", () => {
if (!params.res.writableEnded) {
params.res.end();
}
});
return null;
}
if (params.req.method === "DELETE") {
logMcpLoopbackHttp("session-delete", { method: "DELETE", path: url.pathname });
params.res.writeHead(200);
params.res.end();
return null;
}
if (params.req.method !== "POST") {
logMcpLoopbackHttp("reject", {
reason: "method_not_allowed",
method: params.req.method ?? "",
path: url.pathname,
});
params.res.writeHead(405, { Allow: "POST" });
params.res.writeHead(405, { Allow: "POST, GET, DELETE" });
params.res.end();
return null;
}
+5 -1
View File
@@ -142,6 +142,7 @@ export async function startMcpLoopbackServer(port = 0): Promise<{
}> {
const ownerToken = crypto.randomBytes(32).toString("hex");
const nonOwnerToken = crypto.randomBytes(32).toString("hex");
const mcpSessionId = crypto.randomUUID();
const toolCache = new McpLoopbackToolCache();
const httpServer = createHttpServer((req, res) => {
@@ -226,7 +227,10 @@ export async function startMcpLoopbackServer(port = 0): Promise<{
const payload = Array.isArray(parsed)
? JSON.stringify(responses)
: JSON.stringify(responses[0]);
res.writeHead(200, { "Content-Type": "application/json" });
res.writeHead(200, {
"Content-Type": "application/json",
"Mcp-Session-Id": mcpSessionId,
});
res.end(payload);
} catch (error) {
logWarn(`mcp loopback: request handling failed: ${formatErrorMessage(error)}`);