From 2d9c10ed0df8fb1ae2e79b2b82a1abb2a8ab5b3f Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Wed, 7 Oct 2026 18:57:30 +0530 Subject: [PATCH 1/5] fix(oauth): discover authorization metadata on the origin when a 401 names none (#44) --- .../tinymcp/src/transport/http/discovery.rs | 348 +++++++++++ .../src/transport/http/discovery_tests.rs | 566 ++++++++++++++++++ crates/tinymcp/src/transport/http/mod.rs | 80 ++- 3 files changed, 984 insertions(+), 10 deletions(-) create mode 100644 crates/tinymcp/src/transport/http/discovery.rs create mode 100644 crates/tinymcp/src/transport/http/discovery_tests.rs diff --git a/crates/tinymcp/src/transport/http/discovery.rs b/crates/tinymcp/src/transport/http/discovery.rs new file mode 100644 index 0000000..0ca640d --- /dev/null +++ b/crates/tinymcp/src/transport/http/discovery.rs @@ -0,0 +1,348 @@ +//! Finding an authorization server when a 401 names no protected-resource +//! metadata. +//! +//! A Bearer challenge without `resource_metadata` is not proof of a static +//! token. The 2025-06-18 authorization spec also publishes protected-resource +//! metadata at `/.well-known/oauth-protected-resource[/path]`, and servers on the +//! 2025-03-26 spec are their own authorization server, publishing RFC 8414 +//! metadata on the MCP origin. This module looks in those places, in that +//! order, and nowhere else: default `/authorize` and `/token` paths are never +//! guessed. +//! +//! Every lookup is a `GET` to the MCP server's own origin over a client that +//! follows no redirects and reads at most [`MAX_DOCUMENT_BYTES`], so a server +//! cannot bounce discovery to another host or stall it with an endless body. An +//! authorization server named by protected-resource metadata is read the same +//! way the challenge path reads one. + +use std::time::Duration; + +use futures_util::StreamExt; +use reqwest::{StatusCode, Url}; +use serde_json::Value; + +use super::{McpHttpClient, fill_missing_metadata}; +use crate::transport::redact_endpoint; +use tinymcp_bus::{AuthorizationServerMetadata, ProtectedResourceMetadata}; + +/// The total time a well-known lookup may take before it is abandoned. +pub(super) const DISCOVERY_BUDGET: Duration = Duration::from_secs(5); + +/// The largest metadata document read; anything longer is ignored. +pub(super) const MAX_DOCUMENT_BYTES: usize = 64 * 1024; + +const PROTECTED_RESOURCE_PATH: &str = "/.well-known/oauth-protected-resource"; +const AUTHORIZATION_SERVER_PATH: &str = "/.well-known/oauth-authorization-server"; +const OPENID_CONFIGURATION_PATH: &str = "/.well-known/openid-configuration"; + +/// What the well-known lookup concluded. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum WellKnownOutcome { + /// Metadata naming at least one authorization server was found. + Found(WellKnownAuthorization), + /// Every place was checked and none published usable metadata. + NotFound, + /// A place could not be checked, so the answer may change on a retry. + Transient, +} + +/// Authorization metadata found on the server's origin. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct WellKnownAuthorization { + /// The well-known document that yielded the authorization server. + pub(super) metadata_url: String, + /// The protected-resource metadata, when the server published a valid one. + pub(super) protected_resource_metadata: Option, + /// Every authorization server that could be read. + pub(super) authorization_servers: Vec, +} + +impl WellKnownAuthorization { + /// Whether some authorization server names both endpoints a sign-in needs. + pub(super) fn offers_sign_in(&self) -> bool { + self.authorization_servers.iter().any(|metadata| { + metadata.authorization_endpoint.is_some() && metadata.token_endpoint.is_some() + }) + } +} + +/// One fetched discovery document. +#[derive(Debug)] +enum Fetched { + Document(Value), + Missing, + Transient, +} + +/// What the origin's own authorization-server metadata lookup found. +#[derive(Debug)] +enum OriginLookup { + Found(String, AuthorizationServerMetadata), + Missing, + Transient, +} + +impl McpHttpClient { + /// Looks for authorization metadata on this server's origin, once per + /// client. + /// + /// A definitive answer is cached; a transient one, including running out + /// of [`DISCOVERY_BUDGET`], is not, so a later 401 looks again. + pub(super) async fn well_known_authorization(&self) -> WellKnownOutcome { + if let Some(cached) = self.well_known.lock().clone() { + return cached; + } + + let outcome = tokio::time::timeout(DISCOVERY_BUDGET, self.discover_from_well_known()) + .await + .unwrap_or(WellKnownOutcome::Transient); + + if outcome != WellKnownOutcome::Transient { + *self.well_known.lock() = Some(outcome.clone()); + } + outcome + } + + async fn discover_from_well_known(&self) -> WellKnownOutcome { + let Some(origin) = origin_of(&self.endpoint) else { + return WellKnownOutcome::NotFound; + }; + let mut transient = false; + let mut protected_resource = None; + + for candidate in protected_resource_candidates(&self.endpoint, &origin) { + let document = match self.fetch_discovery_json(&candidate).await { + Fetched::Document(document) => document, + Fetched::Missing => continue, + Fetched::Transient => { + transient = true; + continue; + } + }; + + if let Some(metadata) = same_origin_protected_resource(&document, &origin) { + let (servers, unreadable) = self + .read_authorization_servers(&metadata.authorization_servers) + .await; + if !servers.is_empty() { + return WellKnownOutcome::Found(WellKnownAuthorization { + metadata_url: candidate, + protected_resource_metadata: Some(metadata), + authorization_servers: servers, + }); + } + transient |= unreadable; + protected_resource.get_or_insert((candidate, metadata)); + continue; + } + + if let Some(metadata) = origin_authorization_server(&document, &origin) { + return WellKnownOutcome::Found(WellKnownAuthorization { + metadata_url: candidate, + protected_resource_metadata: None, + authorization_servers: vec![metadata], + }); + } + + tracing::debug!( + url = %redact_endpoint(&candidate), + "[mcp] ignoring a well-known document that is neither same-origin resource nor issuer metadata" + ); + } + + match self.origin_authorization_server(&origin).await { + OriginLookup::Found(metadata_url, metadata) => { + let (metadata_url, protected_resource_metadata) = match protected_resource { + Some((url, resource)) => (url, Some(resource)), + None => (metadata_url, None), + }; + WellKnownOutcome::Found(WellKnownAuthorization { + metadata_url, + protected_resource_metadata, + authorization_servers: vec![metadata], + }) + } + OriginLookup::Transient => WellKnownOutcome::Transient, + OriginLookup::Missing if transient => WellKnownOutcome::Transient, + OriginLookup::Missing => WellKnownOutcome::NotFound, + } + } + + /// Reads each authorization server a protected resource names. + /// + /// Returns those that could be read, and whether any could not. + async fn read_authorization_servers( + &self, + issuers: &[String], + ) -> (Vec, bool) { + let mut servers = Vec::new(); + let mut unreadable = false; + for issuer in issuers { + match self.fetch_authorization_server_metadata(issuer).await { + Ok(metadata) => servers.push(metadata), + Err(error) => { + unreadable = true; + tracing::debug!( + issuer = %redact_endpoint(issuer), + "[mcp] skipping an authorization server whose metadata could not be read: {error}" + ); + } + } + } + (servers, unreadable) + } + + /// Reads RFC 8414 metadata from the origin, then `OpenID` discovery, and + /// accepts a document only when its issuer is the origin itself. + async fn origin_authorization_server(&self, origin: &Url) -> OriginLookup { + let base = origin_text(origin); + let mut transient = false; + let mut found: Option<(String, AuthorizationServerMetadata)> = None; + + for path in [AUTHORIZATION_SERVER_PATH, OPENID_CONFIGURATION_PATH] { + let url = format!("{base}{path}"); + let document = match self.fetch_discovery_json(&url).await { + Fetched::Document(document) => document, + Fetched::Missing => continue, + Fetched::Transient => { + transient = true; + continue; + } + }; + let Some(metadata) = origin_authorization_server(&document, origin) else { + continue; + }; + found = Some(match found { + None => (url, metadata), + Some((first_url, first)) => (first_url, fill_missing_metadata(first, metadata)), + }); + if found.as_ref().is_some_and(|(_, metadata)| { + metadata.authorization_endpoint.is_some() && metadata.token_endpoint.is_some() + }) { + break; + } + } + + match found { + Some((url, metadata)) => OriginLookup::Found(url, metadata), + None if transient => OriginLookup::Transient, + None => OriginLookup::Missing, + } + } + + /// Fetches one discovery document without following redirects. + /// + /// A redirect, a 401, 403, 404 or 410, a body that is not JSON and a body + /// over [`MAX_DOCUMENT_BYTES`] all mean the document is not there. A 5xx or + /// a transport failure means it could not be checked. + async fn fetch_discovery_json(&self, url: &str) -> Fetched { + let response = match self.discovery_http.get(url).send().await { + Ok(response) => response, + Err(error) => { + tracing::debug!(url = %redact_endpoint(url), "[mcp] well-known lookup failed: {error}"); + return Fetched::Transient; + } + }; + + let status = response.status(); + if status.is_server_error() { + return Fetched::Transient; + } + if !status.is_success() { + if !(status.is_redirection() || is_definitive_absence(status)) { + tracing::debug!(url = %redact_endpoint(url), %status, "[mcp] unexpected well-known status"); + } + return Fetched::Missing; + } + if response + .content_length() + .is_some_and(|length| length > MAX_DOCUMENT_BYTES as u64) + { + return Fetched::Missing; + } + + let mut body = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let Ok(chunk) = chunk else { + return Fetched::Transient; + }; + if body.len() + chunk.len() > MAX_DOCUMENT_BYTES { + return Fetched::Missing; + } + body.extend_from_slice(&chunk); + } + + serde_json::from_slice(&body).map_or(Fetched::Missing, Fetched::Document) + } +} + +fn is_definitive_absence(status: StatusCode) -> bool { + matches!( + status, + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN | StatusCode::NOT_FOUND | StatusCode::GONE + ) +} + +/// The scheme, host and port of `endpoint`, with an empty path. +fn origin_of(endpoint: &str) -> Option { + let url = Url::parse(endpoint).ok()?; + if !matches!(url.scheme(), "http" | "https") || url.host().is_none() { + return None; + } + Url::parse(&url.origin().ascii_serialization()).ok() +} + +/// `origin` without its trailing slash, ready to have a path appended. +fn origin_text(origin: &Url) -> String { + origin.as_str().trim_end_matches('/').to_string() +} + +/// Where protected-resource metadata may live: under the endpoint's path +/// first, then at the origin root. +fn protected_resource_candidates(endpoint: &str, origin: &Url) -> Vec { + let base = origin_text(origin); + let root = format!("{base}{PROTECTED_RESOURCE_PATH}"); + let path = Url::parse(endpoint) + .map(|url| url.path().trim_end_matches('/').to_string()) + .unwrap_or_default(); + + if path.is_empty() { + vec![root] + } else { + vec![format!("{root}{path}"), root] + } +} + +/// `document` read as protected-resource metadata for a resource on `origin`. +fn same_origin_protected_resource( + document: &Value, + origin: &Url, +) -> Option { + let metadata: ProtectedResourceMetadata = serde_json::from_value(document.clone()).ok()?; + let resource = Url::parse(&metadata.resource).ok()?; + (resource.origin() == origin.origin()).then_some(metadata) +} + +/// `document` read as authorization-server metadata whose issuer is `origin`. +fn origin_authorization_server( + document: &Value, + origin: &Url, +) -> Option { + let metadata: AuthorizationServerMetadata = serde_json::from_value(document.clone()).ok()?; + issuer_is_origin(&metadata.issuer, origin).then_some(metadata) +} + +/// Whether `issuer` names exactly `origin`, with or without one trailing +/// slash. +/// +/// An origin URL has no path to distinguish, so `https://a.example` and +/// `https://a.example/` identify the same issuer. Anything longer does not. +pub(super) fn issuer_is_origin(issuer: &str, origin: &Url) -> bool { + let base = origin_text(origin); + issuer == base || issuer.strip_suffix('/') == Some(base.as_str()) +} + +#[cfg(test)] +#[path = "discovery_tests.rs"] +mod tests; diff --git a/crates/tinymcp/src/transport/http/discovery_tests.rs b/crates/tinymcp/src/transport/http/discovery_tests.rs new file mode 100644 index 0000000..093ac21 --- /dev/null +++ b/crates/tinymcp/src/transport/http/discovery_tests.rs @@ -0,0 +1,566 @@ +//! Tests for well-known authorization discovery. +//! +//! Each test binds a loopback listener first, so the documents it serves can +//! name the origin they are served from, then drives a real client at it. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use axum::Router; +use axum::body::Body; +use axum::http::StatusCode as AxumStatus; +use axum::response::{IntoResponse, Response as AxumResponse}; +use axum::routing::{get, post}; +use serde_json::{Value, json}; + +use super::*; +use crate::Error; + +const PRM_PATH: &str = "/.well-known/oauth-protected-resource"; +const PRM_AT_MCP_PATH: &str = "/.well-known/oauth-protected-resource/mcp"; +const AS_PATH: &str = "/.well-known/oauth-authorization-server"; +const OIDC_PATH: &str = "/.well-known/openid-configuration"; + +/// A bound listener and the origin it will serve. +struct Origin { + listener: tokio::net::TcpListener, + base: String, +} + +async fn origin() -> Origin { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + Origin { listener, base } +} + +impl Origin { + /// Serves `app` with an MCP endpoint at `/mcp` that answers every POST + /// with a 401 carrying `challenge`, and returns that endpoint. + fn serve(self, challenge: &'static str, app: Router) -> String { + let app = app.route( + "/mcp", + post(move || async move { + ( + AxumStatus::UNAUTHORIZED, + [("WWW-Authenticate", challenge)], + "", + ) + .into_response() + }), + ); + tokio::spawn(async move { axum::serve(self.listener, app).await.unwrap() }); + format!("{}/mcp", self.base) + } +} + +const ZOMATO_CHALLENGE: &str = + "Bearer error=\"invalid_token\", error_description=\"Authentication required\""; + +/// Authorization-server metadata for `issuer`, with endpoints on `base`. +fn issuer_metadata(issuer: &str, base: &str) -> Value { + json!({ + "issuer": issuer, + "authorization_endpoint": format!("{base}/authorize"), + "token_endpoint": format!("{base}/token"), + "registration_endpoint": format!("{base}/register"), + "response_types_supported": ["code"], + "code_challenge_methods_supported": ["S256"], + "token_endpoint_auth_methods_supported": ["none", "client_secret_basic", "client_secret_post"], + }) +} + +fn document(body: Value) -> axum::routing::MethodRouter { + get(move || { + let body = body.clone(); + async move { axum::Json(body) } + }) +} + +fn client(endpoint: &str) -> McpHttpClient { + McpHttpClient::new(endpoint, 5).unwrap() +} + +/// The `resource_metadata` an `initialize` 401 carries. +async fn unauthorized_metadata(client: &McpHttpClient) -> Option { + match client.initialize().await.expect_err("a 401") { + Error::Unauthorized { + resource_metadata, .. + } => resource_metadata, + other => panic!("expected unauthorized, got {other:?}"), + } +} + +/// The Zomato shape: the MCP origin is its own authorization server and serves +/// that metadata at the protected-resource path, with no `resource` member. +async fn zomato_shaped() -> (String, String) { + let origin = origin().await; + let base = origin.base.clone(); + let metadata = issuer_metadata(&format!("{base}/"), &base); + let app = Router::new() + .route(PRM_PATH, document(metadata.clone())) + .route(AS_PATH, document(metadata)); + (origin.serve(ZOMATO_CHALLENGE, app), base) +} + +#[tokio::test] +async fn an_origin_serving_issuer_metadata_at_the_resource_path_is_discovered() { + let (endpoint, base) = zomato_shaped().await; + + let context = client(&endpoint) + .discover_authorization() + .await + .unwrap() + .expect("a 401"); + + assert_eq!(context.protected_resource_metadata, None); + assert_eq!(context.authorization_server_metadata.len(), 1); + let server = &context.authorization_server_metadata[0]; + assert_eq!(server.issuer, format!("{base}/")); + assert_eq!( + server.registration_endpoint.as_deref(), + Some(format!("{base}/register").as_str()) + ); +} + +#[tokio::test] +async fn a_401_from_an_origin_serving_issuer_metadata_advertises_oauth() { + let (endpoint, base) = zomato_shaped().await; + let client = client(&endpoint); + + let error = client.initialize().await.expect_err("a 401"); + + assert!(error.advertises_oauth(), "{error:?}"); + assert_eq!( + unauthorized_metadata(&client).await, + Some(format!("{base}{PRM_PATH}")) + ); +} + +#[tokio::test] +async fn protected_resource_metadata_under_the_endpoint_path_is_followed() { + let origin = origin().await; + let base = origin.base.clone(); + let app = Router::new() + .route( + PRM_AT_MCP_PATH, + document(json!({ + "resource": format!("{base}/mcp"), + "authorization_servers": [base], + })), + ) + .route(AS_PATH, document(issuer_metadata(&base, &base))); + let endpoint = origin.serve("Bearer realm=\"mcp\"", app); + let client = client(&endpoint); + + let context = client.discover_authorization().await.unwrap().unwrap(); + + let resource = context.protected_resource_metadata.expect("a resource"); + assert_eq!(resource.resource, format!("{base}/mcp")); + assert_eq!(context.authorization_server_metadata.len(), 1); + assert_eq!( + unauthorized_metadata(&client).await, + Some(format!("{base}{PRM_AT_MCP_PATH}")) + ); +} + +#[tokio::test] +async fn a_resource_naming_no_authorization_server_falls_back_to_the_origin() { + let origin = origin().await; + let base = origin.base.clone(); + let app = Router::new() + .route( + PRM_PATH, + document(json!({ "resource": format!("{base}/mcp") })), + ) + .route(AS_PATH, document(issuer_metadata(&base, &base))); + let endpoint = origin.serve("Bearer", app); + let client = client(&endpoint); + + let context = client.discover_authorization().await.unwrap().unwrap(); + + assert!(context.protected_resource_metadata.is_some()); + assert_eq!(context.authorization_server_metadata[0].issuer, base); + assert_eq!( + unauthorized_metadata(&client).await, + Some(format!("{base}{PRM_PATH}")) + ); +} + +#[tokio::test] +async fn an_unreadable_named_authorization_server_is_not_cached_as_absent() { + let origin = origin().await; + let base = origin.base.clone(); + let app = Router::new().route( + PRM_PATH, + document(json!({ + "resource": format!("{base}/mcp"), + "authorization_servers": [format!("{base}/missing")], + })), + ); + let endpoint = origin.serve("Bearer", app); + let client = client(&endpoint); + + assert_eq!(unauthorized_metadata(&client).await, None); + assert_eq!(client.well_known.lock().clone(), None); +} + +#[tokio::test] +async fn origin_rfc8414_metadata_alone_is_discovered() { + let origin = origin().await; + let base = origin.base.clone(); + let app = Router::new().route(AS_PATH, document(issuer_metadata(&base, &base))); + let endpoint = origin.serve("Bearer", app); + let client = client(&endpoint); + + let context = client.discover_authorization().await.unwrap().unwrap(); + + assert_eq!(context.protected_resource_metadata, None); + assert_eq!(context.authorization_server_metadata.len(), 1); + assert_eq!( + unauthorized_metadata(&client).await, + Some(format!("{base}{AS_PATH}")) + ); +} + +#[tokio::test] +async fn origin_openid_metadata_alone_is_discovered() { + let origin = origin().await; + let base = origin.base.clone(); + let app = Router::new().route(OIDC_PATH, document(issuer_metadata(&base, &base))); + let endpoint = origin.serve("Bearer", app); + let client = client(&endpoint); + + let context = client.discover_authorization().await.unwrap().unwrap(); + + assert_eq!(context.authorization_server_metadata.len(), 1); + assert_eq!( + unauthorized_metadata(&client).await, + Some(format!("{base}{OIDC_PATH}")) + ); +} + +#[tokio::test] +async fn incomplete_rfc8414_metadata_is_completed_from_openid_metadata() { + let origin = origin().await; + let base = origin.base.clone(); + let app = Router::new() + .route( + AS_PATH, + document( + json!({ "issuer": base, "registration_endpoint": format!("{base}/register") }), + ), + ) + .route(OIDC_PATH, document(issuer_metadata(&base, &base))); + let endpoint = origin.serve("Bearer", app); + + let context = client(&endpoint) + .discover_authorization() + .await + .unwrap() + .unwrap(); + + let server = &context.authorization_server_metadata[0]; + assert!(server.authorization_endpoint.is_some()); + assert!(server.token_endpoint.is_some()); +} + +#[tokio::test] +async fn a_bearer_401_with_no_metadata_anywhere_wants_a_static_credential() { + let origin = origin().await; + let endpoint = origin.serve("Bearer realm=\"mcp\"", Router::new()); + let client = client(&endpoint); + + let context = client.discover_authorization().await.unwrap().unwrap(); + assert_eq!(context.authorization_server_metadata.len(), 0); + assert_eq!(context.protected_resource_metadata, None); + + let error = client.initialize().await.expect_err("a 401"); + assert!(error.is_unauthorized()); + assert!(!error.advertises_oauth()); +} + +#[tokio::test] +async fn a_401_on_every_well_known_path_counts_as_absent() { + let origin = origin().await; + let app = Router::new().fallback(|| async { AxumStatus::UNAUTHORIZED }); + let endpoint = origin.serve("Bearer", app); + let client = client(&endpoint); + + assert_eq!(unauthorized_metadata(&client).await, None); + assert_eq!( + client.well_known.lock().clone(), + Some(WellKnownOutcome::NotFound) + ); +} + +#[tokio::test] +async fn a_basic_challenge_never_triggers_discovery() { + let hits = Arc::new(AtomicUsize::new(0)); + let origin = origin().await; + let base = origin.base.clone(); + let counted = Arc::clone(&hits); + let metadata = issuer_metadata(&base, &base); + let app = Router::new().route( + AS_PATH, + get(move || { + counted.fetch_add(1, Ordering::SeqCst); + let metadata = metadata.clone(); + async move { axum::Json(metadata) } + }), + ); + let endpoint = origin.serve("Basic realm=\"mcp\"", app); + let client = client(&endpoint); + + let context = client.discover_authorization().await.unwrap().unwrap(); + assert_eq!(context.authorization_server_metadata.len(), 0); + assert_eq!(unauthorized_metadata(&client).await, None); + assert_eq!(hits.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn issuer_metadata_for_another_origin_is_ignored() { + let origin = origin().await; + let base = origin.base.clone(); + let foreign = issuer_metadata("https://elsewhere.example", &base); + let app = Router::new() + .route(PRM_PATH, document(foreign.clone())) + .route(AS_PATH, document(foreign)); + let endpoint = origin.serve("Bearer", app); + let client = client(&endpoint); + + let context = client.discover_authorization().await.unwrap().unwrap(); + assert_eq!(context.authorization_server_metadata.len(), 0); + assert_eq!(unauthorized_metadata(&client).await, None); +} + +#[tokio::test] +async fn an_issuer_with_a_path_on_the_same_origin_is_not_the_origin() { + let origin = origin().await; + let base = origin.base.clone(); + let app = Router::new().route( + AS_PATH, + document(issuer_metadata(&format!("{base}/tenant"), &base)), + ); + let endpoint = origin.serve("Bearer", app); + + assert_eq!(unauthorized_metadata(&client(&endpoint)).await, None); +} + +#[tokio::test] +async fn a_resource_on_another_origin_is_ignored() { + let origin = origin().await; + let base = origin.base.clone(); + let app = Router::new().route( + PRM_PATH, + document(json!({ + "resource": "https://elsewhere.example/mcp", + "authorization_servers": [base], + })), + ); + let endpoint = origin.serve("Bearer", app); + + let context = client(&endpoint) + .discover_authorization() + .await + .unwrap() + .unwrap(); + + assert_eq!(context.protected_resource_metadata, None); + assert_eq!(context.authorization_server_metadata.len(), 0); +} + +#[tokio::test] +async fn a_redirect_is_not_followed() { + let hits = Arc::new(AtomicUsize::new(0)); + let target = origin().await; + let target_base = target.base.clone(); + let counted = Arc::clone(&hits); + let metadata = issuer_metadata(&target_base, &target_base); + let target_app = Router::new().fallback(move || { + counted.fetch_add(1, Ordering::SeqCst); + let metadata = metadata.clone(); + async move { axum::Json(metadata) } + }); + target.serve("Bearer", target_app); + + let origin = origin().await; + let app = Router::new().fallback(move |uri: axum::http::Uri| { + let location = format!("{target_base}{}", uri.path()); + async move { (AxumStatus::FOUND, [("Location", location)]).into_response() } + }); + let endpoint = origin.serve("Bearer", app); + let client = client(&endpoint); + + assert_eq!(unauthorized_metadata(&client).await, None); + let context = client.discover_authorization().await.unwrap().unwrap(); + assert_eq!(context.authorization_server_metadata.len(), 0); + assert_eq!(hits.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn an_oversized_document_is_ignored() { + let origin = origin().await; + let base = origin.base.clone(); + let mut metadata = issuer_metadata(&base, &base); + metadata["padding"] = json!("x".repeat(MAX_DOCUMENT_BYTES)); + let app = Router::new().route(AS_PATH, document(metadata)); + let endpoint = origin.serve("Bearer", app); + + assert_eq!(unauthorized_metadata(&client(&endpoint)).await, None); +} + +#[tokio::test] +async fn an_oversized_streamed_document_is_ignored() { + let origin = origin().await; + let base = origin.base.clone(); + let text = issuer_metadata(&base, &base).to_string(); + let app = Router::new().route( + AS_PATH, + get(move || { + let chunks: Vec> = + vec![Ok(" ".repeat(MAX_DOCUMENT_BYTES)), Ok(text.clone())]; + async move { AxumResponse::new(Body::from_stream(futures_util::stream::iter(chunks))) } + }), + ); + let endpoint = origin.serve("Bearer", app); + + assert_eq!(unauthorized_metadata(&client(&endpoint)).await, None); +} + +#[tokio::test] +async fn a_streamed_document_within_the_cap_is_read() { + let origin = origin().await; + let base = origin.base.clone(); + let text = issuer_metadata(&base, &base).to_string(); + let app = Router::new().route( + AS_PATH, + get(move || { + let (head, tail) = text.split_at(10); + let chunks: Vec> = + vec![Ok(head.to_string()), Ok(tail.to_string())]; + async move { AxumResponse::new(Body::from_stream(futures_util::stream::iter(chunks))) } + }), + ); + let endpoint = origin.serve("Bearer", app); + + assert_eq!( + unauthorized_metadata(&client(&endpoint)).await, + Some(format!("{base}{AS_PATH}")) + ); +} + +#[tokio::test] +async fn a_document_that_is_not_json_is_ignored() { + let origin = origin().await; + let app = Router::new().route(AS_PATH, get(|| async { "sign in" })); + let endpoint = origin.serve("Bearer", app); + + assert_eq!(unauthorized_metadata(&client(&endpoint)).await, None); +} + +#[tokio::test] +async fn an_unexpected_client_error_counts_as_absent() { + let origin = origin().await; + let app = Router::new().fallback(|| async { AxumStatus::BAD_REQUEST }); + let endpoint = origin.serve("Bearer", app); + let client = client(&endpoint); + + assert_eq!(unauthorized_metadata(&client).await, None); + assert_eq!( + client.well_known.lock().clone(), + Some(WellKnownOutcome::NotFound) + ); +} + +#[tokio::test] +async fn a_server_error_is_retried_on_the_next_401() { + let failures_left = Arc::new(AtomicUsize::new(1)); + let origin = origin().await; + let base = origin.base.clone(); + let metadata = issuer_metadata(&base, &base); + let app = Router::new().route( + AS_PATH, + get(move || { + let failures_left = Arc::clone(&failures_left); + let metadata = metadata.clone(); + async move { + if failures_left.swap(0, Ordering::SeqCst) > 0 { + return AxumStatus::SERVICE_UNAVAILABLE.into_response(); + } + axum::Json(metadata).into_response() + } + }), + ); + let endpoint = origin.serve("Bearer", app); + let client = client(&endpoint); + + assert_eq!(unauthorized_metadata(&client).await, None); + assert_eq!(client.well_known.lock().clone(), None); + assert_eq!( + unauthorized_metadata(&client).await, + Some(format!("{base}{AS_PATH}")) + ); +} + +#[tokio::test] +async fn a_definitive_answer_is_looked_up_once_per_client() { + let hits = Arc::new(AtomicUsize::new(0)); + let origin = origin().await; + let counted = Arc::clone(&hits); + let app = Router::new().fallback(move || { + counted.fetch_add(1, Ordering::SeqCst); + async { AxumStatus::NOT_FOUND } + }); + let endpoint = origin.serve("Bearer", app); + let client = client(&endpoint); + + unauthorized_metadata(&client).await; + let after_first = hits.load(Ordering::SeqCst); + unauthorized_metadata(&client).await; + + assert!(after_first > 0); + assert_eq!(hits.load(Ordering::SeqCst), after_first); +} + +#[tokio::test] +async fn an_unreachable_well_known_path_is_transient() { + let origin = origin().await; + let app = Router::new().fallback(|| async { + AxumResponse::new(Body::from_stream(futures_util::stream::iter(vec![Err::< + String, + std::io::Error, + >( + std::io::Error::other("reset"), + )]))) + }); + let endpoint = origin.serve("Bearer", app); + let client = client(&endpoint); + + assert_eq!(unauthorized_metadata(&client).await, None); + assert_eq!(client.well_known.lock().clone(), None); +} + +#[test] +fn an_origin_issuer_matches_with_or_without_one_trailing_slash() { + let origin = Url::parse("https://mcp.example").unwrap(); + + assert!(issuer_is_origin("https://mcp.example", &origin)); + assert!(issuer_is_origin("https://mcp.example/", &origin)); + assert!(!issuer_is_origin("https://mcp.example//", &origin)); + assert!(!issuer_is_origin("https://mcp.example/tenant", &origin)); + assert!(!issuer_is_origin("https://other.example", &origin)); +} + +#[test] +fn an_endpoint_without_an_http_origin_is_not_looked_up() { + let client = McpHttpClient::new("file:///tmp/mcp", 1).unwrap(); + let outcome = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(client.well_known_authorization()); + + assert_eq!(outcome, WellKnownOutcome::NotFound); +} diff --git a/crates/tinymcp/src/transport/http/mod.rs b/crates/tinymcp/src/transport/http/mod.rs index 0504f8c..9137d76 100644 --- a/crates/tinymcp/src/transport/http/mod.rs +++ b/crates/tinymcp/src/transport/http/mod.rs @@ -3,7 +3,8 @@ //! [`McpHttpClient`] speaks MCP over HTTP: the `initialize` handshake and //! protocol-version negotiation, `tools/list` and `tools/call`, server-sent //! event draining, session lifecycle through `Mcp-Session-Id`, OAuth discovery -//! from a `WWW-Authenticate` challenge, and a graceful `DELETE` on close. +//! from a `WWW-Authenticate` challenge or the server's well-known metadata, and +//! a graceful `DELETE` on close. //! //! # Three behaviors worth knowing before you read the code //! @@ -30,6 +31,7 @@ //! touches await. A synchronous mutex is the right shape; an async one held //! across a request would serialize the transport onto one in-flight call. +mod discovery; mod headers; mod sse; @@ -46,6 +48,7 @@ use std::collections::HashMap; use crate::error::{Error, Result}; use crate::transport::{redact_endpoint, render_tool_result, validate_protocol_version}; +use discovery::{DISCOVERY_BUDGET, WellKnownOutcome}; use headers::{ apply_auth, header_to_string, mcp_param_headers_from_schema, parse_www_authenticate_challenge, }; @@ -80,6 +83,8 @@ pub struct McpHttpClient { client_info: McpClientInfo, auth: McpAuthConfig, state: Mutex, + discovery_http: reqwest::Client, + well_known: Mutex>, } /// Everything about the current session, guarded together. @@ -180,15 +185,23 @@ impl McpHttpClientBuilder { // downgrades are not stripped — the policy closes that gap. .redirect(redirect_policy()); + let mut discovery_builder = reqwest::Client::builder() + .timeout(DISCOVERY_BUDGET) + .connect_timeout(CONNECT_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()); + if let Some(proxy) = self.proxy.as_ref() { builder = apply_proxy(builder, proxy); + discovery_builder = apply_proxy(discovery_builder, proxy); } - let http = builder.build().map_err(|source| Error::ClientBuild { - // Stripped for the reason on `Error::transport`: a proxy URL can - // carry credentials, and this error is printed. + // Stripped for the reason on `Error::transport`: a proxy URL can carry + // credentials, and this error is printed. + let build_error = |source: reqwest::Error| Error::ClientBuild { source: Box::new(source.without_url()), - })?; + }; + let http = builder.build().map_err(build_error)?; + let discovery_http = discovery_builder.build().map_err(build_error)?; Ok(McpHttpClient { endpoint: self.endpoint, @@ -197,6 +210,8 @@ impl McpHttpClientBuilder { client_info: McpClientInfo::from(&self.identity), auth: self.auth, state: Mutex::new(SessionState::default()), + discovery_http, + well_known: Mutex::new(None), }) } } @@ -473,6 +488,14 @@ impl McpHttpClient { /// challenge from the 401. Returns `Ok(None)` when the server answers /// anything else, which is the "no authorization needed" case. /// + /// A challenge naming `resource_metadata` is followed. A Bearer challenge + /// that names none is looked up on the server's origin instead: protected + /// resource metadata at `/.well-known/oauth-protected-resource` under the + /// endpoint's path and then at the root, and failing that the origin's own + /// RFC 8414 or `OpenID` metadata when its issuer is the origin. Neither the + /// context's shape nor its meaning changes: an empty authorization-server + /// list still means none was found. + /// /// An authorization server whose metadata cannot be fetched is omitted /// rather than failing the whole discovery: a protected resource may name /// several, and one being unreachable should not hide the others. @@ -518,6 +541,17 @@ impl McpHttpClient { None => None, }; + if protected_resource_metadata.is_none() + && is_bearer(&challenge.scheme) + && let WellKnownOutcome::Found(found) = self.well_known_authorization().await + { + return Ok(Some(McpAuthorizationContext { + challenge, + protected_resource_metadata: found.protected_resource_metadata, + authorization_server_metadata: found.authorization_servers, + })); + } + let mut authorization_server_metadata = Vec::new(); if let Some(metadata) = protected_resource_metadata.as_ref() { for issuer in &metadata.authorization_servers { @@ -873,13 +907,9 @@ impl McpHttpClient { let response_headers = response.headers().clone(); if status == StatusCode::UNAUTHORIZED { - // Typed rather than a string, so a caller decides on data. The - // presence of `resource_metadata` is what separates a server that - // wants OAuth from one that wants a static credential. return Err(Error::Unauthorized { endpoint: redact_endpoint(&self.endpoint), - resource_metadata: parse_www_authenticate_challenge(&response_headers) - .and_then(|challenge| challenge.resource_metadata), + resource_metadata: self.oauth_metadata_url(&response_headers).await, }); } @@ -920,6 +950,32 @@ impl McpHttpClient { }) } + /// The metadata URL that shows a 401 wants OAuth, or `None` for a static + /// credential. + /// + /// The challenge's own `resource_metadata` when it has one. Otherwise, for + /// a Bearer challenge, the well-known document that yielded an + /// authorization server with authorize and token endpoints. + async fn oauth_metadata_url(&self, headers: &reqwest::header::HeaderMap) -> Option { + let challenge = parse_www_authenticate_challenge(headers)?; + if challenge.resource_metadata.is_some() { + return challenge.resource_metadata; + } + if !is_bearer(&challenge.scheme) { + return None; + } + match self.well_known_authorization().await { + WellKnownOutcome::Found(found) if found.offers_sign_in() => { + tracing::debug!( + endpoint = %redact_endpoint(&self.endpoint), + "[mcp] a 401 without resource_metadata found oauth metadata on the origin" + ); + Some(found.metadata_url) + } + _ => None, + } + } + /// Reads an SSE body only as far as the first data frame. /// /// A server may hold the stream open after replying; stopping at the reply @@ -946,6 +1002,10 @@ impl McpHttpClient { } } +fn is_bearer(scheme: &str) -> bool { + scheme.eq_ignore_ascii_case("bearer") +} + fn rfc8414_metadata_url(issuer: &str) -> Result { let mut url = Url::parse(issuer).map_err(|error| { Error::malformed(format!("invalid authorization server issuer: {error}")) From 0682ea955c581010f712fa3d7777a4929a1d4b88 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Wed, 7 Oct 2026 18:57:30 +0530 Subject: [PATCH 2/5] fix(oauth): document that discovered origin metadata flags a 401 as oauth (#44) --- crates/tinymcp/src/error/mod.rs | 10 ++++++++-- crates/tinymcp/src/error/mod_tests.rs | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/tinymcp/src/error/mod.rs b/crates/tinymcp/src/error/mod.rs index 24acbb1..4b28a9c 100644 --- a/crates/tinymcp/src/error/mod.rs +++ b/crates/tinymcp/src/error/mod.rs @@ -56,7 +56,12 @@ pub enum Error { Unauthorized { /// The redacted endpoint the 401 came from. endpoint: String, - /// The `resource_metadata` URL the challenge advertised, when it did. + /// The metadata URL that shows the server wants OAuth. + /// + /// The challenge's `resource_metadata` when it named one. For a Bearer + /// challenge that named none, the well-known document on the server's + /// origin that yielded an authorization server with authorize and token + /// endpoints. `None` when neither exists. /// /// Its presence is what distinguishes a server that wants OAuth from /// one that wants a static credential, so it drives which affordance a @@ -472,7 +477,8 @@ impl Error { matches!(self, Self::MissingRuntime { .. }) } - /// Whether the 401 advertised OAuth. + /// Whether the 401 advertised OAuth, in its challenge or through + /// authorization metadata published on the server's origin. /// /// `false` for every error that is not a 401. A server that advertises /// OAuth will refuse a pasted static token however valid it looks, so this diff --git a/crates/tinymcp/src/error/mod_tests.rs b/crates/tinymcp/src/error/mod_tests.rs index 962820b..1c6a0cd 100644 --- a/crates/tinymcp/src/error/mod_tests.rs +++ b/crates/tinymcp/src/error/mod_tests.rs @@ -102,7 +102,7 @@ fn no_other_variant_is_reported_as_unauthorized() { } #[test] -fn only_a_401_advertising_resource_metadata_is_flagged_as_oauth() { +fn only_a_401_with_discovered_oauth_metadata_is_flagged_as_oauth() { // This is what decides between offering a sign-in and offering a token // field. A server that only accepts OAuth refuses a pasted token however // valid it looks. From 48e1191b85a246b702f4d2b7adcbc1c2323f9cda Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Wed, 7 Oct 2026 18:57:31 +0530 Subject: [PATCH 3/5] fix(oauth): sign in through a server that is its own authorization server (#44) --- crates/tinymcp/src/registry/oauth/flow.rs | 24 ++- .../tinymcp/src/registry/oauth/mod_tests.rs | 160 ++++++++++++++++++ 2 files changed, 175 insertions(+), 9 deletions(-) diff --git a/crates/tinymcp/src/registry/oauth/flow.rs b/crates/tinymcp/src/registry/oauth/flow.rs index 86e7638..4747eab 100644 --- a/crates/tinymcp/src/registry/oauth/flow.rs +++ b/crates/tinymcp/src/registry/oauth/flow.rs @@ -140,10 +140,12 @@ impl OAuthFlow { /// /// Decided by probing, not by reading registry metadata, which is often /// wrong about this. A server that answers without a challenge is open; one - /// that challenges with an authorization server [`Self::begin`] can drive - /// (authorize, token and dynamic-registration endpoints, and the - /// authorization-code grant) wants a browser sign-in; anything else wants a - /// static token. + /// whose authorization server [`Self::begin`] can drive (authorize, token + /// and dynamic-registration endpoints, and the authorization-code grant) + /// wants a browser sign-in; anything else wants a static token. The + /// authorization server is found from the challenge's `resource_metadata`, + /// or, for a Bearer challenge without one, from the well-known metadata on + /// the server's origin. /// /// A discovery failure reports a static token rather than an error. The /// user can paste one and find out, which beats being blocked by a probe @@ -199,8 +201,8 @@ impl OAuthFlow { /// /// # Errors /// - /// Returns [`Error::AuthDiscovery`] when no advertised authorization server - /// offers everything the flow needs, [`Error::MalformedResponse`] when + /// Returns [`Error::AuthDiscovery`] when the server advertises no + /// authorization server, or none offers everything the flow needs, [`Error::MalformedResponse`] when /// registration answers with something unusable, plus whatever the /// transport returns. pub async fn begin(&self, store: &S, server_id: &str, redirect_uri: &str) -> Result @@ -229,9 +231,13 @@ impl OAuthFlow { .iter() .find(|metadata| can_drive_sign_in(metadata)) .ok_or_else(|| Error::AuthDiscovery { - detail: "no advertised authorization server offers an authorize endpoint, a token \ - endpoint, and dynamic client registration together" - .to_string(), + detail: if context.authorization_server_metadata.is_empty() { + "the server advertises no authorization server".to_string() + } else { + "no authorization server offers an authorize endpoint, a token endpoint, and \ + dynamic client registration together" + .to_string() + }, challenge: Box::new(context.challenge.clone()), })?; diff --git a/crates/tinymcp/src/registry/oauth/mod_tests.rs b/crates/tinymcp/src/registry/oauth/mod_tests.rs index 6ffb6da..7dbc841 100644 --- a/crates/tinymcp/src/registry/oauth/mod_tests.rs +++ b/crates/tinymcp/src/registry/oauth/mod_tests.rs @@ -1035,6 +1035,166 @@ async fn a_server_that_does_not_want_authorization_is_refused() { ); } +// --------------------------------------------------------------------------- +// A server that is its own authorization server +// --------------------------------------------------------------------------- + +/// An MCP server shaped like Zomato's: its 401 names no `resource_metadata`, +/// and its origin serves its own authorization-server metadata at both the +/// protected-resource and the RFC 8414 well-known paths. +async fn origin_authority(with_registration: bool) -> (String, Arc) { + let state = Arc::new(Authority::default()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + + let mut metadata = json!({ + "issuer": format!("{origin}/"), + "authorization_endpoint": format!("{origin}/authorize"), + "token_endpoint": format!("{origin}/token"), + "registration_endpoint": format!("{origin}/register"), + "response_types_supported": ["code"], + "code_challenge_methods_supported": ["S256"], + }); + if !with_registration { + metadata + .as_object_mut() + .unwrap() + .remove("registration_endpoint"); + } + let document = get(move || { + let metadata = metadata.clone(); + async move { axum::Json(metadata) } + }); + + let app = Router::new() + .route("/.well-known/oauth-protected-resource", document.clone()) + .route("/.well-known/oauth-authorization-server", document) + .route( + "/mcp", + post(|| async { + ( + AxumStatus::UNAUTHORIZED, + [( + "WWW-Authenticate", + "Bearer error=\"invalid_token\", error_description=\"Authentication required\"", + )], + "", + ) + .into_response() + }), + ) + .merge(endpoints()) + .with_state(Arc::clone(&state)); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + (format!("{origin}/mcp"), state) +} + +#[tokio::test] +async fn a_server_that_is_its_own_authorization_server_is_detected_as_oauth() { + let (endpoint, _state) = origin_authority(true).await; + let store = store_with_remote(&endpoint); + + let detection = flow().detect(&store, "srv-1").await.unwrap(); + + assert_eq!(detection.kind, AuthKind::Oauth); + let origin = endpoint.trim_end_matches("/mcp"); + assert_eq!( + detection.authorization_endpoint, + Some(format!("{origin}/authorize")) + ); +} + +#[tokio::test] +async fn a_server_that_is_its_own_authorization_server_signs_in_through_its_origin() { + let (endpoint, state) = origin_authority(true).await; + let store = store_with_remote(&endpoint); + + let url = flow() + .begin(&store, "srv-1", "http://127.0.0.1:7788/callback") + .await + .expect("begin"); + + let origin = endpoint.trim_end_matches("/mcp"); + assert!(url.starts_with(&format!("{origin}/authorize?")), "{url}"); + assert_eq!(state.registrations.load(Ordering::SeqCst), 1); + assert_eq!( + authorize_param(&url, "code_challenge_method").as_deref(), + Some("S256") + ); + assert!(authorize_param(&url, "code_challenge").is_some()); + assert!(authorize_param(&url, "state").is_some()); + assert_eq!( + authorize_param(&url, "resource").as_deref(), + Some(endpoint.as_str()) + ); +} + +#[tokio::test] +async fn an_origin_authorization_server_without_dynamic_registration_wants_a_static_token() { + let (endpoint, state) = origin_authority(false).await; + let store = store_with_remote(&endpoint); + + let detection = flow().detect(&store, "srv-1").await.unwrap(); + assert_eq!(detection.kind, AuthKind::Token); + + let error = flow() + .begin(&store, "srv-1", "http://127.0.0.1:7788/callback") + .await + .expect_err("no registration endpoint"); + match error { + Error::AuthDiscovery { detail, .. } => { + assert!(detail.contains("dynamic client registration"), "{detail}"); + } + other => panic!("expected auth discovery, got {other:?}"), + } + assert_eq!(state.registrations.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn beginning_against_a_server_advertising_no_authorization_server_says_so() { + let app = Router::new().fallback(|| async { + ( + AxumStatus::UNAUTHORIZED, + [("WWW-Authenticate", "Bearer realm=\"mcp\"")], + "", + ) + .into_response() + }); + let base = serve(app).await; + let store = store_with_remote(&format!("{base}/mcp")); + + let error = flow() + .begin(&store, "srv-1", "http://127.0.0.1:7788/callback") + .await + .expect_err("no authorization server"); + + match error { + Error::AuthDiscovery { detail, .. } => { + assert!( + detail.contains("advertises no authorization server"), + "{detail}" + ); + } + other => panic!("expected auth discovery, got {other:?}"), + } +} + +#[tokio::test] +async fn public_endpoints_only_refuses_an_origin_authorization_server_on_loopback() { + let (endpoint, state) = origin_authority(true).await; + let store = store_with_remote(&endpoint); + + let error = flow() + .require_public_endpoints() + .begin(&store, "srv-1", "http://127.0.0.1:7788/callback") + .await + .expect_err("a loopback authorization server"); + + assert!(error.to_string().contains("endpoint refused"), "{error}"); + assert_eq!(state.registrations.load(Ordering::SeqCst), 0); +} + // --------------------------------------------------------------------------- // Completing // --------------------------------------------------------------------------- From befa2e8f8feb15b627a7cb2bacb63a7885a61a9c Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Wed, 7 Oct 2026 18:57:31 +0530 Subject: [PATCH 4/5] test(oauth): pin bridge outcome and status for origin-published metadata (#44) --- .../src/registry/connections/mod_tests.rs | 68 ++++++++++++++++- .../tinymcp/src/tools/bridge_outcome_tests.rs | 76 ++++++++++++++----- 2 files changed, 126 insertions(+), 18 deletions(-) diff --git a/crates/tinymcp/src/registry/connections/mod_tests.rs b/crates/tinymcp/src/registry/connections/mod_tests.rs index ece4a21..3487fe3 100644 --- a/crates/tinymcp/src/registry/connections/mod_tests.rs +++ b/crates/tinymcp/src/registry/connections/mod_tests.rs @@ -10,7 +10,8 @@ use std::collections::BTreeMap; use std::time::Duration; -use axum::routing::post; +use axum::response::IntoResponse; +use axum::routing::{get, post}; use axum::{Json, Router}; use serde_json::{Value, json}; @@ -405,6 +406,71 @@ fn a_transport_failure_is_not_classified_as_an_authentication_one() { assert!(failure.message.contains("500")); } +/// A server whose 401 names no `resource_metadata`, optionally publishing its +/// own authorization-server metadata on its origin. +fn bearer_gated_server(publishes_metadata: bool) -> Router { + let mut app = Router::new().route( + "/", + post(|| async { + ( + axum::http::StatusCode::UNAUTHORIZED, + [("WWW-Authenticate", "Bearer error=\"invalid_token\"")], + "", + ) + .into_response() + }), + ); + if publishes_metadata { + app = app.route( + "/.well-known/oauth-authorization-server", + get(|headers: axum::http::HeaderMap| async move { + let host = headers + .get("host") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + Json(json!({ + "issuer": format!("http://{host}/"), + "authorization_endpoint": format!("http://{host}/authorize"), + "token_endpoint": format!("http://{host}/token"), + "registration_endpoint": format!("http://{host}/register"), + })) + }), + ); + } + app +} + +async fn auth_hint_after_connecting(app: Router) -> Option { + let url = serve(app).await; + let server = install("srv-1", Transport::HttpRemote { url }); + let store = store_with(&server); + let connections = Connections::new(); + let oauth = OAuthFlow::new(None).unwrap(); + + connections + .connect(&store, &oauth, &identity(), None, &server) + .await + .expect_err("a 401"); + connections.auth_hint("srv-1").await +} + +#[tokio::test] +async fn a_401_whose_origin_publishes_authorization_metadata_needs_a_sign_in() { + assert_eq!( + auth_hint_after_connecting(bearer_gated_server(true)).await, + Some(McpAuthHint::OauthRequired) + ); +} + +#[tokio::test] +async fn a_401_with_no_authorization_metadata_anywhere_needs_a_credential() { + assert_eq!( + auth_hint_after_connecting(bearer_gated_server(false)).await, + Some(McpAuthHint::CredentialRequired) + ); +} + // --------------------------------------------------------------------------- // The map itself // --------------------------------------------------------------------------- diff --git a/crates/tinymcp/src/tools/bridge_outcome_tests.rs b/crates/tinymcp/src/tools/bridge_outcome_tests.rs index ce4a277..d228b3e 100644 --- a/crates/tinymcp/src/tools/bridge_outcome_tests.rs +++ b/crates/tinymcp/src/tools/bridge_outcome_tests.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use axum::http::{HeaderMap, HeaderValue, StatusCode}; use axum::response::IntoResponse as _; -use axum::routing::post; +use axum::routing::{get, post}; use axum::{Json, Router}; use serde_json::{Value, json}; use tinymcp_bus::{ @@ -72,21 +72,50 @@ async fn answering_server() -> String { } async fn unauthorized_server(resource_metadata: Option<&'static str>) -> String { - let app = Router::new().route( - "/mcp", - post(move || async move { - let mut headers = HeaderMap::new(); - let challenge = resource_metadata.map_or_else( - || "Bearer realm=\"mcp\"".to_string(), - |url| format!("Bearer resource_metadata=\"{url}\""), - ); - headers.insert( - "www-authenticate", - HeaderValue::from_str(&challenge).unwrap(), - ); - (StatusCode::UNAUTHORIZED, headers, "").into_response() - }), - ); + let app = Router::new().fallback(move || async move { + let mut headers = HeaderMap::new(); + let challenge = resource_metadata.map_or_else( + || "Bearer realm=\"mcp\"".to_string(), + |url| format!("Bearer resource_metadata=\"{url}\""), + ); + headers.insert( + "www-authenticate", + HeaderValue::from_str(&challenge).unwrap(), + ); + (StatusCode::UNAUTHORIZED, headers, "").into_response() + }); + serve(app).await +} + +async fn self_authorizing_server() -> String { + let app = Router::new() + .route( + "/.well-known/oauth-authorization-server", + get(|headers: HeaderMap| async move { + let host = headers + .get("host") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + Json(json!({ + "issuer": format!("http://{host}/"), + "authorization_endpoint": format!("http://{host}/authorize"), + "token_endpoint": format!("http://{host}/token"), + "registration_endpoint": format!("http://{host}/register"), + })) + }), + ) + .route( + "/mcp", + post(|| async { + ( + StatusCode::UNAUTHORIZED, + [("www-authenticate", "Bearer error=\"invalid_token\"")], + "", + ) + .into_response() + }), + ); serve(app).await } @@ -168,7 +197,7 @@ async fn a_401_advertising_oauth_reports_both_flags() { } #[tokio::test] -async fn a_401_without_resource_metadata_is_unauthorized_without_oauth() { +async fn a_401_with_no_oauth_metadata_anywhere_is_unauthorized_without_oauth() { let endpoint = unauthorized_server(None).await; let result = call_tool(registry(&endpoint, McpAuthConfig::None, &[])) .execute(args("whoami")) @@ -180,6 +209,19 @@ async fn a_401_without_resource_metadata_is_unauthorized_without_oauth() { assert!(!error.advertises_oauth); } +#[tokio::test] +async fn a_401_whose_origin_publishes_authorization_server_metadata_advertises_oauth() { + let endpoint = self_authorizing_server().await; + let result = call_tool(registry(&endpoint, McpAuthConfig::None, &[])) + .execute(args("whoami")) + .await + .unwrap(); + let error = outcome_of(&result).error.unwrap(); + assert_eq!(error.code, errors::UNAUTHORIZED); + assert!(error.unauthorized); + assert!(error.advertises_oauth); +} + #[tokio::test] async fn an_unknown_server_reports_unknown_server() { let registry = registry(&closed_endpoint().await, McpAuthConfig::None, &[]); From 0bc009c99e548ca83a37bef49c13457ca1c08521 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Wed, 7 Oct 2026 18:57:31 +0530 Subject: [PATCH 5/5] docs(oauth): describe well-known discovery for a 401 without resource_metadata (#44) --- README.md | 24 ++++++++++++++++++++++++ ROADMAP.md | 2 ++ 2 files changed, 26 insertions(+) diff --git a/README.md b/README.md index 54bd4ff..626967b 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,30 @@ check. `registry::OAuthBundle` is the stored refresh bundle's shape. the authorization server shows on its consent screen; it defaults to `DEFAULT_CLIENT_NAME` (`TinyMCP`). +OAuth discovery starts from the 401. When its challenge names +`resource_metadata`, that protected-resource metadata is followed. When a +Bearer challenge names none, the server's origin is checked in this order: + +1. `/.well-known/oauth-protected-resource` under the endpoint's path, then at + the root. A document whose `resource` is on the same origin is followed to + its authorization servers. A document that is authorization-server metadata + whose `issuer` is the origin is used as the authorization server; some + servers publish theirs there. +2. The origin's own `/.well-known/oauth-authorization-server`, then + `/.well-known/openid-configuration`, accepted only when the `issuer` is the + origin (one trailing slash tolerated). This covers servers on the + 2025-03-26 authorization spec, where the MCP server is its own + authorization server. + +Default `/authorize` and `/token` paths are never guessed. Every lookup is a +`GET` to the MCP origin over a client that follows no redirects, reads at most +64 KiB and gives up after about five seconds. A 3xx, 401, 403, 404 or 410 +counts as absent. A 5xx or a network failure is retried on the next 401. When an +authorization server with authorize and token endpoints turns up, +`Error::Unauthorized::resource_metadata` names the document that yielded it, so +`advertises_oauth` and the connection status report a sign-in. A Basic +challenge is never looked up. + ## Static linking Enable the `static-link` feature when compiling this module into a Rust host. It diff --git a/ROADMAP.md b/ROADMAP.md index 50dd0eb..4e06740 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,6 +18,8 @@ out of scope. A roadmap that lists everything is a roadmap nobody trusts. for host metering and failure surfacing (contract 1.3) - `mcp.json` reading for hosts with their own store (`parse_with`), and a guarded OAuth refresh on the flow +- OAuth discovery for a 401 without `resource_metadata`, from the origin's + well-known protected-resource and authorization-server metadata ## Next