/ 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.
/ will work once rmcp adds the annotation.
librefang/crates/librefang-runtime/src/mcp.rs
Lines 612 to 613 in a067d3c