SevyDevy

Guide · AI and GenAI

MCP TypeScript SDK's October 2026 Advisory Explained: One Server per Connection

22 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • The MCP TypeScript SDK vulnerability of October 2026 let an MCP server running over HTTP send one client's replies to another client. It affects @modelcontextprotocol/server 2.0.0 to 2.2.0 (advisory GHSA-jm63-cq47-j5jp, rated High).
  • MCP, the Model Context Protocol, is the open standard AI apps use to call tools on a server. Each request carries a JSON-RPC id, a label the reply must echo back.
  • The bug needed one server object shared by many clients. Replies were matched to connections by id alone, and every SDK client numbers its requests from 0, so ids collide.
  • The fix is in your code: build a new McpServer and transport per request, or one pair per session. From 2.3.0 the SDK refuses sharing, usually with an ALREADY_CONNECTED error.
  • Beginners can stop after the section on why 2.3.0 breaks old code. Engineers then get hardened code for all three safe patterns, tested on 2.2.0, 2.3.0 and 2.4.0, plus the four other advisories and their off-by-default settings.

What goes wrong when one MCP server object serves every HTTP client?

Replies can reach the wrong person. In the MCP TypeScript SDK vulnerability published in October 2026, a server object shared by many HTTP clients could deliver one client's tool results or notifications to a different client.

This is not an attack that needs a clever payload. It came from a server setup that the SDK's own adapter READMEs showed until the fix. If you copied that example, your code has the pattern.

Picture a common setup. A team runs one MCP server over HTTP so that every employee's AI assistant can look up that employee's support tickets. The setup is made up, but the pattern is exactly the one the advisory describes.

The server creates one McpServer when it starts and reuses it for every request. Two employees ask for their tickets at nearly the same moment. Both requests carry the same id, and one reply can go down the other person's connection.

This guide follows that ticket server from the bug, through the fix, to a check you can run on your own machine. Nothing here shows how to steal data; the demo only shows which code patterns each version accepts.

Which words do you need before reading the advisory?

You need six words: MCP, server, transport, JSON-RPC id, stateless and session. Each one gets a plain meaning here, tied to the ticket server.

  • MCP (Model Context Protocol): an open standard that lets an AI app call tools and read data on another program. The ticket server is an MCP server; each assistant is an MCP client.
  • McpServer: the SDK object that holds your tools and answers requests. In the ticket server, it holds the get-tickets tool.
  • Transport: the SDK object that moves messages over a channel, here HTTP. It reads requests from an HTTP request and writes replies into the matching HTTP response.
  • JSON-RPC id: JSON-RPC is the simple message format MCP uses. Every request has an id, a number or a string, and its reply carries the same id. That is how the caller knows which question a reply answers.
  • Stateless: a mode where each HTTP request stands alone, with no memory of earlier ones. The SDK turns this on when you pass sessionIdGenerator: undefined.
  • Session: a mode where the server hands the client an mcp-session-id header and keeps state for that client until the session ends.

What is the MCP TypeScript SDK vulnerability GHSA-jm63-cq47-j5jp?

The MCP TypeScript SDK vulnerability GHSA-jm63-cq47-j5jp is a cross-client mix-up on HTTP servers. When clients shared one stateless transport, or one McpServer or Server instance, one client's responses and notifications could be delivered to another.

The advisory rates it High, with a CVSS score of 7.7. CVSS is the standard 0 to 10 severity scale. It lists the weakness as CWE-362, a race condition: the outcome depends on two requests overlapping in time.

GHSA-jm63-cq47-j5jp at a glance (from the advisory and the GitHub Advisory Database, checked 26 October 2026)
QuestionAnswer
Package@modelcontextprotocol/server on npm
Affected versions2.0.0 up to but not including 2.3.0
Fixed version2.3.0, released 2 October 2026
SeverityHigh, CVSS 7.7, race condition (CWE-362)
Who is affectedHTTP servers that create the transport or server once at startup and serve several clients
Not affectedstdio servers; servers using createMcpHandler (the SDK's ready-made request handler) with a factory that builds a new server; the older 1.x package @modelcontextprotocol/sdk from 1.26.0
CVECVE-2026-111770, as listed by the GitHub and GitLab advisory databases; the SDK's own advisory said none known (26 October 2026)
Upgrade alone enough?Only if your code already builds a server per request or per session. Otherwise 2.3.0 rejects it and you must change the code

Why did a shared MCP server send replies to the wrong client?

Because the shared object matched replies to connections by JSON-RPC id alone, and ids are not unique across clients. Each SDK client starts its own counter at 0, so two clients send the same ids in normal use.

We saw this directly. With the SDK client's default handshake, every client sent initialize with id 0 and its first tool call with id 1. Two clients, two tool calls, the same id: 1.

When one transport is shared

The HTTP transport keeps a small table from request id to the open HTTP response it should answer on. In the 2.2.0 source, this is a map filled with the request's id and read back with the reply's id.

Client B does. When B's call with id 1 arrives, its entry replaces A's in the table. When A's reply is ready, the lookup for id 1 finds B's open response.

How a shared transport mixes up two id 1 requests

Notice the fourth box: the second request with the same id overwrites the first entry, so the lookup in the sixth box finds the wrong client.

Diagram connections:

  • A sends tools/call id 1 to Table: id 1 = stream A (step 1)
  • Table: id 1 = stream A to B sends tools/call id 1 (A's call still running)
  • B sends tools/call id 1 to Table: id 1 = stream B (same id arrives)
  • Table: id 1 = stream B to Reply id 1 for A is ready (A's tool finishes)
  • Reply id 1 for A is ready to Look up id 1 (send by id)
  • Look up id 1 to B gets A's result (wrong stream)
  • A sends tools/call id 1: Client A → shared transport
  • Table: id 1 = stream A: Transport → its id table
  • B sends tools/call id 1: Client B → shared transport
  • Table: id 1 = stream B: Overwrites A's entry
  • Reply id 1 for A is ready: McpServer → shared transport
  • Look up id 1: Transport → table finds stream B
  • B gets A's result: Transport → Client B

An everyday picture helps. A canteen has two order windows, and each window hands out tokens starting from 1. Both windows send orders to one pickup counter, which keeps a single slip for each token number.

  • Order windows: the two clients, each counting its own requests.
  • Token number: the JSON-RPC id.
  • The counter's slips: the transport's table from id to open connection.
  • A plate of food: the reply.

The second token 1 slip replaces the first, so the first plate goes to the second person. Where the picture breaks: a real counter calls out, and people can object. The SDK asks no one; it pushes the reply straight into the connection its table points to.

When one McpServer is shared

The second shared object is the server. In 2.2.0, calling connect a second time simply replaced the server's current transport, with no error. The fix's pull request describes exactly that.

The final reply was safe here: the 2.2.0 server remembers which transport each request arrived on. Messages sent during a tool call were not. These include progress notifications and requests back to the client, such as sampling or elicitation.

Those messages go out on whichever connection was attached last, labelled with the request's id. If that client has a request with the same id open, the message lands there.

Which code pattern was vulnerable in the MCP TypeScript SDK?

The pattern is one McpServer created at startup, with a new transport per request, connected inside the request handler. The README of @modelcontextprotocol/node 2.1.0 showed it for both Express and plain node:http.

To try the samples in this guide, run npm install @modelcontextprotocol/server @modelcontextprotocol/node @modelcontextprotocol/client zod. Save each file with the .mjs ending so Node treats it as a modern JavaScript module.

Here is the ticket server written that way, with a stand-in echo tool. Do not use it. We ran it on 2.2.0, where it answers both clients with no error, and on 2.3.0, where it fails.

javascript
import { createServer } from 'node:http';
import { McpServer } from '@modelcontextprotocol/server';
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
import * as z from 'zod';

// The pattern the adapter READMEs showed before 2.3.0. Do not use it.
const server = new McpServer({ name: 'tickets', version: '1.0.0' });
server.registerTool(
  'echo',
  { description: 'Echo a word back', inputSchema: z.object({ word: z.string() }) },
  async ({ word }) => ({ content: [{ type: 'text', text: `you said ${word}` }] })
);

createServer(async (req, res) => {
  const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
  await server.connect(transport); // 2.3.0+: rejects with ALREADY_CONNECTED from the 2nd request
  await transport.handleRequest(req, res);
}).listen(3000, '127.0.0.1');

Nothing in this code looks wrong, which is why it spread. The shared object is the const server line at the top, outside the request handler.

Two related setups are just as unsafe: one stateless transport created at startup and reused for every request, and one server that serves every session. All three behave very differently once you upgrade.

Why does MCP server 2.3.0 throw the ALREADY_CONNECTED error after upgrading?

Because 2.3.0 refuses to be shared. From 2.3.0, a Server or McpServer serves one connection at a time, so a second connect call rejects with the error code ALREADY_CONNECTED until you call close.

The release notes list the other guard too. A stateless web-standard transport throws on its second request with the message "Stateless transport cannot be reused across requests". The Node transport answers HTTP 500 instead, and in our runs nothing was logged. With sessions, a second session's initialize fails when one server serves every session.

How the failure shows up depends on your web framework. The release notes say Express 5, Fastify and Hono return HTTP 500. For a shared McpServer, a plain node:http listener with no error handling gets an unhandled rejection that ends the process. If your framework does not catch errors from async handlers, add a try and catch as Pattern 1 does.

We saw that last case. Our shared sample on 2.3.0 crashed with code ALREADY_CONNECTED on the second HTTP request, and both test clients then failed with "fetch failed".

What we saw with two SDK clients calling a tool (26 October 2026). Tested: server 2.2.0, 2.3.0 and 2.4.0 with the matching client, node adapter 2.1.0, 2.1.1 and 2.1.2, zod 4.6.7, Node 22.23.4.
Server patternOn 2.2.0On 2.3.0 and 2.4.0
One McpServer at startup, new transport per requestBoth clients answered, no warningSecond request rejected with ALREADY_CONNECTED, both clients fail
One stateless transport for all requestsBoth clients answered, no warningSecond request gets HTTP 500, both clients fail
New McpServer and transport per requestBoth clients answeredBoth clients answered
One McpServer and transport per sessionBoth clients answeredBoth clients answered
createMcpHandler with a factoryBoth clients answeredBoth clients answered

Read the first two rows carefully. On 2.2.0 the unsafe patterns pass a simple test, because the mix-up needs two requests with the same id in flight at once. The 2.3.0 error is the first time this code tells you anything.

How do you fix a shared MCP server instance?

Give every request, or every session, its own McpServer and its own transport. The advisory lists three safe patterns, and all three ran correctly for us on 2.2.0, 2.3.0 and 2.4.0.

Choosing a safe server pattern

Every path ends with a server object that belongs to one client connection. stdio servers are outside this bug.

Diagram connections:

  • Your MCP server to Served over HTTP?
  • Served over HTTP? to stdio: not affected (No)
  • Served over HTTP? to Need sessions? (Yes)
  • Need sessions? to One server per session (Yes)
  • Need sessions? to Using createMcpHandler? (No)
  • Using createMcpHandler? to Pass a factory (Yes)
  • Using createMcpHandler? to Build both per request (No)
  • Your MCP server: Pick how clients reach it
  • Served over HTTP?: Several clients on one endpoint
  • stdio: not affected: One client per process
  • Need sessions?: State kept between requests
  • One server per session: sessionIdGenerator, map id to transport
  • Using createMcpHandler?: The SDK's request handler helper
  • Pass a factory: Function that builds a new McpServer
  • Build both per request: New McpServer and transport inside handler

Pattern 1: a new server and transport per request

Move the server construction into a function and call it inside the handler. This version also closes both objects when the response ends and turns a thrown error into HTTP 500 instead of a crash. It answers GET with 405, as Pattern 3 does, so no GET request keeps a server open.

javascript
import { createServer } from 'node:http';
import { McpServer } from '@modelcontextprotocol/server';
import {
  NodeStreamableHTTPServerTransport,
  localhostHostValidation,
  localhostOriginValidation,
} from '@modelcontextprotocol/node';
import * as z from 'zod';

// Build a fresh server for every request. Register your tools here.
function buildServer() {
  const server = new McpServer({ name: 'tickets', version: '1.0.0' });
  server.registerTool(
    'echo',
    { description: 'Echo a word back', inputSchema: z.object({ word: z.string() }) },
    async ({ word }) => ({ content: [{ type: 'text', text: `you said ${word}` }] })
  );
  return server;
}

const validateHost = localhostHostValidation();
const validateOrigin = localhostOriginValidation();

createServer(async (req, res) => {
  if (!validateHost(req, res) || !validateOrigin(req, res)) return;
  if (req.method !== 'POST') return void res.writeHead(405).end(); // no long-lived GET streams
  const server = buildServer();
  const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
  res.on('close', () => { transport.close(); server.close(); });
  try {
    await server.connect(transport);
    await transport.handleRequest(req, res);
  } catch (err) {
    console.error(err);
    if (!res.headersSent) res.writeHead(500).end();
  }
}).listen(3000, '127.0.0.1');

Pattern 2: one server and transport per session

If you need sessions, pass a sessionIdGenerator and keep a map from session id to that session's transport. The server is built once per session, never once per process.

This version follows the MCP transport rules for sessions. Only an initialize request may start a new session, and an unknown session id gets HTTP 404, which tells the client to start over. It keeps the same Host and Origin checks as Pattern 1. Because this handler reads the body itself, it must also enforce the 4 MiB size limit the transport would otherwise apply.

javascript
import { createServer } from 'node:http';
import { randomUUID } from 'node:crypto';
import { McpServer, isInitializeRequest } from '@modelcontextprotocol/server';
import {
  NodeStreamableHTTPServerTransport,
  localhostHostValidation,
  localhostOriginValidation,
} from '@modelcontextprotocol/node';
import * as z from 'zod';

function buildServer() {
  const server = new McpServer({ name: 'tickets', version: '1.0.0' });
  server.registerTool(
    'echo',
    { description: 'Echo a word back', inputSchema: z.object({ word: z.string() }) },
    async ({ word }) => ({ content: [{ type: 'text', text: `you said ${word}` }] })
  );
  return server;
}

const MAX_BODY = 4 * 1024 * 1024; // keep equal to the transport's maxRequestBodySize (SDK default 4 MiB)

async function readJson(req) {
  const chunks = [];
  let size = 0;
  for await (const chunk of req) {
    size += chunk.length;
    if (size > MAX_BODY) return { tooLarge: true }; // stop reading early
    chunks.push(chunk);
  }
  const text = Buffer.concat(chunks).toString('utf8');
  try { return { body: text ? JSON.parse(text) : undefined }; } catch { return {}; }
}

function sendError(res, status, message) {
  res.writeHead(status, { 'content-type': 'application/json' });
  res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message }, id: null }));
}

const sessions = new Map(); // session id -> that session's own transport
const validateHost = localhostHostValidation();
const validateOrigin = localhostOriginValidation();

createServer(async (req, res) => {
  if (!validateHost(req, res) || !validateOrigin(req, res)) return;
  try {
    const id = req.headers['mcp-session-id'];
    if (id) {
      const transport = sessions.get(id);
      if (!transport) return sendError(res, 404, 'Session not found'); // client must start over
      return await transport.handleRequest(req, res);
    }
    // No session id: only an initialize request may start a new session.
    const read = req.method === 'POST' ? await readJson(req) : {};
    if (read.tooLarge) return sendError(res, 413, 'Request body too large');
    const body = read.body;
    if (!isInitializeRequest(body)) return sendError(res, 400, 'No session id');
    const transport = new NodeStreamableHTTPServerTransport({
      sessionIdGenerator: () => randomUUID(),
      onsessioninitialized: (newId) => sessions.set(newId, transport),
    });
    transport.onclose = () => sessions.delete(transport.sessionId);
    await buildServer().connect(transport); // one new server per session
    await transport.handleRequest(req, res, body);
  } catch (err) {
    console.error(err);
    if (!res.headersSent) sendError(res, 500, 'Internal error');
  }
}).listen(3000, '127.0.0.1');

We probed it on 2.2.0, 2.3.0 and 2.4.0. A forged Host header got 403, an unknown session id got 404, and a request with no session that was not initialize got 400. A 10 MiB initialize request got 413.

One gap remains for a real deployment: cleanup. In our run, both sessions were still in the map after the clients closed. Add an idle timeout that closes old transports, which also removes them from the map, and cap how many sessions can be open at once.

Pattern 3: createMcpHandler with a factory

createMcpHandler is the SDK's ready-made request handler. Pass it a function that returns a new McpServer, such as createMcpHandler(buildServer), and it builds a fresh one per request. On plain node:http, wrap it with toNodeHandler from @modelcontextprotocol/node.

javascript
import { createServer } from 'node:http';
import { McpServer, createMcpHandler } from '@modelcontextprotocol/server';
import {
  toNodeHandler,
  localhostHostValidation,
  localhostOriginValidation,
} from '@modelcontextprotocol/node';
import * as z from 'zod';

function buildServer() {
  const server = new McpServer({ name: 'tickets', version: '1.0.0' });
  server.registerTool(
    'echo',
    { description: 'Echo a word back', inputSchema: z.object({ word: z.string() }) },
    async ({ word }) => ({ content: [{ type: 'text', text: `you said ${word}` }] })
  );
  return server;
}

// Pass the function itself: the handler calls it to get a new server per request.
const handle = toNodeHandler(createMcpHandler(buildServer));
const validateHost = localhostHostValidation();
const validateOrigin = localhostOriginValidation();

createServer(async (req, res) => {
  if (!validateHost(req, res) || !validateOrigin(req, res)) return;
  await handle(req, res);
}).listen(3000, '127.0.0.1');

In our run it built one server per POST request: six for two clients using the default handshake, four for two clients using auto mode. It answers GET requests with HTTP 405, because this handler keeps no open stream per client.

Do not hand it a function that returns one fixed server you made earlier. From 2.3.0 the handler answers HTTP 500 when the factory returns an instance still serving another request, according to the fix's pull request.

How do you check that your MCP server is fixed?

Check the installed version, ask npm for known advisories, search for shared objects, then call one tool from two clients.

  1. Check the version

    Run npm ls @modelcontextprotocol/server in your project. You want 2.3.0 or later; 2.4.0 is the latest as of 26 October 2026.

  2. Ask npm for advisories

    Run npm audit. On 2.2.0 it lists GHSA-jm63-cq47-j5jp for @modelcontextprotocol/server 2.0.0 - 2.2.0, rated high. On 2.3.0 our project printed: found 0 vulnerabilities. That clears your version, not your settings; two fixes in this batch stay off until you switch them on.

  3. Search for shared instances

    Search your code for new McpServer, new Server, new NodeStreamableHTTPServerTransport and new WebStandardStreamableHTTPServerTransport. Any of these outside a request handler, factory or per-session block is a shared instance.

  4. Start the server

    Start your server on port 3000 locally after upgrading. A shared McpServer fails with ALREADY_CONNECTED, on the first client or, with sessions, on the second. A shared Node transport answers HTTP 500 and logs nothing.

  5. Call it from two clients

    Run the check script below with node check.mjs. Each client should get its own word back.

javascript
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';

for (const word of ['alpha', 'beta']) {
  const client = new Client({ name: `check-${word}`, version: '1.0.0' });
  try {
    await client.connect(new StreamableHTTPClientTransport(new URL('http://127.0.0.1:3000/')));
    const result = await client.callTool({ name: 'echo', arguments: { word } });
    console.log(`${word}: ${result.content[0].text}`);
  } catch (err) {
    console.log(`${word}: failed: ${err.message}`);
  }
  await client.close();
}

The expected output is two lines: "alpha: you said alpha" and "beta: you said beta". With the old shared pattern on 2.3.0, both lines instead end in "failed: fetch failed", because the server process has exited. A server shared across sessions passes alpha and fails beta, so read both lines. On 2.2.0 every unsafe setup passes, which is why the search in step 3 matters more than this script.

This check proves the pattern is accepted, not that the race is gone. Two sequential clients never overlap, which is exactly why 2.2.0 passed the same test.

To exercise overlap too, start many clients at once. This script runs 20 clients in parallel with Promise.all, each with its own word, and counts how many got their own word back.

javascript
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';

// 20 clients call the tool at the same moment, each with its own word.
const words = Array.from({ length: 20 }, (_, i) => `word${i}`);
const results = await Promise.all(words.map(async (word) => {
  const client = new Client({ name: `check-${word}`, version: '1.0.0' });
  await client.connect(new StreamableHTTPClientTransport(new URL('http://127.0.0.1:3000/')));
  const result = await client.callTool({ name: 'echo', arguments: { word } });
  await client.close();
  return result.content[0].text === `you said ${word}`;
}));
console.log(`${results.filter(Boolean).length} of ${words.length} clients got their own word back`);

So trust the structure more than the timing test: the right version, no shared objects in your search, and a server that fails loudly if one slips back in.

If you run a 2.0.0 alpha or beta, the advisory's range does not list it, because it starts at the 2.0.0 release. We checked the 2.0.0-beta.5 package source, and it has neither of the new guards. Treat any pre-release as unpatched.

What else was in the October 2026 MCP TypeScript SDK advisory batch?

Four more advisories for the same SDK were published in the SDK repository between 2 and 15 October and listed in the GitHub Advisory Database on 20 October 2026. Two of them add a protection that is off by default, so upgrading alone changes nothing for those two until you set it.

Two terms in the next table need a gloss first. A bearer token is the access key a client sends in the Authorization header. Its audience is the service it was issued for.

The other four advisories, from each advisory page in the SDK repository (checked 26 October 2026)
AdvisoryWhat went wrongFixed inWhat you must do
GHSA-46jr-4wx8-v453 (High, 7.5)One request inside the default 4 MiB body limit could exhaust server memory during input validationserver 2.3.0, sdk 1.32.0Set maxToolInputElements on McpServer and lower maxRequestBodySize; the limit is off by default
GHSA-rvq5-wwqv-78pq (Moderate, 6.8)Bearer auth accepted a token issued for another serviceserver 2.3.0, express 2.0.2, server-legacy 2.3.1, sdk 1.32.0Pass expectedResource to requireBearerAuth and return the token audience as AuthInfo.resource
GHSA-22jm-h49p-29qw (High, 8.6)Experimental tasks were not tied to the session that created themsdk 1.32.0; 2.x packages not affectedUpgrade, or remove taskStore; the advisory says experimental tasks are deprecated
GHSA-6prh-2h8m-c8cw (Moderate, 6.5)Client transports and OAuth requests followed cross-origin redirects, re-sending custom headers and, on 307 or 308 redirects, bodies that included client_secretclient 2.3.0, sdk 1.32.0Upgrade; do not set redirectPolicy to follow, which brings the old behaviour back

The audience fix has a trap. The advisory warns that if you pass expectedResource but do not return the token's audience from your verifier, every request gets HTTP 401.

A cross-origin redirect is when a server answers "go to this other site instead". The old client followed it and re-sent headers such as an API key, and OAuth token requests could re-send refresh_token and client_secret. If such a redirect may have reached a host you do not control, the advisory says to rotate API keys and client_secret and revoke tokens.

Has the MCP TypeScript SDK had this shared-instance bug before?

Yes, in the older package. GHSA-345p-7cg4-v4c7, published on 4 February 2026, described the same kind of leak in @modelcontextprotocol/sdk 1.10.0 to 1.25.3, fixed in 1.26.0.

We checked the 1.26.0 package: it refuses a second connect and a reused stateless transport. The 2.x server package, first released as 2.0.0 on 27 July 2026 (UTC), did not get the same guards until 2.3.0.

The lesson is about tests, not people. A guard that exists in one major version can be missing in the next. A test that fails when one server object is connected twice catches it whichever version you run; a timing test alone may not.

How do you explain the MCP shared-server bug in a system design interview?

Explain it as a state-ownership problem, then show the fix and the guard. Interviewers listen for whether you can say what state belongs to one connection and where it lives.

  1. Name the state

    A transport keeps a table from request id to open HTTP response. A server keeps a pointer to its current transport. Both belong to one connection.

  2. Show the collision

    Ids are chosen by each client and start from 0, so they are unique only within one connection. A table shared by many connections and keyed by id can collide whenever two requests with the same id are in flight.

  3. Give the fix

    Scope the objects to their owner: one server and transport per request for stateless HTTP, or one pair per session, held in a map keyed by session id.

  4. Add a guard

    Make misuse fail loudly. The SDK's ALREADY_CONNECTED error turns a silent, rare mix-up into an error the first time the object is reused.

  5. Mention the trade-off

    Building a server per request repeats tool registration on every call. Say you would measure that cost before optimising, and never optimise by sharing again.

  6. Close with testing

    Propose an overlap test with many clients, and say why it can pass by luck. Then add a structural test: connecting one server twice must throw.

Questions people ask

Is my stdio MCP server affected by GHSA-jm63-cq47-j5jp?
No. The advisory lists stdio servers as not affected. A stdio server talks to one client through one process's input and output, so there is no second client to mix up. None of the other advisories in this batch affects a stdio-only server either. Keep the SDK current anyway, so you are not stuck on an old version when you add HTTP later.
I use @modelcontextprotocol/sdk 1.x. Am I affected?
Not by this advisory if you run 1.26.0 or later, which already refuses shared instances. Below 1.26.0 you have the earlier leak, GHSA-345p-7cg4-v4c7. Also, three other advisories in the batch affect 1.x up to 1.31.0: the memory, token audience and redirect issues. The experimental tasks issue affects 1.24.0 to 1.31.0. Upgrade to 1.32.0 or later and apply the settings each advisory describes.
Why did my MCP server start crashing after upgrading to 2.3.0?
It is probably sharing one McpServer between requests. From 2.3.0 a second connect rejects with ALREADY_CONNECTED, and a plain node:http handler with no try and catch lets that rejection end the process. Express 5, Fastify and Hono return HTTP 500 instead. A shared Node stateless transport does not crash; it answers 500. Move server creation into the request handler or a per-session map.
Is upgrading to 2.3.0 enough to fix the MCP TypeScript SDK vulnerability?
No. Upgrading makes the unsafe pattern fail with an error instead of mixing up replies, so it stops the leak but also stops your server working. You must restructure the code to build a server and transport per request or per session. The advisory says that code change alone also fixes affected versions if you cannot upgrade yet.
Can I keep one McpServer and call close() between requests?
The fix lets an instance connect again after close(), but that does not make sharing safe. HTTP requests from different clients overlap, and one object can only serve one connection at a time. The advisory names three safe patterns: a factory with createMcpHandler, a new server per request, or one server per session. Use one of those.
Does building a new McpServer for every request slow my server down?
It adds work, because tools are registered each time the server is built. From node adapter 2.1.1, the SDK's README examples use this pattern, and createMcpHandler builds one server per request. Measure it on your own server before worrying. If you need state across requests, use sessions rather than going back to a shared instance.
How is this different from the MCP Python SDK advisories?
They are different packages with different bugs. This guide covers the TypeScript SDK on npm, and its main issue is shared server objects. The Python SDK on PyPI had its own batch of advisories in September and October 2026, covered in our separate guide. Check each SDK you use against its own advisories.

Sources and further reading

Practise this with Lala

Practise