You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
PR #1029 implemented the receive-side SHOULD ("Clients receiving server-to-client requests with no associated outbound request SHOULD respond with a -32602 error") with a coarse check: reject restricted requests (sampling/createMessage, elicitation/create, roots/list; ping exempt) only when the client has no outbound request in flight. It couldn't tell which request a server request belongs to. SEP-2260 defines no wire field, so association is only observable at the transport layer via which HTTP response stream a message arrived on.
This PR closes that gap for streamable HTTP. Now, a restricted request arriving on the GET stream (or on the SSE stream of a POST whose request is no longer in flight) is rejected with-32602 even when unrelated requests are in flight.
How
The streamable HTTP client transport attaches an InboundStreamOrigin marker (Unassociated | OutboundRequest(RequestId)) to each inbound request's non-serialized Extensions, recording whether it arrived on the standalone GET stream or a specific POST's SSE response stream. The marker survives SSE reconnection/resumption.
The service layer translates the marker plus the in-flight responder pool into a PeerRequestAssociation enum (Associated | Unassociated | Unknown { has_pending_outbound_request }) passed to ServiceRole::enforce_peer_request_association.
Still gated on negotiated protocol ≥ 2026-07-28; older peers keep legacy behavior.
Transports without stream separation (stdio, in-process) attach no marker and yield Unknown.
How Has This Been Tested?
Unit tests for the InboundStreamOrigin → PeerRequestAssociation mapping and for enforce_peer_request_association across all three association states.
Transport tests verifying execute_sse_stream marks inbound requests with their stream origin (responses are untouched) and that the origin marker survives SSE resumption/reconnect.
End-to-end test over streamable HTTP (test_sep_2260_stream_enforcement.rs): a restricted request on the originating POST's SSE stream reaches the handler; the same request on the standalone GET stream is rejected with -32602 while an unrelated request is in flight. The scripted server negotiates 2026-07-28 and creates a session id. It's deliberately non-conforming (SEP-2567 removes sessions and the GET endpoint at that version), since receive-side enforcement exists to defend against exactly such servers and rmcp's client tolerates the session id and opens the GET stream.
Breaking Changes
None. New public API: PeerRequestAssociation and the ServiceRole::enforce_peer_request_association hook (with a permissive default). Behavior changes only for streamable HTTP clients negotiating 2026-07-28+ against servers that send restricted requests on the wrong stream.
Types of changes
Bug fix (non-breaking change which fixes an issue)
New feature (non-breaking change which adds functionality)
Breaking change (fix or feature that would cause existing functionality to change)
Possible follow-up: skip session tracking / the standalone GET stream entirely when the negotiated version is ≥ 2026-07-28, making the unassociated-GET-stream state unreachable.
Replaces the has_pending_outbound_request bool on
enforce_peer_request_association with PeerRequestAssociation, so a
stream-separating transport can report per-request association (modelcontextprotocol#1033).
Behavior-preserving: the event loop still passes the coarse signal as
Unknown.
…nt (modelcontextprotocol#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.
…origin (modelcontextprotocol#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.
…tprotocol#1033)
A POST SSE stream resumed via GET + Last-Event-ID (SEP-1699) reconnects
beneath execute_sse_stream, so requests replayed after a resume keep
their OutboundRequest origin. Pins the layering invariant: hoisting
reconnection above the marker attach point would wrongly reject
associated requests with -32602.
Detect handler invocation via a channel asserted empty instead of a
panic in a spawned task (swallowed, cannot fail the test); surface
scripted-server misuse as transport errors rather than panics in the
transport task; bound the tail awaits with 5s timeouts. Correct the
header comment: a 2026-07-28 server minting a session id is not
spec-legal (SEP-2567 removes sessions and the GET endpoint) — the
scripted server is deliberately non-conforming, which is the point of
receive-side enforcement.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the stream-based enforcement described in SEP-2260.
Tracking issue: #1033 (follow-up to #873 / #1029)
Motivation and Context
PR #1029 implemented the receive-side SHOULD ("Clients receiving server-to-client requests with no associated outbound request SHOULD respond with a
-32602error") with a coarse check: reject restricted requests (sampling/createMessage,elicitation/create,roots/list;pingexempt) only when the client has no outbound request in flight. It couldn't tell which request a server request belongs to. SEP-2260 defines no wire field, so association is only observable at the transport layer via which HTTP response stream a message arrived on.This PR closes that gap for streamable HTTP. Now, a restricted request arriving on the GET stream (or on the SSE stream of a POST whose request is no longer in flight) is rejected with
-32602even when unrelated requests are in flight.How
InboundStreamOriginmarker (Unassociated|OutboundRequest(RequestId)) to each inbound request's non-serializedExtensions, recording whether it arrived on the standalone GET stream or a specific POST's SSE response stream. The marker survives SSE reconnection/resumption.PeerRequestAssociationenum (Associated|Unassociated|Unknown { has_pending_outbound_request }) passed toServiceRole::enforce_peer_request_association.RoleClient::enforce_peer_request_associationrejectsUnassociatedrestricted requests with-32602;Associatedis accepted;Unknownfalls back to the coarse in-flight check from feat: route SEP-2260 associated server requests to the originating SSE stream #1029.2026-07-28; older peers keep legacy behavior.Unknown.How Has This Been Tested?
InboundStreamOrigin→PeerRequestAssociationmapping and forenforce_peer_request_associationacross all three association states.execute_sse_streammarks inbound requests with their stream origin (responses are untouched) and that the origin marker survives SSE resumption/reconnect.test_sep_2260_stream_enforcement.rs): a restricted request on the originating POST's SSE stream reaches the handler; the same request on the standalone GET stream is rejected with-32602while an unrelated request is in flight. The scripted server negotiates2026-07-28and creates a session id. It's deliberately non-conforming (SEP-2567 removes sessions and the GET endpoint at that version), since receive-side enforcement exists to defend against exactly such servers and rmcp's client tolerates the session id and opens the GET stream.Breaking Changes
None. New public API:
PeerRequestAssociationand theServiceRole::enforce_peer_request_associationhook (with a permissive default). Behavior changes only for streamable HTTP clients negotiating2026-07-28+against servers that send restricted requests on the wrong stream.Types of changes
Checklist
I have read the MCP Documentation
My code follows the repository's style guidelines
New and existing tests pass locally
I have added appropriate error handling
I have added or updated documentation as needed
Additional context
Possible follow-up: skip session tracking / the standalone GET stream entirely when the negotiated version is ≥
2026-07-28, making the unassociated-GET-stream state unreachable.