Use Streamable HTTP and SSE transports in MCP server instead of stdio

(This is necessary to ensure a single instance, which is required for a
well-defined websocket port)
This commit is contained in:
Dominik Jain 2025-09-10 17:47:46 +02:00
parent 7faca70aa7
commit 16167e2758
5 changed files with 1646 additions and 65 deletions

View File

@ -7,8 +7,8 @@ This document explains how to test the basic WebSocket connection between the MC
The system consists of two main components:
1. **MCP Server** (`mcp-server/`):
- Runs as a traditional MCP server (stdio transport)
- Also runs a WebSocket server on port 8080
- Runs as an MCP server (stdio transport)
- Runs a WebSocket server on port 8080
- Basic WebSocket connection handling (protocol to be defined later)
2. **Penpot Plugin** (`penpot-plugin/`):

View File

@ -4,7 +4,8 @@ A Model Context Protocol (MCP) server that provides Penpot integration capabilit
## Overview
This MCP server implements a clean, object-oriented architecture that allows easy extension with new tools. It currently includes a demonstration tool and provides a foundation for adding Penpot-specific functionality.
This MCP server implements a clean, object-oriented architecture that allows easy extension with new tools.
It currently includes a demonstration tool and provides a foundation for adding Penpot-specific functionality.
## Prerequisites
@ -35,9 +36,22 @@ npm run dev
**Production Mode** (requires build first):
```bash
npm run start
npm start
```
**With Custom Port**:
```bash
npm start -- --port 8080
# OR in development
npm run dev -- --port 8080
# OR directly
node dist/index.js --port 8080
```
**Available Options**:
- `--port, -p <number>`: Port number for the HTTP/SSE server (default: 4401)
- `--help, -h`: Show help message
## Available Commands
| Command | Description |
@ -51,59 +65,60 @@ npm run start
## Claude Desktop Integration
To use this MCP server with Claude Desktop, you need to add it to Claude's configuration file.
The MCP server now supports both modern Streamable HTTP and legacy SSE transports, providing compatibility with various MCP clients.
### 1. Locate Claude Desktop Config
### 1. Start the Server
First, build and start the MCP server:
```bash
cd mcp-server
npm run build
npm start
```
By default, the server runs on port 4401 and provides:
- **Modern Streamable HTTP endpoint**: `http://localhost:4401/mcp`
- **Legacy SSE endpoint**: `http://localhost:4401/sse`
### 2. Configure Claude Desktop
For Claude Desktop integration, you'll need to use a proxy since Claude Desktop requires stdio transport.
**Option A: Using mcp-remote (Recommended)**
Install mcp-remote globally if you haven't already:
```bash
npm install -g mcp-remote
```
Add this to your Claude Desktop configuration file:
**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
**Windows**: `%APPDATA%/Claude/claude_desktop_config.json`
### 2. Add Server Configuration
Edit the config file to include your MCP server:
```json
{
"mcpServers": {
"penpot": {
"command": "node",
"args": [
"/path/to/your/penpot-mcp/mcp-server/dist/index.js"
]
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:4401/sse", "--allow-http"]
}
}
}
```
**Important Notes:**
- Replace `/path/to/your/penpot-mcp/mcp-server/dist/index.js` with the actual absolute path to your built server
- Ensure you've run `npm run build` before adding to Claude Desktop
- On Windows, use forward slashes `/` or double backslashes `\\` in the path
**Option B: Direct HTTP Integration (for other MCP clients)**
### 3. Alternative Development Setup
For MCP clients that support HTTP transport directly, use:
- Modern clients: `http://localhost:4401/mcp`
- Legacy clients: `http://localhost:4401/sse`
For development, you can also run the server directly with ts-node:
```json
{
"mcpServers": {
"penpot-mcp-server": {
"command": "node",
"args": [
"--loader", "ts-node/esm",
"/path/to/your/penpot-mcp/mcp-server/src/index.ts"
],
"env": {}
}
}
}
```
### 4. Restart Claude Desktop
### 3. Restart Claude Desktop
After updating the configuration file, restart Claude Desktop completely for the changes to take effect.
### 5. Verify Integration
### 4. Verify Integration
Once Claude Desktop restarts, you should be able to use the MCP server's tools in your conversations. You can test with the included `hello_world` tool:

File diff suppressed because it is too large Load Diff

View File

@ -19,13 +19,15 @@
"author": "",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^0.4.0",
"class-validator": "^0.14.0",
"@modelcontextprotocol/sdk": "^1.17.5",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"express": "^4.18.0",
"reflect-metadata": "^0.1.13",
"ws": "^8.18.0"
},
"devDependencies": {
"@types/express": "^4.17.0",
"@types/node": "^20.0.0",
"@types/ws": "^8.5.10",
"prettier": "^3.0.0",

View File

@ -1,7 +1,24 @@
#!/usr/bin/env node
/**
* Penpot MCP Server with HTTP and SSE Transport Support
*
* This server implementation supports both modern Streamable HTTP and legacy SSE transports
* instead of the traditional stdio transport. This provides better compatibility with
* web-based MCP clients and allows for more flexible deployment scenarios.
*
* Transport Endpoints:
* - Modern Streamable HTTP: POST/GET/DELETE /mcp
* - Legacy SSE: GET /sse and POST /messages
* - WebSocket (for plugin communication): ws://localhost:8080
*
* Usage:
* - Default port: node dist/index.js (runs on 4401)
* - Custom port: node dist/index.js --port 8080
* - Help: node dist/index.js --help
*/
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { WebSocketServer, WebSocket } from "ws";
@ -19,22 +36,38 @@ class PenpotMcpServer {
private readonly tools: Map<string, Tool>;
private readonly wsServer: WebSocketServer;
private readonly connectedClients: Set<WebSocket> = new Set();
private app: any; // Express app
private readonly port: number;
// Store transports for each session type
private readonly transports = {
streamable: {} as Record<string, any>, // StreamableHTTPServerTransport
sse: {} as Record<string, any> // SSEServerTransport
};
/**
* Creates a new Penpot MCP server instance.
*
* @param port - The port number for the HTTP/SSE server
*/
constructor() {
this.server = new Server({
name: "penpot-mcp-server",
version: "1.0.0",
capabilities: {
tools: {},
constructor(port: number = 4401) {
this.port = port;
this.server = new Server(
{
name: "penpot-mcp-server",
version: "1.0.0",
},
});
{
capabilities: {
tools: {},
},
}
);
this.tools = new Map<string, Tool>();
this.wsServer = new WebSocketServer({ port: 8080 });
this.setupHandlers();
this.setupMcpHandlers();
this.setupWebSocketHandlers();
this.registerTools();
}
@ -61,7 +94,7 @@ class PenpotMcpServer {
* Configures handlers for tool listing and execution requests
* according to the MCP specification.
*/
private setupHandlers(): void {
private setupMcpHandlers(): void {
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: Array.from(this.tools.values()).map((tool) => tool.definition),
@ -85,6 +118,124 @@ class PenpotMcpServer {
});
}
/**
* Sets up HTTP endpoints for modern Streamable HTTP and legacy SSE transports.
*
* Provides backwards compatibility by supporting both transport mechanisms
* for different client capabilities.
*/
private setupHttpEndpoints(): void {
// Modern Streamable HTTP endpoint
this.app.all('/mcp', async (req: any, res: any) => {
await this.handleStreamableHttpRequest(req, res);
});
// Legacy SSE endpoint for older clients
this.app.get('/sse', async (req: any, res: any) => {
await this.handleSseConnection(req, res);
});
// Legacy message endpoint for older clients
this.app.post('/messages', async (req: any, res: any) => {
await this.handleSseMessage(req, res);
});
}
/**
* Handles Streamable HTTP requests for modern MCP clients.
*
* Provides session management and request routing for the new
* streamable HTTP transport protocol.
*/
private async handleStreamableHttpRequest(req: any, res: any): Promise<void> {
const { StreamableHTTPServerTransport } = await import("@modelcontextprotocol/sdk/server/streamableHttp.js");
const { randomUUID } = await import("node:crypto");
const { isInitializeRequest } = await import("@modelcontextprotocol/sdk/types.js");
// Check for existing session ID
const sessionId = req.headers['mcp-session-id'] as string | undefined;
let transport: any;
if (sessionId && this.transports.streamable[sessionId]) {
// Reuse existing transport
transport = this.transports.streamable[sessionId];
} else if (!sessionId && isInitializeRequest(req.body)) {
// New initialization request
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sessionId: string) => {
// Store the transport by session ID
this.transports.streamable[sessionId] = transport;
},
// DNS rebinding protection is disabled by default for backwards compatibility
// If running locally, consider enabling:
// enableDnsRebindingProtection: true,
// allowedHosts: ['127.0.0.1'],
});
// Clean up transport when closed
transport.onclose = () => {
if (transport.sessionId) {
delete this.transports.streamable[transport.sessionId];
}
};
// Connect to the MCP server
await this.server.connect(transport);
} else {
// Invalid request
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Bad Request: No valid session ID provided',
},
id: null,
});
return;
}
// Handle the request
await transport.handleRequest(req, res, req.body);
}
/**
* Handles SSE connection establishment for legacy MCP clients.
*
* Creates and manages Server-Sent Events transport for older
* clients that don't support the streamable HTTP protocol.
*/
private async handleSseConnection(req: any, res: any): Promise<void> {
const { SSEServerTransport } = await import("@modelcontextprotocol/sdk/server/sse.js");
// Create SSE transport for legacy clients
const transport = new SSEServerTransport('/messages', res);
this.transports.sse[transport.sessionId] = transport;
res.on("close", () => {
delete this.transports.sse[transport.sessionId];
});
await this.server.connect(transport);
}
/**
* Handles POST message requests for legacy SSE clients.
*
* Routes messages to the appropriate SSE transport based on
* the provided session identifier.
*/
private async handleSseMessage(req: any, res: any): Promise<void> {
const sessionId = req.query.sessionId as string;
const transport = this.transports.sse[sessionId];
if (transport) {
await transport.handlePostMessage(req, res, req.body);
} else {
res.status(400).send('No transport found for sessionId');
}
}
/**
* Sets up WebSocket connection handlers for plugin communication.
*
@ -115,18 +266,29 @@ class PenpotMcpServer {
console.error("WebSocket server started on port 8080");
}
/**
* Starts the MCP server using stdio transport.
* Starts the MCP server using HTTP and SSE transports.
*
* This method establishes the communication channel and begins
* listening for MCP protocol messages.
* This method establishes the HTTP server and begins listening
* for both modern and legacy MCP protocol connections.
*/
async start(): Promise<void> {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error("Penpot MCP Server started successfully");
console.error("WebSocket server is listening on ws://localhost:8080");
// Import express as ES module and setup HTTP endpoints
const { default: express } = await import('express');
this.app = express();
this.app.use(express.json());
this.setupHttpEndpoints();
return new Promise((resolve) => {
this.app.listen(this.port, () => {
console.error(`Penpot MCP Server started successfully on port ${this.port}`);
console.error(`Modern Streamable HTTP endpoint: http://localhost:${this.port}/mcp`);
console.error(`Legacy SSE endpoint: http://localhost:${this.port}/sse`);
console.error("WebSocket server is listening on ws://localhost:8080");
resolve();
});
});
}
}
@ -138,7 +300,30 @@ class PenpotMcpServer {
*/
async function main(): Promise<void> {
try {
const server = new PenpotMcpServer();
// Parse command line arguments for port configuration
const args = process.argv.slice(2);
let port = 4401; // Default port
for (let i = 0; i < args.length; i++) {
if (args[i] === '--port' || args[i] === '-p') {
if (i + 1 < args.length) {
const portArg = parseInt(args[i + 1], 10);
if (!isNaN(portArg) && portArg > 0 && portArg <= 65535) {
port = portArg;
} else {
console.error("Invalid port number. Using default port 4401.");
}
}
} else if (args[i] === '--help' || args[i] === '-h') {
console.log("Usage: node dist/index.js [options]");
console.log("Options:");
console.log(" --port, -p <number> Port number for the HTTP/SSE server (default: 4401)");
console.log(" --help, -h Show this help message");
process.exit(0);
}
}
const server = new PenpotMcpServer(port);
await server.start();
// Keep the process alive