use axum::{
body::Body,
http::{Request, StatusCode},
Router,
};
use http_body_util::BodyExt;
use tower::ServiceExt;
const PUBLIC_URL: &str = "https://mcp.example.com";
fn test_state_dir() -> std::path::PathBuf {
use std::sync::Once;
static ONCE: Once = Once::new();
let dir = std::env::temp_dir().join("imcp2-router-tests");
ONCE.call_once(|| {
let _ = std::fs::create_dir_all(&dir);
let _ = std::fs::remove_file(dir.join("oauth-clients.json")); });
dir
}
fn server(instance: imcp2::IiInstance, mcp_path: &str) -> imcp2::McpServer {
let state_dir = test_state_dir();
let agent = imcp2::Agent::builder()
.with_url(imcp2::IC_URL)
.build()
.expect("build agent");
imcp2::McpServer::new(imcp2::McpConfig {
agent,
instance,
public_url: PUBLIC_URL.into(),
mcp_path: mcp_path.into(),
clients: imcp2::SharedClients::load(&state_dir),
state_dir,
require_resource: false,
})
}
fn app() -> Router {
let prod = server(imcp2::IiInstance::prod().expect("prod"), "/mcp");
let beta = server(imcp2::IiInstance::beta().expect("beta"), "/mcp-beta");
Router::new()
.nest_service(prod.mcp_path(), prod.mcp_router())
.nest_service(beta.mcp_path(), beta.mcp_router())
.merge(prod.well_known_router())
.merge(beta.well_known_router())
.merge(prod.root_well_known_router())
.merge(imcp2::auth_callbacks_router(&[&prod, &beta]))
}
async fn get_json(app: Router, path: &str) -> (StatusCode, serde_json::Value) {
let resp = app
.oneshot(Request::get(path).body(Body::empty()).unwrap())
.await
.unwrap();
let status = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
(status, json)
}
#[tokio::test]
async fn protected_resource_metadata_follows_each_mount() {
for path in [
"/.well-known/oauth-protected-resource/mcp",
"/.well-known/oauth-protected-resource",
] {
let (status, doc) = get_json(app(), path).await;
assert_eq!(status, StatusCode::OK, "GET {path}");
assert_eq!(doc["resource"], format!("{PUBLIC_URL}/mcp"), "GET {path}");
assert_eq!(doc["authorization_servers"][0], format!("{PUBLIC_URL}/mcp"));
}
let (status, doc) = get_json(app(), "/.well-known/oauth-protected-resource/mcp-beta").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(doc["resource"], format!("{PUBLIC_URL}/mcp-beta"));
assert_eq!(doc["authorization_servers"][0], format!("{PUBLIC_URL}/mcp-beta"));
}
#[tokio::test]
async fn authorization_server_metadata_is_a_path_issuer_per_instance() {
for path in [
"/.well-known/oauth-authorization-server/mcp",
"/mcp/.well-known/oauth-authorization-server",
"/.well-known/oauth-authorization-server",
] {
let (status, doc) = get_json(app(), path).await;
assert_eq!(status, StatusCode::OK, "GET {path}");
assert_eq!(doc["issuer"], format!("{PUBLIC_URL}/mcp"), "GET {path}");
assert_eq!(
doc["authorization_endpoint"],
format!("{PUBLIC_URL}/mcp/oauth/authorize")
);
assert_eq!(doc["token_endpoint"], format!("{PUBLIC_URL}/mcp/oauth/token"));
assert_eq!(
doc["registration_endpoint"],
format!("{PUBLIC_URL}/mcp/oauth/register")
);
assert_eq!(doc["authorization_response_iss_parameter_supported"], true);
assert!(doc.get("scopes_supported").is_none());
}
for path in [
"/.well-known/oauth-authorization-server/mcp-beta",
"/mcp-beta/.well-known/oauth-authorization-server",
] {
let (status, doc) = get_json(app(), path).await;
assert_eq!(status, StatusCode::OK, "GET {path}");
assert_eq!(doc["issuer"], format!("{PUBLIC_URL}/mcp-beta"), "GET {path}");
assert_eq!(
doc["authorization_endpoint"],
format!("{PUBLIC_URL}/mcp-beta/oauth/authorize")
);
}
}
#[tokio::test]
async fn auth_callbacks_document_declares_every_mount() {
let (status, doc) = get_json(app(), "/.well-known/ii-auth-callbacks").await;
assert_eq!(status, StatusCode::OK);
let declared: Vec<String> = doc["callbacks"]
.as_array()
.expect("callbacks array")
.iter()
.map(|e| e.as_str().unwrap().to_string())
.collect();
assert_eq!(
declared,
vec![
format!("{PUBLIC_URL}/mcp/oauth/connect/callback"),
format!("{PUBLIC_URL}/mcp-beta/oauth/connect/callback"),
],
"one entry per instance, under each mount"
);
}
#[tokio::test]
async fn unauthenticated_mcp_requests_get_the_path_aware_challenge() {
for path in ["/mcp", "/mcp/", "/mcp/sub"] {
let resp = app()
.oneshot(Request::post(path).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "POST {path}");
let challenge = resp
.headers()
.get("www-authenticate")
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string();
assert!(
challenge.contains(&format!(
"resource_metadata=\"{PUBLIC_URL}/.well-known/oauth-protected-resource/mcp\""
)),
"POST {path}: challenge should point at the path-aware metadata: {challenge}"
);
assert!(
!challenge.contains("error="),
"POST {path}: bare challenge must omit the error code: {challenge}"
);
}
let resp = app()
.oneshot(Request::post("/mcp-beta").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let challenge = resp
.headers()
.get("www-authenticate")
.and_then(|v| v.to_str().ok())
.unwrap_or_default();
assert!(
challenge.contains("/.well-known/oauth-protected-resource/mcp-beta\""),
"beta challenge: {challenge}"
);
}
#[tokio::test]
async fn dynamic_client_registration_round_trips_and_persists() {
let app = app();
let register = |body: &'static str| {
let app = app.clone();
async move {
let resp = app
.oneshot(
Request::post("/mcp/oauth/register")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
let status = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
(status, serde_json::from_slice::<serde_json::Value>(&bytes).unwrap())
}
};
let (status, doc) = register(r#"{"redirect_uris":["http://127.0.0.1:4321/cb"]}"#).await;
assert_eq!(status, StatusCode::CREATED);
let client_id = doc["client_id"].as_str().expect("a client_id").to_string();
assert_eq!(doc["redirect_uris"][0], "http://127.0.0.1:4321/cb");
let resp = app
.clone()
.oneshot(
Request::get(format!(
"/mcp/oauth/authorize?response_type=code&client_id={client_id}\
&redirect_uri=http://127.0.0.1:4321/cb&code_challenge=abc\
&code_challenge_method=S256"
))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FOUND, "a registered client can start a sign-in");
assert!(resp.headers().contains_key("set-cookie"), "the binding cookie must be set");
let (status, doc) = register(r#"{"redirect_uris":["https://attacker.example/cb"]}"#).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(doc["error"], "invalid_redirect_uri");
let path = test_state_dir().join("oauth-clients.json");
let mut persisted = String::new();
for _ in 0..100 {
persisted = std::fs::read_to_string(&path).unwrap_or_default();
if persisted.contains(&client_id) {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
assert!(
persisted.contains(&client_id),
"the registration must reach {} so it survives a restart",
path.display()
);
assert!(
!persisted.contains("attacker.example"),
"a refused registration must never be stored"
);
}
#[tokio::test]
async fn registration_bounds_redirect_uris() {
let app = app();
let register = |body: String| {
let app = app.clone();
async move {
let resp = app
.oneshot(
Request::post("/mcp/oauth/register")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
let status = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
(status, serde_json::from_slice::<serde_json::Value>(&bytes).unwrap())
}
};
let many: Vec<String> = (0..64).map(|i| format!("http://127.0.0.1:4321/cb{i}")).collect();
let body = serde_json::json!({ "redirect_uris": many.clone() }).to_string();
let (status, doc) = register(body).await;
assert_eq!(status, StatusCode::BAD_REQUEST, "an over-count array is refused");
assert_eq!(doc["error"], "invalid_redirect_uri");
let long = format!("http://127.0.0.1:4321/{}", "a".repeat(4096));
let body = serde_json::json!({ "redirect_uris": [long] }).to_string();
let (status, doc) = register(body).await;
assert_eq!(status, StatusCode::BAD_REQUEST, "an over-long uri is refused");
assert_eq!(doc["error"], "invalid_redirect_uri");
let body = serde_json::json!({
"redirect_uris": many,
"grant_types": ["client_credentials"],
})
.to_string();
let (status, doc) = register(body).await;
assert_eq!(status, StatusCode::BAD_REQUEST, "combined-invalid input is refused");
assert_eq!(
doc["error"], "invalid_redirect_uri",
"redirect overflow must win over the grant-type error"
);
}
#[tokio::test]
async fn invalid_token_challenge_carries_rfc6750_error() {
let resp = app()
.oneshot(
Request::post("/mcp")
.header("authorization", "Bearer bogus")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let challenge = resp
.headers()
.get("www-authenticate")
.and_then(|v| v.to_str().ok())
.unwrap_or_default();
assert!(
challenge.contains("error=\"invalid_token\""),
"presented-but-invalid token must carry the RFC 6750 error: {challenge}"
);
}
#[tokio::test]
async fn authorize_validates_its_inputs() {
let resp = app()
.oneshot(
Request::get("/mcp/oauth/authorize?client_id=x&redirect_uri=https://a.test/cb")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let resp = app()
.oneshot(
Request::get(
"/mcp/oauth/authorize?response_type=code&client_id=unknown\
&redirect_uri=http://127.0.0.1:4321/cb&code_challenge=abc\
&code_challenge_method=S256",
)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let resp = app()
.oneshot(
Request::get(
"/mcp/oauth/authorize?response_type=code&client_id=unknown\
&redirect_uri=http://127.0.0.1:4321/cb&code_challenge=abc",
)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn oauth_endpoints_live_under_each_mount() {
for path in ["/mcp/oauth/token", "/mcp-beta/oauth/token"] {
let resp = app()
.oneshot(
Request::post(path)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(
"grant_type=authorization_code&code=bogus&client_id=x&code_verifier=v",
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST, "POST {path}");
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let doc: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(doc["error"], "invalid_grant", "POST {path}");
}
for (page, redeem) in [
("/mcp/oauth/connect/callback", "/mcp/oauth/connect/redeem"),
("/mcp-beta/oauth/connect/callback", "/mcp-beta/oauth/connect/redeem"),
] {
let resp = app()
.oneshot(Request::get(page).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "GET {page}");
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let html = String::from_utf8_lossy(&bytes);
assert!(
html.contains(redeem),
"GET {page}: the page must post to its own instance's redeem path"
);
}
}