Skip to content

fix(transport): stop keeping a session and GET stream at 2026-07-28 - #1256

Open
LizunovSergey wants to merge 4 commits into
modelcontextprotocol:mainfrom
LizunovSergey:fix/1108-sessionless-at-modern-version
Open

LizunovSergey wants to merge 4 commits into
modelcontextprotocol:mainfrom
LizunovSergey:fix/1108-sessionless-at-modern-version

Conversation

@LizunovSergey

Copy link
Copy Markdown

Closes #1108.

What was wrong

SEP-2567 removes sessions and the standalone GET endpoint at 2026-07-28, so an Mcp-Session-Id and a GET stream are both artifacts of a pre-2026-07-28 server shape. The modern path already gets this right — a server/discover bootstrap sets session_id to None unconditionally. The legacy-shaped paths did not: all three spawn_common_stream call sites were guarded only on if let Some(session_id), never on negotiated_version.

  • Legacy startup. A server that answers legacy initialize with both a session id and protocolVersion: 2026-07-28 got a GET stream.
  • Fallback initialize after server/discover fails, on the initialized notification.
  • Session re-establishment after an expired-session 404, which respawned on the new id without reconsulting the version.

How

The gate goes where a session id is adopted, not at each spawn. session_id_for_version drops the id once the negotiated version has no sessions, and because all three spawn sites are already guarded on the id being present, they are left with nothing to open — no change needed at any of them. The id also stops being echoed on requests.

That makes PeerRequestAssociation::Unassociated unreachable over streamable HTTP rather than merely rejected, and the receive-side enforcement from #1055 stays as the defence for anything that still slips through.

The two open questions from the issue

Both needed a decision to write the change at all, so here is what I picked and why. Happy to move either way.

1. Server returns a session id and negotiates 2026-07-28. The issue weighed dropping it silently against failing startup. I went with dropping it plus a tracing::warn!, rather than either extreme. Failing would break clients against servers that work today over a SHOULD-level hardening, which seemed too blunt; dropping in complete silence hides a real server bug from whoever has to debug it. A warning naming the negotiated version puts the evidence in the log without changing whether the client connects.

2. Should session cleanup be skipped too? I kept it. SessionCleanupInfo is still built from the id the server sent, so the shutdown DELETE still fires and a session the server really did create gets torn down. Skipping cleanup would leak server-side state as the price of a client-side conformance fix, which felt like the wrong trade. The ordering in the diff is deliberate: the cleanup record is built first, then the id is dropped for request and stream purposes.

I did not touch #863; if there is deliberately no session at 2026-07-28, that accessor is a legacy-only affordance, but saying so belongs in that issue.

Tests

New tests/test_streamable_http_sessionless_version.rs, a scripted server that answers legacy initialize with an Mcp-Session-Id header and a configurable protocolVersion, recording every request as (HTTP method, JSON-RPC method, session header):

  • modern_version_drops_the_session_and_opens_no_stream — at 2026-07-28, no GET request arrives at all and no post-handshake POST carries the header.
  • legacy_version_keeps_the_session_and_opens_the_stream — at 2025-11-25, exactly one GET arrives, it carries the session id, and the id is echoed afterwards.

Both are witnesses, checked by mutation. Removing the three gating lines fails modern_version_drops_the_session_and_opens_no_stream while legacy_version_keeps_the_session_and_opens_the_stream still passes — so the legacy assertion is genuinely pinning the unchanged path rather than passing by accident.

Also green, unchanged: test_streamable_http_stale_session (4, and it covers the recovery path this touches), test_client_lifecycle_modes (15), test_server_discover_http (12), test_streamable_http_priming (5), test_discover_http_client_startup (3). cargo +nightly fmt --all --check clean; cargo clippy -- -D warnings clean on the lib and on the new test.

SEP-2567 removes sessions and the standalone GET endpoint at
2026-07-28, so an Mcp-Session-Id and a GET stream are both artifacts of
a pre-2026-07-28 server shape. The modern startup path already gets
this right: a server/discover bootstrap sets session_id to None
unconditionally. The legacy-shaped paths did not, and all three
spawn_common_stream call sites were guarded only on a session id being
present, never on the negotiated version:

  - legacy startup, where a server can answer legacy initialize with
    both a session id and protocolVersion 2026-07-28;
  - the fallback initialize taken after server/discover fails;
  - session re-establishment after an expired-session 404, which
    respawned on the new id without reconsulting the version.

Gating happens where a session id is adopted rather than at each
spawn: session_id_for_version drops the id once the negotiated version
has no sessions, which leaves all three call sites with nothing to open
and stops the id being echoed on requests. PeerRequestAssociation
Unassociated then becomes unreachable over streamable HTTP instead of
merely rejected, and the receive-side enforcement stays as the defence
for anything that still slips through.

A volunteered id is dropped with a warning rather than being treated as
fatal, and the id is still recorded for the shutdown DELETE, so a
session the server really did create is torn down.
@LizunovSergey
LizunovSergey requested a review from a team as a code owner September 10, 2026 22:03
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@github-actions github-actions Bot added T-dependencies Dependencies related changes T-test Testing related changes T-config Configuration file changes T-core Core library changes T-transport Transport layer changes labels Sep 10, 2026
The harness reached an unassociated stream via the standalone GET stream,
which required the client to keep a session while negotiating 2026-07-28.
That combination no longer exists, so the scenario was unreachable and the
test timed out waiting for a rejection.

Enforcement is strict only at >= 2026-07-28, so dropping to a legacy
version would have disabled the very check under test. Use the SSE body of
a post-startup notification POST instead: it carries no request id, so the
stream is Unassociated at a strict protocol version, with an unrelated
request still in flight. The scripted server also stops minting a session
it is not entitled to at that version.
@LizunovSergey

Copy link
Copy Markdown
Author

CI went red on test_sep_2260_stream_enforcement::restricted_request_on_get_stream_rejected_while_unrelated_request_in_flight, and it was a real consequence of this change rather than flake. Pushed a fix; here is the reasoning, since it touches a test from #1033 rather than my own.

Why it broke. That harness reached an unassociated stream through the standalone GET stream, which required the client to hold a session while negotiating 2026-07-28. Its module doc says so explicitly — "rmcp's client tolerates the session id and opens the standalone GET stream — so this is the reachable path where the client has BOTH a GET stream and strict SEP-2260 enforcement." This PR is precisely what removes that tolerance, so the scenario stopped being reachable and the test sat waiting for a rejection that no longer had a stream to arrive on. It is also why only Run Tests (no local feature) caught it: the file is #![cfg(not(feature = "local"))], so it is the one job that compiles it.

Why not just negotiate a legacy version. That was my first instinct and it is wrong — enforce_peer_request_association only tightens at >= V_2026_07_28, so dropping to 2025-11-25 would have switched off the very check under test while leaving it green.

What I did instead. Kept the protocol at 2026-07-28 and moved the unassociated stream to the SSE body of a post-startup notification POST. A POST carrying no request id gets InboundStreamOrigin::Unassociated, so the stream is unassociated by construction, at a strict version, with an unrelated tools/list still in flight — the #1033 scenario intact, minus the session. The scripted server also stops minting a session id it is not entitled to at that version, so it now models a conforming server. One wrinkle worth recording: notifications/initialized cannot be the carrier, because startup treats a stream there as fatal (expect accepted or json, got Sse(None)); it has to be a notification sent after the handshake.

Evidence. Both tests in the file pass. The renamed test is still a witness by mutation — disabling the strict gate in enforce_peer_request_association fails it while the positive twin (restricted_request_on_originating_post_stream_is_dispatched) keeps passing, so it is pinning the enforcement and not the plumbing. The whole no local feature feature set is green locally (the same set CI computes, 0 failures), and cargo +nightly fmt --all --check and cargo clippy --all-targets -- -D warnings are clean.

If you would rather keep that test pinned to the legacy shape and assert the GET stream is gone separately, say the word and I will split it that way instead.

auth_header: config.auth_header.clone(),
protocol_headers: protocol_headers.clone(),
});
session_id = session_id_for_version(session_id, &negotiated_version);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This gate runs after perform_reinitialization has already POSTed notifications/initialized with new_session_id. So during recovery, if it negotiates 2026-07-28, that request still echoes the ID. Should the gate be applied inside perform_reinitialization?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — you're right, and it was a real hole. Fixed in d36ce25.

perform_reinitialization POSTs notifications/initialized itself, so the handshake is already complete by the time it returns and the caller's gate was too late for that one request. The startup path doesn't have this problem because its gate sits above its own initialized POST; the recovery path just wasn't matched to it.

So I moved the gate inside perform_reinitialization, directly after version negotiation and before the notification goes out. The fallback-initialize path was already fine — its gate precedes the notification, which goes through the normal message loop.

The one wrinkle is session cleanup. The caller builds SessionCleanupInfo from the id before gating on purpose, so the shutdown DELETE still tears down a session the server really created — if the gate simply moved inward and returned None, that teardown would be lost. So the function now returns a small Reinitialized struct carrying both the id as sent (cleanup_session_id, for the DELETE) and the gated one (session_id, for requests and streams). A struct rather than a 4-tuple because two Option<Arc<str>> positions next to each other are easy to swap by accident.

Two new tests in test_streamable_http_sessionless_version.rs, both driven through a real expired-session 404 so the recovery path actually runs:

  • replacement_handshake_at_a_modern_version_drops_the_session_too — starts on a 2025-11-25 session, server 404s a tools/list, replacement handshake answers with a new session id and 2026-07-28. Asserts the initialized notification carries no session header, that nothing afterwards does, that no GET stream opens on the replacement session, and that the shutdown DELETE still carries the id.
  • replacement_handshake_at_a_legacy_version_keeps_its_session — same route ending at 2025-11-25, pinning that the legacy recovery path is unchanged: the id is echoed and each session gets its GET stream.

Both checked by mutation. Restoring the old ordering fails the first on exactly your scenario (left: Some("session-issued-by-the-replacement-handshake"), right: None) while the other three still pass; separately, building cleanup from the gated id instead fails the DELETE assertion. The handler also records the teardown DELETE now instead of trying to parse its empty body as JSON, which was panicking in the server task.

Green and unchanged: test_streamable_http_stale_session (4), test_client_lifecycle_modes (15), test_server_discover_http (12), test_streamable_http_priming (5), test_discover_http_client_startup (3). cargo +nightly fmt --all --check clean; clippy clean on the lib and the test target.

The gate was applied to the id `perform_reinitialization` returned, but that
function has already POSTed `notifications/initialized` on the new session by
then. At 2026-07-28 that one request still echoed an Mcp-Session-Id.

Move the gate inside `perform_reinitialization`, where the startup path already
has it relative to its own `initialized` notification, and return the id as the
server sent it alongside the gated one so the shutdown DELETE is unaffected.
Records the shutdown DELETE instead of parsing its empty body as JSON, so the
teardown is assertable rather than a panic in the server task.
auth_header: config.auth_header.clone(),
protocol_headers: protocol_headers.clone(),
});
session_id = session_id_for_version(session_id, &negotiated_version);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Starting the client in ClientLifecycleMode::Auto with the existing recorder takes the fallback path and fails once this line is removed. Would you parameterize the startup tests by lifecycle mode, or add a dedicated pair of fallback tests?

Comment on lines 1598 to 1599
if inline_version.is_some() {
negotiated_version = request_version.clone();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With a per-request _meta.protocolVersion, negotiated_version gets updated here without rechecking session_id.

"-".to_owned(),
session,
));
// Hang so the client keeps the stream if it opens one; the test cancels it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says the handler hangs so the client keeps the stream open, but this branch returns 405 immediately.

Comment on lines +99 to +100
/// rather than echoed, which also leaves every `spawn_common_stream` call site — all three
/// of which are guarded on a session id being present — with no stream to open.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// rather than echoed, which also leaves every `spawn_common_stream` call site — all three
/// of which are guarded on a session id being present — with no stream to open.
/// rather than echoed, which also leaves every `spawn_common_stream` call site — each
/// guarded on a session id being present — with no stream to open.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T-config Configuration file changes T-core Core library changes T-dependencies Dependencies related changes T-test Testing related changes T-transport Transport layer changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SEP-2260 follow-up: skip session tracking and the standalone GET stream at protocol >= 2026-07-28

2 participants