') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); feat: SEP-2260 stream-based enforcement of client receive-side request association by gocamille · Pull Request #1055 · modelcontextprotocol/rust-sdk · GitHub
Skip to content

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

Merged
DaleSeo merged 11 commits into
modelcontextprotocol:mainfrom
gocamille:sep-2260-stream-enforcement
Jul 29, 2026
Merged

feat: SEP-2260 stream-based enforcement of client receive-side request association#1055
DaleSeo merged 11 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
gocamille requested a review from a team as a code ownerJuly 27, 2026 16:21
@github-actionsgithub-actionsBot 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!

@gocamille
gocamilleforce-pushed the sep-2260-stream-enforcement branch from fec6cae to c3bab3aCompareJuly 29, 2026 00:45
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.
Rebase onto 3.0.0: RoleClient::PeerInfo is now ServerPeerInfo (modelcontextprotocol#1065)
and its constructor takes the protocol version directly.
@gocamille
gocamilleforce-pushed the sep-2260-stream-enforcement branch from c3bab3a to ac61fd8CompareJuly 29, 2026 01:29
DaleSeo
DaleSeo previously approved these changes Jul 29, 2026

@DaleSeoDaleSeo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for implementing this, @gocamille! I only have a nitpick.

Comment threadcrates/rmcp/src/service.rs Outdated
Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com>
@gocamille

Copy link
Copy Markdown
ContributorAuthor

Looks like adding suggestions requires another review, so re-requesting -- thank you again @DaleSeo !

@DaleSeo
DaleSeo merged commit 1d0473b into modelcontextprotocol:mainJul 29, 2026
22 checks passed
@github-actionsgithub-actionsBot mentioned this pull request Jul 30, 2026
howardjohn pushed a commit to agentgateway/agentgateway that referenced this pull request Jul 31, 2026
Bump to latest stable release
https://github.com/modelcontextprotocol/rust-sdk/releases/tag/rmcp-v3.1.0
```
Added
classify authorization-required errors (modelcontextprotocol/rust-sdk#1056)
add strict stateless protocol metadata validation (modelcontextprotocol/rust-sdk#1091)
SEP-2260 stream-based enforcement of client receive-side request association (modelcontextprotocol/rust-sdk#1055)
Fixed
(model) decode metadata-bearing input-required results affecting mrtr (modelcontextprotocol/rust-sdk#1097)
require metadata for modern HTTP requests (modelcontextprotocol/rust-sdk#1089)
honor supported_protocol_versions when negotiating initialize (modelcontextprotocol/rust-sdk#1093)
Other
document the ping utility with examples (modelcontextprotocol/rust-sdk#1106)
complete Tier 1 feature docs and finalize roadmap (modelcontextprotocol/rust-sdk#1101)
(conformance) meeting requirements for tier 1 (modelcontextprotocol/rust-sdk#1087)
```
Signed-off-by: Filinto Duran <1373693+filintod@users.noreply.github.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T-coreCore library changesT-serviceService layer changesT-testTesting related changesT-transportTransport layer changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@gocamille@alexhancock@DaleSeo