Skip to content

Commit b61b12f

Browse files
committed
feat(transport): record inbound stream origin on streamable HTTP client (#1033)
The worker attaches an InboundStreamOrigin extension to each inbound server request: Unassociated for the standalone GET stream, OutboundRequest(id) for a POST's SSE stream. Mirror of the OriginatingRequestId marker used by the server side.
1 parent 09af9d0 commit b61b12f

2 files changed

Lines changed: 102 additions & 5 deletions

File tree

crates/rmcp/src/service.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,23 @@ pub(crate) fn in_request_handler_scope() -> bool {
231231
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
232232
pub struct OriginatingRequestId(pub RequestId);
233233

234+
/// Marker in an inbound request's non-serialized [`Extensions`] recording
235+
/// which HTTP response stream it arrived on (SEP-2260): the receive-side
236+
/// mirror of [`OriginatingRequestId`]. Attached by the streamable HTTP
237+
/// client transport; read by the service layer to enforce the client
238+
/// receive-side association check. Never on the wire (SEP-2260 defines no
239+
/// wire field), so transports that serialize messages cannot convey it and
240+
/// the service layer falls back to the coarse in-flight check.
241+
#[derive(Debug, Clone, PartialEq, Eq)]
242+
#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")]
243+
pub enum InboundStreamOrigin {
244+
/// The standalone GET stream, or a POST response stream not tied to an
245+
/// outbound request.
246+
Unassociated,
247+
/// The SSE response stream of the POST that carried this outbound request.
248+
OutboundRequest(RequestId),
249+
}
250+
234251
pub type TxJsonRpcMessage<R> =
235252
JsonRpcMessage<<R as ServiceRole>::Req, <R as ServiceRole>::Resp, <R as ServiceRole>::Not>;
236253
pub type RxJsonRpcMessage<R> = JsonRpcMessage<

crates/rmcp/src/transport/streamable_http_client.rs

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@ use super::common::client_side_sse::{
1919
use crate::{
2020
RoleClient,
2121
model::{
22-
ClientJsonRpcMessage, ClientNotification, ClientRequest, ErrorData, GetMeta,
22+
ClientJsonRpcMessage, ClientNotification, ClientRequest, ErrorData, GetExtensions, GetMeta,
2323
InitializedNotification, JsonObject, ProtocolVersion, RequestId, ServerJsonRpcMessage,
2424
ServerResult,
2525
},
26+
service::InboundStreamOrigin,
2627
transport::{
2728
common::{client_side_sse::SseAutoReconnectStream, mcp_headers},
2829
worker::{Worker, WorkerQuitReason, WorkerSendRequest, WorkerTransport},
@@ -635,6 +636,7 @@ impl<C: StreamableHttpClient> StreamableHttpClientWorker<C> {
635636
+ Send
636637
+ 'static,
637638
sse_worker_tx: tokio::sync::mpsc::Sender<ServerJsonRpcMessage>,
639+
origin: InboundStreamOrigin,
638640
close_on_response: bool,
639641
ct: CancellationToken,
640642
) -> Result<(), StreamableHttpError<C::Error>> {
@@ -649,9 +651,14 @@ impl<C: StreamableHttpClient> StreamableHttpClientWorker<C> {
649651
break;
650652
}
651653
};
652-
let Some(message) = message.transpose()? else {
654+
let Some(mut message) = message.transpose()? else {
653655
break;
654656
};
657+
// SEP-2260: record which stream this request arrived on so the
658+
// service layer can enforce receive-side request association.
659+
if let ServerJsonRpcMessage::Request(request) = &mut message {
660+
request.request.extensions_mut().insert(origin.clone());
661+
}
655662
let is_response = matches!(
656663
message,
657664
ServerJsonRpcMessage::Response(_) | ServerJsonRpcMessage::Error(_)
@@ -719,6 +726,7 @@ impl<C: StreamableHttpClient> StreamableHttpClientWorker<C> {
719726
Self::execute_sse_stream(
720727
sse_stream,
721728
sse_worker_tx,
729+
InboundStreamOrigin::Unassociated,
722730
false,
723731
transport_task_ct.child_token(),
724732
)
@@ -1283,9 +1291,18 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
12831291
);
12841292
}
12851293
let stream_tx = sse_worker_tx.clone();
1294+
let origin = match &stream_request_id {
1295+
Some(id) => {
1296+
InboundStreamOrigin::OutboundRequest(
1297+
id.clone(),
1298+
)
1299+
}
1300+
None => InboundStreamOrigin::Unassociated,
1301+
};
12861302
streams.spawn(async move {
12871303
let result = Self::execute_sse_stream(
1288-
sse_stream, stream_tx, true, stream_ct,
1304+
sse_stream, stream_tx, origin, true,
1305+
stream_ct,
12891306
)
12901307
.await;
12911308
(stream_request_id, result)
@@ -1342,9 +1359,13 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
13421359
.insert(request_id.clone(), stream_ct.clone());
13431360
}
13441361
let stream_tx = sse_worker_tx.clone();
1362+
let origin = match &stream_request_id {
1363+
Some(id) => InboundStreamOrigin::OutboundRequest(id.clone()),
1364+
None => InboundStreamOrigin::Unassociated,
1365+
};
13451366
streams.spawn(async move {
13461367
let result = Self::execute_sse_stream(
1347-
sse_stream, stream_tx, true, stream_ct,
1368+
sse_stream, stream_tx, origin, true, stream_ct,
13481369
)
13491370
.await;
13501371
(stream_request_id, result)
@@ -1769,7 +1790,66 @@ mod tests {
17691790
use serde_json::json;
17701791

17711792
use super::*;
1772-
use crate::model::{ListToolsResult, NumberOrString, ServerResult, Tool};
1793+
use crate::{
1794+
model::{
1795+
GetExtensions, ListToolsResult, NumberOrString, ServerRequest, ServerResult, Tool,
1796+
},
1797+
service::InboundStreamOrigin,
1798+
};
1799+
1800+
#[expect(
1801+
deprecated,
1802+
reason = "sampling (SEP-2577) is still a representative server request"
1803+
)]
1804+
fn sampling_request_message(id: i64) -> ServerJsonRpcMessage {
1805+
use crate::model::{CreateMessageRequest, CreateMessageRequestParams, SamplingMessage};
1806+
ServerJsonRpcMessage::request(
1807+
ServerRequest::CreateMessageRequest(CreateMessageRequest::new(
1808+
CreateMessageRequestParams::new(vec![SamplingMessage::user_text("hi")], 16),
1809+
)),
1810+
NumberOrString::Number(id),
1811+
)
1812+
}
1813+
1814+
#[tokio::test]
1815+
async fn execute_sse_stream_marks_inbound_requests_with_origin() {
1816+
for origin in [
1817+
InboundStreamOrigin::Unassociated,
1818+
InboundStreamOrigin::OutboundRequest(RequestId::Number(3)),
1819+
] {
1820+
let response = ServerJsonRpcMessage::response(
1821+
ServerResult::ListToolsResult(ListToolsResult::default()),
1822+
NumberOrString::Number(1),
1823+
);
1824+
let stream = futures::stream::iter([Ok(sampling_request_message(9)), Ok(response)]);
1825+
let (tx, mut rx) = tokio::sync::mpsc::channel(4);
1826+
StreamableHttpClientWorker::<StatelessReconnectClient>::execute_sse_stream(
1827+
stream,
1828+
tx,
1829+
origin.clone(),
1830+
false,
1831+
CancellationToken::new(),
1832+
)
1833+
.await
1834+
.expect("stream completes");
1835+
1836+
let ServerJsonRpcMessage::Request(request) =
1837+
rx.recv().await.expect("request forwarded")
1838+
else {
1839+
panic!("expected request first");
1840+
};
1841+
assert_eq!(
1842+
request.request.extensions().get::<InboundStreamOrigin>(),
1843+
Some(&origin),
1844+
"inbound requests must carry their stream origin"
1845+
);
1846+
// Responses are correlated by JSON-RPC id; no marker needed or added.
1847+
assert!(matches!(
1848+
rx.recv().await.expect("response forwarded"),
1849+
ServerJsonRpcMessage::Response(_)
1850+
));
1851+
}
1852+
}
17731853

17741854
type ReconnectAttempt = (Option<String>, Option<String>);
17751855

0 commit comments

Comments
 (0)