feat(http): support MCP Streamable HTTP transport on /sse, /mcp, and / for Open WebUI

This commit is contained in:
Lucas Winther
2026-09-07 01:31:04 +02:00
parent 2c1ff8f7db
commit ba1ce2171e
4 changed files with 629 additions and 127 deletions
+174
View File
@@ -337,6 +337,180 @@ describe("Hosted Remote MCP Server (HTTP/SSE)", () => {
await readerB!.cancel();
});
test("POST /sse with initialize message returns HTTP 200 with Mcp-Session-Id and serverInfo (Open WebUI compatibility)", async () => {
const res = await fetch(`${serverUrl}/sse?apiKey=test-streamable-key`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "*/*", // Open WebUI / httpx often sends */* or application/json
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "open-webui", version: "0.5.0" },
},
}),
});
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("application/json");
const sessionId = res.headers.get("mcp-session-id");
expect(sessionId).toBeTruthy();
const data = (await res.json()) as {
jsonrpc: string;
id: number;
result: {
protocolVersion: string;
serverInfo: { name: string; version: string };
};
};
expect(data.jsonrpc).toBe("2.0");
expect(data.id).toBe(1);
expect(data.result.serverInfo.name).toBe("openproject-mcp");
// Subsequent tools/list request with Mcp-Session-Id
const toolsRes = await fetch(`${serverUrl}/sse`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Mcp-Session-Id": sessionId!,
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 2,
method: "tools/list",
params: {},
}),
});
expect(toolsRes.status).toBe(200);
const toolsData = (await toolsRes.json()) as {
jsonrpc: string;
id: number;
result: { tools: Array<{ name: string }> };
};
expect(toolsData.id).toBe(2);
expect(toolsData.result.tools.length).toBeGreaterThan(0);
expect(
toolsData.result.tools.some((t) => t.name === "openproject_list_projects")
).toBe(true);
// DELETE session to clean up
const deleteRes = await fetch(`${serverUrl}/sse`, {
method: "DELETE",
headers: {
"Mcp-Session-Id": sessionId!,
},
});
expect(deleteRes.status).toBe(204);
// Subsequent request on deleted session returns 404
const afterDeleteRes = await fetch(`${serverUrl}/sse`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Mcp-Session-Id": sessionId!,
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 3,
method: "tools/list",
params: {},
}),
});
expect(afterDeleteRes.status).toBe(404);
});
test("Streamable HTTP on /mcp and / root endpoints", async () => {
// 1. /mcp endpoint
const mcpRes = await fetch(`${serverUrl}/mcp`, {
method: "POST",
headers: {
Authorization: "Bearer test-mcp-path-key",
"Content-Type": "application/json",
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 10,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "test-client", version: "1.0.0" },
},
}),
});
expect(mcpRes.status).toBe(200);
const mcpSessionId = mcpRes.headers.get("mcp-session-id");
expect(mcpSessionId).toBeTruthy();
// 2. / root endpoint
const rootRes = await fetch(`${serverUrl}/`, {
method: "POST",
headers: {
"X-OpenProject-Api-Key": "test-root-path-key",
"Content-Type": "application/json",
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 20,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "test-client", version: "1.0.0" },
},
}),
});
expect(rootRes.status).toBe(200);
const rootSessionId = rootRes.headers.get("mcp-session-id");
expect(rootSessionId).toBeTruthy();
expect(rootSessionId).not.toBe(mcpSessionId);
});
test("POST /mcp without API key returns HTTP 401 Unauthorized", async () => {
const res = await fetch(`${serverUrl}/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "test", version: "1.0" },
},
}),
});
expect(res.status).toBe(401);
const data = (await res.json()) as { error: string };
expect(data.error).toContain("Missing OpenProject API key");
});
test("POST /mcp with invalid sessionId returns HTTP 404", async () => {
const res = await fetch(`${serverUrl}/mcp`, {
method: "POST",
headers: {
Authorization: "Bearer test-key",
"Content-Type": "application/json",
"Mcp-Session-Id": "non-existent-uuid",
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 2,
method: "tools/list",
params: {},
}),
});
expect(res.status).toBe(404);
});
test("CLI entrypoint (src/index.ts) boots HTTP server when --port is provided", async () => {
const proc = Bun.spawn(
[