Safeguard
Supply Chain Security•

MCP Python SDK CVE-2026-59951: Capping Streamable HTTP Sessions Before They Cap Your Server

MCP Python SDK before 1.30.0 and 2.2.0 never freed Streamable HTTP sessions. What we measured, how to size the new limits, and a server checker.

Safeguard Security Team
Security Research
12 min read

If you run an MCP server on the official Python SDK over Streamable HTTP, versions 1.8.0 to 1.29.1 and 2.0.0 to 2.1.1 never removed a session. Sessions didn't expire, a client's DELETE ended a session but left it registered, and there was no cap on how many could exist. Anyone who could reach the endpoint could grow the server's memory with ordinary initialize requests until a restart. This is CVE-2026-59951 (GHSA-84m7-p3x7-pcfv), rated medium by GitHub, with a CVSS 3.1 score of 7.5 and a CVSS 4.0 score of 6.9. mcp 1.30.0 and 2.2.0, released on 7 September, fixed it by adding idle expiry and a session cap, both on by default.

This guide is for teams running Python MCP servers that agents or users reach over HTTP. It covers what stateful mode keeps per session, what we measured before and after the fix, how to size the two new settings, when stateless mode is the better answer, and a script that checks your pins and server code.

The short answer

Upgrade mcp to 1.30.0 or 2.2.0 or later. The current release is 2.3.0. If your server doesn't need per-session state, set stateless_http=True, which needs 1.12.0 or later and keeps no sessions at all. If it does, keep the new defaults or tighten them: session_idle_timeout (default 1,800 seconds) and max_sessions (default 10,000). Don't set either to None. Servers that only use stdio or SSE are not affected. Authentication limits who can open sessions, not how many each token holder can open.

What a Streamable HTTP session holds

Streamable HTTP is the MCP transport for remote servers. In stateful mode, which is the default for FastMCP in 1.x, MCPServer in 2.x and StreamableHTTPSessionManager in both, the first initialize request creates a session. The server returns an mcp-session-id header, and the client sends it on every later request. Behind that ID, the session manager keeps a transport object and a running task, and the session keeps the client's parsed initialize parameters.

The advisory lists three ways those entries piled up in affected versions. Sessions had no expiry. A DELETE, which is how a well-behaved client ends a session, closed the session but didn't remove its entry. And a session whose first request was refused stayed registered with its task still running. On top of that, nothing limited the number of sessions.

What we measured

We ran a minimal FastMCP server with one tool on 16 October, opened 2,000 sessions with a standard initialize request, sent a DELETE for each, and counted the entries left in the session manager:

Version and settingsResponsesRegistered after openingRegistered after DELETE
1.29.1, defaults2,000 × 2002,0002,000
1.29.1, stateless_http=True2,000 × 20000
1.30.0, defaults2,000 × 2002,0000
1.30.0, max_sessions=500 (600 requests)500 × 200, 100 × 5035000

On 1.29.1, 2,000 small sessions added about 111 MiB to the process, and none of it was ever released. With session_idle_timeout=5, 50 idle sessions on 1.30.0 were all gone after 8 seconds, and a request with an expired ID got a 404. Python doesn't hand freed memory straight back to the operating system, so resident memory stayed high after the 1.30.0 sessions were removed. What matters is that the entries stop accumulating, which is what turns a slow leak into a restart.

We aren't publishing the load script. Opening sessions in a loop is all it takes, and the advisory describes it.

Sizing the two settings

The fix bounds retention. It doesn't eliminate it, and the advisory says so: a live session still holds its initialize parameters, up to roughly twice the 4 MiB request body limit, and open streams or in-flight requests keep a session alive past the idle timeout. So the cap matters more than the timeout. A client that holds a stream open never goes idle.

That gives a simple upper bound for the memory sessions can pin:

max_sessions × 2 × max_request_body_size

With the defaults, 10,000 × 2 × 4 MiB is about 78 GiB. Real sessions are far smaller, about 57 KiB each in our test, but an attacker chooses the size of their own initialize. On a small host, lower all three, as the advisory recommends. A server for one team's agents might use:

# mcp 1.30.0 or later (1.x)
from mcp.server.fastmcp import FastMCP

mcp = FastMCP(
    "docs",
    max_sessions=200,
    session_idle_timeout=300,
    max_request_body_size=256 * 1024,
)

# mcp 2.2.0 or later: the same keywords go on run() or streamable_http_app()
# server.run(transport="streamable-http", max_sessions=200,
#            session_idle_timeout=300, max_request_body_size=256 * 1024)

That bounds sessions at about 100 MiB in the worst case. max_sessions is a server-wide count, so one client can still fill it and lock others out with 503s until sessions expire. Put per-client rate limits in front of the endpoint as well. Our post on MCP server rate-limiting patterns covers the options.

Shorter timeouts have a cost. The 1.30.0 release notes say a client whose session expired gets a 404 and has to initialize again, and that clients keeping the GET stream open, as the SDK's own client does, aren't affected. Test your clients against a short timeout before you ship it.

Stateless mode is often the better answer

Many MCP servers don't need sessions at all: a tool call comes in, the server answers, nothing carries over. For those, stateless_http=True removes the problem rather than bounding it. Our 1.29.1 run in stateless mode registered nothing. The trade-off, in the SDK's own words, is that each request gets "a completely fresh transport" with no session tracking, so anything you've built on per-session state won't carry over. If your tools answer each call on its own, stateless is the simpler choice, and the advisory names it as the workaround for anyone who can't upgrade.

Check your servers

This script reads mcp pins from requirements files, pyproject.toml, uv.lock, poetry.lock and Pipfile.lock, then parses Python files for FastMCP, MCPServer and StreamableHTTPSessionManager, and for run(transport="streamable-http") and streamable_http_app() calls. For each server it reports whether it's stateless, which session settings it sets, and the upper bound those settings allow. It matches each server to the nearest pinned version. It uses only the Python standard library and needs Python 3.11 or later.

#!/usr/bin/env python3
"""Check Python MCP servers for CVE-2026-59951 (GHSA-84m7-p3x7-pcfv).

Reads pinned mcp versions from requirements files, pyproject.toml and
uv/poetry/Pipfile locks, then parses server code for Streamable HTTP
servers and their session settings. Standard library only, Python 3.11+.

  python3 mcp_session_check.py DIR [--installed]
"""
import ast, json, os, re, sys, tomllib

SKIP = {".git", ".venv", "venv", "node_modules", "__pycache__", "site-packages", "dist", "build"}
DEFAULT_BODY = 4 * 1024 * 1024
DEFAULT_MAX_SESSIONS = 10_000
SERVER_CLASSES = {"FastMCP", "MCPServer", "StreamableHTTPSessionManager"}


def vt(v):
    return tuple(int(x) for x in re.findall(r"\d+", v)[:3]) + (0,) * 3


def classify(v):
    t = vt(v)[:3]
    if t < (1, 8, 0):
        return "not affected (no Streamable HTTP before 1.8.0)"
    if t < (1, 30, 0):
        return "VULNERABLE, upgrade to 1.30.0"
    if (1, 30, 0) <= t < (2, 0, 0) or t >= (2, 2, 0):
        return "fixed"
    return "VULNERABLE, upgrade to 2.2.0"


def pins(root):
    out = []
    for dirpath, dirs, files in os.walk(root):
        dirs[:] = [d for d in dirs if d not in SKIP]
        for f in files:
            p = os.path.join(dirpath, f)
            rel = os.path.relpath(p, root)
            try:
                if re.match(r"requirements.*\.txt$", f) or f == "constraints.txt":
                    for line in open(p):
                        m = re.match(r"\s*mcp(\[[^\]]*\])?\s*==\s*([\w.]+)", line)
                        if m:
                            out.append((rel, m.group(2)))
                elif f in ("uv.lock", "poetry.lock"):
                    for pkg in tomllib.load(open(p, "rb")).get("package", []):
                        if pkg.get("name") == "mcp":
                            out.append((rel, pkg["version"]))
                elif f == "Pipfile.lock":
                    for sect in json.load(open(p)).values():
                        if isinstance(sect, dict) and "mcp" in sect:
                            out.append((rel, sect["mcp"].get("version", "").lstrip("=")))
                elif f == "pyproject.toml":
                    deps = tomllib.load(open(p, "rb")).get("project", {}).get("dependencies", [])
                    for d in deps:
                        m = re.match(r"mcp(\[[^\]]*\])?\s*==\s*([\w.]+)", d)
                        if m:
                            out.append((rel, m.group(2)))
            except (OSError, ValueError, tomllib.TOMLDecodeError):
                pass
    return out


def literal(node):
    if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Mult, ast.Add)):
        left, right = literal(node.left), literal(node.right)
        if isinstance(left, int) and isinstance(right, int):
            return left * right if isinstance(node.op, ast.Mult) else left + right
    try:
        return ast.literal_eval(node)
    except ValueError:
        return ast.unparse(node)


def servers(root):
    found = []
    for dirpath, dirs, files in os.walk(root):
        dirs[:] = [d for d in dirs if d not in SKIP]
        for f in files:
            if not f.endswith(".py"):
                continue
            p = os.path.join(dirpath, f)
            try:
                tree = ast.parse(open(p).read(), p)
            except (SyntaxError, UnicodeDecodeError):
                continue
            ctors, transports = [], []
            for n in ast.walk(tree):
                if not isinstance(n, ast.Call):
                    continue
                name = n.func.attr if isinstance(n.func, ast.Attribute) else getattr(n.func, "id", "")
                kw = {k.arg: literal(k.value) for k in n.keywords if k.arg}
                if name in SERVER_CLASSES:
                    ctors.append((n.lineno, name, kw))
                elif name == "run" and "transport" in kw or name == "streamable_http_app":
                    t = kw.get("transport", "streamable-http") if name == "run" else "streamable-http"
                    transports.append((n.lineno, t, kw))
            if ctors or transports:
                found.append((os.path.relpath(p, root), ctors, transports))
    return found


def size(n):
    for unit, scale in (("GiB", 2**30), ("MiB", 2**20), ("KiB", 2**10)):
        if n >= scale:
            return f"{n / scale:.1f} {unit}".replace(".0 ", " ")
    return f"{n} B"


def review(kw, version):
    notes = []
    if kw.get("stateless_http") is True or kw.get("stateless") is True:
        return ["stateless: no sessions kept, not affected"]
    if version and classify(version).startswith("VULNERABLE"):
        return [f"stateful on mcp {version}: sessions never expire and are never removed, AFFECTED"]
    idle = kw.get("session_idle_timeout", "default 1800s")
    cap = kw.get("max_sessions", DEFAULT_MAX_SESSIONS)
    body = kw.get("max_request_body_size", DEFAULT_BODY)
    notes.append(f"stateful, session_idle_timeout={idle}, max_sessions={cap}")
    if idle is None:
        notes.append("WARN idle expiry disabled")
    if cap is None:
        notes.append("WARN session cap disabled")
    elif isinstance(cap, int) and isinstance(body, int):
        notes.append(f"upper bound held by initialize params ~{size(cap * 2 * body)} "
                     f"({cap} sessions x 2 x {size(body)} body limit)")
    return notes


def main():
    root = sys.argv[1] if len(sys.argv) > 1 else "."
    bad = 0
    print("mcp versions:")
    found = pins(root)
    if "--installed" in sys.argv:
        from importlib.metadata import version, PackageNotFoundError
        try:
            found.append(("this environment", version("mcp")))
        except PackageNotFoundError:
            pass
    for where, v in found:
        c = classify(v)
        bad += c.startswith("VULNERABLE")
        print(f"  {where}: mcp {v}: {c}")
    if not found:
        print("  no pinned mcp version found")
    def version_for(rel):
        d = os.path.dirname(rel)
        while True:
            for where, v in found:
                if os.path.dirname(where) == d:
                    return v
            if not d:
                return None
            d = os.path.dirname(d)

    print("Servers:")
    for rel, ctors, transports in servers(root):
        http = [t for t in transports if t[1] in ("streamable-http", "http")]
        for line, cls, kw in ctors:
            if cls == "StreamableHTTPSessionManager" or http:
                merged = dict(kw)
                for _, _, tkw in http:
                    merged.update(tkw)
                print(f"  {rel}:{line} {cls}: " + "; ".join(review(merged, version_for(rel))))
            else:
                kinds = sorted({t[1] for t in transports}) or ["stdio (default)"]
                print(f"  {rel}:{line} {cls}: transport {', '.join(kinds)}, not affected")
    sys.exit(1 if bad else 0)


if __name__ == "__main__":
    main()

We ran it on 16 October against a fixture repository with six MCP servers. The lockfiles are hand-written samples:

$ python3 mcp_session_check.py agents
mcp versions:
  tickets/requirements.txt: mcp 1.29.1: VULNERABLE, upgrade to 1.30.0
  docs/requirements.txt: mcp 1.30.0: fixed
  search/requirements.txt: mcp 1.29.1: VULNERABLE, upgrade to 1.30.0
  local/requirements.txt: mcp 1.30.0: fixed
  billing/uv.lock: mcp 2.1.1: VULNERABLE, upgrade to 2.2.0
  reports/uv.lock: mcp 2.3.0: fixed
Servers:
  tickets/server.py:3 FastMCP: stateful on mcp 1.29.1: sessions never expire and are never removed, AFFECTED
  docs/server.py:3 FastMCP: stateful, session_idle_timeout=300, max_sessions=200; upper bound held by initialize params ~100 MiB (200 sessions x 2 x 256 KiB body limit)
  search/server.py:3 FastMCP: stateless: no sessions kept, not affected
  local/server.py:3 FastMCP: transport stdio (default), not affected
  billing/server.py:3 MCPServer: stateful on mcp 2.1.1: sessions never expire and are never removed, AFFECTED
  reports/app.py:6 MCPServer: stateful, session_idle_timeout=default 1800s, max_sessions=None; WARN session cap disabled

Two lines are worth reading closely. search is on a vulnerable version but stateless, so it was never exposed. reports is on the fixed 2.3.0 but sets max_sessions=None, which switches the cap off and brings back unbounded growth for any client that keeps its streams open.

What to do

  1. Upgrade to mcp 1.30.0 or 2.2.0 or later, and check indirect use with pip show mcp or uv tree, since agent frameworks pull the SDK in.
  2. Go stateless where you can, with stateless_http=True.
  3. Size the limits for the host. Keep max_sessions and session_idle_timeout set, and lower max_request_body_size if your initialize traffic is small.
  4. Never set None on either setting for a server that untrusted clients can reach.
  5. Watch session counts and 503s. A rising count with no matching traffic, or 503s for real clients, means someone is filling the cap.

The fair view

This is a resource leak, not a data breach. It needs network access to the endpoint, and many MCP servers run locally over stdio or sit behind authentication with a handful of known clients. The maintainers fixed it with sensible defaults, documented the remaining limits plainly, and the advisory's "Am I affected?" section answers the question in a few lines.

The timeline is the familiar part. The fixed releases shipped on 7 September, listed in the release notes under "Behaviour changes", with no mention of a vulnerability. The repository advisory followed on 30 September, the CVE record on 14 October, and GitHub's reviewed advisory and OSV on 15 October. pip-audit and other tools that read PyPI's vulnerability data or OSV had nothing to report for over five weeks after the fix. For remote MCP servers, the wider lesson is that a session is server memory a client can create on demand. Count it, cap it, and expire it, whatever the SDK's defaults. The advisory credits rharang, evilgensec, Madan301, subchat and nickelsec with reporting the issue.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.