Skip to content

Commit 07c1514

Browse files
committed
fix(gateway): advertise the reachable host in OAuth/MCP metadata on a network bind
When bound to 0.0.0.0 the OAuth/MCP discovery metadata (RFC 8414 issuer + endpoints, RFC 9728 resource / authorization_servers, and the 401 WWW-Authenticate resource_metadata URL) advertised http://localhost:<port> — unresolvable for a remote LAN client, breaking the inbound OAuth flow. Add effective_base_url(public_base_url, network_bind, host, fallback): - a configured public base URL is pinned (tunnel/https), else - on a network bind, derive from the request Host header (the LAN IP / hostname / mDNS name the client actually used), else - fall back to the configured local base. Gated on network_bind, so loopback advertising is byte-identical to before — all 24 streamable_http integration tests (incl. the full OAuth e2e flow) pass unchanged. GatewayState carries public_base_url + network_bind; the two metadata handlers and the /mcp 401 path use the helper. Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent 8615776 commit 07c1514

4 files changed

Lines changed: 138 additions & 9 deletions

File tree

crates/mcpmux-gateway/src/mcp/oauth_middleware.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,21 @@ pub async fn mcp_oauth_middleware(
4444
.map(|ctx| ctx.trace_id.clone())
4545
.unwrap_or_else(|| "??????".to_string());
4646

47+
// Advertise the address the client actually reached us on (or the configured
48+
// public base URL) so a gateway bound to 0.0.0.0 returns a resource-metadata
49+
// URL the remote client can resolve — see `effective_base_url`.
4750
let base_url = {
4851
let state = services.gateway_state.read().await;
49-
state.base_url.clone()
52+
let host = request
53+
.headers()
54+
.get(header::HOST)
55+
.and_then(|v| v.to_str().ok());
56+
crate::server::effective_base_url(
57+
state.public_base_url.as_deref(),
58+
state.network_bind,
59+
host,
60+
&state.base_url,
61+
)
5062
};
5163

5264
// System-wide inbound auth can be disabled (localhost-only convenience):

crates/mcpmux-gateway/src/server/handlers.rs

Lines changed: 102 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -66,19 +66,100 @@ pub struct OAuthServerMetadata {
6666
pub client_id_metadata_document_supported: Option<bool>,
6767
}
6868

69+
/// The base URL to advertise in OAuth / MCP metadata.
70+
///
71+
/// Precedence:
72+
/// 1. An explicitly configured public base URL (e.g. an https tunnel origin) —
73+
/// pinned regardless of how the request arrived.
74+
/// 2. On a network bind (`0.0.0.0`), the host the client actually reached the
75+
/// gateway on (the request `Host` header), so the advertised endpoints are
76+
/// the LAN IP / hostname / mDNS name the client used, not `localhost`.
77+
/// 3. Otherwise the configured local fallback (`base_url`, http://localhost:port).
78+
pub(crate) fn effective_base_url(
79+
public_base_url: Option<&str>,
80+
network_bind: bool,
81+
host_header: Option<&str>,
82+
local_fallback: &str,
83+
) -> String {
84+
if let Some(public) = public_base_url.map(str::trim).filter(|s| !s.is_empty()) {
85+
return public.trim_end_matches('/').to_string();
86+
}
87+
if network_bind {
88+
if let Some(host) = host_header.map(str::trim).filter(|s| !s.is_empty()) {
89+
return format!("http://{}", host.trim_end_matches('/'));
90+
}
91+
}
92+
local_fallback.trim_end_matches('/').to_string()
93+
}
94+
95+
#[cfg(test)]
96+
mod base_url_tests {
97+
use super::effective_base_url;
98+
99+
const LOCAL: &str = "http://localhost:45818";
100+
101+
#[test]
102+
fn public_base_url_is_pinned_over_host() {
103+
assert_eq!(
104+
effective_base_url(
105+
Some("https://mcp.example.com/"),
106+
true,
107+
Some("192.168.1.5:45818"),
108+
LOCAL
109+
),
110+
"https://mcp.example.com"
111+
);
112+
}
113+
114+
#[test]
115+
fn network_bind_advertises_the_request_host() {
116+
assert_eq!(
117+
effective_base_url(None, true, Some("192.168.1.5:45818"), LOCAL),
118+
"http://192.168.1.5:45818"
119+
);
120+
}
121+
122+
#[test]
123+
fn network_bind_without_host_falls_back() {
124+
assert_eq!(effective_base_url(None, true, None, LOCAL), LOCAL);
125+
}
126+
127+
#[test]
128+
fn loopback_bind_ignores_host_and_keeps_fallback() {
129+
// Local-only behavior is unchanged even if a Host header is present.
130+
assert_eq!(
131+
effective_base_url(None, false, Some("evil.example"), LOCAL),
132+
LOCAL
133+
);
134+
}
135+
}
136+
69137
/// OAuth metadata endpoint (RFC 8414)
70138
pub async fn oauth_metadata(
71139
axum::extract::State(app_state): axum::extract::State<AppState>,
140+
headers: axum::http::HeaderMap,
72141
) -> Result<Json<OAuthServerMetadata>, StatusCode> {
73142
// When inbound auth is disabled, don't advertise an authorization server —
74143
// otherwise MCP clients that probe discovery start an OAuth flow even
75144
// though `/mcp` accepts them without a token. 404 makes them connect
76145
// tokenlessly.
77-
if app_state.gateway_state.read().await.auth_disabled() {
78-
return Err(StatusCode::NOT_FOUND);
79-
}
146+
let (public_base_url, network_bind) = {
147+
let state = app_state.gateway_state.read().await;
148+
if state.auth_disabled() {
149+
return Err(StatusCode::NOT_FOUND);
150+
}
151+
(state.public_base_url.clone(), state.network_bind)
152+
};
80153
info!("[Gateway] OAuth metadata request - serving authorization server metadata");
81-
let base = &app_state.base_url;
154+
let host = headers
155+
.get(axum::http::header::HOST)
156+
.and_then(|v| v.to_str().ok());
157+
let base = effective_base_url(
158+
public_base_url.as_deref(),
159+
network_bind,
160+
host,
161+
&app_state.base_url,
162+
);
82163
Ok(Json(OAuthServerMetadata {
83164
issuer: base.to_string(),
84165
authorization_endpoint: format!("{}/oauth/authorize", base),
@@ -111,14 +192,27 @@ pub struct ProtectedResourceMetadata {
111192
/// This tells MCP clients where to find the authorization server
112193
pub async fn resource_metadata(
113194
axum::extract::State(app_state): axum::extract::State<AppState>,
195+
headers: axum::http::HeaderMap,
114196
) -> Result<Json<ProtectedResourceMetadata>, StatusCode> {
115197
// See `oauth_metadata`: stay silent about auth when it's disabled so clients
116198
// don't kick off OAuth against a gateway that accepts them tokenlessly.
117-
if app_state.gateway_state.read().await.auth_disabled() {
118-
return Err(StatusCode::NOT_FOUND);
119-
}
199+
let (public_base_url, network_bind) = {
200+
let state = app_state.gateway_state.read().await;
201+
if state.auth_disabled() {
202+
return Err(StatusCode::NOT_FOUND);
203+
}
204+
(state.public_base_url.clone(), state.network_bind)
205+
};
120206
info!("[Gateway] Protected resource metadata request");
121-
let base = &app_state.base_url;
207+
let host = headers
208+
.get(axum::http::header::HOST)
209+
.and_then(|v| v.to_str().ok());
210+
let base = effective_base_url(
211+
public_base_url.as_deref(),
212+
network_bind,
213+
host,
214+
&app_state.base_url,
215+
);
122216
Ok(Json(ProtectedResourceMetadata {
123217
resource: format!("{}/mcp", base),
124218
authorization_servers: vec![base.to_string()],

crates/mcpmux-gateway/src/server/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ mod state;
1717
// inbound auth is disabled, and driving the full inbound OAuth flow
1818
// (register → authorize → consent → token → authenticated /mcp) end to end.
1919
// AppState is also used throughout this module.
20+
pub(crate) use handlers::effective_base_url;
2021
pub use handlers::{
2122
oauth_authorize, oauth_consent_approve, oauth_metadata, oauth_register, oauth_token,
2223
resource_metadata, AppState,
@@ -184,6 +185,8 @@ impl GatewayServer {
184185
// Configure gateway state
185186
let mut state = GatewayState::new(domain_event_tx.clone());
186187
state.set_base_url(config.base_url());
188+
state.set_public_base_url(config.public_base_url.clone());
189+
state.set_network_bind(config.is_network_bind());
187190
if let Some(jwt_secret) = dependencies.jwt_secret.clone() {
188191
state.set_jwt_secret(jwt_secret);
189192
}

crates/mcpmux-gateway/src/server/state.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@ pub struct ClientSession {
4343
pub struct GatewayState {
4444
/// Base URL for this gateway (e.g., "http://localhost:3100")
4545
pub base_url: String,
46+
/// Configured public base URL (e.g. an https tunnel origin). When set it is
47+
/// advertised verbatim in OAuth/MCP metadata; when None the advertised base
48+
/// is the request Host (on a network bind) or `base_url` (loopback).
49+
pub public_base_url: Option<String>,
50+
/// True when the gateway is bound to a non-loopback address. Lets the
51+
/// metadata handlers advertise the host a remote client actually used
52+
/// instead of `localhost`, without changing local-only behavior.
53+
pub network_bind: bool,
4654
/// Active client sessions
4755
pub sessions: HashMap<Uuid, ClientSession>,
4856
/// Access key to client ID mapping
@@ -73,6 +81,8 @@ impl GatewayState {
7381
pub fn new(domain_event_tx: broadcast::Sender<DomainEvent>) -> Self {
7482
Self {
7583
base_url: "http://localhost:3100".to_string(), // Default
84+
public_base_url: None,
85+
network_bind: false,
7686
sessions: HashMap::new(),
7787
access_keys: HashMap::new(),
7888
oauth_tokens: HashMap::new(),
@@ -92,6 +102,16 @@ impl GatewayState {
92102
self.base_url = base_url;
93103
}
94104

105+
/// Set the configured public base URL (None = local-only / host-derived).
106+
pub fn set_public_base_url(&mut self, public_base_url: Option<String>) {
107+
self.public_base_url = public_base_url;
108+
}
109+
110+
/// Record whether the gateway is bound to a non-loopback address.
111+
pub fn set_network_bind(&mut self, network_bind: bool) {
112+
self.network_bind = network_bind;
113+
}
114+
95115
/// Whether inbound MCP auth is disabled — connections may be accepted
96116
/// without a Bearer token. See [`Self::auth_disabled`] field docs.
97117
pub fn auth_disabled(&self) -> bool {

0 commit comments

Comments
 (0)