⬆️ 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
This commit is contained in:
Dr. Dominik Jain 2026-09-23 15:07:19 +02:00 committed by GitHub
parent 065f4eb401
commit 6031f6c318
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 223 additions and 332 deletions

View File

@ -181,11 +181,6 @@ http {
proxy_http_version 1.1;
}
location /mcp/sse {
proxy_pass http://127.0.0.1:4401/sse;
proxy_http_version 1.1;
}
location /admin {
proxy_pass http://127.0.0.1:6063/admin;
}

View File

@ -11,9 +11,3 @@ location /mcp/stream {
proxy_pass $mcp_stream_backend;
proxy_http_version 1.1;
}
location /mcp/sse {
set $mcp_sse_backend $PENPOT_MCP_URI/sse$is_args$args;
proxy_pass $mcp_sse_backend;
proxy_http_version 1.1;
}

View File

@ -806,8 +806,8 @@ PENPOT_MCP_URI: http://penpot-mcp:4401
PENPOT_MCP_URI_WS: http://penpot-mcp:4402
```
- `PENPOT_MCP_URI`: The URI of the MCP server, used for the streamable HTTP and SSE
endpoints.
- `PENPOT_MCP_URI`: The URI of the MCP server, used for the Streamable HTTP
endpoint.
- `PENPOT_MCP_URI_WS`: The URI of the MCP server used for the websocket connection.
The defaults match the service name used in the official `docker-compose.yaml`. Change

View File

@ -50,7 +50,7 @@ Follow the steps below to enable the integration.
### Prerequisites
The project requires [Node.js](https://nodejs.org/) (tested with v22.x).
The project requires [Node.js](https://nodejs.org/) 20 or later (tested with v22.x).
### 1. Starting the MCP Server and the Plugin Server
@ -160,13 +160,13 @@ This bootstrap command will:
> inspection.
> (If you are using a standard commercial model, it almost certainly supports vision already.)
By default, the server runs on port 4401 and provides:
By default, the server provides a Streamable HTTP endpoint at `http://localhost:4401/mcp`.
- **Modern Streamable HTTP endpoint**: `http://localhost:4401/mcp`
- **Legacy SSE endpoint**: `http://localhost:4401/sse`
The legacy `/sse` and `/messages` endpoints are no longer supported.
Clients using the legacy SSE transport must switch to Streamable HTTP at `/mcp`.
You can change the port by setting the `PENPOT_MCP_SERVER_PORT` environment variable
before starting the server. These endpoints can be used directly by MCP clients that support them.
before starting the server. This endpoint can be used directly by MCP clients that support Streamable HTTP.
Simply configure the client to connect the MCP server by providing the respective URL.
#### Configuring your client
@ -186,7 +186,7 @@ More information on connecting your client follows below.
#### Using a Proxy for stdio Transport
The `mcp-remote` package can proxy stdio transport to HTTP/SSE,
The `mcp-remote` package can proxy stdio transport to Streamable HTTP,
allowing clients that support only stdio to connect to the MCP server indirectly.
Use it to provide the launch command for your MCP client as follows:
@ -259,18 +259,18 @@ The Penpot MCP server can be configured using environment variables.
### Server Configuration
| Environment Variable | Description | Default |
|--------------------------------------------------|----------------------------------------------------------------------------|----------------|
| `PENPOT_MCP_SERVER_HOST` | Address on which the MCP server listens (binds to) | `localhost` |
| `PENPOT_MCP_SERVER_PORT` | Port for the HTTP/SSE server | `4401` |
| `PENPOT_MCP_WEBSOCKET_PORT` | Port for the WebSocket server (plugin connection) | `4402` |
| `PENPOT_MCP_REPL_PORT` | Port for the REPL server (development/debugging) | `4403` |
| `PENPOT_MCP_REPL_HOST` | Address on which the REPL server listens (binds to) | `localhost` |
| `PENPOT_MCP_REPL_ENABLE` | Explicitly enable/disable the REPL server. Set to `true` to enable. When unset, defaults to the value of `PENPOT_MCP_DEVENV`. The REPL server never starts in multi-user mode. | (unset) |
| `PENPOT_MCP_REMOTE_MODE` | Enable remote mode (disables file system access). Set to `true` to enable. | `false` |
| `PENPOT_MCP_DEVENV` | Enable Penpot development environment tools in local single-user mode. Set to `true` to enable. | `false` |
| `PENPOT_MCP_TOOL_TIMEOUT_S` | Timeout, in seconds, for tool calls dispatched to the Penpot plugin | `120` |
| `PENPOT_MCP_EXPORT_SHAPE_MAX_PARALLEL_REQUESTS` | Maximum number of parallel export shape requests (multi-user mode only). | `0` (no limit) |
| Environment Variable | Description | Default |
|--------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------|
| `PENPOT_MCP_SERVER_HOST` | Address on which the MCP server listens (binds to) | `localhost` |
| `PENPOT_MCP_SERVER_PORT` | Port for the MCP server | `4401` |
| `PENPOT_MCP_WEBSOCKET_PORT` | Port for the WebSocket server (plugin connection) | `4402` |
| `PENPOT_MCP_REPL_PORT` | Port for the REPL server (development/debugging) | `4403` |
| `PENPOT_MCP_REPL_HOST` | Address on which the REPL server listens (binds to) | `localhost` |
| `PENPOT_MCP_REPL_ENABLE` | Explicitly enable/disable the REPL server. Set to `true` to enable. When unset, defaults to the value of `PENPOT_MCP_DEVENV`. The REPL server never starts in multi-user mode. | (unset) |
| `PENPOT_MCP_REMOTE_MODE` | Enable remote mode (disables file system access). Set to `true` to enable. | `false` |
| `PENPOT_MCP_DEVENV` | Enable Penpot development environment tools in local single-user mode. Set to `true` to enable. | `false` |
| `PENPOT_MCP_TOOL_TIMEOUT_S` | Timeout, in seconds, for tool calls dispatched to the Penpot plugin | `120` |
| `PENPOT_MCP_EXPORT_SHAPE_MAX_PARALLEL_REQUESTS` | Maximum number of parallel export shape requests (multi-user mode only). | `0` (no limit) |
| `PENPOT_MCP_REDIS_URI` | Redis connection URI (e.g. `redis://host:6379`) enabling multi-instance horizontal scaling via Redis pub/sub task routing (multi-user mode only). When unset, the server runs in single-instance mode, requiring the plugin and MCP client to connect to the same instance. | (unset) |
### Logging Configuration

View File

@ -5,7 +5,7 @@
"type": "module",
"main": "dist/index.js",
"scripts": {
"build:server": "esbuild src/index.ts --bundle --platform=node --target=node18 --format=esm --outfile=dist/index.js --external:@modelcontextprotocol/* --external:ws --external:express --external:class-transformer --external:class-validator --external:reflect-metadata --external:pino --external:pino-pretty --external:pino-loki --external:js-yaml --external:sharp --external:nrepl-client --external:ioredis",
"build:server": "esbuild src/index.ts --bundle --platform=node --target=node20 --format=esm --outfile=dist/index.js --external:@modelcontextprotocol/* --external:ws --external:express --external:class-transformer --external:class-validator --external:reflect-metadata --external:pino --external:pino-pretty --external:pino-loki --external:js-yaml --external:sharp --external:nrepl-client --external:ioredis",
"build": "pnpm run build:server && node scripts/copy-resources.js",
"build:types": "tsc --emitDeclarationOnly --outDir dist",
"start": "node dist/index.js",
@ -24,9 +24,13 @@
],
"author": "",
"license": "MIT",
"engines": {
"node": ">=20"
},
"packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@modelcontextprotocol/node": "^2.0.0",
"@modelcontextprotocol/server": "^2.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"express": "^5.2.1",
@ -42,6 +46,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@modelcontextprotocol/client": "^2.0.0",
"@penpot/mcp-common": "workspace:../common",
"@types/express": "^5.0.6",
"@types/js-yaml": "^4.0.9",

View File

@ -18,8 +18,7 @@
* 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 { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
import { PenpotMcpServer } from "../src/PenpotMcpServer";
import { ExportShapeTool } from "../src/tools/ExportShapeTool";

View File

@ -0,0 +1,119 @@
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();
}
});

View File

@ -1,7 +1,9 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { createMcpHandler, McpServer, type McpHttpHandler } from "@modelcontextprotocol/server";
import { toNodeHandler } from "@modelcontextprotocol/node";
import type { Server as HttpServer } from "node:http";
import type { Express } from "express";
import { z } from "zod";
import { AsyncLocalStorage } from "async_hooks";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { ExecuteCodeTool } from "./tools/ExecuteCodeTool";
import { PluginBridge } from "./PluginBridge";
import { RedisBridge } from "./RedisBridge";
@ -28,17 +30,6 @@ export interface SessionContext {
userToken?: string;
}
/**
* Represents an active Streamable HTTP session, grouping the transport, MCP server, and session metadata.
*/
class StreamableSession {
constructor(
public readonly transport: StreamableHTTPServerTransport,
public readonly userToken: string | undefined,
public lastActiveTime: number
) {}
}
/**
* Holds information about a registered tool, including its instance, name, and configuration.
*/
@ -46,7 +37,7 @@ class ToolInfo {
constructor(
public readonly instance: Tool<any>,
public readonly name: string,
public readonly config: { description: string; inputSchema: any }
public readonly config: { description: string; inputSchema: z.ZodObject<z.ZodRawShape> }
) {}
}
@ -68,11 +59,6 @@ export function shouldStartReplServer(isReplEnabled: boolean, isMultiUserMode: b
}
export class PenpotMcpServer {
/**
* Timeout, in minutes, for idle sessions (Streamable HTTP and SSE) before they are automatically closed and removed.
*/
private static readonly SESSION_TIMEOUT_MINUTES = 60;
/**
* Determines whether the server is running in a Penpot development
* environment, based on the given environment variables.
@ -122,7 +108,9 @@ export class PenpotMcpServer {
private readonly logger = createLogger("PenpotMcpServer");
private readonly tools: ToolInfo[];
public readonly configLoader: ConfigurationLoader;
private app: any;
private app!: Express;
private httpServer?: HttpServer;
private readonly mcpHandler: McpHttpHandler;
public readonly pluginBridge: PluginBridge;
private readonly replServer: ReplServer | null;
private apiDocs: ApiDocs;
@ -130,22 +118,15 @@ export class PenpotMcpServer {
private readonly connectionInstructions: string;
/**
* Manages session-specific context, particularly user tokens for each request.
* Carries the user token through each request and its asynchronous tool execution.
*/
private readonly sessionContext = new AsyncLocalStorage<SessionContext>();
private readonly streamableTransports: Record<string, StreamableSession> = {};
private readonly sseTransports: Record<
string,
{ transport: SSEServerTransport; userToken?: string; lastActiveTime: number }
> = {};
public readonly host: string;
public readonly port: number;
public readonly webSocketPort: number;
public readonly replHost: string;
public readonly replPort: number;
private sessionTimeoutInterval: ReturnType<typeof setInterval> | undefined;
/**
* Optional Redis bridge for multi-instance task routing; present only when running
@ -182,6 +163,7 @@ export class PenpotMcpServer {
this.connectionInstructions = this.configLoader.getBaseInstructions();
this.tools = this.initTools();
this.mcpHandler = createMcpHandler(() => this.createMcpServer());
// Enable multi-instance task routing when running in multi-user mode with a
// configured Redis URI. Without it, the server operates in single-instance mode,
@ -292,7 +274,7 @@ export class PenpotMcpServer {
this.logger.info(`Registering tool: ${instance.getToolName()}`);
return new ToolInfo(instance, instance.getToolName(), {
description: instance.getToolDescription(),
inputSchema: instance.getInputSchema(),
inputSchema: z.object(instance.getInputSchema()),
});
});
}
@ -307,158 +289,20 @@ export class PenpotMcpServer {
);
for (const tool of this.tools) {
server.registerTool(tool.name, tool.config, async (args: any) => tool.instance.execute(args));
server.registerTool(tool.name, tool.config, async (args) => tool.instance.execute(args));
}
return server;
}
/**
* Starts a periodic timer that closes and removes Streamable HTTP and SSE sessions that have been
* idle for longer than {@link SESSION_TIMEOUT_MINUTES}.
*/
private startSessionTimeoutChecker(): void {
const timeoutMs = PenpotMcpServer.SESSION_TIMEOUT_MINUTES * 60 * 1000;
const checkIntervalMs = timeoutMs / 2;
this.sessionTimeoutInterval = setInterval(() => {
this.logger.info("Checking for stale sessions...");
const now = Date.now();
let removed = 0;
for (const session of Object.values(this.streamableTransports)) {
if (now - session.lastActiveTime > timeoutMs) {
session.transport.close();
removed++;
}
}
for (const [id, session] of Object.entries(this.sseTransports)) {
if (now - session.lastActiveTime > timeoutMs) {
this.logger.info(`Closing stale SSE session ${id}`);
session.transport.close();
delete this.sseTransports[id];
removed++;
}
}
this.logger.info(
`Removed ${removed} stale session(s); total sessions remaining: ${
Object.keys(this.streamableTransports).length + Object.keys(this.sseTransports).length
}`
);
}, checkIntervalMs);
}
private setupHttpEndpoints(): void {
/**
* Modern Streamable HTTP connection endpoint.
*
* New sessions are created on initialize requests (no mcp-session-id header).
* Subsequent requests for an existing session are routed to the stored transport,
* with the session context populated from the stored userToken.
*/
this.app.all("/mcp", async (req: any, res: any) => {
const sessionId = req.headers["mcp-session-id"] as string | undefined;
let userToken: string | undefined = undefined;
let transport: StreamableHTTPServerTransport;
// obtain transport and user token for the session, either from an existing session or by creating a new one
if (sessionId && this.streamableTransports[sessionId]) {
// existing session: reuse stored transport and token
const session = this.streamableTransports[sessionId];
transport = session.transport;
userToken = session.userToken;
session.lastActiveTime = Date.now();
this.logger.info(
`Received request for existing session with id=${sessionId}; userTokenFp=${PenpotMcpServer.tokenFingerprint(session.userToken)}`
);
} else {
// No locally-known session for this request. Either a brand-new session
// (no session ID) or a session that was initialized on another instance
// and routed here by the load balancer (session ID present but unknown
// locally), which we adopt rather than reject.
const isAdoptedSession = sessionId !== undefined;
userToken = req.query.userToken as string | undefined;
this.logger.info(
`${isAdoptedSession ? `Adopting session initialized on another instance with id=${sessionId}` : "Received new session request"}; userTokenFp=${PenpotMcpServer.tokenFingerprint(userToken)}`
);
const { randomUUID } = await import("node:crypto");
const server = this.createMcpServer();
transport = new StreamableHTTPServerTransport({
// For an adopted session, reuse the existing ID; otherwise generate a new one.
sessionIdGenerator: () => (isAdoptedSession ? sessionId! : randomUUID()),
onsessioninitialized: (id) => {
this.streamableTransports[id] = new StreamableSession(transport, userToken, Date.now());
this.logger.info(
`Session initialized with id=${id} for userTokenFp=${PenpotMcpServer.tokenFingerprint(userToken)}; total sessions: ${Object.keys(this.streamableTransports).length}`
);
},
});
if (isAdoptedSession) {
// Pre-initialize the transport so that the SDK's validateSession() accepts
// subsequent (non-initialize) requests for this session ID. The SDK stores
// these on the inner WebStandardStreamableHTTPServerTransport as plain
// (non-#private) properties; validateSession() checks exactly _initialized
// and sessionId. Verified against @modelcontextprotocol/sdk 1.25.3.
//
// Since no initialize request will arrive for an adopted session, the
// onsessioninitialized callback will not fire; register the session here.
const inner = (transport as any)._webStandardTransport;
inner._initialized = true;
inner.sessionId = sessionId;
this.streamableTransports[sessionId!] = new StreamableSession(transport, userToken, Date.now());
}
transport.onclose = () => {
if (transport.sessionId) {
this.logger.info(
`Closing session with id=${transport.sessionId} for userTokenFp=${PenpotMcpServer.tokenFingerprint(userToken)}`
);
delete this.streamableTransports[transport.sessionId];
}
};
await server.connect(transport);
}
// handle the request
await this.sessionContext.run({ userToken }, async () => {
await transport.handleRequest(req, res, req.body);
});
});
/**
* Legacy SSE connection endpoint.
*/
this.app.get("/sse", async (req: any, res: any) => {
const handleMcpRequest = toNodeHandler(this.mcpHandler);
this.app.all("/mcp", async (req, res) => {
const userToken = req.query.userToken as string | undefined;
await this.sessionContext.run({ userToken }, async () => {
const transport = new SSEServerTransport("/messages", res);
this.sseTransports[transport.sessionId] = { transport, userToken, lastActiveTime: Date.now() };
const server = this.createMcpServer();
await server.connect(transport);
res.on("close", () => {
delete this.sseTransports[transport.sessionId];
server.close();
});
});
});
/**
* SSE message POST endpoint (using previously established session)
*/
this.app.post("/messages", async (req: any, res: any) => {
const sessionId = req.query.sessionId as string;
const session = this.sseTransports[sessionId];
if (session) {
session.lastActiveTime = Date.now();
await this.sessionContext.run({ userToken: session.userToken }, async () => {
await session.transport.handlePostMessage(req, res, req.body);
});
} else {
res.status(400).send("No transport found for sessionId");
}
this.logger.info(
`Received MCP request: method=${req.body?.method ?? "<none>"}; userTokenFp=${PenpotMcpServer.tokenFingerprint(userToken)}`
);
await this.sessionContext.run({ userToken }, () => handleMcpRequest(req, res, req.body));
});
}
@ -469,8 +313,8 @@ export class PenpotMcpServer {
this.setupHttpEndpoints();
return new Promise((resolve) => {
this.app.listen(this.port, this.host, async () => {
return new Promise((resolve, reject) => {
this.httpServer = this.app.listen(this.port, this.host, async () => {
this.logger.info(`Multi-user mode: ${this.isMultiUserMode()}`);
this.logger.info(
`Multi-instance mode with Redis-backed transport: ${this.redisBridge ? "true" : "false"}`
@ -478,10 +322,9 @@ export class PenpotMcpServer {
this.logger.info(`Remote mode: ${this.isRemoteMode()}`);
this.logger.info(`DevEnv mode: ${this.isDevEnv()}`);
this.logger.info(`Modern Streamable HTTP endpoint: http://${this.host}:${this.port}/mcp`);
this.logger.info(`Legacy SSE endpoint: http://${this.host}:${this.port}/sse`);
this.logger.info(`WebSocket server URL: ws://${this.host}:${this.webSocketPort}`);
// start the REPL server (devenv only) and session timeout checker
// start the REPL server when enabled
if (this.replServer) {
await this.replServer.start();
} else if (this.isMultiUserMode()) {
@ -491,10 +334,10 @@ export class PenpotMcpServer {
"REPL server disabled (set PENPOT_MCP_REPL_ENABLE=true or PENPOT_MCP_DEVENV=true to enable)"
);
}
this.startSessionTimeoutChecker();
resolve();
});
this.httpServer.once("error", reject);
});
}
@ -505,7 +348,13 @@ export class PenpotMcpServer {
*/
public async stop(): Promise<void> {
this.logger.info("Stopping Penpot MCP Server...");
clearInterval(this.sessionTimeoutInterval);
const httpClosed = this.httpServer
? new Promise<void>((resolve, reject) => {
this.httpServer!.close((error) => (error ? reject(error) : resolve()));
})
: Promise.resolve();
await this.mcpHandler.close();
await httpClosed;
await this.pluginBridge.close();
await this.redisBridge?.close();
if (this.replServer) {

View File

@ -1,4 +1,4 @@
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import type { CallToolResult } from "@modelcontextprotocol/server";
type CallToolContent = CallToolResult["content"][number];
type TextItem = Extract<CallToolContent, { type: "text" }>;

160
mcp/pnpm-lock.yaml generated
View File

@ -206,9 +206,12 @@ importers:
packages/server:
dependencies:
'@modelcontextprotocol/sdk':
specifier: ^1.29.0
version: 1.30.0(supports-color@10.2.2)(zod@4.4.3)
'@modelcontextprotocol/node':
specifier: ^2.0.0
version: 2.0.0(@modelcontextprotocol/server@2.0.0)(hono@4.13.5)
'@modelcontextprotocol/server':
specifier: ^2.0.0
version: 2.0.0
class-transformer:
specifier: ^0.5.1
version: 0.5.1
@ -249,6 +252,9 @@ importers:
specifier: ^4.4.3
version: 4.4.3
devDependencies:
'@modelcontextprotocol/client':
specifier: ^2.0.0
version: 2.0.0
'@penpot/mcp-common':
specifier: workspace:../common
version: link:../common
@ -629,16 +635,28 @@ packages:
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@modelcontextprotocol/sdk@1.30.0':
resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==}
engines: {node: '>=18'}
'@modelcontextprotocol/client@2.0.0':
resolution: {integrity: sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==}
engines: {node: '>=20'}
'@modelcontextprotocol/core@2.0.0':
resolution: {integrity: sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==}
engines: {node: '>=20'}
'@modelcontextprotocol/node@2.0.0':
resolution: {integrity: sha512-Y4hAC2XdGDUdDOCbLDOCA4+aL3NUldjsOWlDL/YwpAxrPhRm1xHd7lZ+mLacvZ9t3PaH28wgNoaLQGrIk1P2pg==}
engines: {node: '>=20'}
peerDependencies:
'@cfworker/json-schema': ^4.1.1
zod: ^3.25 || ^4.0
'@modelcontextprotocol/server': ^2.0.0
hono: ^4.11.4
peerDependenciesMeta:
'@cfworker/json-schema':
hono:
optional: true
'@modelcontextprotocol/server@2.0.0':
resolution: {integrity: sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==}
engines: {node: '>=20'}
'@oxc-project/types@0.147.0':
resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==}
@ -820,17 +838,6 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
ajv-formats@3.0.1:
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
peerDependencies:
ajv: ^8.0.0
peerDependenciesMeta:
ajv:
optional: true
ajv@8.20.0:
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
ansi-regex@6.2.2:
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
engines: {node: '>=12'}
@ -914,10 +921,6 @@ packages:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
cors@2.8.6:
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
engines: {node: '>= 0.10'}
create-require@1.1.1:
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
@ -1015,12 +1018,6 @@ packages:
resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==}
engines: {node: '>=18.0.0'}
express-rate-limit@8.6.2:
resolution: {integrity: sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==}
engines: {node: '>= 16'}
peerDependencies:
express: '>= 4.11'
express@5.2.1:
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
engines: {node: '>= 18'}
@ -1028,15 +1025,9 @@ packages:
fast-copy@4.0.4:
resolution: {integrity: sha512-eVAiWVNPSEGIzDl5yPuLrx8fNMogScXvD9xp1Kzd41FjRIz2I3sSIcxsFeM5EzFfHAfobdvs8ZySffUopljvIA==}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-safe-stringify@2.1.1:
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
fast-uri@3.1.6:
resolution: {integrity: sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@ -1116,10 +1107,6 @@ packages:
resolution: {integrity: sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w==}
engines: {node: '>=20.0.0'}
ip-address@10.5.0:
resolution: {integrity: sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==}
engines: {node: '>= 12'}
ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
@ -1141,12 +1128,6 @@ packages:
resolution: {integrity: sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==}
hasBin: true
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
json-schema-typed@8.0.2:
resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
libphonenumber-js@1.13.11:
resolution: {integrity: sha512-ETER2kMaIFTI/Nh1a8Gk03dUF/SL0VZqtI+CcVHZxp5WIHYwNS7S+uiYZDYCvLy3lOR4/DAD5jf0h5WkePPpqg==}
@ -1265,10 +1246,6 @@ packages:
nrepl-client@0.3.0:
resolution: {integrity: sha512-EcROXUrzlGHKOdu/E/5WB0OESCI0iGHhdXeYk9cULYtd72eFJrM/Q1umvjTBfKWlT62y76cnyLG/3CmSCqT12w==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
@ -1373,10 +1350,6 @@ packages:
reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
rolldown@1.2.6:
resolution: {integrity: sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==}
engines: {node: ^20.19.0 || >=22.12.0}
@ -1639,11 +1612,6 @@ packages:
resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==}
engines: {node: '>=6'}
zod-to-json-schema@3.25.2:
resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==}
peerDependencies:
zod: ^3.25.28 || ^4
zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
@ -1859,27 +1827,31 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)':
'@modelcontextprotocol/client@2.0.0':
dependencies:
'@hono/node-server': 2.1.1(hono@4.13.5)
ajv: 8.20.0
ajv-formats: 3.0.1(ajv@8.20.0)
content-type: 1.0.5
cors: 2.8.6
'@modelcontextprotocol/core': 2.0.0
cross-spawn: 7.0.6
eventsource: 3.0.7
eventsource-parser: 3.1.1
express: 5.2.1(supports-color@10.2.2)
express-rate-limit: 8.6.2(express@5.2.1(supports-color@10.2.2))(supports-color@10.2.2)
hono: 4.13.5
jose: 6.2.10
json-schema-typed: 8.0.2
pkce-challenge: 5.0.1
raw-body: 3.0.2
zod: 4.4.3
zod-to-json-schema: 3.25.2(zod@4.4.3)
transitivePeerDependencies:
- supports-color
'@modelcontextprotocol/core@2.0.0':
dependencies:
zod: 4.4.3
'@modelcontextprotocol/node@2.0.0(@modelcontextprotocol/server@2.0.0)(hono@4.13.5)':
dependencies:
'@hono/node-server': 2.1.1(hono@4.13.5)
'@modelcontextprotocol/server': 2.0.0
optionalDependencies:
hono: 4.13.5
'@modelcontextprotocol/server@2.0.0':
dependencies:
'@modelcontextprotocol/core': 2.0.0
zod: 4.4.3
'@oxc-project/types@0.147.0': {}
@ -2008,17 +1980,6 @@ snapshots:
acorn@8.18.0: {}
ajv-formats@3.0.1(ajv@8.20.0):
optionalDependencies:
ajv: 8.20.0
ajv@8.20.0:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.1.6
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
ansi-regex@6.2.2: {}
ansi-styles@6.2.3: {}
@ -2096,11 +2057,6 @@ snapshots:
cookie@0.7.2: {}
cors@2.8.6:
dependencies:
object-assign: 4.1.1
vary: 1.1.2
create-require@1.1.1: {}
cross-env@10.1.0:
@ -2197,14 +2153,6 @@ snapshots:
dependencies:
eventsource-parser: 3.1.1
express-rate-limit@8.6.2(express@5.2.1(supports-color@10.2.2))(supports-color@10.2.2):
dependencies:
debug: 4.4.3(supports-color@10.2.2)
express: 5.2.1(supports-color@10.2.2)
ip-address: 10.5.0
transitivePeerDependencies:
- supports-color
express@5.2.1(supports-color@10.2.2):
dependencies:
accepts: 2.0.0
@ -2240,12 +2188,8 @@ snapshots:
fast-copy@4.0.4: {}
fast-deep-equal@3.1.3: {}
fast-safe-stringify@2.1.1: {}
fast-uri@3.1.6: {}
fdir@6.5.0(picomatch@4.0.7):
optionalDependencies:
picomatch: 4.0.7
@ -2329,8 +2273,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
ip-address@10.5.0: {}
ipaddr.js@1.9.1: {}
is-promise@4.0.0: {}
@ -2345,10 +2287,6 @@ snapshots:
dependencies:
argparse: 2.0.1
json-schema-traverse@1.0.0: {}
json-schema-typed@8.0.2: {}
libphonenumber-js@1.13.11: {}
lightningcss-android-arm64@1.33.0:
@ -2429,8 +2367,6 @@ snapshots:
bencode: 2.0.3
tree-kill: 1.2.2
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
on-exit-leak-free@2.1.2: {}
@ -2540,8 +2476,6 @@ snapshots:
reflect-metadata@0.2.2: {}
require-from-string@2.0.2: {}
rolldown@1.2.6:
dependencies:
'@oxc-project/types': 0.147.0
@ -2819,8 +2753,4 @@ snapshots:
yn@3.1.1: {}
zod-to-json-schema@3.25.2(zod@4.4.3):
dependencies:
zod: 4.4.3
zod@4.4.3: {}