feat(transport): Streamable HTTP mode — one shared MCP process for all sessions (v1.2.0) (#13)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: newblacc <newblacc@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxime Roy (new.blacc)
2026-05-20 14:51:08 +02:00
committed by GitHub
co-authored by Claude Sonnet 4.6 newblacc
parent 9846e0ba0f
commit 046719aabc
7 changed files with 318 additions and 24 deletions
+2 -5
View File
@@ -1,10 +1,7 @@
name: Release & Publish name: Release & Publish
on: on:
workflow_run: workflow_dispatch:
workflows: [CI]
types: [completed]
branches: [main]
concurrency: concurrency:
group: release-main group: release-main
@@ -18,7 +15,7 @@ jobs:
check: check:
name: Check for releasable commits name: Check for releasable commits
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event.workflow_run.conclusion == 'success' if: always()
outputs: outputs:
bump: ${{ steps.bump.outputs.bump }} bump: ${{ steps.bump.outputs.bump }}
new_version: ${{ steps.bump.outputs.new_version }} new_version: ${{ steps.bump.outputs.new_version }}
+18
View File
@@ -156,3 +156,21 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
### Fixed ### 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 - `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)
+4
View File
@@ -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_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) - 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 - 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).
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "excalidraw-mcp-sentinel", "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", "description": "Hardened, self-hosted Excalidraw MCP server with SQLite persistence, multi-tenancy, auto-sync, security middleware, and 369 tests",
"main": "dist/index.js", "main": "dist/index.js",
"type": "module", "type": "module",
+41 -6
View File
@@ -35,6 +35,7 @@ import {
} from './types.js'; } from './types.js';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
import { startCanvasServer, stopCanvasServer } from './server.js'; import { startCanvasServer, stopCanvasServer } from './server.js';
import { startMcpHttpServer, resolveTransportMode } from './mcp-http.js';
import { import {
initDb, closeDb, initDb, closeDb,
searchElements as dbSearchElements, searchElements as dbSearchElements,
@@ -1034,7 +1035,10 @@ const tools: Tool[] = [
]; ];
// Initialize MCP server // Initialize MCP server
const server = new Server( // 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", name: "mcp-excalidraw-server",
version: "2.0.0", version: "2.0.0",
@@ -1048,7 +1052,12 @@ const server = new Server(
}])) }]))
} }
} }
); );
registerHandlers(server);
return server;
}
const server = createMcpServer();
// Helper function: previously converted text → label format for Excalidraw. // Helper function: previously converted text → label format for Excalidraw.
// Now a no-op because the canvas REST API materializes label/text into native // 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; 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 // Set up request handler for tool calls
server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) => { server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) => {
try { try {
@@ -1855,7 +1868,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
if (params.filePath) { if (params.filePath) {
const safePath = sanitizeFilePath(params.filePath); const safePath = sanitizeFilePath(params.filePath);
fs.writeFileSync(safePath, jsonString, 'utf-8'); await fs.promises.writeFile(safePath, jsonString, 'utf-8');
return { return {
content: [{ content: [{
type: 'text', type: 'text',
@@ -1884,7 +1897,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
let sceneData: any; let sceneData: any;
if (params.filePath) { if (params.filePath) {
const safeImportPath = sanitizeFilePath(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); sceneData = JSON.parse(fileContent);
assertNoDangerousKeys(sceneData, 'import_scene filePath'); assertNoDangerousKeys(sceneData, 'import_scene filePath');
} else if (params.data) { } else if (params.data) {
@@ -1996,9 +2009,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
if (params.filePath) { if (params.filePath) {
const safeImagePath = sanitizeFilePath(params.filePath); const safeImagePath = sanitizeFilePath(params.filePath);
if (params.format === 'svg') { if (params.format === 'svg') {
fs.writeFileSync(safeImagePath, result.data, 'utf-8'); await fs.promises.writeFile(safeImagePath, result.data, 'utf-8');
} else { } else {
fs.writeFileSync(safeImagePath, Buffer.from(result.data, 'base64')); await fs.promises.writeFile(safeImagePath, Buffer.from(result.data, 'base64'));
} }
return { return {
content: [{ content: [{
@@ -2874,6 +2887,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
return { tools }; return { tools };
}); });
} // end registerHandlers
// Start server // Start server
async function runServer(): Promise<void> { async function runServer(): Promise<void> {
try { try {
@@ -2904,6 +2919,26 @@ async function runServer(): Promise<void> {
logger.warn('MCP tools will work without real-time canvas sync'); 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(); const transport = new StdioServerTransport();
logger.debug('Connecting to stdio transport...'); logger.debug('Connecting to stdio transport...');
+98
View File
@@ -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<string, StreamableHTTPServerTransport> = {};
// 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<HttpServer> {
const app = express();
mountMcpRoutes(app, createServer);
return new Promise<HttpServer>((resolve, reject) => {
const httpServer = app.listen(port, host, () => resolve(httpServer));
httpServer.on('error', reject);
});
}
+142
View File
@@ -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();
});
});