Skip to content

Commit 397d416

Browse files
feat: route SEP-2260 associated server requests to the originating SSE stream (#1029)
* feat: implement SEP-2260 require server requests to associate with client requests * feat(service): attach originating request id to in-handler outbound requests (SEP-2260) Reuses the ORIGINATING_REQUEST task-local from #1027; the marker rides the request's non-serialized Extensions so the streamable HTTP server can route associated requests to the originating SSE stream. * feat(transport): route associated server requests to originating SSE stream (SEP-2260) * test: end-to-end SEP-2260 stream routing over streamable HTTP * test: register SEP-2260 routing test with required-features; drop dead list_tools * docs: document SEP-2260 association requirement and stream routing * docs: fix redundant explicit link on OriginatingRequestId * docs: note marker is role-agnostic; add reason to deprecated expect Review follow-ups: the OriginatingRequestId rustdoc now states the marker is attached for both roles with only the server transport reading it, and the test's deprecation suppression carries a reason per house style. * docs: condense inline comments to repo convention Trims multi-line inline comments added in this branch down to the terse one-to-two-line style used elsewhere in the codebase, keeping only the non-obvious rationale (spec references, fallback invariant, version choice). * docs: condense SEP-2260 rustdoc sections Tightens the duplicated per-method association section from eleven lines to five, and trims the marker and SessionManager docs to the same information in fewer words. * docs: single-source SEP-2260 caller requirements on OriginatingRequestId * feat(service): reject unassociated server-to-client requests on the client (SEP-2260) Implements the client receive-side of SEP-2260: restricted server requests (sampling/createMessage, roots/list, elicitation/create) received while the client has no outbound request in flight are answered with -32602 invalid params instead of being dispatched to the handler. Gated on negotiated protocol >= 2026-07-28; ping is exempt. With a request in flight we cannot tell which one the server request belongs to (SEP-2260 defines no wire field), so this is a deliberate under-approximation of the spec's SHOULD; exact stream-based enforcement for streamable HTTP needs receive-side provenance plumbing and is left as a follow-up. --------- Co-authored-by: Alex Hancock <alexhancock@block.xyz>
1 parent aad4d4e commit 397d416

9 files changed

Lines changed: 853 additions & 6 deletions

File tree

crates/rmcp/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,3 +445,8 @@ required-features = [
445445
"reqwest",
446446
]
447447
path = "tests/test_streamable_http_disconnect_cancel.rs"
448+
449+
[[test]]
450+
name = "test_sep_2260_stream_routing"
451+
required-features = ["server", "elicitation", "transport-streamable-http-server", "reqwest"]
452+
path = "tests/test_sep_2260_stream_routing.rs"

crates/rmcp/src/service.rs

Lines changed: 128 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,29 @@ pub trait ServiceRole: std::fmt::Debug + Send + Sync + 'static + Copy + Clone {
149149
) -> impl Future<Output = ()> + MaybeSendFuture {
150150
async {}
151151
}
152+
153+
#[doc(hidden)]
154+
fn enforce_request_association(
155+
_request: &Self::Req,
156+
_peer_info: Option<&Self::PeerInfo>,
157+
_in_request_handler_scope: bool,
158+
) -> Result<(), ServiceError> {
159+
Ok(())
160+
}
161+
162+
/// Receive-side counterpart of [`Self::enforce_request_association`]:
163+
/// SEP-2260 says clients receiving a server-to-client request with no
164+
/// associated outbound request should reject it with invalid params. An
165+
/// error return is sent back to the peer instead of dispatching to the
166+
/// handler.
167+
#[doc(hidden)]
168+
fn enforce_peer_request_association(
169+
_peer_request: &Self::PeerReq,
170+
_peer_info: Option<&Self::PeerInfo>,
171+
_has_pending_outbound_request: bool,
172+
) -> Result<(), McpError> {
173+
Ok(())
174+
}
152175
}
153176

154177
pub(crate) fn uses_legacy_lifecycle(
@@ -159,6 +182,33 @@ pub(crate) fn uses_legacy_lifecycle(
159182
&& protocol_version.is_none_or(|version| version < &ProtocolVersion::V_2026_07_28)
160183
}
161184

185+
tokio::task_local! {
186+
pub(crate) static ORIGINATING_REQUEST: RequestId;
187+
}
188+
189+
pub(crate) fn in_request_handler_scope() -> bool {
190+
ORIGINATING_REQUEST.try_with(|_| ()).is_ok()
191+
}
192+
193+
/// Marker in an outbound request's non-serialized [`Extensions`] identifying
194+
/// the in-flight peer request it was issued from (SEP-2260). Attached for both
195+
/// roles whenever a request is sent from within a request handler; the
196+
/// streamable HTTP server reads it to deliver server-initiated requests on the
197+
/// originating request's SSE stream. Never on the wire (SEP-2260 defines no
198+
/// wire field), so session managers that serialize messages between processes
199+
/// lose it and such requests fall back to the standalone stream with a warning.
200+
///
201+
/// # Caller requirements
202+
///
203+
/// From protocol version `2026-07-28`, server-to-client sampling, roots, and
204+
/// elicitation requests must be issued while handling a client request;
205+
/// outside a handler they return an `invalid_request` error. The association
206+
/// is task-local and does not cross `tokio::spawn`, so use the task manager
207+
/// for long-running work.
208+
#[derive(Debug, Clone, PartialEq, Eq)]
209+
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
210+
pub struct OriginatingRequestId(pub RequestId);
211+
162212
pub type TxJsonRpcMessage<R> =
163213
JsonRpcMessage<<R as ServiceRole>::Req, <R as ServiceRole>::Resp, <R as ServiceRole>::Not>;
164214
pub type RxJsonRpcMessage<R> = JsonRpcMessage<
@@ -725,6 +775,16 @@ impl<R: ServiceRole> Peer<R> {
725775
options: PeerRequestOptions,
726776
subscription_sender: Option<SubscriptionChannel<R::PeerNot>>,
727777
) -> Result<RequestHandle<R>, ServiceError> {
778+
R::enforce_request_association(
779+
&request,
780+
self.peer_info().as_deref(),
781+
in_request_handler_scope(),
782+
)?;
783+
if let Ok(originating) = ORIGINATING_REQUEST.try_with(|id| id.clone()) {
784+
request
785+
.extensions_mut()
786+
.insert(OriginatingRequestId(originating));
787+
}
728788
let id = self.request_id_provider.next_request_id();
729789
let progress_token = self.progress_token_provider.next_progress_token();
730790
if let Some(metadata) = self.client_request_metadata.get() {
@@ -1389,6 +1449,24 @@ where
13891449
..
13901450
})) => {
13911451
tracing::debug!(%id, ?request, "received request");
1452+
if let Err(error) = R::enforce_peer_request_association(
1453+
&request,
1454+
peer.peer_info().as_deref(),
1455+
!local_responder_pool.is_empty(),
1456+
) {
1457+
tracing::warn!(%id, message = %error.message, "rejected peer request");
1458+
// send directly: the sink proxy path would drop the
1459+
// error since the request was never registered in
1460+
// local_ct_pool
1461+
let send = transport.send(JsonRpcMessage::error(error, Some(id)));
1462+
let current_span = tracing::Span::current();
1463+
response_send_tasks.spawn(async move {
1464+
if let Err(error) = send.await {
1465+
tracing::error!(%error, "fail to send rejection error");
1466+
}
1467+
}.instrument(current_span));
1468+
continue;
1469+
}
13921470
{
13931471
let service = shared_service.clone();
13941472
let sink = sink_proxy_tx.clone();
@@ -1409,9 +1487,10 @@ where
14091487
extensions,
14101488
};
14111489
let current_span = tracing::Span::current();
1490+
let handler_id = id.clone();
14121491
spawn_service_task(async move {
1413-
let result = service
1414-
.handle_request(request, context)
1492+
let result = ORIGINATING_REQUEST
1493+
.scope(handler_id, service.handle_request(request, context))
14151494
.await;
14161495
let response = match result {
14171496
Ok(result) => {
@@ -1608,3 +1687,50 @@ where
16081687
dg: ct.drop_guard(),
16091688
}
16101689
}
1690+
1691+
#[cfg(all(test, feature = "server"))]
1692+
mod sep2260_marker_tests {
1693+
use std::sync::Arc;
1694+
1695+
use super::*;
1696+
use crate::model::{PingRequest, RequestId, ServerRequest};
1697+
1698+
fn ping() -> ServerRequest {
1699+
ServerRequest::PingRequest(PingRequest {
1700+
method: Default::default(),
1701+
extensions: Default::default(),
1702+
})
1703+
}
1704+
1705+
async fn send_and_capture(scope: Option<RequestId>) -> <RoleServer as ServiceRole>::Req {
1706+
// peer_info None keeps enforcement non-strict; only the sink message matters.
1707+
let (peer, mut rx) =
1708+
Peer::<RoleServer>::new(Arc::new(AtomicU32RequestIdProvider::default()), None);
1709+
let send = peer.send_request_with_option(ping(), PeerRequestOptions::no_options());
1710+
let _handle = match scope {
1711+
Some(id) => ORIGINATING_REQUEST.scope(id, send).await.unwrap(),
1712+
None => send.await.unwrap(),
1713+
};
1714+
let PeerSinkMessage::Request { request, .. } = rx.recv().await.expect("sink message")
1715+
else {
1716+
panic!("expected a request sink message");
1717+
};
1718+
request
1719+
}
1720+
1721+
#[tokio::test]
1722+
async fn outbound_request_carries_originating_id_when_in_scope() {
1723+
let request = send_and_capture(Some(RequestId::Number(7))).await;
1724+
let marker = request
1725+
.extensions()
1726+
.get::<OriginatingRequestId>()
1727+
.expect("marker attached");
1728+
assert_eq!(marker.0, RequestId::Number(7));
1729+
}
1730+
1731+
#[tokio::test]
1732+
async fn outbound_request_has_no_marker_outside_scope() {
1733+
let request = send_and_capture(None).await;
1734+
assert!(request.extensions().get::<OriginatingRequestId>().is_none());
1735+
}
1736+
}

crates/rmcp/src/service/client.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,35 @@ impl ServiceRole for RoleClient {
213213
}
214214
}
215215

216+
// SEP-2260: with no outbound request in flight there is nothing the
217+
// server request could be associated with, so reject it. With one in
218+
// flight we cannot tell which request it belongs to (no wire field), so
219+
// we accept — an under-approximation of the spec's SHOULD.
220+
fn enforce_peer_request_association(
221+
peer_request: &Self::PeerReq,
222+
peer_info: Option<&Self::PeerInfo>,
223+
has_pending_outbound_request: bool,
224+
) -> Result<(), ErrorData> {
225+
let restricted = matches!(
226+
peer_request,
227+
ServerRequest::CreateMessageRequest(_)
228+
| ServerRequest::ListRootsRequest(_)
229+
| ServerRequest::ElicitRequest(_)
230+
);
231+
if !restricted {
232+
return Ok(());
233+
}
234+
let strict =
235+
peer_info.is_some_and(|info| info.protocol_version >= ProtocolVersion::V_2026_07_28);
236+
if strict && !has_pending_outbound_request {
237+
return Err(ErrorData::invalid_params(
238+
"SEP-2260: server-to-client requests must be associated with an in-flight client request",
239+
None,
240+
));
241+
}
242+
Ok(())
243+
}
244+
216245
async fn invalidate_response_cache(peer: &Peer<Self>, notification: &Self::PeerNot) {
217246
match notification {
218247
ServerNotification::ResourceUpdatedNotification(notification) => {

crates/rmcp/src/service/server.rs

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,31 @@ impl ServiceRole for RoleServer {
4949
_ => None,
5050
}
5151
}
52+
53+
fn enforce_request_association(
54+
request: &Self::Req,
55+
peer_info: Option<&Self::PeerInfo>,
56+
in_request_handler_scope: bool,
57+
) -> Result<(), ServiceError> {
58+
let restricted = matches!(
59+
request,
60+
ServerRequest::CreateMessageRequest(_)
61+
| ServerRequest::ListRootsRequest(_)
62+
| ServerRequest::ElicitRequest(_)
63+
);
64+
if !restricted {
65+
return Ok(());
66+
}
67+
let strict =
68+
peer_info.is_some_and(|info| info.protocol_version >= ProtocolVersion::V_2026_07_28);
69+
if strict && !in_request_handler_scope {
70+
return Err(ServiceError::McpError(ErrorData::invalid_request(
71+
"SEP-2260: server-to-client requests must be associated with an originating client request",
72+
None,
73+
)));
74+
}
75+
Ok(())
76+
}
5277
}
5378

5479
/// It represents the error that may occur when serving the server.
@@ -744,6 +769,10 @@ impl Peer<RoleServer> {
744769
}
745770
}
746771

772+
/// # SEP-2260: request association
773+
///
774+
/// From protocol version `2026-07-28` this must be issued while handling a
775+
/// client request; see [`OriginatingRequestId`].
747776
#[deprecated(
748777
since = "1.8.0",
749778
note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577"
@@ -778,16 +807,32 @@ impl Peer<RoleServer> {
778807
}
779808
}
780809
method!(
810+
/// # SEP-2260: request association
811+
///
812+
/// From protocol version `2026-07-28` this must be issued while handling a
813+
/// client request; see [`OriginatingRequestId`].
781814
#[deprecated(
782815
since = "1.8.0",
783816
note = "Roots is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577"
784817
)]
785818
peer_req list_roots ListRootsRequest() => ListRootsResult
786819
);
787820
#[cfg(feature = "elicitation")]
788-
method!(peer_req create_elicitation ElicitRequest(ElicitRequestParams) => ElicitResult);
821+
method!(
822+
/// # SEP-2260: request association
823+
///
824+
/// From protocol version `2026-07-28` this must be issued while handling a
825+
/// client request; see [`OriginatingRequestId`].
826+
peer_req create_elicitation ElicitRequest(ElicitRequestParams) => ElicitResult
827+
);
789828
#[cfg(feature = "elicitation")]
790-
method!(peer_req_with_timeout create_elicitation_with_timeout ElicitRequest(ElicitRequestParams) => ElicitResult);
829+
method!(
830+
/// # SEP-2260: request association
831+
///
832+
/// From protocol version `2026-07-28` this must be issued while handling a
833+
/// client request; see [`OriginatingRequestId`].
834+
peer_req_with_timeout create_elicitation_with_timeout ElicitRequest(ElicitRequestParams) => ElicitResult
835+
);
791836

792837
method!(peer_not notify_cancelled CancelledNotification(CancelledNotificationParam));
793838
method!(peer_not notify_progress ProgressNotification(ProgressNotificationParam));
@@ -1014,6 +1059,11 @@ impl Peer<RoleServer> {
10141059
/// # Ok(())
10151060
/// # }
10161061
/// ```
1062+
///
1063+
/// # SEP-2260: request association
1064+
///
1065+
/// From protocol version `2026-07-28` this must be issued while handling a
1066+
/// client request; see [`OriginatingRequestId`].
10171067
#[cfg(all(feature = "schemars", feature = "elicitation"))]
10181068
pub async fn elicit<T>(&self, message: impl Into<String>) -> Result<Option<T>, ElicitationError>
10191069
where
@@ -1075,6 +1125,11 @@ impl Peer<RoleServer> {
10751125
/// # Ok(())
10761126
/// # }
10771127
/// ```
1128+
///
1129+
/// # SEP-2260: request association
1130+
///
1131+
/// From protocol version `2026-07-28` this must be issued while handling a
1132+
/// client request; see [`OriginatingRequestId`].
10781133
#[cfg(all(feature = "schemars", feature = "elicitation"))]
10791134
pub async fn elicit_with_timeout<T>(
10801135
&self,
@@ -1170,6 +1225,11 @@ impl Peer<RoleServer> {
11701225
/// Ok(())
11711226
/// }
11721227
/// ```
1228+
///
1229+
/// # SEP-2260: request association
1230+
///
1231+
/// From protocol version `2026-07-28` this must be issued while handling a
1232+
/// client request; see [`OriginatingRequestId`].
11731233
#[cfg(feature = "elicitation")]
11741234
pub async fn elicit_url(
11751235
&self,
@@ -1221,6 +1281,11 @@ impl Peer<RoleServer> {
12211281
/// Ok(())
12221282
/// }
12231283
/// ```
1284+
///
1285+
/// # SEP-2260: request association
1286+
///
1287+
/// From protocol version `2026-07-28` this must be issued while handling a
1288+
/// client request; see [`OriginatingRequestId`].
12241289
#[cfg(feature = "elicitation")]
12251290
pub async fn elicit_url_with_timeout(
12261291
&self,

0 commit comments

Comments
 (0)