Files
excalidraw-mcp-sentinel/tests/backend/mcp-http.test.ts
T
046719aabc 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>
2026-05-20 14:51:08 +02:00

143 lines
4.4 KiB
TypeScript

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();
});
});