Skip to content

Commit e1d0188

Browse files
committed
feat(service): enforce SEP-2260 client receive-side check per stream origin (#1033)
The event loop maps InboundStreamOrigin plus the in-flight responder pool to PeerRequestAssociation: restricted requests arriving on the standalone GET stream are now rejected with -32602 even while unrelated outbound requests are in flight.
1 parent b61b12f commit e1d0188

1 file changed

Lines changed: 92 additions & 3 deletions

File tree

crates/rmcp/src/service.rs

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,28 @@ pub(crate) fn uses_legacy_lifecycle(
204204
&& protocol_version.is_none_or(|version| version < &ProtocolVersion::V_2026_07_28)
205205
}
206206

207+
/// Translate the transport-attached [`InboundStreamOrigin`] marker (if any)
208+
/// and the in-flight outbound request pool into the association passed to
209+
/// [`ServiceRole::enforce_peer_request_association`].
210+
pub(crate) fn peer_request_association<Req: crate::model::GetExtensions, V>(
211+
request: &Req,
212+
local_responder_pool: &std::collections::HashMap<RequestId, V>,
213+
) -> PeerRequestAssociation {
214+
match request.extensions().get::<InboundStreamOrigin>() {
215+
None => PeerRequestAssociation::Unknown {
216+
has_pending_outbound_request: !local_responder_pool.is_empty(),
217+
},
218+
Some(InboundStreamOrigin::Unassociated) => PeerRequestAssociation::Unassociated,
219+
Some(InboundStreamOrigin::OutboundRequest(id)) => {
220+
if local_responder_pool.contains_key(id) {
221+
PeerRequestAssociation::Associated
222+
} else {
223+
PeerRequestAssociation::Unassociated
224+
}
225+
}
226+
}
227+
}
228+
207229
tokio::task_local! {
208230
pub(crate) static ORIGINATING_REQUEST: RequestId;
209231
}
@@ -1487,9 +1509,7 @@ where
14871509
if let Err(error) = R::enforce_peer_request_association(
14881510
&request,
14891511
peer.peer_info().as_deref(),
1490-
PeerRequestAssociation::Unknown {
1491-
has_pending_outbound_request: !local_responder_pool.is_empty(),
1492-
},
1512+
peer_request_association(&request, &local_responder_pool),
14931513
) {
14941514
tracing::warn!(%id, message = %error.message, "rejected peer request");
14951515
// send directly: the sink proxy path would drop the
@@ -1770,4 +1790,73 @@ mod sep2260_marker_tests {
17701790
let request = send_and_capture(None).await;
17711791
assert!(request.extensions().get::<OriginatingRequestId>().is_none());
17721792
}
1793+
1794+
#[test]
1795+
#[expect(
1796+
deprecated,
1797+
reason = "Sampling is deprecated by SEP-2577 but remains the canonical restricted request"
1798+
)]
1799+
fn peer_request_association_maps_stream_origin() {
1800+
use std::collections::HashMap;
1801+
1802+
use crate::model::{
1803+
CreateMessageRequest, CreateMessageRequestParams, SamplingMessage, ServerRequest,
1804+
};
1805+
1806+
fn sampling(origin: Option<InboundStreamOrigin>) -> ServerRequest {
1807+
let mut request = CreateMessageRequest::new(CreateMessageRequestParams::new(
1808+
vec![SamplingMessage::user_text("hi")],
1809+
16,
1810+
));
1811+
if let Some(origin) = origin {
1812+
request.extensions.insert(origin);
1813+
}
1814+
ServerRequest::CreateMessageRequest(request)
1815+
}
1816+
1817+
let empty: HashMap<RequestId, ()> = HashMap::new();
1818+
let in_flight: HashMap<RequestId, ()> = HashMap::from([(RequestId::Number(7), ())]);
1819+
1820+
// No marker (stdio): coarse signal.
1821+
assert_eq!(
1822+
peer_request_association(&sampling(None), &in_flight),
1823+
PeerRequestAssociation::Unknown {
1824+
has_pending_outbound_request: true
1825+
}
1826+
);
1827+
assert_eq!(
1828+
peer_request_association(&sampling(None), &empty),
1829+
PeerRequestAssociation::Unknown {
1830+
has_pending_outbound_request: false
1831+
}
1832+
);
1833+
// Standalone GET stream: unassociated even with requests in flight.
1834+
assert_eq!(
1835+
peer_request_association(
1836+
&sampling(Some(InboundStreamOrigin::Unassociated)),
1837+
&in_flight
1838+
),
1839+
PeerRequestAssociation::Unassociated
1840+
);
1841+
// Originating POST stream of an in-flight request: associated.
1842+
assert_eq!(
1843+
peer_request_association(
1844+
&sampling(Some(InboundStreamOrigin::OutboundRequest(
1845+
RequestId::Number(7)
1846+
))),
1847+
&in_flight
1848+
),
1849+
PeerRequestAssociation::Associated
1850+
);
1851+
// Stream of a request that is no longer in flight: unassociated.
1852+
assert_eq!(
1853+
peer_request_association(
1854+
&sampling(Some(InboundStreamOrigin::OutboundRequest(
1855+
RequestId::Number(8)
1856+
))),
1857+
&in_flight
1858+
),
1859+
PeerRequestAssociation::Unassociated
1860+
);
1861+
}
17731862
}

0 commit comments

Comments
 (0)