Skip to content

[rmcp] at the call site). The helper is correct and #2401

Description

@github-actions

/ will work once rmcp adds the annotation.

/// boundary (see TODO(rmcp) at the call site). The helper is correct and
/// will work once rmcp adds the annotation.

            }
        }

        // Try loading a cached OAuth token and inject as Authorization header.
        let mut used_oauth_token = false;
        if let Some(provider) = oauth_provider {
            if let Some(token) = provider.load_token(url).await {
                debug!(url = %url, "Injecting cached OAuth token for MCP connection");
                if let (Ok(hn), Ok(hv)) = (
                    HeaderName::from_bytes(b"authorization"),
                    HeaderValue::from_str(&format!("Bearer {token}")),
                ) {
                    custom_headers.insert(hn, hv);
                    used_oauth_token = true;
                }
            }
        }

        let mut config = StreamableHttpClientTransportConfig::default();
        config.uri = Arc::from(url);
        config.custom_headers = custom_headers;

        let transport = StreamableHttpClientTransport::from_config(config);

        match ().into_dyn().serve(transport).await {
            Ok(client) => {
                // Discover tools via rmcp (with timeout)
                let timeout = std::time::Duration::from_secs(60);
                let tools = tokio::time::timeout(timeout, client.list_all_tools())
                    .await
                    .map_err(|_| {
                        "MCP tools/list timed out after 60s for Streamable HTTP".to_string()
                    })?
                    .map_err(|e| format!("MCP tools/list failed: {e}"))?;

                let auth_state = if used_oauth_token {
                    crate::mcp_oauth::McpAuthState::Authorized {
                        expires_at: None,
                        tokens: None,
                    }
                } else {
                    crate::mcp_oauth::McpAuthState::NotRequired
                };

                Ok((McpInner::Rmcp(client), Some(tools), auth_state))
            }
            Err(e) => {
                // Attempt structured extraction first: walk the source() chain and
                // downcast to StreamableHttpError::AuthRequired to get the
                // www_authenticate_header without fragile string parsing.
                //
                // TODO(rmcp): ClientInitializeError::TransportError does not annotate
                // its `error: DynamicTransportError` field with #[source], so the
                // source() chain is broken at that boundary — the downcast always
                // returns None in practice. The fallback substring check below is the
                // effective working path until rmcp adds #[source] to that field.
                let err_dyn: &(dyn std::error::Error + 'static) = &e;
                let www_authenticate = Self::extract_auth_required(err_dyn);

                if www_authenticate.is_none() {
                    // Fall back to substring check so we don't regress if rmcp ever
                    // changes its Display output to not include these markers.
                    let error_str = e.to_string();
                    let is_auth_error = error_str.contains("401")
                        || error_str.contains("Unauthorized")
                        || error_str.contains("Auth required");
                    if !is_auth_error {
                        return Err(format!(
                            "MCP Streamable HTTP connection failed: {error_str}"
                        ));
                    }
                    debug!(
                        url = %url,
                        "401 detected via fallback string match — structured downcast did not reach"
                    );
                }

                debug!(url = %url, "MCP server returned auth error, attempting OAuth discovery");

                // Use the structured header if we got one; otherwise scrape from
                // the error Display output (fallback-only — see extract_www_authenticate).
                let error_str = e.to_string();
                let www_authenticate =
                    www_authenticate.or_else(|| Self::extract_www_authenticate(&error_str));

                // Discover OAuth metadata using three-tier resolution.
                let metadata = crate::mcp_oauth::discover_oauth_metadata(
                    url,
                    www_authenticate.as_deref(),
                    oauth_config,
                )
                .await
                .map_err(|discovery_err| {
                    format!(
                        "MCP Streamable HTTP connection failed (auth required but OAuth \
                         discovery failed): {discovery_err}"
                    )
                })?;

                // Signal that auth is needed — the API layer will drive the
                // PKCE flow via the UI instead of the daemon opening a browser.
                warn!(
                    url = %url,
                    auth_endpoint = %metadata.authorization_endpoint,
                    "MCP server requires OAuth — deferring to API layer"
                );
                Err("OAUTH_NEEDS_AUTH".to_string())
            }
        }
    }

    /// Walk the `std::error::Error::source()` chain and attempt to downcast each
    /// node to `StreamableHttpError<reqwest::Error>`. Returns the
    /// `www_authenticate_header` string if an `AuthRequired` variant is found.
    ///
    /// In practice this returns `None` today because
    /// `ClientInitializeError::TransportError` does not annotate its inner
    /// `DynamicTransportError` with `#[source]`, breaking the chain at that
    /// boundary (see TODO(rmcp) at the call site). The helper is correct and
    /// will work once rmcp adds the annotation.
    fn extract_auth_required(err: &(dyn std::error::Error + 'static)) -> Option<String> {
        use rmcp::transport::streamable_http_client::{AuthRequiredError, StreamableHttpError};

        let mut cur: Option<&(dyn std::error::Error + 'static)> = Some(err);
        while let Some(e) = cur {
            if let Some(StreamableHttpError::AuthRequired(AuthRequiredError {
                www_authenticate_header,
                ..
            })) = e.downcast_ref::<StreamableHttpError<reqwest::Error>>()
            {
                return Some(www_authenticate_header.clone());
            }
            cur = e.source();
        }
        None
    }

    /// **Fallback only.** Try to extract a WWW-Authenticate header value from
    /// an error's `Display` output.
    ///
    /// rmcp's `StreamableHttpError` embeds the header in its `Debug`/`Display`
    /// format as `www_authenticate_header: "..."`. This helper scrapes that
    /// pattern out. It is kept as a fallback for when the structured downcast
    /// via [`Self::extract_auth_required`] cannot traverse the error chain.
    fn extract_www_authenticate(error: &str) -> Option<String> {
        let marker = "www_authenticate_header: \"";
        let start = error.find(marker)? + marker.len();
        let rest = &error[start..];
        let end = rest.find('"')?;
        Some(rest[..end].to_string())
    }

    /// Send the MCP `initialize` handshake over SSE transport.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions