diff --git a/apps/desktop/src-tauri/src/commands/gateway.rs b/apps/desktop/src-tauri/src/commands/gateway.rs index ad3ae231..0f66f09b 100644 --- a/apps/desktop/src-tauri/src/commands/gateway.rs +++ b/apps/desktop/src-tauri/src/commands/gateway.rs @@ -125,6 +125,7 @@ pub(crate) async fn shutdown_gateway_handle(mut handle: mcpmux_gateway::GatewayS /// mcpmux app instead of the dialog rendering invisibly under another /// window. const GATEWAY_PUBLIC_BASE_URL_KEY: &str = "gateway.public_base_url"; +const GATEWAY_NETWORK_ACCESS_KEY: &str = "gateway.network_access_enabled"; pub(crate) fn normalize_public_base_url(raw: &str) -> Result, String> { let trimmed = raw.trim(); @@ -189,6 +190,33 @@ pub(crate) async fn load_public_base_url(app_state: &AppState) -> Option load_public_base_url_from_repo(&app_state.settings_repository).await } +/// The address the gateway binds to: loopback by default, or `0.0.0.0` (all +/// interfaces) once the user opts into network access so other devices on the +/// LAN can reach it. +pub(crate) fn bind_host_for(network_access: bool) -> &'static str { + if network_access { + "0.0.0.0" + } else { + "127.0.0.1" + } +} + +pub(crate) async fn load_network_access_from_repo( + settings_repository: &Arc, +) -> bool { + settings_repository + .get(GATEWAY_NETWORK_ACCESS_KEY) + .await + .ok() + .flatten() + .map(|value| value == "true") + .unwrap_or(false) +} + +pub(crate) async fn load_network_access(app_state: &AppState) -> bool { + load_network_access_from_repo(&app_state.settings_repository).await +} + pub(crate) fn advertised_base_url(public_base_url: Option<&str>, port: u16) -> String { public_base_url .map(str::trim) @@ -981,9 +1009,13 @@ pub async fn start_gateway( // Create dependencies using DI builder pattern let dependencies = create_gateway_dependencies(&app_state, app_handle.clone())?; + // Bind all interfaces when the user opted into network access so other + // devices on the LAN can reach the gateway; loopback-only otherwise. + let network_access = load_network_access(&app_state).await; + // Create gateway config let config = mcpmux_gateway::GatewayConfig { - host: "127.0.0.1".to_string(), // Bind address must be IP + host: bind_host_for(network_access).to_string(), port: final_port, public_base_url: public_base_url.clone(), enable_cors: true, @@ -1337,6 +1369,37 @@ pub async fn reset_gateway_public_base_url(app_state: State<'_, AppState>) -> Re Ok(()) } +/// Whether the gateway is configured to bind all network interfaces (`0.0.0.0`). +#[tauri::command] +pub async fn get_gateway_network_access(app_state: State<'_, AppState>) -> Result { + Ok(load_network_access(&app_state).await) +} + +/// Enable or disable binding the gateway to all interfaces (`0.0.0.0`) so other +/// devices on the network can reach it. Off (default) keeps it on `127.0.0.1` +/// (this machine only). Restart the gateway for the change to take effect. +#[tauri::command] +pub async fn set_gateway_network_access( + enabled: bool, + app_state: State<'_, AppState>, +) -> Result<(), String> { + app_state + .settings_repository + .set( + GATEWAY_NETWORK_ACCESS_KEY, + if enabled { "true" } else { "false" }, + ) + .await + .map_err(|e| e.to_string())?; + + if enabled { + info!("[Gateway] Network access enabled — will bind 0.0.0.0 on next start/restart"); + } else { + info!("[Gateway] Network access disabled — will bind 127.0.0.1 on next start/restart"); + } + Ok(()) +} + /// Which port source a startup attempt would use. /// /// Kept as a string-valued enum for clean JSON serialization to the UI. @@ -1971,4 +2034,10 @@ mod public_base_url_tests { "https://mcp.example.com" ); } + + #[test] + fn bind_host_for_maps_network_access_to_address() { + assert_eq!(super::bind_host_for(false), "127.0.0.1"); + assert_eq!(super::bind_host_for(true), "0.0.0.0"); + } } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 0306a4aa..5cde1a72 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -421,6 +421,10 @@ pub fn run() { let final_port = preferred_port; let public_base_url = crate::commands::gateway::load_public_base_url_from_repo(&settings_repo).await; let url = crate::commands::gateway::advertised_base_url(public_base_url.as_deref(), final_port); + // Bind all interfaces when the user opted into network access so other + // devices on the LAN can reach the gateway; loopback-only otherwise. + let network_access = + crate::commands::gateway::load_network_access_from_repo(&settings_repo).await; let local_url = format!("http://localhost:{}", final_port); info!("Auto-starting gateway on {} (advertising {})", local_url, url); @@ -469,7 +473,7 @@ pub fn run() { // Create gateway config let config = mcpmux_gateway::GatewayConfig { - host: "127.0.0.1".to_string(), // Bind address must be IP + host: crate::commands::gateway::bind_host_for(network_access).to_string(), port: final_port, public_base_url: public_base_url.clone(), enable_cors: true, @@ -958,6 +962,8 @@ pub fn run() { commands::get_gateway_public_url_settings, commands::set_gateway_public_base_url, commands::reset_gateway_public_base_url, + commands::get_gateway_network_access, + commands::set_gateway_network_access, commands::probe_gateway_start, commands::take_pending_port_conflict, commands::start_gateway, diff --git a/apps/desktop/src/features/settings/SettingsPage.tsx b/apps/desktop/src/features/settings/SettingsPage.tsx index c9ee5798..46f7f41c 100644 --- a/apps/desktop/src/features/settings/SettingsPage.tsx +++ b/apps/desktop/src/features/settings/SettingsPage.tsx @@ -130,6 +130,8 @@ export function SettingsPage() { // gateway with no access key — used by the one-click per-workspace install. const [authDisabled, setAuthDisabled] = useState(false); const [savingAuthDisabled, setSavingAuthDisabled] = useState(false); + const [networkAccess, setNetworkAccess] = useState(false); + const [savingNetworkAccess, setSavingNetworkAccess] = useState(false); // Meta-tools master switch — gates the entire `mcpmux_*` namespace. @@ -397,6 +399,34 @@ export function SettingsPage() { .catch((err) => console.error('Failed to load auth setting:', err)); }, []); + // Load the network-access (0.0.0.0 bind) toggle on mount. + useEffect(() => { + invoke('get_gateway_network_access') + .then(setNetworkAccess) + .catch((err) => console.error('Failed to load network-access setting:', err)); + }, []); + + const updateNetworkAccess = async (enabled: boolean) => { + const prev = networkAccess; + setNetworkAccess(enabled); + setSavingNetworkAccess(true); + try { + await invoke('set_gateway_network_access', { enabled }); + success( + 'Settings saved', + enabled + ? 'Gateway will bind 0.0.0.0 — restart it to become reachable on your network.' + : 'Gateway will bind 127.0.0.1 — restart it to return to this machine only.' + ); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error'; + error('Failed to update network access', msg); + setNetworkAccess(prev); + } finally { + setSavingNetworkAccess(false); + } + }; + const updateAuthDisabled = async (disabled: boolean) => { const prev = authDisabled; setAuthDisabled(disabled); @@ -785,6 +815,98 @@ export function SettingsPage() { +
+
+
+ +
+ +

+ Bind the gateway to all network interfaces ( + 0.0.0.0) so other machines on your + network can connect to the same MCP servers. Off keeps it on{' '} + 127.0.0.1 (this machine only). + Restart the gateway to apply. +

+
+
+ +
+ + {networkAccess ? ( +
+ +
+ {authDisabled ? ( + <> +

+ Exposed without authentication +

+

+ Authentication is off and the gateway is reachable on your network — + anyone who can reach this machine can use every connected MCP server + and its stored credentials. Turn authentication back on under + Security, or only enable this on a network you trust. +

+ + ) : ( + <> +

+ Reachable on your network +

+

+ Connecting clients still need to be approved, but traffic is plain + HTTP — only enable this on a network you trust. From another device, + replace localhost with this + machine's LAN IP, e.g.{' '} + + http://192.168.1.x: + {portSettings.activePort ?? portSettings.defaultPort}/mcp + + . +

+

+ Per-client OAuth approval happens on this machine, so a remote + client that signs in via OAuth (e.g. ChatGPT) can't finish approval + over the network yet — front the gateway with the public URL + a + tunnel for that. For plain LAN sharing, pair this with + authentication disabled. +

+ + )} +
+ +
+ ) : null} +
+ {publicUrlSettings?.activePublicBaseUrl && (publicUrlSettings.configuredPublicBaseUrl ?? publicUrlSettings.localBaseUrl) && publicUrlSettings.activePublicBaseUrl !== diff --git a/crates/mcpmux-gateway/src/mcp/oauth_middleware.rs b/crates/mcpmux-gateway/src/mcp/oauth_middleware.rs index 5a8f279b..0a9be4da 100644 --- a/crates/mcpmux-gateway/src/mcp/oauth_middleware.rs +++ b/crates/mcpmux-gateway/src/mcp/oauth_middleware.rs @@ -44,9 +44,21 @@ pub async fn mcp_oauth_middleware( .map(|ctx| ctx.trace_id.clone()) .unwrap_or_else(|| "??????".to_string()); + // Advertise the address the client actually reached us on (or the configured + // public base URL) so a gateway bound to 0.0.0.0 returns a resource-metadata + // URL the remote client can resolve — see `effective_base_url`. let base_url = { let state = services.gateway_state.read().await; - state.base_url.clone() + let host = request + .headers() + .get(header::HOST) + .and_then(|v| v.to_str().ok()); + crate::server::effective_base_url( + state.public_base_url.as_deref(), + state.network_bind, + host, + &state.base_url, + ) }; // System-wide inbound auth can be disabled (localhost-only convenience): diff --git a/crates/mcpmux-gateway/src/server/handlers.rs b/crates/mcpmux-gateway/src/server/handlers.rs index faaff8fc..bc86bc4d 100644 --- a/crates/mcpmux-gateway/src/server/handlers.rs +++ b/crates/mcpmux-gateway/src/server/handlers.rs @@ -66,19 +66,151 @@ pub struct OAuthServerMetadata { pub client_id_metadata_document_supported: Option, } +/// Build an `http://HOST` (or `http://HOST:PORT`) origin from a request `Host` +/// header. Returns `None` when the value is not a bare authority — it carries +/// userinfo, a path, or is otherwise unparseable — so the caller keeps the +/// configured local base instead of advertising an attacker-shaped value. +fn host_to_http_origin(host: &str) -> Option { + let parsed = url::Url::parse(&format!("http://{host}")).ok()?; + if !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.path() != "/" + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return None; + } + let host_str = parsed.host_str()?; + Some(match parsed.port() { + Some(port) => format!("http://{host_str}:{port}"), + None => format!("http://{host_str}"), + }) +} + +/// The base URL to advertise in OAuth / MCP metadata. +/// +/// Precedence: +/// 1. An explicitly configured public base URL (e.g. an https tunnel origin) — +/// pinned regardless of how the request arrived. +/// 2. On a network bind (`0.0.0.0`), the host the client actually reached the +/// gateway on (the request `Host` header), so the advertised endpoints are +/// the LAN IP / hostname / mDNS name the client used, not `localhost`. +/// 3. Otherwise the configured local fallback (`base_url`, http://localhost:port). +pub(crate) fn effective_base_url( + public_base_url: Option<&str>, + network_bind: bool, + host_header: Option<&str>, + local_fallback: &str, +) -> String { + if let Some(public) = public_base_url.map(str::trim).filter(|s| !s.is_empty()) { + return public.trim_end_matches('/').to_string(); + } + if network_bind { + if let Some(origin) = host_header + .map(str::trim) + .filter(|s| !s.is_empty()) + .and_then(host_to_http_origin) + { + return origin; + } + } + local_fallback.trim_end_matches('/').to_string() +} + +#[cfg(test)] +mod base_url_tests { + use super::effective_base_url; + + const LOCAL: &str = "http://localhost:45818"; + + #[test] + fn public_base_url_is_pinned_over_host() { + assert_eq!( + effective_base_url( + Some("https://mcp.example.com/"), + true, + Some("192.168.1.5:45818"), + LOCAL + ), + "https://mcp.example.com" + ); + } + + #[test] + fn network_bind_advertises_the_request_host() { + assert_eq!( + effective_base_url(None, true, Some("192.168.1.5:45818"), LOCAL), + "http://192.168.1.5:45818" + ); + } + + #[test] + fn network_bind_without_host_falls_back() { + assert_eq!(effective_base_url(None, true, None, LOCAL), LOCAL); + } + + #[test] + fn loopback_bind_ignores_host_and_keeps_fallback() { + // Local-only behavior is unchanged even if a Host header is present. + assert_eq!( + effective_base_url(None, false, Some("evil.example"), LOCAL), + LOCAL + ); + } + + #[test] + fn network_bind_rejects_malformed_host_and_falls_back() { + // Embedded userinfo, a path, or garbage is not advertised. + assert_eq!( + effective_base_url(None, true, Some("user@evil.example"), LOCAL), + LOCAL + ); + assert_eq!( + effective_base_url(None, true, Some("evil.example/oauth/authorize"), LOCAL), + LOCAL + ); + assert_eq!(effective_base_url(None, true, Some(" "), LOCAL), LOCAL); + } + + #[test] + fn network_bind_preserves_port_and_ipv6_host() { + assert_eq!( + effective_base_url(None, true, Some("[::1]:45818"), LOCAL), + "http://[::1]:45818" + ); + assert_eq!( + effective_base_url(None, true, Some("host.local"), LOCAL), + "http://host.local" + ); + } +} + /// OAuth metadata endpoint (RFC 8414) pub async fn oauth_metadata( axum::extract::State(app_state): axum::extract::State, + headers: axum::http::HeaderMap, ) -> Result, 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); - } + let (public_base_url, network_bind) = { + let state = app_state.gateway_state.read().await; + if state.auth_disabled() { + return Err(StatusCode::NOT_FOUND); + } + (state.public_base_url.clone(), state.network_bind) + }; info!("[Gateway] OAuth metadata request - serving authorization server metadata"); - let base = &app_state.base_url; + let host = headers + .get(axum::http::header::HOST) + .and_then(|v| v.to_str().ok()); + let base = effective_base_url( + public_base_url.as_deref(), + network_bind, + host, + &app_state.base_url, + ); Ok(Json(OAuthServerMetadata { issuer: base.to_string(), authorization_endpoint: format!("{}/oauth/authorize", base), @@ -111,14 +243,27 @@ 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, + headers: axum::http::HeaderMap, ) -> Result, 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); - } + let (public_base_url, network_bind) = { + let state = app_state.gateway_state.read().await; + if state.auth_disabled() { + return Err(StatusCode::NOT_FOUND); + } + (state.public_base_url.clone(), state.network_bind) + }; info!("[Gateway] Protected resource metadata request"); - let base = &app_state.base_url; + let host = headers + .get(axum::http::header::HOST) + .and_then(|v| v.to_str().ok()); + let base = effective_base_url( + public_base_url.as_deref(), + network_bind, + host, + &app_state.base_url, + ); Ok(Json(ProtectedResourceMetadata { resource: format!("{}/mcp", base), authorization_servers: vec![base.to_string()], diff --git a/crates/mcpmux-gateway/src/server/mod.rs b/crates/mcpmux-gateway/src/server/mod.rs index 869bbc94..41de6ac8 100644 --- a/crates/mcpmux-gateway/src/server/mod.rs +++ b/crates/mcpmux-gateway/src/server/mod.rs @@ -17,6 +17,7 @@ mod state; // inbound auth is disabled, and driving the full inbound OAuth flow // (register → authorize → consent → token → authenticated /mcp) end to end. // AppState is also used throughout this module. +pub(crate) use handlers::effective_base_url; pub use handlers::{ oauth_authorize, oauth_consent_approve, oauth_metadata, oauth_register, oauth_token, resource_metadata, AppState, @@ -29,7 +30,9 @@ pub use startup::{AutoConnectResult, StartupOrchestrator, TokenRefreshResult}; pub use state::{ClientSession, GatewayState}; use axum::{ + extract::ConnectInfo, middleware, + response::IntoResponse, routing::{delete, get, post, put}, Router, }; @@ -94,13 +97,30 @@ impl GatewayConfig { .unwrap_or_else(|| format!("http://localhost:{}", self.port)) } + /// True when the gateway binds to a non-loopback address (e.g. `0.0.0.0` + /// or a specific LAN interface) — i.e. it is intentionally exposed on the + /// network rather than being local-only. + pub fn is_network_bind(&self) -> bool { + let host = self.host.trim(); + !(host.is_empty() || host == "127.0.0.1" || host == "::1" || host == "localhost") + } + /// Host values accepted by rmcp's DNS rebinding protection. /// /// rmcp's Streamable HTTP service defaults to loopback-only Host headers. /// When the gateway is published through a reverse proxy or Cloudflare /// Tunnel, ChatGPT reaches it with the public host, so that hostname must /// be explicitly allowlisted. + /// + /// When bound to a non-loopback address the gateway is exposed on the LAN + /// and reached by IP / hostname / mDNS name we can't enumerate ahead of + /// time, so the allowlist is relaxed to empty — rmcp treats an empty list + /// as allow-all. The OAuth + per-client consent layer remains the gate. pub fn allowed_hosts(&self) -> Vec { + if self.is_network_bind() { + return Vec::new(); + } + let mut hosts = vec![ "localhost".to_string(), "127.0.0.1".to_string(), @@ -142,6 +162,46 @@ impl GatewayConfig { } } +/// The desktop-only client-management routes (list / update / delete clients). +/// `/oauth/clients/{id}/features` is intentionally excluded — it is the public +/// client-facing endpoint. +fn is_management_path(path: &str) -> bool { + path == "/oauth/clients" + || (path.starts_with("/oauth/clients/") && !path.ends_with("/features")) +} + +/// Reject the desktop-only client-management endpoints when the request comes +/// from a non-loopback peer. +/// +/// On a loopback bind every peer is local, so this is a no-op. On a `0.0.0.0` +/// (network) bind the whole router is exposed, but client enumeration / CRUD +/// must stay off the LAN — the OAuth flow and `/oauth/clients/{id}/features` +/// remain reachable. The peer socket address (not the spoofable `Host` header) +/// is the trust signal. Falls open only when no peer address is available +/// (an embedded/test server without `ConnectInfo`), which never happens on the +/// real network listener. +async fn restrict_management_to_loopback( + request: axum::extract::Request, + next: middleware::Next, +) -> axum::response::Response { + if is_management_path(request.uri().path()) { + let peer_is_local = request + .extensions() + .get::>() + .map(|info| info.0.ip().is_loopback()) + .unwrap_or(true); + if !peer_is_local { + warn!("[Gateway] Rejected non-loopback access to a client-management endpoint"); + return ( + axum::http::StatusCode::FORBIDDEN, + "Client management is only available from this machine", + ) + .into_response(); + } + } + next.run(request).await +} + /// MCP Gateway Server /// /// Self-contained server that manages its own services and lifecycle. @@ -167,6 +227,8 @@ impl GatewayServer { // Configure gateway state let mut state = GatewayState::new(domain_event_tx.clone()); state.set_base_url(config.base_url()); + state.set_public_base_url(config.public_base_url.clone()); + state.set_network_bind(config.is_network_bind()); if let Some(jwt_secret) = dependencies.jwt_secret.clone() { state.set_jwt_secret(jwt_secret); } @@ -445,7 +507,9 @@ impl GatewayServer { )) // Rate limiting on OAuth endpoints .layer(axum::Extension(rate_limiter)) - .layer(middleware::from_fn(rate_limit::rate_limit_middleware)); + .layer(middleware::from_fn(rate_limit::rate_limit_middleware)) + // Keep desktop-only client management off the LAN on a 0.0.0.0 bind. + .layer(middleware::from_fn(restrict_management_to_loopback)); // Add CORS if enabled if self.config.enable_cors { @@ -561,12 +625,15 @@ impl GatewayServer { info!("[Gateway] Ready to accept connections (servers connecting in background)"); - axum::serve(listener, router) - .with_graceful_shutdown(async move { - shutdown.await; - info!("[Gateway] Graceful shutdown signal received — closing listener"); - }) - .await?; + axum::serve( + listener, + router.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(async move { + shutdown.await; + info!("[Gateway] Graceful shutdown signal received — closing listener"); + }) + .await?; info!("[Gateway] Listener closed, run_with_shutdown returning"); Ok(()) @@ -679,4 +746,47 @@ mod config_tests { assert!(hosts.contains(&"localhost".to_string())); assert!(!hosts.iter().any(|h| h.contains("not a url"))); } + + fn config_on_host(host: &str) -> GatewayConfig { + GatewayConfig { + host: host.to_string(), + ..Default::default() + } + } + + #[test] + fn is_network_bind_distinguishes_loopback_from_exposed() { + for h in ["127.0.0.1", "::1", "localhost", ""] { + assert!(!config_on_host(h).is_network_bind(), "{h:?} is loopback"); + } + for h in ["0.0.0.0", "::", "192.168.1.50"] { + assert!( + config_on_host(h).is_network_bind(), + "{h:?} is a network bind" + ); + } + } + + #[test] + fn allowed_hosts_relaxes_to_allow_all_on_network_bind() { + // rmcp treats an empty allow-list as allow-all; on a network bind we + // can't enumerate the LAN host clients will use, so we relax to that. + assert!(config_on_host("0.0.0.0").allowed_hosts().is_empty()); + // Loopback bind keeps the strict allow-list. + assert!(config_on_host("127.0.0.1") + .allowed_hosts() + .contains(&"localhost".to_string())); + } + + #[test] + fn management_path_matching_excludes_features_and_oauth_flow() { + assert!(super::is_management_path("/oauth/clients")); // list + assert!(super::is_management_path("/oauth/clients/abc123")); // update/delete + // Client-facing + OAuth-flow + other routes are NOT loopback-gated. + assert!(!super::is_management_path("/oauth/clients/abc123/features")); + assert!(!super::is_management_path("/oauth/authorize")); + assert!(!super::is_management_path("/oauth/token")); + assert!(!super::is_management_path("/mcp")); + assert!(!super::is_management_path("/health")); + } } diff --git a/crates/mcpmux-gateway/src/server/state.rs b/crates/mcpmux-gateway/src/server/state.rs index d16190bb..20fae594 100644 --- a/crates/mcpmux-gateway/src/server/state.rs +++ b/crates/mcpmux-gateway/src/server/state.rs @@ -43,6 +43,14 @@ pub struct ClientSession { pub struct GatewayState { /// Base URL for this gateway (e.g., "http://localhost:3100") pub base_url: String, + /// Configured public base URL (e.g. an https tunnel origin). When set it is + /// advertised verbatim in OAuth/MCP metadata; when None the advertised base + /// is the request Host (on a network bind) or `base_url` (loopback). + pub public_base_url: Option, + /// True when the gateway is bound to a non-loopback address. Lets the + /// metadata handlers advertise the host a remote client actually used + /// instead of `localhost`, without changing local-only behavior. + pub network_bind: bool, /// Active client sessions pub sessions: HashMap, /// Access key to client ID mapping @@ -73,6 +81,8 @@ impl GatewayState { pub fn new(domain_event_tx: broadcast::Sender) -> Self { Self { base_url: "http://localhost:3100".to_string(), // Default + public_base_url: None, + network_bind: false, sessions: HashMap::new(), access_keys: HashMap::new(), oauth_tokens: HashMap::new(), @@ -92,6 +102,16 @@ impl GatewayState { self.base_url = base_url; } + /// Set the configured public base URL (None = local-only / host-derived). + pub fn set_public_base_url(&mut self, public_base_url: Option) { + self.public_base_url = public_base_url; + } + + /// Record whether the gateway is bound to a non-loopback address. + pub fn set_network_bind(&mut self, network_bind: bool) { + self.network_bind = network_bind; + } + /// Whether inbound MCP auth is disabled — connections may be accepted /// without a Bearer token. See [`Self::auth_disabled`] field docs. pub fn auth_disabled(&self) -> bool { diff --git a/tests/rust/tests/streamable_http/mod.rs b/tests/rust/tests/streamable_http/mod.rs index f470130d..ea942c25 100644 --- a/tests/rust/tests/streamable_http/mod.rs +++ b/tests/rust/tests/streamable_http/mod.rs @@ -8,4 +8,5 @@ mod auth_disable; mod auth_oauth_e2e; mod gateway_notifications; +mod network_advertising; mod notifications; diff --git a/tests/rust/tests/streamable_http/network_advertising.rs b/tests/rust/tests/streamable_http/network_advertising.rs new file mode 100644 index 00000000..6793f866 --- /dev/null +++ b/tests/rust/tests/streamable_http/network_advertising.rs @@ -0,0 +1,190 @@ +//! Network-bind advertising: when the gateway is bound to a non-loopback +//! address (`network_bind = true`) the OAuth/MCP discovery metadata must +//! advertise the host the client actually reached it on (the request `Host` +//! header) so a remote client gets a resolvable URL instead of `localhost`. +//! On a loopback bind the static base URL is used regardless of `Host`. +//! +//! Drives the real `oauth_metadata` / `resource_metadata` handlers over HTTP. + +use axum::{routing::get, Router}; +use mcpmux_core::{DomainEvent, ServerDiscoveryService, ServerLogManager}; +use mcpmux_gateway::server::{ + oauth_metadata, resource_metadata, AppState, DependenciesBuilder, GatewayDependencies, + GatewayState, ServiceContainer, +}; +use mcpmux_storage::SqliteSpaceRepository; +use std::sync::Arc; +use tokio::sync::broadcast; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use tests::db::TestDatabase; +use tests::mocks::*; + +struct Harness { + base: String, + ct: CancellationToken, +} + +impl Harness { + /// Boot a gateway serving only the OAuth-discovery endpoints, with inbound + /// auth enabled (so metadata is advertised) and the given `network_bind`. + /// `public_base_url` stays unset so the Host-derivation path is exercised. + async fn start(network_bind: bool) -> Self { + let ct = CancellationToken::new(); + let space_id = Uuid::new_v4(); + + let test_db = TestDatabase::in_memory(); + let database = Arc::new(tokio::sync::Mutex::new(test_db.db)); + + let space_repo = Arc::new(SqliteSpaceRepository::new(database.clone())); + let space = mcpmux_core::domain::Space { + id: space_id, + name: "Test Space".to_string(), + icon: None, + description: None, + is_default: true, + sort_order: 0, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + mcpmux_core::SpaceRepository::create(&*space_repo, &space) + .await + .expect("create space"); + mcpmux_core::SpaceRepository::set_default(&*space_repo, &space_id) + .await + .expect("set default"); + + let deps = DependenciesBuilder::new() + .with_installed_server_repo(Arc::new(MockInstalledServerRepository::new())) + .with_credential_repo(Arc::new(MockCredentialRepository::new())) + .with_backend_oauth_repo(Arc::new(MockOutboundOAuthRepository::new())) + .with_feature_repo(Arc::new(MockServerFeatureRepository::new()) + as Arc) + .with_feature_set_repo(Arc::new(MockFeatureSetRepository::new()) + as Arc) + .with_server_discovery(Arc::new(ServerDiscoveryService::new( + std::path::PathBuf::from("test-data"), + std::path::PathBuf::from("test-spaces"), + ))) + .with_log_manager(Arc::new(ServerLogManager::new( + mcpmux_core::LogConfig::default(), + ))) + .with_database(database) + .build() + .expect("build dependencies"); + let deps = GatewayDependencies { + space_repo: space_repo as Arc, + ..deps + }; + + let (event_tx, _) = broadcast::channel::(64); + let mut gw_state = GatewayState::new(event_tx.clone()); + gw_state.set_base_url("http://127.0.0.1:0".to_string()); + gw_state.set_network_bind(network_bind); + // public_base_url stays None; auth stays enabled so metadata is served. + let gateway_state = Arc::new(tokio::sync::RwLock::new(gw_state)); + + let services = Arc::new(ServiceContainer::initialize( + &deps, + event_tx.clone(), + gateway_state, + )); + + let app_state = AppState { + gateway_state: services.gateway_state.clone(), + services: services.clone(), + base_url: "http://127.0.0.1:0".to_string(), + }; + let router = Router::new() + .route( + "/.well-known/oauth-authorization-server", + get(oauth_metadata), + ) + .route( + "/.well-known/oauth-protected-resource/mcp", + get(resource_metadata), + ) + .with_state(app_state); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let port = listener.local_addr().unwrap().port(); + let ct_clone = ct.clone(); + tokio::spawn(async move { + axum::serve(listener, router) + .with_graceful_shutdown(async move { ct_clone.cancelled().await }) + .await + .unwrap(); + }); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + Self { + base: format!("http://127.0.0.1:{port}"), + ct, + } + } +} + +impl Drop for Harness { + fn drop(&mut self) { + self.ct.cancel(); + } +} + +#[tokio::test] +async fn network_bind_advertises_the_request_host() { + let h = Harness::start(true).await; + let client = reqwest::Client::new(); + // The address a remote client reached us on (different from the loopback + // socket we actually bound). The advertised metadata must reflect this. + let lan = "mcpmux.lan:8080"; + + let meta: serde_json::Value = client + .get(format!("{}/.well-known/oauth-authorization-server", h.base)) + .header(reqwest::header::HOST, lan) + .send() + .await + .expect("request") + .json() + .await + .expect("json"); + assert_eq!(meta["issuer"], format!("http://{lan}")); + assert_eq!( + meta["authorization_endpoint"], + format!("http://{lan}/oauth/authorize") + ); + assert_eq!(meta["token_endpoint"], format!("http://{lan}/oauth/token")); + + let res: serde_json::Value = client + .get(format!( + "{}/.well-known/oauth-protected-resource/mcp", + h.base + )) + .header(reqwest::header::HOST, lan) + .send() + .await + .expect("request") + .json() + .await + .expect("json"); + assert_eq!(res["resource"], format!("http://{lan}/mcp")); + assert_eq!(res["authorization_servers"][0], format!("http://{lan}")); +} + +#[tokio::test] +async fn loopback_bind_ignores_request_host() { + let h = Harness::start(false).await; + let meta: serde_json::Value = reqwest::Client::new() + .get(format!("{}/.well-known/oauth-authorization-server", h.base)) + .header(reqwest::header::HOST, "mcpmux.lan:8080") + .send() + .await + .expect("request") + .json() + .await + .expect("json"); + // network_bind = false → the configured base URL is advertised, Host ignored. + assert_eq!(meta["issuer"], "http://127.0.0.1:0"); +}