Skip to content

feat: SEP-2260 stream-based enforcement of client receive-side request association - #1055

Open
gocamille wants to merge 10 commits into
modelcontextprotocol:mainfrom
gocamille:sep-2260-stream-enforcement
Open

feat: SEP-2260 stream-based enforcement of client receive-side request association#1055
gocamille wants to merge 10 commits into
modelcontextprotocol:mainfrom
gocamille:sep-2260-stream-enforcement

Conversation

@gocamille

Copy link
Copy Markdown
Contributor

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 -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.
  • RoleClient::enforce_peer_request_association rejects Unassociated restricted requests with -32602; Associated is accepted; Unknown falls back to the coarse in-flight check from feat: route SEP-2260 associated server requests to the originating SSE stream #1029.
  • 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 InboundStreamOriginPeerRequestAssociation 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)
  • Documentation update

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.

gocamille and others added 10 commits July 26, 2026 23:56
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.
…tocol#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.
@gocamille
gocamille requested a review from a team as a code owner July 27, 2026 16:21
@github-actions github-actions Bot added T-test Testing related changes T-core Core library changes T-service Service layer changes T-transport Transport layer changes labels Jul 27, 2026
@alexhancock

Copy link
Copy Markdown
Contributor

Nice. I should be able to review tomorrow if @DaleSeo @jamadeo don't get to it first!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T-core Core library changes T-service Service layer changes T-test Testing related changes T-transport Transport layer changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants