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
1214use std:: {
1315 collections:: VecDeque ,
@@ -23,7 +25,8 @@ use axum::{
2325} ;
2426use 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]
350441async 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