* 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"].
The roadmap pinned a specific patch release as "the current stable
release", so it drifted out of date as soon as the next release shipped.
Reference the releases page instead of a hardcoded version, and drop the
dated status line.
Historical entries (v3.0.0 ship date, the v1.4.0 fix reference) keep
their versions since they record facts that do not change.
`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.
Add a Ping section to the README covering both directions of the
utility: constructing and sending a PingRequest via send_request, and
the automatic default handler response (with an override example for
custom liveness logic). Links to the MCP ping spec.
Ping was the only non-experimental feature lacking user-facing
documentation and an example.