Skip to content

Commit 04fd607

Browse files
committed
test(sep-2567): cover the fallback handshake and a per-request version
The startup tests only reached the legacy handshake directly. Auto mode reaches the same handshake through a refused `server/discover`, which the transport runs from inside its message loop and which adopts the session id at its own gate. Parameterize the startup helper by lifecycle mode and assert both shapes. Two more tests move the version mid-session with `_meta.protocolVersion`: to 2026-07-28, where the id must be gone from that request onwards while the teardown DELETE still carries it, and to another legacy version, where nothing about the id changes. Also corrects the GET branch comment, which described hanging to hold a stream open while the branch refuses the request outright.
1 parent ce40fb8 commit 04fd607

1 file changed

Lines changed: 161 additions & 19 deletions

File tree

‎crates/rmcp/tests/test_streamable_http_sessionless_version.rs‎

Lines changed: 161 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
))]
77
//! SEP-2567 removes sessions and the standalone GET stream at 2026-07-28. A legacy-shaped
88
//! handshake can still answer with an `Mcp-Session-Id` while negotiating that version; the
9-
//! client must not then echo the id or open the stream. That holds for the replacement
10-
//! handshake after an expired-session 404 as much as for the first one.
9+
//! client must not then echo the id or open the stream. That holds for every handshake that
10+
//! can settle on such a version — reached directly, reached as a fallback after
11+
//! `server/discover` is refused, or replacing an expired session after a 404 — and for a
12+
//! request that moves the transport there itself with its own `_meta.protocolVersion`.
1113
1214
use std::{
1315
collections::VecDeque,
@@ -23,7 +25,8 @@ use axum::{
2325
};
2426
use rmcp::{
2527
ClientLifecycleMode, ClientServiceExt,
26-
model::ClientInfo,
28+
model::{ClientInfo, ClientRequest, ListToolsRequest, ProtocolVersion, RequestMetaObject},
29+
service::PeerRequestOptions,
2730
transport::{
2831
StreamableHttpClientTransport, streamable_http_client::StreamableHttpClientTransportConfig,
2932
},
@@ -106,17 +109,28 @@ impl Recorder {
106109
}
107110

108111
/// Session headers on everything after the handshake that is not the teardown DELETE,
109-
/// which carries the id on purpose so the server tears the session down.
112+
/// which carries the id on purpose so the server tears the session down. A
113+
/// `server/discover` probe precedes the handshake, so it is not evidence either way.
110114
fn session_headers_after_handshake(&self) -> Vec<Option<String>> {
111115
self.calls()
112116
.into_iter()
113117
.filter(|(http_method, jsonrpc_method, _)| {
114-
jsonrpc_method != "initialize" && http_method != "DELETE"
118+
jsonrpc_method != "initialize"
119+
&& jsonrpc_method != "server/discover"
120+
&& http_method != "DELETE"
115121
})
116122
.map(|(.., session)| session)
117123
.collect()
118124
}
119125

126+
/// Every `tools/list` the client sent, in order.
127+
fn list_requests(&self) -> Vec<Call> {
128+
self.calls()
129+
.into_iter()
130+
.filter(|(_, jsonrpc_method, _)| jsonrpc_method == "tools/list")
131+
.collect()
132+
}
133+
120134
/// Everything the client sent after the last `initialize`, i.e. on the session that
121135
/// handshake established. Empty if it never re-initialized.
122136
fn calls_on_replacement_session(&self) -> Vec<Call> {
@@ -148,7 +162,8 @@ async fn handler(
148162
"-".to_owned(),
149163
session,
150164
));
151-
// Hang so the client keeps the stream if it opens one; the test cancels it.
165+
// Refused the way a server without the endpoint refuses it. The attempt is already
166+
// recorded above, which is all these tests need to know.
152167
return Response::builder()
153168
.status(StatusCode::METHOD_NOT_ALLOWED)
154169
.body(Body::empty())
@@ -256,20 +271,41 @@ async fn serve(recorder: &Recorder) -> (String, CancellationToken, tokio::task::
256271
(format!("http://{address}/mcp"), ct, server)
257272
}
258273

259-
async fn start_client(uri: String) -> rmcp::service::RunningService<rmcp::RoleClient, ClientInfo> {
274+
async fn start_client(
275+
uri: String,
276+
startup: ClientLifecycleMode,
277+
) -> rmcp::service::RunningService<rmcp::RoleClient, ClientInfo> {
260278
let transport = StreamableHttpClientTransport::from_config(
261279
StreamableHttpClientTransportConfig::with_uri(uri),
262280
);
263281
ClientInfo::default()
264-
.serve_with_lifecycle(transport, ClientLifecycleMode::Initialize)
282+
.serve_with_lifecycle(transport, startup)
265283
.await
266284
.expect("client should start against a legacy handshake")
267285
}
268286

269-
async fn connect_and_record(negotiated_version: &'static str) -> Recorder {
287+
/// The legacy handshake, reached directly.
288+
fn direct_startup() -> ClientLifecycleMode {
289+
ClientLifecycleMode::Initialize
290+
}
291+
292+
/// The same handshake reached as a fallback: `server/discover` goes first and the scripted
293+
/// server refuses it. The transport runs that `initialize` from inside its message loop
294+
/// rather than at startup, so the session id is adopted at a different place.
295+
fn fallback_startup() -> ClientLifecycleMode {
296+
ClientLifecycleMode::Auto {
297+
preferred_versions: vec![ProtocolVersion::V_2026_07_28],
298+
legacy_version: Some(ProtocolVersion::V_2025_11_25),
299+
}
300+
}
301+
302+
async fn connect_and_record(
303+
startup: ClientLifecycleMode,
304+
negotiated_version: &'static str,
305+
) -> Recorder {
270306
let recorder = Recorder::new((SESSION_ID, negotiated_version));
271307
let (uri, ct, server) = serve(&recorder).await;
272-
let client = start_client(uri).await;
308+
let client = start_client(uri, startup).await;
273309

274310
// Give a standalone GET stream, if one were opened, time to reach the server.
275311
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
@@ -287,7 +323,7 @@ async fn connect_through_recovery(replacement_version: &'static str) -> Recorder
287323
(REPLACEMENT_SESSION_ID, replacement_version),
288324
);
289325
let (uri, ct, server) = serve(&recorder).await;
290-
let client = start_client(uri).await;
326+
let client = start_client(uri, direct_startup()).await;
291327

292328
// The first tools/list is answered 404; the transport re-initializes and retries it.
293329
client
@@ -305,10 +341,47 @@ async fn connect_through_recovery(replacement_version: &'static str) -> Recorder
305341
recorder
306342
}
307343

308-
#[tokio::test]
309-
async fn modern_version_drops_the_session_and_opens_no_stream() {
310-
let recorder = connect_and_record("2026-07-28").await;
344+
/// Start on a legacy session, list tools on the negotiated version, then list them again on
345+
/// a request carrying its own `_meta.protocolVersion` (SEP-2575) — which replaces the
346+
/// negotiated version for that request and everything after it.
347+
async fn connect_and_override_version(inline_version: ProtocolVersion) -> Recorder {
348+
let recorder = Recorder::new((SESSION_ID, "2025-11-25"));
349+
let (uri, ct, server) = serve(&recorder).await;
350+
let client = start_client(uri, direct_startup()).await;
351+
352+
// The first list is the control: it goes out on the negotiated version, so the
353+
// recording shows the id being echoed before the override moves the version.
354+
client
355+
.list_tools(None)
356+
.await
357+
.expect("list tools on the negotiated version");
358+
359+
let mut meta = RequestMetaObject::new();
360+
meta.set_protocol_version(inline_version);
361+
client
362+
.send_request_with_option(
363+
ClientRequest::ListToolsRequest(ListToolsRequest {
364+
method: Default::default(),
365+
params: None,
366+
extensions: Default::default(),
367+
}),
368+
PeerRequestOptions::default().with_meta(meta),
369+
)
370+
.await
371+
.expect("send the list that carries its own version")
372+
.await_response()
373+
.await
374+
.expect("the overriding list should be answered");
375+
376+
client.cancel().await.expect("cancel client");
377+
ct.cancel();
378+
let _ = server.await;
379+
recorder
380+
}
311381

382+
/// A handshake that settled on a version without sessions: nothing the server volunteered
383+
/// is echoed, and no standalone GET stream is opened.
384+
fn assert_session_dropped(recorder: &Recorder) {
312385
// No standalone GET stream: SEP-2567 removed the endpoint at this version.
313386
assert_eq!(
314387
recorder.get_requests(),
@@ -327,11 +400,9 @@ async fn modern_version_drops_the_session_and_opens_no_stream() {
327400
);
328401
}
329402

330-
#[tokio::test]
331-
async fn legacy_version_keeps_the_session_and_opens_the_stream() {
332-
let recorder = connect_and_record("2025-11-25").await;
333-
334-
// The legacy shape is untouched: the stream is opened and carries the session id.
403+
/// A handshake that settled on a legacy version: the shape is untouched, so the stream is
404+
/// opened and the id is echoed.
405+
fn assert_session_kept(recorder: &Recorder) {
335406
let gets = recorder.get_requests();
336407
assert_eq!(
337408
gets.len(),
@@ -346,6 +417,26 @@ async fn legacy_version_keeps_the_session_and_opens_the_stream() {
346417
);
347418
}
348419

420+
#[tokio::test]
421+
async fn modern_version_drops_the_session_and_opens_no_stream() {
422+
assert_session_dropped(&connect_and_record(direct_startup(), "2026-07-28").await);
423+
}
424+
425+
#[tokio::test]
426+
async fn legacy_version_keeps_the_session_and_opens_the_stream() {
427+
assert_session_kept(&connect_and_record(direct_startup(), "2025-11-25").await);
428+
}
429+
430+
#[tokio::test]
431+
async fn fallback_handshake_at_a_modern_version_drops_the_session_too() {
432+
assert_session_dropped(&connect_and_record(fallback_startup(), "2026-07-28").await);
433+
}
434+
435+
#[tokio::test]
436+
async fn fallback_handshake_at_a_legacy_version_keeps_its_session() {
437+
assert_session_kept(&connect_and_record(fallback_startup(), "2025-11-25").await);
438+
}
439+
349440
#[tokio::test]
350441
async fn replacement_handshake_at_a_modern_version_drops_the_session_too() {
351442
let recorder = connect_through_recovery("2026-07-28").await;
@@ -418,3 +509,54 @@ async fn replacement_handshake_at_a_legacy_version_keeps_its_session() {
418509
recorder.get_requests()
419510
);
420511
}
512+
513+
#[tokio::test]
514+
async fn a_request_that_moves_to_a_modern_version_drops_the_session() {
515+
let recorder = connect_and_override_version(ProtocolVersion::V_2026_07_28).await;
516+
517+
let lists = recorder.list_requests();
518+
assert_eq!(
519+
lists.len(),
520+
2,
521+
"expected the control list and the overriding one, got {lists:?}"
522+
);
523+
assert_eq!(
524+
lists[0].2.as_deref(),
525+
Some(SESSION_ID),
526+
"the control list on the negotiated version should still carry the id"
527+
);
528+
// The version the request declares is the version it is sent at, so the id has to be
529+
// gone on this request and not merely on the next one.
530+
assert_eq!(
531+
lists[1].2, None,
532+
"the request that moved the transport to a version without sessions echoed \
533+
Mcp-Session-Id: {lists:?}"
534+
);
535+
536+
// Dropping the id does not leak the session the server really did create.
537+
let deletes = recorder.delete_requests();
538+
assert!(
539+
deletes
540+
.iter()
541+
.any(|(.., session)| session.as_deref() == Some(SESSION_ID)),
542+
"the session was never deleted at shutdown: {deletes:?}"
543+
);
544+
}
545+
546+
#[tokio::test]
547+
async fn a_request_that_moves_to_another_legacy_version_keeps_the_session() {
548+
let recorder = connect_and_override_version(ProtocolVersion::V_2025_06_18).await;
549+
550+
let lists = recorder.list_requests();
551+
assert_eq!(
552+
lists.len(),
553+
2,
554+
"expected the control list and the overriding one, got {lists:?}"
555+
);
556+
// The version still has sessions, so moving to it changes nothing about the id.
557+
assert_eq!(
558+
lists[1].2.as_deref(),
559+
Some(SESSION_ID),
560+
"a move between legacy versions dropped a session id that version still has: {lists:?}"
561+
);
562+
}

0 commit comments

Comments
 (0)