`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>
* fix(auth): preserve OAuth discovery transport errors
* refactor!: expose domain-specific boxed OAuth HTTP errors
Let custom OAuth HTTP clients preserve native error types and source chains
while keeping the type-erased boundary explicit in the public API.
BREAKING CHANGE: OAuthHttpClientError is now a boxed error alias; custom
clients should return native errors with .into() or box them directly.
---------
Co-authored-by: Theodore Ni <3806110+tjni@users.noreply.github.com>