penpot/mcp/packages/server/scripts/integration-test-export-image-semaphore.ts
Dr. Dominik Jain 14b53ecfec
Bound MCP memory consumption by limiting parallel exports & response size (#9748)
*  Bound the size of plugin task responses

When using the integrated remote MCP server, bound response size.
All responses are passed to LLMs, which themselves impose bounds.
This is a measure to bound memory usage in the centrally provided
MCP server.

GitHub #9493

*  Bound parallelism in ExportShapeTool

Use an integer semaphore to bound parallel requests to this
memory-intensive tool, thus bounding memory usage.

GitHub #9493

*  Add (manual) integration test script for ExportShapeTool parallelism

Add dependency tsx to facilitate executions.

GitHub #9493

*  Make number of parallel export requests configurable in ExportShapeTool

Use env var PENPOT_MCP_EXPORT_SHAPE_MAX_PARALLEL_REQUESTS to configure
the maximum number of requests in multi-user mode (default 0, no limit).
2026-05-19 19:37:29 +02:00

117 lines
4.2 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 } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
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);
});