Repository navigation
fix(transport): keep 401/403 status when the body is a JSON-RPC error - #1341
Open
aton-of-data wants to merge 1 commit into
Open
aton-of-data wants to merge 1 commit into
aton-of-data wants to merge 1 commit into
Conversation
A 401 or 403 without a WWW-Authenticate header falls through to the
non-success branch of the reqwest client's post_message, where a
JSON-RPC error body was returned as an ordinary JSON response. Callers
then saw a plain JSON-RPC error and could not tell that the server had
rejected their credentials.
Skip the JSON-RPC error branch for 401 and 403 so these responses end
as UnexpectedServerResponse("HTTP 401 Unauthorized: ..."), the same as
a plain-text body, and as legacy_discover_response already does.
Fixes modelcontextprotocol#1339
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1339
What's wrong
When a Streamable HTTP server answers a POST with 401 or 403, no
WWW-Authenticateheader, and a JSON-RPC error body, the reqwest client returnsOk(StreamableHttpPostResponse::Json(..)). The caller sees an ordinary JSON-RPC error (ClientInitializeError::JsonRpcErrorduring initialize,ServiceError::McpErrorafterwards) and has no way to tell that the server rejected its credentials. The same 401 with a plain-text body already comes back asUnexpectedServerResponse("HTTP 401 Unauthorized: …"), so the status only gets lost when the body is JSON-RPC.Why it happens (line numbers at c1a8eea,
crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs):WWW-Authenticateis present. A 401/403 without the header falls through.legacy_discover_response, the JSON branch at L289-301 parses the body withparse_json_rpc_errorand returns it asJsonwithout looking at the status.HTTP {status}: {body}error at L303.The fix
The JSON-RPC branch is now skipped for 401 and 403. These responses fall through to the existing
UnexpectedServerResponse("HTTP {status}: {body}"), the same result as a plain-text 401/403. Other 4xx/5xx JSON-RPC bodies are still returned asJson, as #748 intended.Why this shape:
legacy_discover_response(crates/rmcp/src/transport/streamable_http_client.rsL374, L383) says "Keep authentication failures and server errors on their original paths" and excludes 401/403 for that reason.crates/rmcp/src/transport/common/unix_socket.rsL270-284) never parses a JSON-RPC body, so the same response already ends up asUnexpectedServerResponsethere.get_stream_401_without_challenge_is_not_auth_requiredintests/test_streamable_http_get_stream_auth_challenge.rsalready says that a 401 without a challenge must be an error but notAuthRequired.AuthRequired/InsufficientScopewith an empty challenge.www_authenticate_headeris a requiredString(L139, L155).StreamableHttpError::auth_challenge()(L261) andClientInitializeError::auth_challenge()(service/client.rs) would then returnSome("")as a challenge forAuthorizationRequest::with_challenge.AuthClientwould also run its refresh-and-retry path (transport/common/auth/streamable_http_client.rsL47). Carrying the status alongside the JSON-RPC error (option (b) in the issue) would mean changing public types.If you would rather have a dedicated variant for "401/403 without a challenge", that would be a follow-up with an API change. I kept this PR to the smallest fix that stops the status from being lost.
How it was reproduced
I added two tests to
crates/rmcp/tests/test_streamable_http_4xx_error_body.rs, using the same local axum mock server the file already uses. They POST a ping to a server that answers 401 (and 403) withapplication/json, a JSON-RPC error body and noWWW-Authenticate. They assertUnexpectedServerResponsewhose message starts withHTTP 401 Unauthorized(andHTTP 403 Forbidden).On unmodified main, both tests fail:
With the fix,
test result: ok. 6 passed; 0 failed.What was verified
All commands were run with stable 1.97, because the pinned 1.96 toolchain and nightly could not be downloaded in my environment.
cargo test -p rmcp --features client,transport-streamable-http-client,transport-streamable-http-client-reqwest --test test_streamable_http_4xx_error_body: 2 failed / 4 passed before the fix, 6 passed after.cargo test -p rmcp --all-features: 885 passed, 0 failed, 15 ignored. Note that this file is gated onnot(feature = "local"), so it does not run here.cargo test -p rmcp --features "<all rmcp features except local and __*>"(the justfile's second test step): 1316 passed, 0 failed, 18 ignored. This run includes the new tests.cargo test -p rmcp --lib --no-default-features: 139 passed.cargo clippy --all-targets --all-features -- -D warningsand the CI's no-default-features "all except local" clippy: both clean apart from oneclippy::question_markhit intransport/auth.rs:2501. That hit is in a file this PR doesn't touch, and it also appears on unmodified main with clippy 1.97. I reran both with-A clippy::question_markand they passed.rustfmt --edition 2024 --checkon the two touched files only. It passes. The stable formatter skips the nightly-only import options, and this change touches no imports.What was not verified
cargo +nightly fmt --all -- --check, as CI runs it.cargo semver-checks/cargo public-api. No public items change, but I did not run these tools.ServiceExt::serve/ initialize. The tests driveStreamableHttpClient::post_messagedirectly.