Skip to content

Commit 09af9d0

Browse files
committed
refactor(service): express SEP-2260 receive-side association as an enum
Replaces the has_pending_outbound_request bool on enforce_peer_request_association with PeerRequestAssociation, so a stream-separating transport can report per-request association (#1033). Behavior-preserving: the event loop still passes the coarse signal as Unknown.
1 parent c284607 commit 09af9d0

2 files changed

Lines changed: 116 additions & 12 deletions

File tree

crates/rmcp/src/service.rs

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,17 +163,39 @@ pub trait ServiceRole: std::fmt::Debug + Send + Sync + 'static + Copy + Clone {
163163
/// SEP-2260 says clients receiving a server-to-client request with no
164164
/// associated outbound request should reject it with invalid params. An
165165
/// error return is sent back to the peer instead of dispatching to the
166-
/// handler.
166+
/// handler. The `association` argument is the transport-observed stream
167+
/// association; see [`PeerRequestAssociation`].
167168
#[doc(hidden)]
168169
fn enforce_peer_request_association(
169170
_peer_request: &Self::PeerReq,
170171
_peer_info: Option<&Self::PeerInfo>,
171-
_has_pending_outbound_request: bool,
172+
_association: PeerRequestAssociation,
172173
) -> Result<(), McpError> {
173174
Ok(())
174175
}
175176
}
176177

178+
/// How an inbound peer request relates to this side's in-flight outbound
179+
/// requests, as observed by the transport (SEP-2260).
180+
///
181+
/// SEP-2260 defines no wire field for request association; only a
182+
/// stream-separating transport (streamable HTTP) can distinguish the first
183+
/// two variants. Transports without stream separation (stdio, in-process)
184+
/// yield [`Self::Unknown`], preserving the coarse in-flight check.
185+
#[derive(Debug, Clone, PartialEq, Eq)]
186+
#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")]
187+
pub enum PeerRequestAssociation {
188+
/// Arrived on the response stream of an outbound request that is still
189+
/// awaiting its response.
190+
Associated,
191+
/// Arrived on a stream tied to no in-flight outbound request (e.g. the
192+
/// streamable HTTP standalone GET stream).
193+
Unassociated,
194+
/// The transport supplied no stream provenance; only the coarse
195+
/// "is anything in flight" signal exists.
196+
Unknown { has_pending_outbound_request: bool },
197+
}
198+
177199
pub(crate) fn uses_legacy_lifecycle(
178200
protocol_version: Option<&ProtocolVersion>,
179201
uses_discover_lifecycle: bool,
@@ -1448,7 +1470,9 @@ where
14481470
if let Err(error) = R::enforce_peer_request_association(
14491471
&request,
14501472
peer.peer_info().as_deref(),
1451-
!local_responder_pool.is_empty(),
1473+
PeerRequestAssociation::Unknown {
1474+
has_pending_outbound_request: !local_responder_pool.is_empty(),
1475+
},
14521476
) {
14531477
tracing::warn!(%id, message = %error.message, "rejected peer request");
14541478
// send directly: the sink proxy path would drop the

crates/rmcp/src/service/client.rs

Lines changed: 89 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -238,14 +238,14 @@ impl ServiceRole for RoleClient {
238238
}
239239
}
240240

241-
// SEP-2260: with no outbound request in flight there is nothing the
242-
// server request could be associated with, so reject it. With one in
243-
// flight we cannot tell which request it belongs to (no wire field), so
244-
// we accept — an under-approximation of the spec's SHOULD.
241+
// SEP-2260: reject restricted server requests that the transport observed
242+
// arriving with no in-flight outbound request association. Transports
243+
// without stream separation (stdio) report `Unknown`, where the coarse
244+
// in-flight check is the best available under-approximation of the SHOULD.
245245
fn enforce_peer_request_association(
246246
peer_request: &Self::PeerReq,
247247
peer_info: Option<&Self::PeerInfo>,
248-
has_pending_outbound_request: bool,
248+
association: PeerRequestAssociation,
249249
) -> Result<(), ErrorData> {
250250
let restricted = matches!(
251251
peer_request,
@@ -258,13 +258,24 @@ impl ServiceRole for RoleClient {
258258
}
259259
let strict =
260260
peer_info.is_some_and(|info| info.protocol_version >= ProtocolVersion::V_2026_07_28);
261-
if strict && !has_pending_outbound_request {
262-
return Err(ErrorData::invalid_params(
261+
if !strict {
262+
return Ok(());
263+
}
264+
let associated = match association {
265+
PeerRequestAssociation::Associated => true,
266+
PeerRequestAssociation::Unassociated => false,
267+
PeerRequestAssociation::Unknown {
268+
has_pending_outbound_request,
269+
} => has_pending_outbound_request,
270+
};
271+
if associated {
272+
Ok(())
273+
} else {
274+
Err(ErrorData::invalid_params(
263275
"SEP-2260: server-to-client requests must be associated with an in-flight client request",
264276
None,
265-
));
277+
))
266278
}
267-
Ok(())
268279
}
269280

270281
async fn invalidate_response_cache(peer: &Peer<Self>, notification: &Self::PeerNot) {
@@ -2215,3 +2226,72 @@ mod tests {
22152226
assert_eq!(peer.discover(meta).await.unwrap(), expected);
22162227
}
22172228
}
2229+
2230+
#[cfg(test)]
2231+
mod sep2260_association_tests {
2232+
use super::*;
2233+
use crate::{
2234+
model::{
2235+
CreateMessageRequest, CreateMessageRequestParams, SamplingMessage, ServerCapabilities,
2236+
},
2237+
service::PeerRequestAssociation,
2238+
};
2239+
2240+
fn sampling_request() -> ServerRequest {
2241+
ServerRequest::CreateMessageRequest(CreateMessageRequest::new(
2242+
CreateMessageRequestParams::new(vec![SamplingMessage::user_text("hi")], 16),
2243+
))
2244+
}
2245+
2246+
fn server_info(version: ProtocolVersion) -> ServerInfo {
2247+
let mut info = ServerInfo::new(ServerCapabilities::default());
2248+
info.protocol_version = version;
2249+
info
2250+
}
2251+
2252+
fn enforce(info: &ServerInfo, association: PeerRequestAssociation) -> Result<(), ErrorData> {
2253+
RoleClient::enforce_peer_request_association(&sampling_request(), Some(info), association)
2254+
}
2255+
2256+
#[test]
2257+
fn strict_rejects_unassociated() {
2258+
let info = server_info(ProtocolVersion::V_2026_07_28);
2259+
let err = enforce(&info, PeerRequestAssociation::Unassociated).unwrap_err();
2260+
assert_eq!(err.code, crate::model::ErrorCode::INVALID_PARAMS);
2261+
}
2262+
2263+
#[test]
2264+
fn strict_accepts_associated() {
2265+
let info = server_info(ProtocolVersion::V_2026_07_28);
2266+
assert!(enforce(&info, PeerRequestAssociation::Associated).is_ok());
2267+
}
2268+
2269+
#[test]
2270+
fn strict_unknown_falls_back_to_coarse_check() {
2271+
let info = server_info(ProtocolVersion::V_2026_07_28);
2272+
assert!(
2273+
enforce(
2274+
&info,
2275+
PeerRequestAssociation::Unknown {
2276+
has_pending_outbound_request: true
2277+
}
2278+
)
2279+
.is_ok()
2280+
);
2281+
assert!(
2282+
enforce(
2283+
&info,
2284+
PeerRequestAssociation::Unknown {
2285+
has_pending_outbound_request: false
2286+
}
2287+
)
2288+
.is_err()
2289+
);
2290+
}
2291+
2292+
#[test]
2293+
fn legacy_protocol_accepts_even_unassociated() {
2294+
let info = server_info(ProtocolVersion::V_2025_11_25);
2295+
assert!(enforce(&info, PeerRequestAssociation::Unassociated).is_ok());
2296+
}
2297+
}

0 commit comments

Comments
 (0)