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:
co-authored by
Claude Sonnet 4.6
newblacc
parent
9846e0ba0f
commit
046719aabc
@@ -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 }}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+53
-18
@@ -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<void> {
|
||||
try {
|
||||
@@ -2904,6 +2919,26 @@ async function runServer(): Promise<void> {
|
||||
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...');
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user