Skip to content

fix(runtime-mcp): actually extract WWW-Authenticate from rmcp AuthRequired#2429

Merged
houko merged 1 commit into
mainfrom
fix/remove-dead-rmcp-downcast
Apr 14, 2026
Merged

fix(runtime-mcp): actually extract WWW-Authenticate from rmcp AuthRequired#2429
houko merged 1 commit into
mainfrom
fix/remove-dead-rmcp-downcast

Conversation

@houko

@houko houko commented Apr 14, 2026

Copy link
Copy Markdown
Contributor

Summary

The MCP auth-discovery code had two parallel dead paths for extracting the www_authenticate_header from an rmcp::ClientInitializeError, plus a TODO(rmcp): comment promising that one of them would work "once upstream adds #[source]". The bot auto-filed two issues (#2401, #2402) against the TODO. When I looked at why the TODO existed at all, I found both paths were dead and the diagnosis was wrong.

What was dead

  1. extract_auth_required walked std::error::Error::source(). The TODO blamed a missing #[source] on ClientInitializeError::TransportError::error, but the real fix isn't to wait for upstream — DynamicTransportError already exposes its pub error: Box<dyn Error> publicly. We don't need source() at all; we can match on the outer variant and downcast the box directly.

  2. extract_www_authenticate scraped e.to_string() for a www_authenticate_header: "…" marker. This never matched in production because rmcp's Display for StreamableHttpError::AuthRequired is literally #[error("Auth required")] — the field name only appears in Debug, never in any Display layer. Dead since day one.

Net behavioural effect: every auth-required MCP handshake fell through OAuth discovery with www_authenticate = None, silently skipping Tier 1 (resource_metadata from the header) and always going to Tier 2 (.well-known on the server URL). That worked for servers that host OAuth metadata at the well-known path but failed for the rest.

The fix

Direct match + downcast, no source() traversal, no string scraping:

fn extract_auth_header_from_error(e: &ClientInitializeError) -> Option<String> {
    let ClientInitializeError::TransportError { error: dyn_err, .. } = e else {
        return None;
    };
    let streamable = dyn_err
        .error
        .downcast_ref::<StreamableHttpError<reqwest::Error>>()?;
    if let StreamableHttpError::AuthRequired(AuthRequiredError {
        www_authenticate_header, ..
    }) = streamable {
        Some(www_authenticate_header.clone())
    } else {
        None
    }
}

The substring check ("401" / "Unauthorized" / "Auth required") stays only as a defensive fallback so we don't regress if rmcp reshapes its chain.

Closes

Both were auto-generated from the same TODO comment, which is now gone and was the wrong diagnosis.

Tests

  • Replaced the two extract_auth_required tests (which only exercised negative paths because AuthRequiredError is #[non_exhaustive] and the positive path couldn't be constructed from outside rmcp) with a single test_extract_auth_header_from_error_returns_none_for_non_transport_variant that pins the "bail out on the wrong outer variant" invariant.
  • cargo test -p librefang-runtime-mcp --lib — 49 passed
  • cargo clippy -p librefang-runtime-mcp --all-targets -- -D warnings — clean

Test plan

  • cargo test -p librefang-runtime-mcp --lib
  • cargo clippy -p librefang-runtime-mcp --all-targets -- -D warnings
  • CI full workspace build

…uired

The original code had two parallel paths to pull \`www_authenticate_header\`
out of an \`rmcp::ClientInitializeError\`, and both were dead code:

1. \`extract_auth_required\` walked \`std::error::Error::source()\` hoping to
   downcast to \`StreamableHttpError::AuthRequired\`, but
   \`ClientInitializeError::TransportError::error\` is not annotated with
   \`#[source]\`, so \`source()\` never reaches inside \`DynamicTransportError\`.
   A \`TODO(rmcp)\` comment blamed the missing annotation, but (a) that's
   hypothetical future work and (b) it isn't the right diagnosis — see #2.

2. \`extract_www_authenticate\` scraped \`e.to_string()\` for a
   \`www_authenticate_header: "..."\` marker. Also dead in practice:
   rmcp's \`Display\` for \`StreamableHttpError::AuthRequired\` is literally
   \`#[error("Auth required")]\` — the field name never appears in any
   Display layer of the chain, only in \`Debug\`. The scraper looked busy
   but never returned \`Some(_)\`.

Net effect: every auth-required MCP handshake silently went into OAuth
discovery with \`www_authenticate = None\`, so Tier 1 discovery
(resource_metadata from the header) was permanently skipped and we
always fell through to Tier 2 (\`.well-known\` on the server URL).

Fix: \`DynamicTransportError\` already exposes its
\`pub error: Box<dyn Error + Send + Sync>\` publicly, so we can match
\`ClientInitializeError::TransportError { error, .. }\` directly, then
\`downcast_ref::<StreamableHttpError<reqwest::Error>>()\` on the box.
The downcast reaches \`AuthRequired\` without any \`source()\` traversal
or upstream annotation changes.

- Replace both dead helpers with a single
  \`extract_auth_header_from_error\` that does the direct match + downcast.
- Keep the substring check (\`"401"\` / \`"Unauthorized"\` /
  \`"Auth required"\`) only as a defensive fallback so we don't regress
  if rmcp ever reshapes its chain.
- Drop the \`TODO(rmcp)\` comment (no longer blocked on upstream).
- Replace the two old unit tests with
  \`test_extract_auth_header_from_error_returns_none_for_non_transport_variant\`.
  The positive case can't be constructed from outside rmcp because
  \`AuthRequiredError\` is \`#[non_exhaustive]\`, but the negative-path test
  pins the "bail out on the wrong variant" invariant.

Closes #2401, #2402 — the bot issues auto-generated from the TODO
comment. The TODO is gone and the functionality it promised is now
actually present.

Verified: \`cargo test -p librefang-runtime-mcp --lib\` — 49 passed.
\`cargo clippy -p librefang-runtime-mcp --all-targets -- -D warnings\`
— clean.
@houko
houko merged commit 1f82c5d into main Apr 14, 2026
8 checks passed
@houko
houko deleted the fix/remove-dead-rmcp-downcast branch April 14, 2026 04:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant