penpot/mcp/packages/server/src/PenpotMcpServer.http.test.ts
Dr. Dominik Jain 6031f6c318
⬆️ Upgrade MCP SDK to v2, removing SSE support and HTTP session management (#11841)
* ⬆️ Upgrade MCP SDK to v2 and remove HTTP sessions

MCP's per-request protocol removes the need to retain HTTP sessions.
Use the v2 handler to manage each request's transport and lifecycle,
so requests can reach any server instance without session affinity
or the workaround that adopts sessions through private SDK fields.

Keep legacy SSE support and the shared plugin and Redis bridges.
Remove the shared expiry checker, including legacy SSE idle expiry;
SSE connections now remain until disconnection or server shutdown.
Verify stateless requests, token isolation, and legacy compatibility.

Resolves #11827

AI-assisted-by: gpt-6-astra

* 🔥 Remove legacy MCP SSE support

Use Streamable HTTP as the sole MCP client transport so the server no
longer needs a separate SSE connection registry or lifecycle.

Remove /sse and /messages, their nginx routes, and the server-legacy
dependency. Legacy SSE clients must switch to /mcp; older Streamable
HTTP clients remain supported. Document the migration and verify that
the removed endpoints return 404.

Resolves #11846

AI-assisted-by: gpt-6-astra
2026-09-23 15:07:19 +02:00

120 lines
4.3 KiB
TypeScript

import assert from "node:assert/strict";
import { afterEach, beforeEach, test } from "node:test";
import { type CallToolResult, Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
import { PenpotMcpServer } from "./PenpotMcpServer";
let server: PenpotMcpServer;
let baseUrl: string;
let previousEnv: NodeJS.ProcessEnv;
let nextPort = 16_500;
beforeEach(async () => {
previousEnv = { ...process.env };
process.env.PENPOT_MCP_SERVER_HOST = "127.0.0.1";
process.env.PENPOT_MCP_SERVER_PORT = String(nextPort++);
process.env.PENPOT_MCP_WEBSOCKET_PORT = "0";
process.env.PENPOT_MCP_DEVENV = "false";
process.env.PENPOT_MCP_REPL_ENABLE = "false";
delete process.env.PENPOT_MCP_REDIS_URI;
server = new PenpotMcpServer(true);
baseUrl = `http://127.0.0.1:${server.port}`;
await server.start();
});
afterEach(async () => {
await server?.stop();
process.env = previousEnv;
});
async function modernRequest(method: string, params: Record<string, unknown> = {}, query = "") {
return fetch(`${baseUrl}/mcp${query}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
"Mcp-Method": method,
...(typeof params.name === "string" ? { "Mcp-Name": params.name } : {}),
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method,
params: {
...params,
_meta: {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { name: "penpot-test", version: "1" },
"io.modelcontextprotocol/clientCapabilities": {},
},
},
}),
});
}
test("serves repeated modern client requests without allocating a session", async () => {
const client = new Client(
{ name: "modern-test", version: "1" },
{ versionNegotiation: { mode: { pin: "2026-07-28" } } }
);
const transport = new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`));
try {
await client.connect(transport);
for (let i = 0; i < 2; i++) {
const result = await client.listTools();
assert.equal(transport.sessionId, undefined);
const tool = result.tools.find((tool) => tool.name === "execute_code");
assert.ok(tool);
assert.equal(tool.inputSchema.type, "object");
assert.deepEqual(tool.inputSchema.required, ["code"]);
}
} finally {
await client.close();
}
});
test("isolates user tokens across overlapping tool calls with the same request ID", async (t) => {
let release!: () => void;
const bothStarted = new Promise<void>((resolve) => {
release = resolve;
});
let started = 0;
t.mock.method(server.pluginBridge, "executePluginTask", async () => {
if (++started === 2) release();
await bothStarted;
return { data: server.getSessionContext()?.userToken ?? null };
});
const results = await Promise.all(
["alice", "bob"].map(async (token) => {
const response = await modernRequest(
"tools/call",
{
name: "execute_code",
arguments: { code: "return 1;" },
},
`?userToken=${token}`
);
const body = (await response.json()) as { result: CallToolResult };
assert.equal(response.status, 200, JSON.stringify(body));
const content = body.result.content[0];
assert.equal(content.type, "text");
return JSON.parse(content.text);
})
);
assert.deepEqual(results, ["alice", "bob"]);
assert.equal(server.getSessionContext(), undefined);
});
test("supports older Streamable HTTP clients without allocating a session", async () => {
const client = new Client({ name: "legacy-test", version: "1" });
const transport = new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`));
try {
await client.connect(transport);
assert.equal(transport.sessionId, undefined);
const result = await client.listTools();
assert.ok(result.tools.some((tool) => tool.name === "execute_code"));
} finally {
await client.close();
}
});