Describe the bug
PlatformMcpModule connects each request's transport to the single MCP_SERVER instance. With @modelcontextprotocol/server 2.x, closing a transport runs the server's _onclose, which aborts every request handler still in flight on that server, not only those received on the closing transport. The SDK then drops the result of an aborted handler without replying.
So when two tools/call requests overlap and the quicker one finishes first, dispatch closes the quicker request's transport, the slower handler is aborted, and the slower HTTP request never gets a response. It hangs until the client times out. MCP clients do send overlapping tool calls.
It only shows when the overlapping requests carry different JSON-RPC ids. With equal ids, the second request replaces the first one's abort controller in the server's map, so the first escapes the abort. A test that reuses id: 1 for every request will not see it.
Where it happens:
packages/platform/platform-mcp/src/http/services/PlatformMcpModule.ts:18: protected server = inject<McpServer>(MCP_SERVER);
- Same file, lines 67–74: the transport is connected to that shared server (line 70) and closed on
res close and in finally.
packages/platform/platform-mcp/src/common/services/McpServerFactory.ts:70: MCP_SERVER is a singleton.
@modelcontextprotocol/server@2.1.0, dist/src-D-y6h4N7.mjs: _onclose (line 6398) aborts every entry of _requestHandlerAbortControllers (line 6413), and the response path returns early once aborted (line 6524).
To Reproduce
npm init -y && npm pkg set type=module
npm i @tsed/platform-express@8.38.8 @tsed/platform-mcp@8.38.8 express@5
- Save the snippet below as
repro.mjs
node repro.mjs
Output:
quick: quick done
slow: no answer (TimeoutError)
node repro.mjs --same-id (both requests use id 1) answers both.
node repro.mjs --patched (dispatch builds a server per request with the exported createMcpServer()) answers both.
Expected behavior
Each request gets its own response, however other requests overlap with it:
quick: quick done
slow: slow done
Code snippets
import "@tsed/platform-express";
import "@tsed/platform-mcp";
import {Configuration} from "@tsed/di";
import {PlatformExpress} from "@tsed/platform-express";
import {createMcpServer, defineTool, PlatformMcpModule} from "@tsed/platform-mcp";
import {NodeStreamableHTTPServerTransport} from "@modelcontextprotocol/node";
import express from "express";
const PORT = 8093;
const URL = `http://localhost:${PORT}/mcp`;
let slowStarted;
const started = new Promise((resolve) => (slowStarted = resolve));
let releaseSlow;
defineTool({
name: "slow",
description: "Answers once the script releases it",
handler: () =>
new Promise((resolve) => {
slowStarted();
releaseSlow = () => resolve({content: [{type: "text", text: "slow done"}]});
})
});
defineTool({
name: "quick",
description: "Answers at once",
handler: async () => ({content: [{type: "text", text: "quick done"}]})
});
if (process.argv.includes("--patched")) {
// Same as PlatformMcpModule.dispatch, with a server built for this request.
PlatformMcpModule.prototype.dispatch = async function ($ctx) {
const server = createMcpServer();
const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
...this.settings.transportOptions
});
const res = $ctx.response.getRes();
try {
await server.connect(transport);
await transport.handleRequest($ctx.request.getReq(), res, $ctx.request.body);
} finally {
await server.close();
}
};
}
class Server {}
Configuration({})(Server);
function callTool(id, name) {
return fetch(URL, {
method: "POST",
headers: {"content-type": "application/json", accept: "application/json, text/event-stream"},
body: JSON.stringify({jsonrpc: "2.0", id, method: "tools/call", params: {name, arguments: {}}}),
signal: AbortSignal.timeout(5000)
})
.then((response) => response.json())
.then((body) => body.result?.content?.[0]?.text ?? JSON.stringify(body))
.catch((error) => `no answer (${error.name})`);
}
const platform = await PlatformExpress.bootstrap(Server, {
httpPort: PORT,
httpsPort: false,
logger: {level: "off"},
middlewares: [express.json()],
mcp: {enabled: true, path: "/mcp", name: "repro", version: "1.0.0"}
});
await platform.listen();
const slowIdOffset = process.argv.includes("--same-id") ? 0 : 1;
const slow = callTool(1, "slow");
await started;
const quick = await callTool(1 + slowIdOffset, "quick");
releaseSlow();
console.log("quick:", quick);
console.log("slow: ", await slow);
await platform.stop();
process.exit(0);
Repository URL example
None: the snippet above is self-contained.
OS
Linux (CachyOS, kernel 7.2.8)
Node version
v24.21.0
Library version
@tsed/platform-mcp 8.38.8 (all @tsed/* at 8.38.8), @modelcontextprotocol/server 2.1.0, @modelcontextprotocol/node 2.1.0, express 5.2.1
Additional context
Suggested fix: in stateless mode, build the server inside dispatch for each request, for example with createMcpServer() as the --patched branch does, and close it when the request ends. Tool, resource and prompt definitions are already singleton providers, so only their registration is repeated per request. The SDK's own stateless example does the same: @modelcontextprotocol/sdk 1.x, examples/server/honoWebStandardStreamableHttp, "create a fresh transport and server per request (stateless)".
One caveat for that change: tools registered imperatively on the injected MCP_SERVER (for example inject(MCP_SERVER).registerTool(...) in a provider's $onReady) would no longer be served, since they live only on the singleton. Only the providers collected by createMcpServer() would be.
src/cli/services/mcpStreamableServer.ts has the same shape: a new transport per request (line 13), all connected to one server (line 23).
Describe the bug
PlatformMcpModuleconnects each request's transport to the singleMCP_SERVERinstance. With@modelcontextprotocol/server2.x, closing a transport runs the server's_onclose, which aborts every request handler still in flight on that server, not only those received on the closing transport. The SDK then drops the result of an aborted handler without replying.So when two
tools/callrequests overlap and the quicker one finishes first,dispatchcloses the quicker request's transport, the slower handler is aborted, and the slower HTTP request never gets a response. It hangs until the client times out. MCP clients do send overlapping tool calls.It only shows when the overlapping requests carry different JSON-RPC ids. With equal ids, the second request replaces the first one's abort controller in the server's map, so the first escapes the abort. A test that reuses
id: 1for every request will not see it.Where it happens:
packages/platform/platform-mcp/src/http/services/PlatformMcpModule.ts:18:protected server = inject<McpServer>(MCP_SERVER);resclose and infinally.packages/platform/platform-mcp/src/common/services/McpServerFactory.ts:70:MCP_SERVERis a singleton.@modelcontextprotocol/server@2.1.0,dist/src-D-y6h4N7.mjs:_onclose(line 6398) aborts every entry of_requestHandlerAbortControllers(line 6413), and the response path returns early once aborted (line 6524).To Reproduce
npm init -y && npm pkg set type=modulenpm i @tsed/platform-express@8.38.8 @tsed/platform-mcp@8.38.8 express@5repro.mjsnode repro.mjsOutput:
node repro.mjs --same-id(both requests use id 1) answers both.node repro.mjs --patched(dispatchbuilds a server per request with the exportedcreateMcpServer()) answers both.Expected behavior
Each request gets its own response, however other requests overlap with it:
Code snippets
Repository URL example
None: the snippet above is self-contained.
OS
Linux (CachyOS, kernel 7.2.8)
Node version
v24.21.0
Library version
@tsed/platform-mcp 8.38.8 (all @tsed/* at 8.38.8), @modelcontextprotocol/server 2.1.0, @modelcontextprotocol/node 2.1.0, express 5.2.1
Additional context
Suggested fix: in stateless mode, build the server inside
dispatchfor each request, for example withcreateMcpServer()as the--patchedbranch does, and close it when the request ends. Tool, resource and prompt definitions are already singleton providers, so only their registration is repeated per request. The SDK's own stateless example does the same:@modelcontextprotocol/sdk1.x,examples/server/honoWebStandardStreamableHttp, "create a fresh transport and server per request (stateless)".One caveat for that change: tools registered imperatively on the injected
MCP_SERVER(for exampleinject(MCP_SERVER).registerTool(...)in a provider's$onReady) would no longer be served, since they live only on the singleton. Only the providers collected bycreateMcpServer()would be.src/cli/services/mcpStreamableServer.tshas the same shape: a new transport per request (line 13), all connected to one server (line 23).