Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 8 additions & 2 deletions crates/tinymcp/src/error/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/tinymcp/src/error/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
68 changes: 67 additions & 1 deletion crates/tinymcp/src/registry/connections/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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<McpAuthHint> {
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
// ---------------------------------------------------------------------------
Expand Down
24 changes: 15 additions & 9 deletions crates/tinymcp/src/registry/oauth/flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<S>(&self, store: &S, server_id: &str, redirect_uri: &str) -> Result<String>
Expand Down Expand Up @@ -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()),
})?;

Expand Down
160 changes: 160 additions & 0 deletions crates/tinymcp/src/registry/oauth/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Authority>) {
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
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading