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
|
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 }}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
+53
-18
@@ -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,21 +1035,29 @@ 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.
|
||||||
name: "mcp-excalidraw-server",
|
function createMcpServer(): Server {
|
||||||
version: "2.0.0",
|
const server = new Server(
|
||||||
description: "Programmatic canvas toolkit for Excalidraw with file I/O, image export, and real-time sync"
|
{
|
||||||
},
|
name: "mcp-excalidraw-server",
|
||||||
{
|
version: "2.0.0",
|
||||||
capabilities: {
|
description: "Programmatic canvas toolkit for Excalidraw with file I/O, image export, and real-time sync"
|
||||||
tools: Object.fromEntries(tools.map(tool => [tool.name, {
|
},
|
||||||
description: tool.description,
|
{
|
||||||
inputSchema: tool.inputSchema
|
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.
|
// 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...');
|
||||||
|
|
||||||
|
|||||||
@@ -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