Skip to content

feat: route SEP-2260 associated server requests to the originating SSE stream#1029

Merged
alexhancock merged 12 commits into
modelcontextprotocol:mainfrom
gocamille:sep-2260-transport-association
Jul 23, 2026
Merged

feat: route SEP-2260 associated server requests to the originating SSE stream#1029
alexhancock merged 12 commits into
modelcontextprotocol:mainfrom
gocamille:sep-2260-transport-association

Conversation

@gocamille

@gocamille gocamille commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Builds on #1027 to implement SEP-2260's transport-layer requirement: server -> client requests associated with a client request are delivered on the originating request's SSE stream, not the standalone GET stream.

Tracking issue: #873

Motivation and Context

#1027 enforces that sampling/createMessage, elicitation/create, and roots/list are only sent while handling a client request. On streamable HTTP, the transport still delivered all server-initiated requests on the standalone GET stream, so the association was not observable by clients. SEP-2260 requires associated requests to ride the originating request's stream.

How

  • Added OriginatingRequestId, a marker in the outbound request's non-serialized Extensions, attached at the send choke point (Peer::send_request_with_option) whenever ORIGINATING_REQUEST (from feat: Implement SEP-2260 require server requests to associate with client requests #1027) is in scope. SEP-2260 defines no wire field, so nothing changes on the wire.
  • LocalSessionWorker::resolve_outbound_channel reads the marker and routes the request to the originating request's SSE channel via the existing resource_router with close: false so the stream stays open for the response.
  • If the originating request has already completed, falls back to the standalone stream with a tracing::warn! rather than dropping the request.
  • Documented on SessionManager that implementations serializing messages between processes lose the marker and need their own association mechanism; documented on the restricted request helpers that the association does not cross tokio::spawn.
  • Client receive-side (per review): restricted server-to-client requests received while the client has no outbound request in flight are rejected with -32602 (Invalid Params), gated on negotiated protocol 2026-07-28+, ping exempt — the "Clients receiving server-to-client requests with no associated outbound request SHOULD respond with a -32602" line from SEP-2260. With a request in flight we cannot tell which one the server request belongs to (no wire field), so this is a deliberate under-approximation; exact stream-based enforcement is a follow-up.

How Has This Been Tested?

  • Unit tests for marker attachment in/out of handler scope (service.rs) and for the three routing outcomes (local.rs).
  • End-to-end test_sep_2260_stream_routing: real streamable HTTP server; asserts an in-handler elicitation/create appears on the originating tools/call POST SSE stream and never on the standalone GET stream. Pins protocol 2025-11-25 since SEP-2567 serves 2026-07-28+ statelessly with no standalone GET stream to test against.
  • Client receive-side tests drive a raw JSON-RPC server over a duplex pipe (a compliant rmcp server can't emit an unassociated request at 2026-07-28+): unassociated sampling/createMessage rejected with -32602, permissive behavior preserved at 2025-11-25, unassociated ping still answered.

Breaking Changes

None. New public type rmcp::service::OriginatingRequestId; routing behavior changes only in the bundled LocalSessionManager. Custom SessionManager implementations are unaffected (documented limitation).

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

The marker is role-agnostic. The completed-originating-request fallback is deliberate as enforcement already happened at the service layer and dropping a committed in-flight request would hang the handler awaiting a reply, so we deliver on the standalone stream and warn instead.

One design note: the TypeScript SDK threads this association explicitly as relatedRequestId on transport send options. The equivalent here means a breaking change to Transport/PeerSinkMessage to carry data only this transport consumes, so this PR uses the existing Extensions side-channel instead. But if those traits ever take a breaking revision, promoting the association to an explicit field would be a follow-up (and it would also let serializing session managers comply with SEP-2260).

@gocamille
gocamille requested a review from a team as a code owner July 23, 2026 07:24
@github-actions github-actions Bot added T-dependencies Dependencies related changes T-test Testing related changes T-config Configuration file changes T-core Core library changes T-service Service layer changes T-transport Transport layer changes labels Jul 23, 2026

@alexhancock alexhancock left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a comment about a SHOULD in the spec, but looking good to me.

}
}

fn enforce_request_association(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason we don't also do this in service/client.rs? Trying to figure out where this part of the spec is implemented:

Clients recieving server-to-client requests with no associated outbound request SHOULD respond with a -32602 (Invalid Params) error.

I think it may be missing?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes @alexhancock it wasn't implemented anywhere, good catch. SEP-2260 defines no wire field, so the exact association is only seen at the transport layer and our streamable HTTP client merges the standalone GET stream and POST-response SSE streams into one flat stream before the service layer sees anything. On stdio there's no channel separation at all.

In f0b7fd3 if the client receives one of these requests with no outbound request in flight, it now responds with -32602. This only applies when the negotiated protocol is 2026-07-28+, and ping is exempt. The check lives in a new receive-side ServiceRole::enforce_peer_request_association hook, mirroring the send-side one from #1027.

For now the check is intentionally loose: with a request in flight we can't tell which outbound request the server request belongs to, so we accept it. Doing this properly (rejecting these requests when they arrive on the standalone GET stream, even while unrelated requests are in flight) needs the client transport to keep track of which stream each message came in on. If it's helpful I can file a follow-up so this PR stays scoped to routing plus the coarse check.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That sounds good to me for now, yes. Thanks for the updated commit.

alexhancock and others added 12 commits July 23, 2026 11:53
…equests (SEP-2260)

Reuses the ORIGINATING_REQUEST task-local from modelcontextprotocol#1027; the marker rides
the request's non-serialized Extensions so the streamable HTTP server
can route associated requests to the originating SSE stream.
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.
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).
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.
…lient (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.
@gocamille
gocamille force-pushed the sep-2260-transport-association branch from f0b7fd3 to 4039fd9 Compare July 23, 2026 15:57
@gocamille
gocamille requested a review from alexhancock July 23, 2026 16:43
@alexhancock
alexhancock merged commit 397d416 into modelcontextprotocol:main Jul 23, 2026
21 checks passed
@alexhancock

Copy link
Copy Markdown
Contributor

Very good. Thanks @gocamille !

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

Labels

T-config Configuration file changes T-core Core library changes T-dependencies Dependencies related 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.

Implement SEP-2260: Server requests must associate with a client request

2 participants