Merge commit from fork

* fix: add transport isolation guards to prevent cross-client data leaks

When a single McpServer or stateless transport is reused across multiple
client connections, responses can be routed to the wrong client due to
message ID collisions. This is a data leak vulnerability (CWE-362).

Two guards added:
- Protocol.connect() throws if already connected to a transport
- Stateless transport.handleRequest() throws if called more than once

Also fixes three examples that shared a single McpServer across sessions:
- standaloneSseWithGetStreamableHttp.ts
- ssePollingExample.ts
- elicitationFormExample.ts

Related: #820, #204, #243

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: correct misleading test name and add per-request cleanup

- Rename 'should reject second SSE stream even in stateless mode' to
  'should allow multiple SSE streams in stateless mode with per-request
  transports' since the test now asserts both streams succeed (status 200)
- Add mcpServer.close() after per-request handling in
  stateManagementStreamableHttp.test.ts to prevent resource leaks,
  matching the pattern used in streamableHttp.test.ts
- Remove unused mcpServers array that collected but never cleaned up
  per-request server instances

* thread abort through sendRequest and notification to prevent crosstalk

* test: remove dummy objects from stateless setupServer return

In stateless mode, setupServer() was creating unused McpServer and
StreamableHTTPServerTransport instances solely to satisfy the return
type. Make mcpServer/serverTransport optional in the return type and
simplify the stateless beforeEach/afterEach to only track server and
baseUrl.

* fix: use McpError for sendRequest abort guard and fix Hono example reuse

- sendRequest abort guard now throws McpError(ErrorCode.ConnectionClosed)
  instead of plain Error for consistency with the rest of the codebase
- Hono example updated to create fresh transport and server per request,
  fixing breakage from the stateless transport reuse guard

* fix: use separate resolvers per protocol in transport isolation test

Address review feedback: each protocol handler now has its own resolver
returning distinct data (responseForA vs responseForB), so the test
properly demonstrates that responses route to the correct transport.
Uses explicit 'handler entered' promises for deterministic synchronization.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Paul Carleton 2026-02-04 18:23:03 +00:00 • committed by GitHub
commit a05be176ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -17,9 +17,11 @@ import { listenOnRandomPort } from '../helpers/http.js';
describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
const { z } = entry;
describe('Streamable HTTP Transport Session Management', () => {
// Function to set up the server with optional session management
async function setupServer(withSessionManagement: boolean) {
const server: Server = createServer();
/**
* Helper to create and configure a fresh McpServer instance with standard
* resources, prompts, and tools for testing.
*/
function createMcpServer(): McpServer {
const mcpServer = new McpServer(
{ name: 'test-server', version: '1.0.0' },
{
@ -67,43 +69,67 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
}
);
// Create transport with or without session management
const serverTransport = new StreamableHTTPServerTransport({
sessionIdGenerator: withSessionManagement
? () => randomUUID() // With session management, generate UUID
: undefined // Without session management, return undefined
});
return mcpServer;
}
await mcpServer.connect(serverTransport);
// Function to set up the server with optional session management
async function setupServer(withSessionManagement: boolean): Promise<{
server: Server;
mcpServer?: McpServer;
serverTransport?: StreamableHTTPServerTransport;
baseUrl: URL;
}> {
const server: Server = createServer();
server.on('request', async (req, res) => {
await serverTransport.handleRequest(req, res);
});
if (withSessionManagement) {
// Stateful mode: single transport + server for the session
const mcpServer = createMcpServer();
const serverTransport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID()
});
// Start the server on a random port
const baseUrl = await listenOnRandomPort(server);
await mcpServer.connect(serverTransport);
return { server, mcpServer, serverTransport, baseUrl };
server.on('request', async (req, res) => {
await serverTransport.handleRequest(req, res);
});
// Start the server on a random port
const baseUrl = await listenOnRandomPort(server);
return { server, mcpServer, serverTransport, baseUrl };
} else {
// Stateless mode: create a fresh transport + server per request
// to comply with the guard that stateless transports cannot be reused.
server.on('request', async (req, res) => {
const mcpServer = createMcpServer();
const serverTransport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined
});
await mcpServer.connect(serverTransport);
await serverTransport.handleRequest(req, res);
// Close the per-request mcpServer after handling to avoid leaks
await mcpServer.close();
});
// Start the server on a random port
const baseUrl = await listenOnRandomPort(server);
return { server, baseUrl };
}
}
describe('Stateless Mode', () => {
let server: Server;
let mcpServer: McpServer;
let serverTransport: StreamableHTTPServerTransport;
let baseUrl: URL;
beforeEach(async () => {
const setup = await setupServer(false);
server = setup.server;
mcpServer = setup.mcpServer;
serverTransport = setup.serverTransport;
baseUrl = setup.baseUrl;
});
afterEach(async () => {
// Clean up resources
await mcpServer.close().catch(() => {});
await serverTransport.close().catch(() => {});
server.close();
});