Skip to content
Merged
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: 18 additions & 6 deletions crates/mcpmux-gateway/src/server/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,17 @@ pub struct OAuthServerMetadata {
/// OAuth metadata endpoint (RFC 8414)
pub async fn oauth_metadata(
axum::extract::State(app_state): axum::extract::State<AppState>,
) -> Json<OAuthServerMetadata> {
) -> Result<Json<OAuthServerMetadata>, StatusCode> {
// When inbound auth is disabled, don't advertise an authorization server —
// otherwise MCP clients that probe discovery start an OAuth flow even
// though `/mcp` accepts them without a token. 404 makes them connect
// tokenlessly.
if app_state.gateway_state.read().await.auth_disabled() {
return Err(StatusCode::NOT_FOUND);
}
info!("[Gateway] OAuth metadata request - serving authorization server metadata");
let base = &app_state.base_url;
Json(OAuthServerMetadata {
Ok(Json(OAuthServerMetadata {
issuer: base.to_string(),
authorization_endpoint: format!("{}/oauth/authorize", base),
token_endpoint: format!("{}/oauth/token", base),
Expand All @@ -88,7 +95,7 @@ pub async fn oauth_metadata(

// MCP spec 2025-11-25: Advertise CIMD support
client_id_metadata_document_supported: Some(true),
})
}))
}

/// OAuth Protected Resource Metadata (RFC 9728)
Expand All @@ -104,14 +111,19 @@ pub struct ProtectedResourceMetadata {
/// This tells MCP clients where to find the authorization server
pub async fn resource_metadata(
axum::extract::State(app_state): axum::extract::State<AppState>,
) -> Json<ProtectedResourceMetadata> {
) -> Result<Json<ProtectedResourceMetadata>, StatusCode> {
// See `oauth_metadata`: stay silent about auth when it's disabled so clients
// don't kick off OAuth against a gateway that accepts them tokenlessly.
if app_state.gateway_state.read().await.auth_disabled() {
return Err(StatusCode::NOT_FOUND);
}
info!("[Gateway] Protected resource metadata request");
let base = &app_state.base_url;
Json(ProtectedResourceMetadata {
Ok(Json(ProtectedResourceMetadata {
resource: format!("{}/mcp", base),
authorization_servers: vec![base.to_string()],
scopes_supported: Some(vec!["mcp".to_string(), "offline_access".to_string()]),
})
}))
}

/// OAuth authorization query params
Expand Down
5 changes: 4 additions & 1 deletion crates/mcpmux-gateway/src/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ mod service_container;
mod startup;
mod state;

use handlers::AppState; // Import AppState
// Exposed for integration tests that mount these routes against a real
// ServiceContainer (e.g. asserting the OAuth-discovery endpoints 404 when
// inbound auth is disabled). AppState is also used throughout this module.
pub use handlers::{oauth_metadata, resource_metadata, AppState};

pub use dependencies::{DependenciesBuilder, GatewayDependencies};
pub use handlers::PendingAuthorization;
Expand Down
67 changes: 64 additions & 3 deletions tests/rust/tests/streamable_http/auth_disable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,16 @@ use axum::{
http::{Request, StatusCode},
middleware,
response::{IntoResponse, Response},
routing::post,
routing::{get, post},
Router,
};
use mcpmux_core::{DomainEvent, ServerDiscoveryService, ServerLogManager};
use mcpmux_gateway::{
mcp::mcp_oauth_middleware,
server::{DependenciesBuilder, GatewayDependencies, GatewayState, ServiceContainer},
server::{
oauth_metadata, resource_metadata, AppState, DependenciesBuilder, GatewayDependencies,
GatewayState, ServiceContainer,
},
};
use mcpmux_storage::SqliteSpaceRepository;
use std::sync::Arc;
Expand All @@ -44,6 +47,7 @@ async fn echo_client_id(req: Request<Body>) -> Response {

struct Harness {
url: String,
base: String,
ct: CancellationToken,
}

Expand Down Expand Up @@ -112,10 +116,30 @@ impl Harness {
gateway_state,
));

let router = Router::new().route("/mcp", post(echo_client_id)).layer(
let mcp_router = Router::new().route("/mcp", post(echo_client_id)).layer(
middleware::from_fn_with_state(services.clone(), mcp_oauth_middleware),
);

// Mount the OAuth-discovery endpoints so we can assert they 404 when
// inbound auth is disabled (don't advertise auth the gateway won't ask
// for).
let app_state = AppState {
gateway_state: services.gateway_state.clone(),
services: services.clone(),
base_url: "http://127.0.0.1:0".to_string(),
};
let discovery_router = Router::new()
.route(
"/.well-known/oauth-protected-resource",
get(resource_metadata),
)
.route(
"/.well-known/oauth-authorization-server",
get(oauth_metadata),
)
.with_state(app_state);
let router = mcp_router.merge(discovery_router);

let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
Expand All @@ -131,6 +155,7 @@ impl Harness {

Self {
url: format!("http://127.0.0.1:{port}/mcp"),
base: format!("http://127.0.0.1:{port}"),
ct,
}
}
Expand Down Expand Up @@ -178,3 +203,39 @@ async fn auth_required_gateway_rejects_request_without_token() {
"default gateway must reject a tokenless request"
);
}

#[tokio::test]
async fn authless_gateway_does_not_advertise_oauth_discovery() {
// With inbound auth disabled, the OAuth-discovery endpoints must 404 so MCP
// clients don't start an OAuth flow against a gateway that accepts them
// without a token.
let h = Harness::start(true).await;
let client = reqwest::Client::new();
for path in [
"/.well-known/oauth-protected-resource",
"/.well-known/oauth-authorization-server",
] {
let resp = client
.get(format!("{}{path}", h.base))
.send()
.await
.expect("request");
assert_eq!(
resp.status(),
reqwest::StatusCode::NOT_FOUND,
"{path} must 404 when auth is disabled"
);
}
}

#[tokio::test]
async fn auth_required_gateway_advertises_oauth_discovery() {
// The default (auth required) still serves discovery so real OAuth works.
let h = Harness::start(false).await;
let resp = reqwest::Client::new()
.get(format!("{}/.well-known/oauth-protected-resource", h.base))
.send()
.await
.expect("request");
assert_eq!(resp.status(), reqwest::StatusCode::OK);
}
Loading