mirror of
https://github.com/penpot/penpot.git
synced 2026-09-27 06:16:14 +00:00
* ⬆️ 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
120 lines
4.3 KiB
TypeScript
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();
|
|
}
|
|
});
|