* fix(sse): saturate exponential reconnect backoff
ExponentialBackoff::retry computed the reconnect multiplier with
2u32.pow(current_times). With max_times unset, current_times can reach
the bit width, panicking in debug builds and wrapping to a zero delay in
release builds for long-lived SSE clients. Use saturating_pow and
Duration::saturating_mul so the delay stays monotonic and panic-free.
* fix(sse): cap exponential reconnect backoff at a bounded max delay
Saturating the multiplier alone can still yield decades-long sleeps once
current_times reaches the bit width, pinning the stream in
tokio::time::sleep without reconnecting or terminating. Add an optional
max_delay (default 30s) that clamps the computed delay, keeping the
backoff monotonic and panic-free while guaranteeing the client retries.
* fix(sse): default max_delay to None to preserve unbounded backoff
Per maintainer feedback, leave ExponentialBackoff::default() unbounded so
the fix stays a pure overflow bug-fix. The saturating multiplier removes
the debug panic / release wrap from #1198, while max_delay stays opt-in
for callers that want a bounded reconnect delay.
* fix: resolve clippy warnings across workspace
* ci: also run clippy on rmcp with all features except local
--all-features enables the local feature, which cfg-gates out
tower.rs and most of the test suite, so the existing clippy step
never actually lints them.
* feat(auth): request ID-JAGs with enterprise refresh tokens
* feat(auth): exchange ID-JAGs for MCP access tokens
Redeem ID-JAGs at the approved resource authorization server and return
its bearer token, lifetime, and effective granted scopes. Preserve scope
narrowing and redacted diagnostics, and reuse the default HTTP adapter.
Support independently configured client authentication at both servers.
Document the exchange profile and test redirects and staged failures.
Partially addresses modelcontextprotocol/rust-sdk#531.
* test(conformance): cover EMA refresh-token exchange
* fix: allow concurrent streamable http requests
* fix: keep streamable http recovery responsive
Keep cancellation and replies available while old session POSTs finish.
Bound the wait for old POSTs and the replacement initialization handshake.
Do not retry interrupted POSTs because the server may have processed them.
Add regressions for recovery, queued cancellation, control timeouts, and
server replies needed by active requests.
* fix: preserve response and cancellation ordering
* refactor: clarify streamable http control flow
* fix: match stream responses against pending request ids
Match responses against all pending requests before removing a stream
registration. Keep distinct numeric and string ids separate while preserving
the existing fallback for servers that stringify numeric ids.
Add a mixed-id regression and keep a separate registration owner alive in
the abandoned-cancellation test.
* feat: make streamable http control timeouts configurable
* fix(rmcp): preserve elicitation requestedSchema $schema dialect
ElicitationSchema round-trips through ElicitationSchemaWire, which had no
field for the top-level `$schema` keyword. A dialect declared on an incoming
requestedSchema (allowed since protocol revision 2025-11-25) was therefore
accepted but silently dropped on re-serialization.
Add a `schema` field to both the public struct and the wire bridge, thread it
through the two `From` conversions, and expose a `with_schema` setter. The
field is `skip_serializing_if = "Option::is_none"`, so schemas without a
dialect serialize unchanged.
Refs #1168
* test(rmcp): regenerate server message schema snapshot for elicitation $schema
The $schema dialect field added to ElicitationSchema changes the
schemars-generated ServerJsonRpcMessage schema. Regenerate the golden
snapshot so test_server_json_rpc_message_schema passes.
* fix(rmcp): generate elicitation $schema as optional string in schemars
Use schemars(with = "String") on ElicitationSchema::$schema so
schemars generates "type": "string" instead of ["string", "null"].
`ClientLifecycleMode::Auto` only fell back from `server/discover` on
`-32601`, so legacy servers that reject the probe with other codes
(`-32600`, `-32602`, implementation-defined errors) failed to connect
even though `initialize` would have succeeded.
The previous attempt (indicates_legacy_server) classified the failure
after the fact by reverse-engineering the error type. This rewrite moves
the classification into `discover_startup` itself, where the full context
(request id, response correlation, transport state) is still available.
`discover_startup` now returns `DiscoverOutcome`: `Modern` on success,
`Legacy(error)` when the probe received a complete, correlated JSON-RPC
error whose code is not a modern-era rejection. Every other failure
becomes `Err`, so `Auto` simply matches the outcome — no methods on
`ClientInitializeError`, no downcast, no transport-specific types
leaking into the generic lifecycle layer.
Additional fixes that fall out naturally:
- Response correlation is now checked in `expect_response` for both
success and error branches. Previously error responses skipped id
correlation entirely. A new `UncorrelatedErrorResponse` variant
surfaces responses that cannot be tied to the request.
- When both discover and the legacy fallback fail, a
`LegacyFallbackFailed` compound error preserves both phases instead
of discarding the discover error.
Fixes#1040.
`post_message_with_max_sse_event_size` turns a 401 or 403 carrying a
`WWW-Authenticate` header into `StreamableHttpError::AuthRequired` /
`InsufficientScope`. `get_stream_with_max_sse_event_size`, in the same file,
handles only 405 and then falls through to `error_for_status()?`, so the same
challenge becomes an opaque `StreamableHttpError::Client`.
That matters because `AuthClient::get_stream` routes through
`call_reacting_to_challenges`, whose whole purpose is to catch `AuthRequired`,
run `try_refresh_or_reauth()`, and retry once. It never sees that variant from
this path, so an expired token on the standalone SSE stream is never refreshed —
the stream just fails, while the identical expiry on `post_message` recovers
silently.
Copies the two blocks verbatim from `post_message` in the same file.
`unix_socket.rs` already does the same thing in its own `get_stream`.
Adds four tests against an axum mock server: a 401 with a challenge maps to
`AuthRequired`, a 403 maps to `InsufficientScope` with the scope extracted, a
401 *without* a challenge is still not `AuthRequired`, and 405 keeps reporting
`ServerDoesNotSupportSse`. The first two fail before this change with
`Err(Client(reqwest::Error { kind: Status(401, None) }))`; the last two pass
either way and exist to catch an over-broad fix.
`SseAutoReconnectStream::poll_next` recursed into itself for every event it
skips — control frames, data-less frames, and frames whose data fails to
deserialize — plus once more on every state transition.
The inner stream returns `Poll::Ready` for each event it can parse out of
already-buffered bytes, so a burst of skipped events has no yield point
between them. Each one adds a stack frame, and `poll_next` is a large frame.
A client connected to a server that emits a run of non-JSON `message` frames
overflows the stack and aborts the process.
Wrap the body in a `loop` and replace the four `self.poll_next(cx)` tail calls
with `continue`. `this` is re-derived from `self.as_mut().project()` at the top
of each iteration, so the borrows end cleanly per iteration; no other logic
changes. The diff is mostly the resulting re-indent — review with `?w=1`.
Adds `skipped_events_do_not_grow_the_stack`, which feeds 50,000 undeserializable
frames through the stream. Before this change it aborts with
`fatal runtime error: stack overflow` (SIGABRT); after, it passes.
* fix(model): decode metadata-bearing input-required results
The draft permits every Result, including InputRequiredResult, to carry
_meta and recommends that servers include
io.modelcontextprotocol/serverInfo on every response. It also requires
an InputRequiredResult to contain inputRequests or requestState so the
client knows what to provide or how to resume the request.
ServerResult uses untagged deserialization and attempts CallToolResult
before InputRequiredResult. Because CallToolResult accepts _meta and
defaults missing content to an empty list, it consumed valid
input_required results carrying metadata. Unknown inputRequests and
requestState fields were then discarded, leaving clients without the
information needed to continue.
Reject resultType input_required when deserializing CallToolResult so
Serde proceeds to the InputRequiredResult variant. Add regression
coverage proving that inputRequests, requestState, and serverInfo
metadata are preserved.
Spec:
41d9e938e9/schema/draft/schema.ts (L137-L151)41d9e938e9/schema/draft/schema.ts (L198-L224)41d9e938e9/schema/draft/schema.ts (L541-L565)
Signed-off-by: Filinto Duran <1373693+filintod@users.noreply.github.com>
* incorporate feedback - close gap in inputrequired not properly checking that at least one of inputRequest or requestState is present
Signed-off-by: Filinto Duran <1373693+filintod@users.noreply.github.com>
---------
Signed-off-by: Filinto Duran <1373693+filintod@users.noreply.github.com>
* refactor(service): express SEP-2260 receive-side association as an enum
Replaces the has_pending_outbound_request bool on
enforce_peer_request_association with PeerRequestAssociation, so a
stream-separating transport can report per-request association (#1033).
Behavior-preserving: the event loop still passes the coarse signal as
Unknown.
* feat(transport): record inbound stream origin on streamable HTTP client (#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.
* feat(service): enforce SEP-2260 client receive-side check per stream origin (#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.
* test: end-to-end SEP-2260 stream-based enforcement over streamable HTTP (#1033)
* docs: cross-link SEP-2260 stream markers
* test: origin marker survives SSE resumption per SEP-2260 (#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.
* docs: trim SEP-2260 comments to spec rationale
* docs: align SEP-2577 expect reason with sibling suppressions
* test: harden SEP-2260 stream-enforcement e2e (#1033)
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.
* test: adapt SEP-2260 association tests to ServerPeerInfo
Rebase onto 3.0.0: RoleClient::PeerInfo is now ServerPeerInfo (#1065)
and its constructor takes the protocol version directly.
* chore: add 'Copy' macro to new PeerRequestAssociation
Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com>
---------
Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com>
* fix: stamp server info on graceful subscription results
* fix: expose subscription server info metadata
---------
Co-authored-by: mrcs64 <9069178+mrcs64@users.noreply.github.com>