fix(auth): map 401/403 challenges on the SSE GET stream - #1152
Merged
DaleSeo merged 1 commit intoAug 7, 2026
Merged
Conversation
`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.
DaleSeo
approved these changes
Aug 7, 2026
DaleSeo
left a comment
Member
There was a problem hiding this comment.
Thanks for the fix, @shoemoney!
Merged
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.
Summary
post_message_with_max_sse_event_sizemaps a 401 or 403 carrying aWWW-Authenticateheader ontoStreamableHttpError::AuthRequired/InsufficientScope.get_stream_with_max_sse_event_size, in the same file, handles only 405 and then falls straight through toerror_for_status()?, so the identical challenge comes back as an opaqueStreamableHttpError::Client.crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs:WWW-AuthenticateWWW-Authenticatepost_message_…(L181–211)AuthRequiredInsufficientScopeget_stream_…(L94–97, before this PR)Client(Status(401))Client(Status(403))unix_socket.rsget_stream_…(L441–472)AuthRequiredInsufficientScopeWhy it matters
AuthClient::get_streamroutes throughcall_reacting_to_challenges, whose entire job is to catchAuthRequired, calltry_refresh_or_reauth(), and retry once. It never receives that variant from this path, so an expired token on the standalone SSE stream is never refreshed — the stream just fails. The same expiry onpost_messagerecovers silently, which makes this look like an intermittent SSE problem rather than an auth one.The change
The two blocks copied verbatim from
post_messagein the same file, inserted beforeerror_for_status()?. No new imports —WWW_AUTHENTICATE,Cow,extract_scope_from_headerand the error types are already in scope for the sibling.Testing
crates/rmcp/tests/test_streamable_http_get_stream_auth_challenge.rs, built on the harness in the existingtest_streamable_http_4xx_error_body.rs(same axum mock-server shape).AuthRequiredErr(Client(reqwest::Error { kind: Status(401, None) }))InsufficientScope, scope extractedErr(Client(… Status(403, None) …))AuthRequiredServerDoesNotSupportSseThe bottom two pass either way on purpose: they exist to catch an over-broad fix, not to demonstrate the bug. Without a challenge header there is nothing for the caller to act on, so a bare 401 must keep falling through to the ordinary error path, and 405 must keep its dedicated meaning.
cargo test -p rmcp --libwith the same features: 416 passed / 0 failed, identical to the baseline onmain— no regression, and the delta is entirely in the new integration binary.cargo fmt -p rmcp -- --checkclean and touching only these files;cargo clippy -p rmcp --libexits 0 with no errors.Notes
Toolchain. Built with stable 1.97.1;
rust-toolchain.tomlpins 1.96 and.githooksrunscargo +nightly fmt. There is no rustup on this machine, so formatting was applied with stable rustfmt and the repo's nightly-only options (imports_granularity,group_imports) were skipped with a warning. This change adds no imports and the formatting is unchanged from the copied sibling, but CI on 1.96 is the authoritative check.Deliberately not fixed.
get_streaminunix_socket.rsalready handles both cases, so nothing to do there. I have not touchedauth.rs— #1102 is open against that file and this PR does not overlap it.AI assistance disclosure
Per the modelcontextprotocol AI policy: this change was made in conjunction with my pair programmer, Claude Code.
Extent, so you know how much scrutiny to apply: the defect was surfaced by an automated sweep I run across MCP-ecosystem repos, and the patch was written with Claude Code working alongside me. I reviewed it before filing — the before/after test output, the baseline test counts, and the lint/format runs quoted above were executed on my machine, not pasted from a model. I understand what the change does and why, and replies on this PR are mine.