Skip to content

Commit 4039fd9

Browse files
committed
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.
1 parent cdce2a0 commit 4039fd9

3 files changed

Lines changed: 176 additions & 1 deletion

File tree

crates/rmcp/src/service.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,20 @@ pub trait ServiceRole: std::fmt::Debug + Send + Sync + 'static + Copy + Clone {
158158
) -> Result<(), ServiceError> {
159159
Ok(())
160160
}
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+
}
161175
}
162176

163177
pub(crate) fn uses_legacy_lifecycle(
@@ -1435,6 +1449,24 @@ where
14351449
..
14361450
})) => {
14371451
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+
}
14381470
{
14391471
let service = shared_service.clone();
14401472
let sink = sink_proxy_tx.clone();

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/tests/test_sep_2260_request_association.rs

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ use rmcp::{
1212
},
1313
service::RequestContext,
1414
};
15-
use tokio::sync::oneshot;
15+
use serde_json::{Value, json};
16+
use tokio::{
17+
io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream, Lines, ReadHalf, WriteHalf},
18+
sync::oneshot,
19+
};
1620

1721
#[derive(Clone)]
1822
struct SamplingServer {
@@ -157,3 +161,113 @@ async fn generic_send_request_bypass_rejected() -> anyhow::Result<()> {
157161
let _ = server_handle.await?;
158162
Ok(())
159163
}
164+
165+
// A compliant rmcp server cannot produce an unassociated server-to-client
166+
// request at >= 2026-07-28 (send-side enforcement blocks it), so the client's
167+
// receive-side enforcement is exercised with a raw JSON-RPC server.
168+
type RawServer = (
169+
Lines<BufReader<ReadHalf<DuplexStream>>>,
170+
WriteHalf<DuplexStream>,
171+
);
172+
173+
async fn raw_initialize(io: DuplexStream, protocol_version: &str) -> anyhow::Result<RawServer> {
174+
let (read, mut write) = tokio::io::split(io);
175+
let mut lines = BufReader::new(read).lines();
176+
let init: Value = serde_json::from_str(&lines.next_line().await?.expect("initialize request"))?;
177+
assert_eq!(init["method"], "initialize");
178+
let response = json!({
179+
"jsonrpc": "2.0",
180+
"id": init["id"],
181+
"result": {
182+
"protocolVersion": protocol_version,
183+
"capabilities": {},
184+
"serverInfo": { "name": "raw-server", "version": "0.0.0" }
185+
}
186+
});
187+
write.write_all(format!("{response}\n").as_bytes()).await?;
188+
let initialized: Value =
189+
serde_json::from_str(&lines.next_line().await?.expect("initialized notification"))?;
190+
assert_eq!(initialized["method"], "notifications/initialized");
191+
Ok((lines, write))
192+
}
193+
194+
async fn raw_request(server: &mut RawServer, request: Value) -> anyhow::Result<Value> {
195+
let (lines, write) = server;
196+
write.write_all(format!("{request}\n").as_bytes()).await?;
197+
Ok(serde_json::from_str(
198+
&lines.next_line().await?.expect("response"),
199+
)?)
200+
}
201+
202+
fn raw_sampling_request(id: u32) -> Value {
203+
json!({
204+
"jsonrpc": "2.0",
205+
"id": id,
206+
"method": "sampling/createMessage",
207+
"params": {
208+
"messages": [{ "role": "user", "content": { "type": "text", "text": "hi" } }],
209+
"maxTokens": 16
210+
}
211+
})
212+
}
213+
214+
#[tokio::test]
215+
async fn unassociated_server_request_rejected_with_invalid_params() -> anyhow::Result<()> {
216+
let (client_io, server_io) = tokio::io::duplex(4096);
217+
let raw = tokio::spawn(async move {
218+
let mut server = raw_initialize(server_io, "2026-07-28").await?;
219+
raw_request(&mut server, raw_sampling_request(100)).await
220+
});
221+
222+
let client = SamplingClient.serve(client_io).await?;
223+
let response = raw.await??;
224+
assert_eq!(
225+
response["error"]["code"], -32602,
226+
"SEP-2260: unassociated server-to-client request must be rejected with invalid params, got {response}"
227+
);
228+
229+
client.cancel().await?;
230+
Ok(())
231+
}
232+
233+
#[tokio::test]
234+
async fn unassociated_server_request_allowed_on_legacy_protocol() -> anyhow::Result<()> {
235+
let (client_io, server_io) = tokio::io::duplex(4096);
236+
let raw = tokio::spawn(async move {
237+
let mut server = raw_initialize(server_io, "2025-11-25").await?;
238+
raw_request(&mut server, raw_sampling_request(100)).await
239+
});
240+
241+
let client = SamplingClient.serve(client_io).await?;
242+
let response = raw.await??;
243+
assert_eq!(
244+
response["result"]["model"], "test-model",
245+
"pre-2026-07-28 peers keep the permissive behavior, got {response}"
246+
);
247+
248+
client.cancel().await?;
249+
Ok(())
250+
}
251+
252+
#[tokio::test]
253+
async fn unassociated_ping_allowed() -> anyhow::Result<()> {
254+
let (client_io, server_io) = tokio::io::duplex(4096);
255+
let raw = tokio::spawn(async move {
256+
let mut server = raw_initialize(server_io, "2026-07-28").await?;
257+
raw_request(
258+
&mut server,
259+
json!({ "jsonrpc": "2.0", "id": 101, "method": "ping" }),
260+
)
261+
.await
262+
});
263+
264+
let client = SamplingClient.serve(client_io).await?;
265+
let response = raw.await??;
266+
assert!(
267+
response.get("error").is_none(),
268+
"SEP-2260 excepts ping from request association, got {response}"
269+
);
270+
271+
client.cancel().await?;
272+
Ok(())
273+
}

0 commit comments

Comments
 (0)