Repository navigation
Conversation
|
@gocamille is on triage rotation this week and is the reviewer for this one. Labels come from the PR labeler. Please review the change. Automated triage. Labels are a first pass and the assigned maintainer has the final say. |
d6c270b to
91e692b
Compare
A 401 or 403 without a `WWW-Authenticate` challenge is settled by its
status alone: there is no challenge to react to, and the response body
adds nothing a client can act on. The reqwest Streamable HTTP client
still surfaced it inconsistently: POST read the whole body and returned
`UnexpectedServerResponse("HTTP 401 Unauthorized: <body>")`, so callers
had to parse strings and an unfinished body blocked the request, while
GET returned the backend's `reqwest::Error`.
Classify challenge-less 401 and 403 as `HttpStatusError` on GET and POST,
without reading the body, the way 429 already is. A 401 or 403 that carries
a challenge keeps its `AuthRequired` / `InsufficientScope` precedence.
Make the metadata reachable without naming the transport type:
`StreamableHttpError::HttpStatus` now exposes the `HttpStatusError` as its
source, and `ClientInitializeError::TransportError`,
`ServerInitializeError::TransportError` and `ServiceError::TransportSend`
expose their `DynamicTransportError`, so walking an error's source chain
finds it at initialization and at runtime. A challenge-less 401 also
counts as `is_authorization_required`, since the request lacked valid
credentials (RFC 9110 §15.5.2) and the MCP authorization spec has clients
fall back to the well-known URIs when no `resource_metadata` is advertised.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
91e692b to
109d574
Compare
|
This would also fix a regression we hit after moving to 3.9.0. Could the same approach cover one more case? 3.9.0 drops the status of an oversized 4xx. The RUSTSEC-2026-0462 fix bounds a non-success POST body to
Request: when a non-success body exceeds the limit, could the error carry the status for every status, not only 429/5xx? For example, return Happy to open a separate issue if you'd rather keep this PR scoped to 401/403. |
Problem
A 401 or 403 that carries no
WWW-Authenticatechallenge did not keep a structured HTTP status in the reqwest Streamable HTTP client:UnexpectedServerResponse("HTTP 401 Unauthorized: <body>"). Callers had to parse the string, the body was retained in the error, and an unfinished body blocked the request even though the status alone already settled it.reqwest::Errorfromerror_for_status(), so callers had to depend on the backend type.A 401/403 with a challenge was already returned without reading the body (
AuthRequired/InsufficientScope), as was a 429 (HttpStatusError).Changes
HttpStatusErroron GET and POST, without reading the body, including when the body is a JSON-RPC error. 401/403 with a challenge keep theirAuthRequired/InsufficientScopeprecedence. 404 session expiry, non-auth JSON-RPC error bodies, the sessionless discover fallback, 429 and 5xx handling are unchanged.StreamableHttpError::HttpStatusnow exposes theHttpStatusErroras itssource()(Display is unchanged,HTTP 401), andClientInitializeError::TransportError,ServerInitializeError::TransportErrorandServiceError::TransportSendexpose theirDynamicTransportErrorassource(). WalkingClientInitializeError → DynamicTransportError → StreamableHttpError → HttpStatusErrorfinds it, at initialization and at runtime.ClientInitializeError::is_authorization_required()is also true for a challenge-less 401, since the request lacked valid credentials and the MCP spec has discovery fall back to the well-known URIs when noresource_metadatais advertised.auth_challenge()staysNonefor it. A challenge-less 403 is not classified as authorization required.Before / after on the wire
Reproduction: a minimal MCP server (rmcp's own Streamable HTTP server behind a token gate) and a client probe that prints what the SDK hands back, committed on this fork at
tadasant/rust-sdk@5b3f78a(README). It is not a workspace member and depends on../crates/rmcpby path, so the same binaries run against any checkout. Captured output: before, upstreammainc164688 · after, this PR 109d574. Ports and the worker type path are elided below.Scenarios
no-challengeandno-challenge-unfinished-body. The server answers an unauthenticated request with 401 and an under-scoped one with 403, noWWW-Authenticatefield,Content-Type: application/jsonand a JSON-RPC error body; in the second scenario the body never finishes.Before (upstream
main):sequenceDiagram participant C as rmcp client participant S as MCP server C->>S: POST /mcp initialize (no token) S-->>C: 401, no WWW-Authenticate<br/>Content-Type: application/json<br/>{"jsonrpc":"2.0","id":1,"error":{…}} Note over C: no challenge, so the response falls through to the body path C->>S: reads the whole body Note over C: blocks for as long as the server keeps the body open C->>C: UnexpectedServerResponse("HTTP 401 Unauthorized: {…body…}") Note over C: is_authorization_required() = false, auth_challenge() = None<br/>body retained in the error C->>S: GET /mcp (no token) S-->>C: 401, no WWW-Authenticate C->>C: Client(reqwest::Error "HTTP status client error (401 Unauthorized) for url …") Note over C: a different, backend-typed error for the same conditionAfter (this PR):
sequenceDiagram participant C as rmcp client participant S as MCP server C->>S: POST /mcp initialize (no token) S-->>C: 401, no WWW-Authenticate<br/>Content-Type: application/json<br/>{"jsonrpc":"2.0","id":1,"error":{…}} Note over C: the status settles it, the body is not read C->>C: HttpStatus { status: 401, retry_after: None } Note over C: is_authorization_required() = true, auth_challenge() = None C->>S: GET /mcp (no token) S-->>C: 401, no WWW-Authenticate C->>C: HttpStatus { status: 401, retry_after: None } Note over C: same typed error on both methods, reachable through source() C->>S: GET /.well-known/oauth-protected-resource/mcp (well-known fallback, MCP spec)main(c164688)no-challenge,initializewith no tokenErr(Send message error Transport […] error: unexpected server response: HTTP 401 Unauthorized: {"jsonrpc":"2.0","id":1,"error":{"code":-32001,"message":"Unauthorized"}}, when send initialize request)Err(Send message error Transport […] error: HTTP 401, when send initialize request)is_authorization_required()/auth_challenge()false/Nonetrue/Noneno-challenge, POST with no tokenUnexpectedServerResponse("HTTP 401 Unauthorized: {…body…}")HttpStatus { status: 401, retry_after: None }no-challenge, GET with no tokenClient(reqwest: HTTP status client error (401 Unauthorized) for url (…))HttpStatus { status: 401, retry_after: None }no-challenge, POST / GET with under-scoped tokenUnexpectedServerResponse("HTTP 403 Forbidden: {…body…}")/Client(reqwest: … 403 Forbidden …)HttpStatus { status: 403, retry_after: None }on bothno-challenge-unfinished-body,initializeand POSTHttpStatus { status: 401 }immediately (403with the under-scoped token)no-challenge-unfinished-body, GETClient(reqwest: … 401 …)(status known, body not read)HttpStatus { status: 401, retry_after: None }Unchanged in both revisions: the
basic-then-bearerandquoted-textscenarios (the subject of #1418) behave the same with and without this PR.How the fix maps to the specs
HttpStatusError { status: 401 | 403, retry_after }, the same typed shape on GET and POST, reachable through the error's source chain at initialization and at runtime.tests/test_streamable_http_status_metadata.rs::challenge_less_auth_failures_return_body_free_metadata(401/403 × GET/POST × JSON/text body × finished/unfinished),test_streamable_http_4xx_error_body.rs::http_40{1,3}_json_rpc_error_body_without_challenge_keeps_status,test_streamable_http_get_stream_auth_challenge.rs::get_stream_auth_failure_without_challenge_keeps_status,test_auth_error_classification.rs::http_status_remains_available_as_an_error_sourceis_authorization_required()reports it. With no challenge there is nothing forauth_challenge()to return.test_auth_error_classification.rs::classifies_challenge_less_http_401_only;auto_discovery_preserves_metadata_without_legacy_fallbackassertsis_authorization_required() == (status == 401)andauth_challenge() == NoneWWW-Authenticateheaders when present; otherwise, they MUST fall back to constructing and requesting the well-known URIs in the order listed above." (2025-11-25, 2026-07-28); the discovery sequence diagram labels the response "HTTP 401 Unauthorized (may include WWW-Authenticate header)".is_authorization_required()can startAuthorizationRequestwithoutwith_challenge, and the SDK's existingresolve_metadata_from_challenge(None)path performs the well-known fallback.classifies_challenge_less_http_401_only(classification); the fallback itself is existing, already-tested behaviour and is not changed here.error="insufficient_scope"there is no step-up to run, and the refusal may be unrelated to credentials.classifies_challenge_less_http_401_only(403 branch),auto_discovery_preserves_metadata_without_legacy_fallback::forbiddenWWW-Authenticateheader with theBearerscheme and additional parameters:error="insufficient_scope"…scope=…" (2025-11-25)auth_challenges_take_precedence_over_status_metadata(401/403 × GET/POST, unfinished JSON body)resource_metadata: "Abort or use pre-configured values" after the well-known probes fail; nothing in the MCP spec or the RFCs specifies what a client should surface for a 401/403 that has no challenge.HttpStatusErroris the SDK's own design choice, consistent with #1386 ("No response body, request URL, credentials, or other headers are retained") and with #1341 (keep the HTTP status rather than pass a 401 body off as an ordinary JSON-RPC error). The trade-off is that a human-readable message in the body is no longer in the error; the status andRetry-Afterare.challenge_less_auth_failures_return_body_free_metadataandassert_status_errorcheck the error renders no body, URL, session or token (private-*markers).Relationship to #1418
#1418 (preserve repeated
WWW-Authenticatefields) touches the same 401/403 region oftransport/common/reqwest/streamable_http_client.rs. The two are independent: #1418 replaces the challenge-present branches with a sharedauth_challenge_errorhelper, this PR changeshttp_status_errorand the POST body path below them. Whichever lands second needs a small rebase. Together they give: challenge present →AuthRequired/InsufficientScopecarrying every field; no challenge →HttpStatus.Compatibility
StreamableHttpErroris#[non_exhaustive]. Callers that matchedUnexpectedServerResponse(POST) orClient(_)(GET) for a challenge-less 401/403 now receiveHttpStatus; this is the intended behaviour change. The SemVer and Public API CI jobs pass without a breaking-change marker.StreamableHttpError::HttpStatuskeeps itsDisplay(HTTP 503) but now has asource();ClientInitializeError::TransportError,ServerInitializeError::TransportErrorandServiceError::TransportSendnow have asource(). All are additive for error-chain walkers. A "caused by" printer will showHTTP 503twice in a row (variant, thenHttpStatusError), the same pattern the enum's other#[from]variants andDynamicTransportErroralready produce.is_authorization_required()is documented (code anddocs/OAUTH_SUPPORT.md) to also cover a challenge-less 401.Validation
Run locally on Rust 1.96 (the pinned toolchain) with the commands CI uses:
cargo clippy --all-targets --all-features -- -D warningscargo clippy -p rmcp --lib --no-default-features --features <f> -- -D warningsforclient,auth,transport-streamable-http-client,transport-streamable-http-client-reqwest, and no featurescargo test -p rmcp --features client,server,auth,transport-streamable-http-client-reqwest,transport-streamable-http-client-unix-socket --lib --test test_streamable_http_get_stream_auth_challenge --test test_streamable_http_status_metadata --test test_streamable_http_4xx_error_body --test test_auth_error_classification --test test_streamable_http_reconnect_classificationcargo +nightly fmt --all -- --check,typos,git diff --checkOn unmodified
main, the two…without_challenge_keeps_statustests fail withUnexpectedServerResponse("HTTP 401 Unauthorized: {…}"), which confirms the body-containing string error.Scope / review notes
UnexpectedServerResponsefor a challenge-less 401/403 (and for 429/5xx), sois_authorization_required()staysfalsefor it.HttpStatusError's docs scope the behaviour to the reqwest client.AuthClientstill refreshes a token only onAuthRequired; a challenge-less 401 propagates without a silent refresh, as before.Noticed, not filed
classify_reconnect_errortreats a challenge-less 401/403 on the GET stream as retryable (it did before too, as aClienterror); repeating the GET with the same credentials cannot help.🤖 Generated with Claude Code