173 lines
7.9 KiB
JavaScript
173 lines
7.9 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import http from "node:http";
|
|
import { startHttpToolServer } from "../src/mcp-http.js";
|
|
|
|
async function startServer(t, options) {
|
|
try { return await startHttpToolServer(options); }
|
|
catch (error) {
|
|
if (error?.code === "EPERM") { t.skip("Local TCP listeners are not permitted in this sandbox"); return null; }
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function closeServer(server) {
|
|
return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
}
|
|
|
|
function openSse(url, headers) {
|
|
return new Promise((resolve, reject) => {
|
|
const request = http.get(url, { headers }, (response) => {
|
|
response.setEncoding("utf8");
|
|
let body = "";
|
|
response.on("data", (chunk) => { body += chunk; });
|
|
resolve({ status: response.statusCode, sessionId: response.headers["x-session-id"], body: () => body, close: () => request.destroy() });
|
|
});
|
|
request.on("error", reject);
|
|
});
|
|
}
|
|
|
|
function rpc(url, token, sessionId, payload) {
|
|
return fetch(url, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", Authorization: `Bearer ${token}`, ...(sessionId ? { "x-session-id": sessionId } : {}) },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
}
|
|
|
|
function streamableRpc(url, token, sessionId, payload) {
|
|
return fetch(url, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
Authorization: `Bearer ${token}`,
|
|
...(sessionId ? { "mcp-session-id": sessionId } : {})
|
|
},
|
|
body: JSON.stringify(payload)
|
|
});
|
|
}
|
|
|
|
async function waitForBody(sse, expected) {
|
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
if (sse.body().includes(expected)) return;
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
}
|
|
assert.fail(`Timed out waiting for SSE payload: ${expected}`);
|
|
}
|
|
|
|
const tools = [{ name: "echo", description: "test tool", inputSchema: { type: "object" } }];
|
|
|
|
test("HTTP transport fails closed without credentials", async () => {
|
|
assert.throws(() => startHttpToolServer({ serverName: "x", serverVersion: "1", tools, callTool: async () => ({}) }), /requires at least one API key/);
|
|
});
|
|
|
|
test("HTTP transport binds sessions to an authenticated identity", async (t) => {
|
|
const listener = await startServer(t, {
|
|
serverName: "secure-server",
|
|
serverVersion: "1.0.0",
|
|
tools,
|
|
apiKeys: ["agent-a=token-a", "agent-b=token-b"],
|
|
callTool: async (_name, _arguments, context) => ({ subject: context.subject, sessionId: context.sessionId, workspaceSubpath: context.workspaceSubpath }),
|
|
host: "127.0.0.1",
|
|
port: 0
|
|
});
|
|
if (!listener) return;
|
|
const baseUrl = `http://${listener.host}:${listener.port}`;
|
|
try {
|
|
const noAuth = await fetch(`${baseUrl}/health`);
|
|
assert.equal(noAuth.status, 401);
|
|
const sse = await openSse(`${baseUrl}/events?workspaceSubpath=project-a`, { Authorization: "Bearer token-a" });
|
|
try {
|
|
assert.equal(sse.status, 200);
|
|
assert.ok(sse.sessionId);
|
|
const absentSession = await rpc(`${baseUrl}/mcp`, "token-a", null, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "echo" } });
|
|
assert.equal(absentSession.status, 401);
|
|
const stolenSession = await rpc(`${baseUrl}/mcp`, "token-b", sse.sessionId, { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "echo" } });
|
|
assert.equal(stolenSession.status, 401);
|
|
const response = await rpc(`${baseUrl}/mcp`, "token-a", sse.sessionId, { jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "echo" } });
|
|
const body = await response.json();
|
|
assert.equal(response.status, 200);
|
|
assert.equal(body.result.structuredContent.subject, "agent-a");
|
|
assert.equal(body.result.structuredContent.workspaceSubpath, "project-a");
|
|
const changedSubpath = await fetch(`${baseUrl}/mcp?workspaceSubpath=other`, { method: "POST", headers: { "content-type": "application/json", Authorization: "Bearer token-a", "x-session-id": sse.sessionId }, body: JSON.stringify({ jsonrpc: "2.0", id: 4, method: "ping" }) });
|
|
assert.equal(changedSubpath.status, 400);
|
|
} finally { sse.close(); }
|
|
} finally { await closeServer(listener.server); }
|
|
});
|
|
|
|
test("Streamable HTTP initializes, scopes requests, and deletes MCP sessions", async (t) => {
|
|
const initializedSessions = [];
|
|
const listener = await startServer(t, {
|
|
serverName: "streamable-server", serverVersion: "1.0.0", tools, apiKeys: ["token-a"],
|
|
callTool: async (_name, _arguments, context) => ({ subject: context.subject, sessionId: context.sessionId }),
|
|
onSessionInitialize: (context) => initializedSessions.push(context),
|
|
host: "127.0.0.1", port: 0
|
|
});
|
|
if (!listener) return;
|
|
const baseUrl = `http://${listener.host}:${listener.port}`;
|
|
try {
|
|
const initialize = await streamableRpc(`${baseUrl}/mcp`, "token-a", null, {
|
|
jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "test", version: "1" } }
|
|
});
|
|
const sessionId = initialize.headers.get("mcp-session-id");
|
|
assert.equal(initialize.status, 200);
|
|
assert.ok(sessionId);
|
|
assert.deepEqual(initializedSessions, [{
|
|
sessionId,
|
|
subject: "shared",
|
|
workspaceSubpath: null,
|
|
protocolVersion: "2025-03-26"
|
|
}]);
|
|
assert.equal(initialize.headers.get("mcp-protocol-version"), "2025-03-26");
|
|
assert.equal((await initialize.json()).result.protocolVersion, "2025-03-26");
|
|
|
|
const toolList = await streamableRpc(`${baseUrl}/mcp`, "token-a", sessionId, { jsonrpc: "2.0", id: 2, method: "tools/list" });
|
|
assert.equal(toolList.status, 200);
|
|
assert.equal((await toolList.json()).result.tools[0].name, "echo");
|
|
|
|
const removed = await fetch(`${baseUrl}/mcp`, { method: "DELETE", headers: { Authorization: "Bearer token-a", "mcp-session-id": sessionId } });
|
|
assert.equal(removed.status, 204);
|
|
const afterDelete = await streamableRpc(`${baseUrl}/mcp`, "token-a", sessionId, { jsonrpc: "2.0", id: 3, method: "ping" });
|
|
assert.equal(afterDelete.status, 404);
|
|
} finally { await closeServer(listener.server); }
|
|
});
|
|
|
|
test("Legacy SSE fallback on /mcp exposes a message endpoint and returns JSON-RPC messages", async (t) => {
|
|
const listener = await startServer(t, {
|
|
serverName: "legacy-server", serverVersion: "1.0.0", tools, apiKeys: ["token-a"], callTool: async () => ({}), host: "127.0.0.1", port: 0
|
|
});
|
|
if (!listener) return;
|
|
const baseUrl = `http://${listener.host}:${listener.port}`;
|
|
const sse = await openSse(`${baseUrl}/mcp`, { Authorization: "Bearer token-a" });
|
|
try {
|
|
assert.equal(sse.status, 200);
|
|
await waitForBody(sse, "event: endpoint");
|
|
const endpoint = /data: "([^"]+)"/.exec(sse.body())?.[1];
|
|
assert.ok(endpoint);
|
|
const response = await fetch(new URL(endpoint, `${baseUrl}/mcp`), {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", Authorization: "Bearer token-a" },
|
|
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" })
|
|
});
|
|
assert.equal(response.status, 202);
|
|
await waitForBody(sse, '"id":1');
|
|
} finally {
|
|
sse.close();
|
|
await closeServer(listener.server);
|
|
}
|
|
});
|
|
|
|
test("CORS is denied by default and can be explicitly allowlisted", async (t) => {
|
|
const listener = await startServer(t, {
|
|
serverName: "cors-server", serverVersion: "1.0.0", tools, apiKeys: ["token"], corsOrigins: ["https://console.example"], callTool: async () => ({}), host: "127.0.0.1", port: 0
|
|
});
|
|
if (!listener) return;
|
|
const url = `http://${listener.host}:${listener.port}/health`;
|
|
try {
|
|
const denied = await fetch(url, { headers: { Authorization: "Bearer token", Origin: "https://evil.example" } });
|
|
assert.equal(denied.headers.get("access-control-allow-origin"), null);
|
|
const allowed = await fetch(url, { headers: { Authorization: "Bearer token", Origin: "https://console.example" } });
|
|
assert.equal(allowed.headers.get("access-control-allow-origin"), "https://console.example");
|
|
} finally { await closeServer(listener.server); }
|
|
});
|