11//! SEP-2260 follow-up (#1033): stream-based receive-side enforcement.
22//!
33//! Scripted streamable HTTP "server": answers a legacy initialize with
4- //! protocol 2026-07-28 and a session id (spec-legal; rmcp's own server is
5- //! stateless at that version, but the client must be correct against any
6- //! server), so the client has BOTH a standalone GET stream and strict
4+ //! protocol 2026-07-28 AND a session id. That combination is NOT
5+ //! spec-compliant: the 2026-07-28 revision removes protocol-level sessions
6+ //! and the standalone GET stream (SEP-2567; transports spec: "do not mint
7+ //! or echo session IDs"). Receive-side enforcement (#1033) exists precisely
8+ //! to protect the client from non-conforming servers, and rmcp's client
9+ //! tolerates the session id and opens the standalone GET stream — so this
10+ //! is the reachable path where the client has BOTH a GET stream and strict
711//! SEP-2260 enforcement.
812#![ cfg( all(
913 feature = "client" ,
1014 feature = "transport-streamable-http-client" ,
1115 not( feature = "local" )
1216) ) ]
13- #![ allow( deprecated) ]
17+ #![ expect(
18+ deprecated,
19+ reason = "Sampling is deprecated by SEP-2577 but remains the canonical restricted request"
20+ ) ]
1421
1522use std:: { collections:: HashMap , sync:: Arc , time:: Duration } ;
1623
@@ -70,7 +77,8 @@ impl StreamableHttpClient for ScriptedServer {
7077 _custom_headers : HashMap < HeaderName , HeaderValue > ,
7178 ) -> Result < StreamableHttpPostResponse , StreamableHttpError < Self :: Error > > {
7279 let value = serde_json:: to_value ( & message) . expect ( "serialize client message" ) ;
73- self . posted . send ( value. clone ( ) ) . expect ( "test alive" ) ;
80+ // Receiver drop is normal at test teardown; never panic in the transport task.
81+ let _ = self . posted . send ( value. clone ( ) ) ;
7482 if value[ "method" ] == "initialize" {
7583 let mut info = ServerInfo :: new ( ServerCapabilities :: default ( ) ) ;
7684 info. protocol_version = ProtocolVersion :: V_2026_07_28 ;
@@ -84,12 +92,14 @@ impl StreamableHttpClient for ScriptedServer {
8492 ) ) ;
8593 }
8694 if matches ! ( message, ClientJsonRpcMessage :: Request ( _) ) {
87- let rx = self
88- . post_stream
89- . lock ( )
90- . await
91- . take ( )
92- . expect ( "exactly one non-initialize request POST in this test" ) ;
95+ // Fail as a transport error rather than panicking: this code runs
96+ // in the transport task, where a panic is swallowed and shows up
97+ // only as an opaque timeout in the test.
98+ let rx = self . post_stream . lock ( ) . await . take ( ) . ok_or_else ( || {
99+ StreamableHttpError :: Client ( std:: io:: Error :: other (
100+ "scripted server expects exactly one non-initialize request POST" ,
101+ ) )
102+ } ) ?;
93103 return Ok ( StreamableHttpPostResponse :: Sse ( message_stream ( rx) , None ) ) ;
94104 }
95105 Ok ( StreamableHttpPostResponse :: Accepted )
@@ -123,8 +133,10 @@ impl StreamableHttpClient for ScriptedServer {
123133
124134#[ derive( Clone ) ]
125135struct SamplingClient {
126- // Some(tx): forward sampling params; None: sampling must never reach the handler.
127- on_sampling : Option < mpsc:: UnboundedSender < CreateMessageRequestParams > > ,
136+ // Every invocation is recorded here. Tests assert on the receiver (the
137+ // negative test asserts it stays empty); a panic in this handler would
138+ // run in a spawned task and be silently swallowed.
139+ sampled : mpsc:: UnboundedSender < CreateMessageRequestParams > ,
128140}
129141
130142impl ClientHandler for SamplingClient {
@@ -133,10 +145,7 @@ impl ClientHandler for SamplingClient {
133145 params : CreateMessageRequestParams ,
134146 _context : RequestContext < RoleClient > ,
135147 ) -> Result < CreateMessageResult , rmcp:: ErrorData > {
136- let Some ( tx) = & self . on_sampling else {
137- panic ! ( "sampling request must not reach the handler in this test" ) ;
138- } ;
139- tx. send ( params) . expect ( "test alive" ) ;
148+ let _ = self . sampled . send ( params) ;
140149 Ok ( CreateMessageResult :: new (
141150 SamplingMessage :: assistant_text ( "pong" ) ,
142151 "test-model" . to_string ( ) ,
@@ -171,21 +180,27 @@ async fn next_posted(posted: &mut mpsc::UnboundedReceiver<Value>) -> Value {
171180 . expect ( "channel open" )
172181}
173182
174- /// Drive startup + one in-flight tools/list; return (client, posted rx,
175- /// get stream tx, post stream tx, in-flight call handle, tools/list id).
176- async fn setup (
177- handler : SamplingClient ,
178- ) -> (
179- rmcp:: service:: RunningService < RoleClient , SamplingClient > ,
180- mpsc:: UnboundedReceiver < Value > ,
181- mpsc:: Sender < Value > ,
182- mpsc:: Sender < Value > ,
183- tokio:: task:: JoinHandle < Result < rmcp:: model:: ListToolsResult , rmcp:: ServiceError > > ,
184- Value ,
185- ) {
183+ struct Harness {
184+ client : rmcp:: service:: RunningService < RoleClient , SamplingClient > ,
185+ /// Every message the client POSTs to the scripted server.
186+ posted : mpsc:: UnboundedReceiver < Value > ,
187+ /// Feeds the standalone GET stream.
188+ get_tx : mpsc:: Sender < Value > ,
189+ /// Feeds the SSE stream of the in-flight tools/list POST.
190+ post_tx : mpsc:: Sender < Value > ,
191+ /// In-flight tools/list call (response withheld until the test releases it).
192+ call : tokio:: task:: JoinHandle < Result < rmcp:: model:: ListToolsResult , rmcp:: ServiceError > > ,
193+ tools_list_id : Value ,
194+ /// Sampling params seen by the client handler.
195+ sampled : mpsc:: UnboundedReceiver < CreateMessageRequestParams > ,
196+ }
197+
198+ /// Drive startup + one in-flight tools/list.
199+ async fn setup ( ) -> Harness {
186200 let ( get_tx, get_rx) = mpsc:: channel ( 8 ) ;
187201 let ( post_tx, post_rx) = mpsc:: channel ( 8 ) ;
188202 let ( posted_tx, mut posted_rx) = mpsc:: unbounded_channel ( ) ;
203+ let ( sampled_tx, sampled_rx) = mpsc:: unbounded_channel ( ) ;
189204 let server = ScriptedServer {
190205 get_stream : Arc :: new ( Mutex :: new ( Some ( get_rx) ) ) ,
191206 post_stream : Arc :: new ( Mutex :: new ( Some ( post_rx) ) ) ,
@@ -195,9 +210,15 @@ async fn setup(
195210 server,
196211 StreamableHttpClientTransportConfig :: with_uri ( "http://scripted/mcp" ) ,
197212 ) ;
198- let client = serve_client_with_lifecycle ( handler, transport, ClientLifecycleMode :: Initialize )
199- . await
200- . expect ( "initialize against scripted server" ) ;
213+ let client = serve_client_with_lifecycle (
214+ SamplingClient {
215+ sampled : sampled_tx,
216+ } ,
217+ transport,
218+ ClientLifecycleMode :: Initialize ,
219+ )
220+ . await
221+ . expect ( "initialize against scripted server" ) ;
201222
202223 // initialize + notifications/initialized already posted during startup.
203224 assert_eq ! ( next_posted( & mut posted_rx) . await [ "method" ] , "initialize" ) ;
@@ -213,7 +234,15 @@ async fn setup(
213234 assert_eq ! ( tools_list[ "method" ] , "tools/list" ) ;
214235 let tools_list_id = tools_list[ "id" ] . clone ( ) ;
215236
216- ( client, posted_rx, get_tx, post_tx, call, tools_list_id)
237+ Harness {
238+ client,
239+ posted : posted_rx,
240+ get_tx,
241+ post_tx,
242+ call,
243+ tools_list_id,
244+ sampled : sampled_rx,
245+ }
217246}
218247
219248/// #1033 scenario 1: a restricted request on the standalone GET stream while
@@ -222,12 +251,11 @@ async fn setup(
222251#[ tokio:: test]
223252async fn restricted_request_on_get_stream_rejected_while_unrelated_request_in_flight ( )
224253-> anyhow:: Result < ( ) > {
225- let ( client, mut posted_rx, get_tx, post_tx, call, tools_list_id) =
226- setup ( SamplingClient { on_sampling : None } ) . await ;
254+ let mut h = setup ( ) . await ;
227255
228- get_tx. send ( sampling_request ( 100 ) ) . await ?;
256+ h . get_tx . send ( sampling_request ( 100 ) ) . await ?;
229257
230- let rejection = next_posted ( & mut posted_rx ) . await ;
258+ let rejection = next_posted ( & mut h . posted ) . await ;
231259 assert_eq ! (
232260 rejection[ "id" ] , 100 ,
233261 "reply to the sampling request: {rejection}"
@@ -238,25 +266,28 @@ async fn restricted_request_on_get_stream_rejected_while_unrelated_request_in_fl
238266 request in flight, got {rejection}"
239267 ) ;
240268
241- post_tx. send ( tools_list_response ( & tools_list_id) ) . await ?;
242- call. await ??;
243- client. cancel ( ) . await ?;
269+ h. post_tx
270+ . send ( tools_list_response ( & h. tools_list_id ) )
271+ . await ?;
272+ tokio:: time:: timeout ( Duration :: from_secs ( 5 ) , h. call ) . await ???;
273+ // Rejected means rejected: the handler must not ALSO have been invoked.
274+ assert ! (
275+ h. sampled. try_recv( ) . is_err( ) ,
276+ "handler must not see the rejected sampling request"
277+ ) ;
278+ tokio:: time:: timeout ( Duration :: from_secs ( 5 ) , h. client . cancel ( ) ) . await ??;
244279 Ok ( ( ) )
245280}
246281
247282/// Positive twin: the same restricted request arriving on the SSE stream of
248283/// the originating POST is dispatched to the handler and answered.
249284#[ tokio:: test]
250285async fn restricted_request_on_originating_post_stream_is_dispatched ( ) -> anyhow:: Result < ( ) > {
251- let ( sampled_tx, mut sampled_rx) = mpsc:: unbounded_channel ( ) ;
252- let ( client, mut posted_rx, _get_tx, post_tx, call, tools_list_id) = setup ( SamplingClient {
253- on_sampling : Some ( sampled_tx) ,
254- } )
255- . await ;
286+ let mut h = setup ( ) . await ;
256287
257- post_tx. send ( sampling_request ( 200 ) ) . await ?;
288+ h . post_tx . send ( sampling_request ( 200 ) ) . await ?;
258289
259- let response = next_posted ( & mut posted_rx ) . await ;
290+ let response = next_posted ( & mut h . posted ) . await ;
260291 assert_eq ! (
261292 response[ "id" ] , 200 ,
262293 "reply to the sampling request: {response}"
@@ -265,12 +296,14 @@ async fn restricted_request_on_originating_post_stream_is_dispatched() -> anyhow
265296 response[ "result" ] [ "model" ] , "test-model" ,
266297 "request on the originating POST stream must reach the handler, got {response}"
267298 ) ;
268- tokio:: time:: timeout ( Duration :: from_secs ( 5 ) , sampled_rx . recv ( ) )
299+ tokio:: time:: timeout ( Duration :: from_secs ( 5 ) , h . sampled . recv ( ) )
269300 . await ?
270301 . expect ( "handler invoked" ) ;
271302
272- post_tx. send ( tools_list_response ( & tools_list_id) ) . await ?;
273- call. await ??;
274- client. cancel ( ) . await ?;
303+ h. post_tx
304+ . send ( tools_list_response ( & h. tools_list_id ) )
305+ . await ?;
306+ tokio:: time:: timeout ( Duration :: from_secs ( 5 ) , h. call ) . await ???;
307+ tokio:: time:: timeout ( Duration :: from_secs ( 5 ) , h. client . cancel ( ) ) . await ??;
275308 Ok ( ( ) )
276309}
0 commit comments