From 046719aabc88de5fc1a70a35eb16316dcfd1a2df Mon Sep 17 00:00:00 2001 From: "Maxime Roy (new.blacc)" Date: Wed, 20 May 2026 14:51:08 +0200 Subject: [PATCH] =?UTF-8?q?feat(transport):=20Streamable=20HTTP=20mode=20?= =?UTF-8?q?=E2=80=94=20one=20shared=20MCP=20process=20for=20all=20sessions?= =?UTF-8?q?=20(v1.2.0)=20(#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(ci): release fires on workflow_dispatch, not on every CI pass Replaced workflow_run trigger (fired automatically when CI completed on main) with workflow_dispatch. The full semantic-release automation is preserved — bump detection, version commit, tag, GitHub Release, NPM + Docker publish — but now runs only when explicitly triggered. Co-Authored-By: Claude Sonnet 4.6 * feat(transport): add Streamable HTTP mode — one shared process for all MCP clients (v1.2.0) Add MCP_TRANSPORT=http mode backed by StreamableHTTPServerTransport. Each Claude Code session connects to the shared long-lived process via HTTP (port 3031 by default) instead of spawning a new stdio process per session, eliminating per-session process multiplication. Sessions are isolated by mcp-session-id header. Also refactor src/index.ts to extract createMcpServer()/registerHandlers() for clean per-session server instantiation, and upgrade fs.writeFileSync/readFileSync calls to fs.promises async variants in export/import tool handlers. 9 new tests cover transport resolution, session isolation, teardown, and startMcpHttpServer. All 528 tests pass. Bumps v1.1.0 → v1.2.0. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: newblacc Co-authored-by: Claude Sonnet 4.6 --- .github/workflows/release.yml | 7 +- CHANGELOG.md | 18 +++++ CLAUDE.md | 4 + package.json | 2 +- src/index.ts | 71 ++++++++++++----- src/mcp-http.ts | 98 +++++++++++++++++++++++ tests/backend/mcp-http.test.ts | 142 +++++++++++++++++++++++++++++++++ 7 files changed, 318 insertions(+), 24 deletions(-) create mode 100644 src/mcp-http.ts create mode 100644 tests/backend/mcp-http.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3290b9a..4d62ee4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,10 +1,7 @@ name: Release & Publish on: - workflow_run: - workflows: [CI] - types: [completed] - branches: [main] + workflow_dispatch: concurrency: group: release-main @@ -18,7 +15,7 @@ jobs: check: name: Check for releasable commits runs-on: ubuntu-latest - if: github.event.workflow_run.conclusion == 'success' + if: always() outputs: bump: ${{ steps.bump.outputs.bump }} new_version: ${{ steps.bump.outputs.new_version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 5af0a03..42d8df3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -156,3 +156,21 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ### Fixed - `npm install -g excalidraw-mcp-sentinel` crashed on Windows — `postinstall` script used Unix-only `2>/dev/null || true` syntax which cmd.exe does not support; replaced with a cross-platform `node -e` inline script + +- 2026-05-14: chore(ci): release workflow now manual (workflow_dispatch) -- no longer fires automatically on every CI pass on main + +## [1.2.0] - 2026-05-20 + +### Added +- Streamable HTTP transport mode (`MCP_TRANSPORT=http`): single long-lived process serves all MCP clients over HTTP instead of spawning a new stdio process per session. Each client gets its own isolated `Server` instance routed by `mcp-session-id` header. Eliminates per-session process overhead for multi-session setups. +- `src/mcp-http.ts`: `mountMcpRoutes`, `startMcpHttpServer`, `resolveTransportMode` — full HTTP session lifecycle (POST/GET/DELETE /mcp, session map, `StreamableHTTPServerTransport`) +- `createMcpServer()` factory and `registerHandlers()` in `src/index.ts` — clean per-session server instantiation for HTTP mode +- 9 new tests in `tests/backend/mcp-http.test.ts` covering transport resolution, session isolation, session teardown, and `startMcpHttpServer` +- `CLAUDE.md`: Strict Installation Decoupling rule +- launchd agent (`~/Library/LaunchAgents/com.user.excalidraw-mcp.plist`) for single-instance persistence on macOS + +### Changed +- `runServer()` now checks `MCP_TRANSPORT` env var; defaults to stdio (backward-compatible) +- `fs.writeFileSync`/`readFileSync` calls in export/import tool handlers converted to `fs.promises` async variants + +### Total tests: 528 (31 files) diff --git a/CLAUDE.md b/CLAUDE.md index 71e72ed..de5cc3d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -136,3 +136,7 @@ When exploring or understanding code in supported languages (JS, TS, Python, Go, - Use `smart_outline(file_path)` instead of Read to understand file structure (~1-2K tokens vs ~12K+) - Use `smart_unfold(file_path, symbol_name)` instead of Read for viewing specific functions (~400-2K tokens) - Fall back to Grep for exact string/regex searches, Read for non-code files and files under 100 lines + +## Strict Installation Decoupling + +Once installed (e.g., to ~/.local/bin), the project binary must NEVER depend on the local repository path (~/DevOpsSec) for execution, configuration, or data. All paths must be relative to the installation root or use standard system config paths (~/.config). diff --git a/package.json b/package.json index a568fc4..2df9610 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "excalidraw-mcp-sentinel", - "version": "1.1.0", + "version": "1.2.0", "description": "Hardened, self-hosted Excalidraw MCP server with SQLite persistence, multi-tenancy, auto-sync, security middleware, and 369 tests", "main": "dist/index.js", "type": "module", diff --git a/src/index.ts b/src/index.ts index 1e9c6a8..c8aa0a9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,6 +35,7 @@ import { } from './types.js'; import fetch from 'node-fetch'; import { startCanvasServer, stopCanvasServer } from './server.js'; +import { startMcpHttpServer, resolveTransportMode } from './mcp-http.js'; import { initDb, closeDb, searchElements as dbSearchElements, @@ -1034,21 +1035,29 @@ const tools: Tool[] = [ ]; // Initialize MCP server -const server = new Server( - { - name: "mcp-excalidraw-server", - version: "2.0.0", - description: "Programmatic canvas toolkit for Excalidraw with file I/O, image export, and real-time sync" - }, - { - capabilities: { - tools: Object.fromEntries(tools.map(tool => [tool.name, { - description: tool.description, - inputSchema: tool.inputSchema - }])) +// Build a fresh MCP server with all request handlers registered. Called once +// for the stdio singleton below, and once per client session in HTTP mode. +function createMcpServer(): Server { + const server = new Server( + { + name: "mcp-excalidraw-server", + version: "2.0.0", + description: "Programmatic canvas toolkit for Excalidraw with file I/O, image export, and real-time sync" + }, + { + capabilities: { + tools: Object.fromEntries(tools.map(tool => [tool.name, { + description: tool.description, + inputSchema: tool.inputSchema + }])) + } } - } -); + ); + registerHandlers(server); + return server; +} + +const server = createMcpServer(); // Helper function: previously converted text → label format for Excalidraw. // Now a no-op because the canvas REST API materializes label/text into native @@ -1057,6 +1066,10 @@ function convertTextToLabel(element: ServerElement): ServerElement { return element; } +// Register all request handlers on a server instance. Module-scope so it can be +// called per-session in HTTP mode and once for the stdio singleton. +function registerHandlers(server: Server): void { + // Set up request handler for tool calls server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) => { try { @@ -1855,7 +1868,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) if (params.filePath) { const safePath = sanitizeFilePath(params.filePath); - fs.writeFileSync(safePath, jsonString, 'utf-8'); + await fs.promises.writeFile(safePath, jsonString, 'utf-8'); return { content: [{ type: 'text', @@ -1884,7 +1897,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) let sceneData: any; if (params.filePath) { const safeImportPath = sanitizeFilePath(params.filePath); - const fileContent = fs.readFileSync(safeImportPath, 'utf-8'); + const fileContent = await fs.promises.readFile(safeImportPath, 'utf-8'); sceneData = JSON.parse(fileContent); assertNoDangerousKeys(sceneData, 'import_scene filePath'); } else if (params.data) { @@ -1996,9 +2009,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) if (params.filePath) { const safeImagePath = sanitizeFilePath(params.filePath); if (params.format === 'svg') { - fs.writeFileSync(safeImagePath, result.data, 'utf-8'); + await fs.promises.writeFile(safeImagePath, result.data, 'utf-8'); } else { - fs.writeFileSync(safeImagePath, Buffer.from(result.data, 'base64')); + await fs.promises.writeFile(safeImagePath, Buffer.from(result.data, 'base64')); } return { content: [{ @@ -2874,6 +2887,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools }; }); +} // end registerHandlers + // Start server async function runServer(): Promise { try { @@ -2904,6 +2919,26 @@ async function runServer(): Promise { logger.warn('MCP tools will work without real-time canvas sync'); } + // HTTP mode: one shared process serves many clients over Streamable HTTP. + // Each client session gets its own MCP server via createMcpServer. The MCP + // endpoint listens on its own port (MCP_HTTP_PORT) so it stays reachable + // even when the canvas port is reused by another process. + if (resolveTransportMode(process.env) === 'http') { + const mcpPort = parseInt(process.env['MCP_HTTP_PORT'] || '3031', 10); + await startMcpHttpServer(createMcpServer, mcpPort); + logger.info(`Excalidraw MCP server running on HTTP (Streamable) at http://127.0.0.1:${mcpPort}/mcp`); + + const shutdownHttp = async () => { + logger.info('Shutting down (HTTP mode)'); + try { await stopCanvasServer(); } catch {} + try { closeDb(); } catch {} + process.exit(0); + }; + process.on('SIGTERM', shutdownHttp); + process.on('SIGINT', shutdownHttp); + return; + } + const transport = new StdioServerTransport(); logger.debug('Connecting to stdio transport...'); diff --git a/src/mcp-http.ts b/src/mcp-http.ts new file mode 100644 index 0000000..60e4701 --- /dev/null +++ b/src/mcp-http.ts @@ -0,0 +1,98 @@ +/** + * Streamable HTTP transport wiring for the MCP server. + * + * Lets a single long-lived process serve many MCP clients over HTTP instead of + * each client spawning its own stdio process. Each client session gets its own + * MCP `Server` instance (cheap in-process object) routed by `mcp-session-id`. + */ +import type { Application, Request, Response } from 'express'; +import type { Server as HttpServer } from 'node:http'; +import express from 'express'; +import { randomUUID } from 'node:crypto'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; +import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; + +export type TransportMode = 'stdio' | 'http'; + +/** Decide transport from the environment. stdio is the default (back-compat). */ +export function resolveTransportMode(env: NodeJS.ProcessEnv): TransportMode { + return (env['MCP_TRANSPORT'] || '').toLowerCase() === 'http' ? 'http' : 'stdio'; +} + +/** + * Mount POST/GET/DELETE `/mcp` routes on an existing Express app. + * + * @param app the Express app (shares the canvas server's httpServer) + * @param createServer factory returning a fresh MCP `Server` per session + */ +export function mountMcpRoutes(app: Application, createServer: () => Server): void { + const transports: Record = {}; + // Dedicated parser so /mcp accepts larger bodies than the canvas API's 100kb cap. + const jsonParser = express.json({ limit: '5mb' }); + + app.post('/mcp', jsonParser, async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + let transport: StreamableHTTPServerTransport; + + if (sessionId && transports[sessionId]) { + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + // Plain JSON responses (no SSE) — clean request/response for Claude clients. + enableJsonResponse: true, + onsessioninitialized: (sid) => { + transports[sid] = transport; + }, + }); + transport.onclose = () => { + if (transport.sessionId) delete transports[transport.sessionId]; + }; + const server = createServer(); + await server.connect(transport); + } else { + res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Bad Request: no valid session ID' }, + id: null, + }); + return; + } + + await transport.handleRequest(req, res, req.body); + }); + + const handleSessionRequest = async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + await transports[sessionId]!.handleRequest(req, res); + }; + + app.get('/mcp', handleSessionRequest); + app.delete('/mcp', handleSessionRequest); +} + +/** + * Start a dedicated HTTP server hosting the MCP `/mcp` endpoint on its own port. + * + * Kept independent of the canvas server so MCP stays reachable even when the + * canvas port is owned/reused by another process. + * + * @returns the listening http.Server (resolves once bound) + */ +export function startMcpHttpServer( + createServer: () => Server, + port: number, + host = '127.0.0.1', +): Promise { + const app = express(); + mountMcpRoutes(app, createServer); + return new Promise((resolve, reject) => { + const httpServer = app.listen(port, host, () => resolve(httpServer)); + httpServer.on('error', reject); + }); +} diff --git a/tests/backend/mcp-http.test.ts b/tests/backend/mcp-http.test.ts new file mode 100644 index 0000000..f7f132b --- /dev/null +++ b/tests/backend/mcp-http.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import request from 'supertest'; +import express, { type Express } from 'express'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import type { Server as HttpServer } from 'node:http'; +import { mountMcpRoutes, resolveTransportMode, startMcpHttpServer } from '../../src/mcp-http.js'; + +// A minimal real MCP server so the SDK initialize handshake succeeds. +function makeServer(): Server { + const server = new Server( + { name: 'test-shared-server', version: '1.0.0' }, + { capabilities: { tools: {} } } + ); + server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [] })); + return server; +} + +const INIT_BODY = { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'test-client', version: '1.0.0' }, + }, +}; + +const ACCEPT = 'application/json, text/event-stream'; + +describe('resolveTransportMode', () => { + it('defaults to stdio when MCP_TRANSPORT is unset', () => { + expect(resolveTransportMode({})).toBe('stdio'); + }); + + it('returns http when MCP_TRANSPORT=http (case-insensitive)', () => { + expect(resolveTransportMode({ MCP_TRANSPORT: 'http' })).toBe('http'); + expect(resolveTransportMode({ MCP_TRANSPORT: 'HTTP' })).toBe('http'); + }); + + it('falls back to stdio for any other value', () => { + expect(resolveTransportMode({ MCP_TRANSPORT: 'sse' })).toBe('stdio'); + }); +}); + +describe('mountMcpRoutes', () => { + let app: Express; + let serverInstances: number; + + beforeEach(() => { + serverInstances = 0; + app = express(); + mountMcpRoutes(app, () => { + serverInstances += 1; + return makeServer(); + }); + }); + + it('creates a session on initialize and returns a session id header', async () => { + const res = await request(app) + .post('/mcp') + .set('Content-Type', 'application/json') + .set('Accept', ACCEPT) + .send(INIT_BODY); + + expect(res.status).toBe(200); + expect(res.headers['mcp-session-id']).toBeTruthy(); + expect(serverInstances).toBe(1); + }); + + it('gives each initialize its own isolated session id and server instance', async () => { + const a = await request(app).post('/mcp').set('Accept', ACCEPT).send(INIT_BODY); + const b = await request(app).post('/mcp').set('Accept', ACCEPT).send(INIT_BODY); + + expect(a.headers['mcp-session-id']).toBeTruthy(); + expect(b.headers['mcp-session-id']).toBeTruthy(); + expect(a.headers['mcp-session-id']).not.toBe(b.headers['mcp-session-id']); + expect(serverInstances).toBe(2); + }); + + it('rejects a POST with no session id that is not an initialize request', async () => { + const res = await request(app) + .post('/mcp') + .set('Accept', ACCEPT) + .send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); + + expect(res.status).toBe(400); + }); + + it('rejects a GET with an unknown session id', async () => { + const res = await request(app) + .get('/mcp') + .set('Accept', ACCEPT) + .set('mcp-session-id', 'does-not-exist'); + + expect(res.status).toBe(400); + }); + + it('tears down a session on DELETE with a valid session id', async () => { + const init = await request(app).post('/mcp').set('Accept', ACCEPT).send(INIT_BODY); + const sid = init.headers['mcp-session-id']; + + const del = await request(app) + .delete('/mcp') + .set('Accept', ACCEPT) + .set('mcp-session-id', sid); + + expect(del.status).toBeLessThan(500); + + // After teardown the session id is no longer valid. + const after = await request(app) + .get('/mcp') + .set('Accept', ACCEPT) + .set('mcp-session-id', sid); + expect(after.status).toBe(400); + }); +}); + +describe('startMcpHttpServer', () => { + let httpServer: HttpServer; + + afterEach(() => { + httpServer?.close(); + }); + + it('listens on its own port and serves initialize', async () => { + httpServer = await startMcpHttpServer(makeServer, 0); // port 0 = ephemeral + const addr = httpServer.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + expect(port).toBeGreaterThan(0); + + const res = await request(`http://127.0.0.1:${port}`) + .post('/mcp') + .set('Content-Type', 'application/json') + .set('Accept', ACCEPT) + .send(INIT_BODY); + + expect(res.status).toBe(200); + expect(res.headers['mcp-session-id']).toBeTruthy(); + }); +});