mirror of
https://github.com/penpot/penpot.git
synced 2026-09-24 04:46: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
116 lines
4.1 KiB
TypeScript
116 lines
4.1 KiB
TypeScript
/**
|
|
* One-off integration test for the parallelism bound around image exports.
|
|
*
|
|
* Setup:
|
|
* - Stubs ExportShapeTool.exportImage to sleep SLEEP_MS instead of doing real work,
|
|
* so no actual plugin connection is needed.
|
|
* - Replaces the static parallelism semaphore with one of size N.
|
|
* - Starts a PenpotMcpServer in multi-user mode on three random free ports.
|
|
* - Fires M > N parallel MCP clients that each call the export_shape tool.
|
|
*
|
|
* Expectations (observed manually from the server's console output):
|
|
* - "Semaphore 'ExportShapeTool' saturated; request queued (k waiting)" lines appear
|
|
* at INFO level (at least M - N of them).
|
|
* - All M tool calls return successfully.
|
|
* - Total elapsed wall-clock time is approximately ceil(M / N) * SLEEP_MS.
|
|
*
|
|
* Invoke from packages/server with:
|
|
* pnpm run test:integration:export
|
|
*/
|
|
import * as net from "node:net";
|
|
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
|
|
|
|
import { PenpotMcpServer } from "../src/PenpotMcpServer";
|
|
import { ExportShapeTool } from "../src/tools/ExportShapeTool";
|
|
import { TextResponse } from "../src/ToolResponse";
|
|
import { Semaphore } from "../src/utils/Semaphore";
|
|
|
|
// === parameters ===
|
|
|
|
const N = 3;
|
|
const M = 6;
|
|
const SLEEP_MS = 5_000;
|
|
|
|
// === helpers ===
|
|
|
|
async function findFreePort(): Promise<number> {
|
|
return new Promise((resolve, reject) => {
|
|
const srv = net.createServer();
|
|
srv.unref();
|
|
srv.on("error", reject);
|
|
srv.listen(0, "127.0.0.1", () => {
|
|
const port = (srv.address() as net.AddressInfo).port;
|
|
srv.close(() => resolve(port));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function callExportShape(url: URL, idx: number): Promise<unknown> {
|
|
const client = new Client({ name: `integration-test-client-${idx}`, version: "0.0.0" });
|
|
const transport = new StreamableHTTPClientTransport(url);
|
|
await client.connect(transport);
|
|
try {
|
|
return await client.callTool({
|
|
name: "export_shape",
|
|
arguments: { shapeId: "selection" },
|
|
});
|
|
} finally {
|
|
await client.close();
|
|
}
|
|
}
|
|
|
|
// === main ===
|
|
|
|
async function main(): Promise<void> {
|
|
// dynamic ports must be set before PenpotMcpServer is constructed
|
|
const httpPort = await findFreePort();
|
|
process.env.PENPOT_MCP_SERVER_HOST = "127.0.0.1";
|
|
process.env.PENPOT_MCP_SERVER_PORT = String(httpPort);
|
|
process.env.PENPOT_MCP_WEBSOCKET_PORT = String(await findFreePort());
|
|
process.env.PENPOT_MCP_REPL_PORT = String(await findFreePort());
|
|
|
|
// shrink the gate and stub the worker
|
|
(ExportShapeTool as any).parallelismSemaphore = new Semaphore("ExportShapeTool", N);
|
|
(ExportShapeTool.prototype as any).exportImage = async (): Promise<TextResponse> => {
|
|
await new Promise((r) => setTimeout(r, SLEEP_MS));
|
|
return new TextResponse("stubbed export");
|
|
};
|
|
|
|
const server = new PenpotMcpServer(/* isMultiUser */ true);
|
|
await server.start();
|
|
|
|
console.log(`\n=== integration test: N=${N} permits, M=${M} clients, sleep=${SLEEP_MS}ms ===\n`);
|
|
|
|
// fire M parallel tool calls, each via its own MCP session with a distinct userToken
|
|
const start = Date.now();
|
|
const results = await Promise.allSettled(
|
|
Array.from({ length: M }, (_, i) =>
|
|
callExportShape(new URL(`http://127.0.0.1:${httpPort}/mcp?userToken=test-token-${i}`), i)
|
|
)
|
|
);
|
|
const elapsed = Date.now() - start;
|
|
|
|
await server.stop();
|
|
|
|
// report
|
|
const failures = results.map((r, i) => ({ r, i })).filter(({ r }) => r.status === "rejected");
|
|
|
|
console.log(`\n=== results ===`);
|
|
console.log(` elapsed: ${elapsed}ms (expected ~${Math.ceil(M / N) * SLEEP_MS}ms)`);
|
|
console.log(` succeeded: ${M - failures.length}/${M}`);
|
|
if (failures.length > 0) {
|
|
console.log(` failures:`);
|
|
for (const { r, i } of failures) {
|
|
console.log(` [${i}] ${(r as PromiseRejectedResult).reason}`);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
console.log(`\nAll ${M} tool calls succeeded. Scroll up to verify saturation log lines.\n`);
|
|
process.exit(0);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error("Integration test crashed:", err);
|
|
process.exit(1);
|
|
});
|