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>
This commit is contained in:
parent
aad4d4e4c6
commit
397d416fdc
2 changed files with 853 additions and 6 deletions
|
|
@ -213,6 +213,35 @@ impl ServiceRole for RoleClient {
|
|||
}
|
||||
}
|
||||
|
||||
// SEP-2260: with no outbound request in flight there is nothing the
|
||||
// server request could be associated with, so reject it. With one in
|
||||
// flight we cannot tell which request it belongs to (no wire field), so
|
||||
// we accept — an under-approximation of the spec's SHOULD.
|
||||
fn enforce_peer_request_association(
|
||||
peer_request: &Self::PeerReq,
|
||||
peer_info: Option<&Self::PeerInfo>,
|
||||
has_pending_outbound_request: bool,
|
||||
) -> Result<(), ErrorData> {
|
||||
let restricted = matches!(
|
||||
peer_request,
|
||||
ServerRequest::CreateMessageRequest(_)
|
||||
| ServerRequest::ListRootsRequest(_)
|
||||
| ServerRequest::ElicitRequest(_)
|
||||
);
|
||||
if !restricted {
|
||||
return Ok(());
|
||||
}
|
||||
let strict =
|
||||
peer_info.is_some_and(|info| info.protocol_version >= ProtocolVersion::V_2026_07_28);
|
||||
if strict && !has_pending_outbound_request {
|
||||
return Err(ErrorData::invalid_params(
|
||||
"SEP-2260: server-to-client requests must be associated with an in-flight client request",
|
||||
None,
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn invalidate_response_cache(peer: &Peer<Self>, notification: &Self::PeerNot) {
|
||||
match notification {
|
||||
ServerNotification::ResourceUpdatedNotification(notification) => {
|
||||
|
|
|
|||
|
|
@ -49,6 +49,31 @@ impl ServiceRole for RoleServer {
|
|||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn enforce_request_association(
|
||||
request: &Self::Req,
|
||||
peer_info: Option<&Self::PeerInfo>,
|
||||
in_request_handler_scope: bool,
|
||||
) -> Result<(), ServiceError> {
|
||||
let restricted = matches!(
|
||||
request,
|
||||
ServerRequest::CreateMessageRequest(_)
|
||||
| ServerRequest::ListRootsRequest(_)
|
||||
| ServerRequest::ElicitRequest(_)
|
||||
);
|
||||
if !restricted {
|
||||
return Ok(());
|
||||
}
|
||||
let strict =
|
||||
peer_info.is_some_and(|info| info.protocol_version >= ProtocolVersion::V_2026_07_28);
|
||||
if strict && !in_request_handler_scope {
|
||||
return Err(ServiceError::McpError(ErrorData::invalid_request(
|
||||
"SEP-2260: server-to-client requests must be associated with an originating client request",
|
||||
None,
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// It represents the error that may occur when serving the server.
|
||||
|
|
@ -744,6 +769,10 @@ impl Peer<RoleServer> {
|
|||
}
|
||||
}
|
||||
|
||||
/// # SEP-2260: request association
|
||||
///
|
||||
/// From protocol version `2026-07-28` this must be issued while handling a
|
||||
/// client request; see [`OriginatingRequestId`].
|
||||
#[deprecated(
|
||||
since = "1.8.0",
|
||||
note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577"
|
||||
|
|
@ -778,6 +807,10 @@ impl Peer<RoleServer> {
|
|||
}
|
||||
}
|
||||
method!(
|
||||
/// # SEP-2260: request association
|
||||
///
|
||||
/// From protocol version `2026-07-28` this must be issued while handling a
|
||||
/// client request; see [`OriginatingRequestId`].
|
||||
#[deprecated(
|
||||
since = "1.8.0",
|
||||
note = "Roots is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577"
|
||||
|
|
@ -785,9 +818,21 @@ impl Peer<RoleServer> {
|
|||
peer_req list_roots ListRootsRequest() => ListRootsResult
|
||||
);
|
||||
#[cfg(feature = "elicitation")]
|
||||
method!(peer_req create_elicitation ElicitRequest(ElicitRequestParams) => ElicitResult);
|
||||
method!(
|
||||
/// # SEP-2260: request association
|
||||
///
|
||||
/// From protocol version `2026-07-28` this must be issued while handling a
|
||||
/// client request; see [`OriginatingRequestId`].
|
||||
peer_req create_elicitation ElicitRequest(ElicitRequestParams) => ElicitResult
|
||||
);
|
||||
#[cfg(feature = "elicitation")]
|
||||
method!(peer_req_with_timeout create_elicitation_with_timeout ElicitRequest(ElicitRequestParams) => ElicitResult);
|
||||
method!(
|
||||
/// # SEP-2260: request association
|
||||
///
|
||||
/// From protocol version `2026-07-28` this must be issued while handling a
|
||||
/// client request; see [`OriginatingRequestId`].
|
||||
peer_req_with_timeout create_elicitation_with_timeout ElicitRequest(ElicitRequestParams) => ElicitResult
|
||||
);
|
||||
|
||||
method!(peer_not notify_cancelled CancelledNotification(CancelledNotificationParam));
|
||||
method!(peer_not notify_progress ProgressNotification(ProgressNotificationParam));
|
||||
|
|
@ -1014,6 +1059,11 @@ impl Peer<RoleServer> {
|
|||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
///
|
||||
/// # SEP-2260: request association
|
||||
///
|
||||
/// From protocol version `2026-07-28` this must be issued while handling a
|
||||
/// client request; see [`OriginatingRequestId`].
|
||||
#[cfg(all(feature = "schemars", feature = "elicitation"))]
|
||||
pub async fn elicit<T>(&self, message: impl Into<String>) -> Result<Option<T>, ElicitationError>
|
||||
where
|
||||
|
|
@ -1075,6 +1125,11 @@ impl Peer<RoleServer> {
|
|||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
///
|
||||
/// # SEP-2260: request association
|
||||
///
|
||||
/// From protocol version `2026-07-28` this must be issued while handling a
|
||||
/// client request; see [`OriginatingRequestId`].
|
||||
#[cfg(all(feature = "schemars", feature = "elicitation"))]
|
||||
pub async fn elicit_with_timeout<T>(
|
||||
&self,
|
||||
|
|
@ -1170,6 +1225,11 @@ impl Peer<RoleServer> {
|
|||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// # SEP-2260: request association
|
||||
///
|
||||
/// From protocol version `2026-07-28` this must be issued while handling a
|
||||
/// client request; see [`OriginatingRequestId`].
|
||||
#[cfg(feature = "elicitation")]
|
||||
pub async fn elicit_url(
|
||||
&self,
|
||||
|
|
@ -1221,6 +1281,11 @@ impl Peer<RoleServer> {
|
|||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// # SEP-2260: request association
|
||||
///
|
||||
/// From protocol version `2026-07-28` this must be issued while handling a
|
||||
/// client request; see [`OriginatingRequestId`].
|
||||
#[cfg(feature = "elicitation")]
|
||||
pub async fn elicit_url_with_timeout(
|
||||
&self,
|
||||
|
|
|
|||
Loading…
Reference in a new issue