MCP

MCP vulnerabilities in 2026: real bugs and how to fix them

If 2025 was the year everyone installed MCP servers, autumn 2026 is the year the bills arrived. In a few weeks the advisories piled up: an authentication bypass now on CISA's list of exploited vulnerabilities, a CVSS 9.9 remote code execution, SSRF in the reference fetch server, and a stack of fixes in both official SDKs.

The good news: none of these are exotic. Every one is a classic web security mistake, wearing an MCP badge. This post walks through the bugs (all checked against NVD and the GitHub advisories on 16 October 2026), what each one teaches, and the TypeScript fix. If you want the risk overview first, start with MCP security: the real risks.

The short version#

BugWhat went wrongFixed inLesson
LiteLLM, CVE-2026-59822Failed auth fell back to an empty user1.84.0Fail closed
Both SDKs, bearer authAccepted tokens issued for another serviceTS 1.32.0 / 2.3.0, Py 1.30.0 / 2.2.0Check the audience
Langflow, CVE-2026-105697Ran any command from an MCP server config1.10.3Never run config as code
mcp-server-fetch, CVE-2026-104120SSRF through the url argument2026.10.10Block internal addresses
x64dbg-MCP, CVE-2026-107824No auth, listening on 0.0.0.01.1Bind to localhost, require auth
Both SDKs, OAuth clientCredentials sent to a server-chosen auth serverTS 1.31.0 / 2.2.0, Py 1.30.0 / 2.2.0Upgrade your clients too

Step zero: upgrade the SDKs#

Several fixes landed in the official SDKs between late September and 15 October. For the TypeScript SDK they include the OAuth client sending credentials to an authorization server chosen by the MCP server (CVE-2026-104850), bearer authentication accepting tokens issued for another service (GHSA-rvq5-wwqv-78pq), HTTP clients re-sending custom headers and bodies across cross-origin redirects (GHSA-6prh-2h8m-c8cw), missing request size limits (GHSA-f897-62hf-6gxq), and, in v2, shared transports delivering one HTTP client's messages to another (GHSA-jm63-cq47-j5jp). The Python SDK fixed the same families of bugs.

SDKUpgrade to at leastLatest on 16 Oct 2026
TypeScript v1: @modelcontextprotocol/sdk1.32.01.32.1
TypeScript v2: @modelcontextprotocol/server, /client2.3.02.3.1
Python: mcp1.30.0 on 1.x, 2.2.0 on 2.x2.3.0
bash
npm ls @modelcontextprotocol/sdk @modelcontextprotocol/server
npm audit

Upgrading fixes the SDK. It does not fix the code you wrote around it, which is what the rest of this post is about.

1. Auth that fails open (LiteLLM)#

Before 1.84.0, LiteLLM's MCP endpoint could be reached with a fabricated Authorization header. The fabricated header triggered an OAuth passthrough fallback, and the fallback replaced the failed key check with an empty user object, so the request reached MCP tools without a valid key (advisory). CISA added it to its Known Exploited Vulnerabilities catalog on 2 September 2026, which means attackers were already using it.

The lesson is older than MCP: every failure path in authentication must end in "no". Not a default user, not a guest, not a fallback. One function, one exit for success:

ts
type Claims = { sub: string; aud: string | string[] };
type Verify = (token: string) => Promise<Claims>;

export async function authenticate(
  header: string | undefined,
  verify: Verify,
  audience: string,
): Promise<Claims | null> {
  const token = header?.match(/^Bearer (\S+)$/)?.[1];
  if (!token) return null;
  try {
    const claims = await verify(token);
    const aud = [claims.aud].flat();
    return aud.includes(audience) ? claims : null;
  } catch {
    return null; // never fall back to a default user
  }
}

verify is your JWT library's verification call (signature, expiry, issuer). The function returns claims or null, and nothing else.

2. Tokens issued for someone else (both SDKs)#

Look at the aud line above. Both official SDKs shipped fixes because their bearer authentication accepted tokens that were valid, but issued for a different service. The MCP security best practices are blunt: servers MUST NOT accept tokens that were not explicitly issued for them. A valid signature only proves someone issued the token. The audience proves it was issued for you.

3. Commands from config (Langflow, CVSS 9.9)#

Before 1.10.3, Langflow launched whatever command and args a user put into an MCP server configuration, with no allowlist, wrapped in bash -c. Anyone who could edit MCP server settings could run any OS command on the host. With the default LANGFLOW_AUTO_LOGIN=true, an exposed instance handed out a token without credentials, so on a default setup no account was needed (CVE-2026-105697).

An MCP stdio "server" is a program you run. A config field that names a program is code execution with extra steps. If your app lets users add servers, launch only configurations someone approved, and never through a shell:

ts
export interface ServerConfig {
  command: string;
  args: string[];
}
const APPROVED: ServerConfig[] = [
  { command: "node", args: ["servers/repo-tools.js"] },
];
export const isApproved = (c: ServerConfig) =>
  APPROVED.some(
    (a) =>
      a.command === c.command &&
      a.args.length === c.args.length &&
      a.args.every((arg, i) => arg === c.args[i]),
  );

Then start it with spawn(cfg.command, cfg.args, { shell: false }). Compare the whole argument list: node servers/repo-tools.js --eval=... is not the approved server, and npx -y some-package is "run whatever that package is today." The spec's guidance for clients points the same way: show the exact command, untruncated, and get consent before running it.

4. SSRF in the fetch server#

The reference mcp-server-fetch, up to release 2026.6.4, let the url argument point anywhere, including internal services and cloud metadata endpoints (CVE-2026-104120). The 2026.10.10 release on PyPI adds a guard: it refuses private, loopback and link-local destinations unless you pass --allow-private-ips. (I checked the published package, not just the repository.)

The URL a tool receives is model output, and model output can be steered by prompt injection. If your server fetches URLs, resolve the host and refuse internal addresses:

ts
import { BlockList, isIP } from "node:net";
import { lookup } from "node:dns/promises";

const blocked = new BlockList();
for (const [net, bits] of [
  ["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10],
  ["127.0.0.0", 8], ["169.254.0.0", 16], ["172.16.0.0", 12],
  ["192.168.0.0", 16], ["224.0.0.0", 3],
] as const) blocked.addSubnet(net, bits, "ipv4");
for (const [net, bits] of [
  ["::", 127], ["fc00::", 7], ["fe80::", 10], ["ff00::", 8],
] as const) blocked.addSubnet(net, bits, "ipv6");

function isBlocked(ip: string): boolean {
  const v4 = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i)?.[1];
  if (v4) return blocked.check(v4, "ipv4");
  return blocked.check(ip, isIP(ip) === 6 ? "ipv6" : "ipv4");
}
ts
export async function assertPublic(raw: string): Promise<URL> {
  const url = new URL(raw);
  if (!["http:", "https:"].includes(url.protocol)) {
    throw new Error(`blocked scheme: ${url.protocol}`);
  }
  const host = url.hostname.replace(/^\[|\]$/g, "");
  const addrs = isIP(host)
    ? [host]
    : (await lookup(host, { all: true })).map((a) => a.address);
  const bad = addrs.find(isBlocked);
  if (bad) throw new Error(`blocked address: ${host} -> ${bad}`);
  return url;
}

Then never let the HTTP client follow redirects on its own. A public URL that redirects to 169.254.169.254 is the oldest SSRF trick there is, and the spec's SSRF section lists redirect chains explicitly:

ts
/** Fetch that re-checks every redirect hop instead of trusting it. */
export async function safeFetch(raw: string, maxHops = 3) {
  let url = await assertPublic(raw);
  for (let hop = 0; hop <= maxHops; hop++) {
    const res = await fetch(url, { redirect: "manual" });
    const next = res.headers.get("location");
    if (res.status < 300 || res.status > 399 || !next) return res;
    url = await assertPublic(new URL(next, url).href);
  }
  throw new Error(`too many redirects from ${raw}`);
}

The tests block, among others: localhost, 169.254.169.254, [::1], [::ffff:127.0.0.1] (IPv4 hiding in IPv6), [fd00::1], file:///etc/passwd, and http://2130706433/, which is 127.0.0.1 written as a decimal number. The URL parser normalizes that one for you, which is exactly why you check the parsed host, not the string.

One honest limit: checking DNS and then fetching resolves the name twice, so a hostile DNS server can answer differently the second time (DNS rebinding). For servers that can reach sensitive networks, pin the resolved address or send all traffic through an egress proxy that enforces the same rules.

5. A debugger on the network (x64dbg-MCP)#

Before 1.1, the x64dbg MCP plugin exposed every debugger tool over HTTP with no authentication, listening on 0.0.0.0 by default. Anyone who could reach the port could attach to processes, read and write memory, and write files anywhere (CVE-2026-107824).

This is the exact scenario the spec's transport rules exist for: validate Origin, bind local servers to 127.0.0.1, and authenticate every connection. With the authenticate function from section 1:

ts
import { createServer, type IncomingMessage } from "node:http";

export function mcpHttpServer(verify: Verify, audience: string) {
  const origins = new Set(["http://localhost:3000"]);
  const ok = (req: IncomingMessage) =>
    !req.headers.origin || origins.has(req.headers.origin);
  return createServer(async (req, res) => {
    if (!ok(req)) return void res.writeHead(403).end();
    const auth = req.headers.authorization;
    const who = await authenticate(auth, verify, audience);
    if (!who) return void res.writeHead(401).end();
    res.writeHead(200).end(`hello ${who.sub}`); // MCP handler here
  });
}

mcpHttpServer(verify, "https://mcp.example.com")
  .listen(3000, "127.0.0.1"); // not 0.0.0.0

In the tests, a real server answers 401 with no token, with a forged token and with a valid token for another audience, 403 for a foreign Origin, and 200 only for a valid token issued for this server.

A checklist for your MCP code this week#

  • SDKs at least at the versions in the table above, and npm audit clean.
  • Every auth path ends in null except the one that verified the token and its audience.
  • No server config field is ever executed unless it matches an approved command exactly.
  • Any tool that fetches URLs blocks internal addresses and re-checks redirects.
  • HTTP servers bind to 127.0.0.1 locally, check Origin and require auth.
  • You know which MCP servers your team runs, and who updates them.

How this was tested#

All the TypeScript above was type-checked and run under Node 24's test runner: 7 tests covering the blocked and allowed addresses, a redirect to the metadata address, Node's fetch exposing the Location header with redirect: "manual", the approved-command check, the auth function, and a real HTTP server on localhost. The vulnerability details come from NVD, the vendors' GitHub advisories and, for the fetch server, the published PyPI package. Advisories keep coming, so check the SDK security pages before you trust a "latest version" from any blog post, including this one.

Sources: MCP security best practices (spec 2026-07-28), TypeScript SDK advisories, Python SDK advisories, CVE-2026-59822, CVE-2026-105697, CVE-2026-104120, CVE-2026-107824.

Frequently asked questions

Which MCP SDK versions fix the 2026 advisories?

As of 16 October 2026: for the TypeScript SDK, @modelcontextprotocol/sdk 1.32.0 or newer on v1, and 2.3.0 or newer for the v2 packages. For Python, the mcp package 1.30.0 or newer on 1.x, or 2.2.0 or newer on 2.x. Check the advisories for anything published after that date.

Is the official MCP fetch server safe to use?

Releases up to 2026.6.4 were affected by an SSRF vulnerability (CVE-2026-104120). The 2026.10.10 release on PyPI includes a guard that refuses private, loopback and link-local destinations unless you pass --allow-private-ips. Upgrade, and do not pass that flag on servers that can reach internal networks.

What does it mean that an MCP CVE is on CISA's KEV list?

CISA's Known Exploited Vulnerabilities catalog lists flaws with evidence of exploitation in the wild. LiteLLM's MCP authentication bypass, CVE-2026-59822, was added on 2 September 2026. If you run LiteLLM's MCP endpoint, upgrade to 1.84.0 or newer.

Why must an MCP server check the token audience?

A token issued for another service can be replayed against your server if you only check that it is valid. The MCP specification says servers must not accept tokens that were not explicitly issued for them, which means checking the audience claim.

How do I protect an MCP server from SSRF?

Resolve the target host, refuse private, loopback, link-local and metadata addresses, and re-check every redirect hop instead of letting the HTTP client follow redirects. For full protection against DNS rebinding, pin the resolved address or send traffic through an egress proxy.