Skip to content

Commit 569950e

Browse files
committed
test(gateway): integration coverage for network-bind metadata advertising
Drives the real oauth_metadata / resource_metadata handlers over HTTP: - with network_bind=true, a request whose Host is a remote LAN authority gets metadata (issuer/endpoints, resource, authorization_servers) advertising that host — not localhost; - with network_bind=false, the same Host is ignored and the static base URL is advertised (local-only behavior unchanged). Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent d53254d commit 569950e

2 files changed

Lines changed: 191 additions & 0 deletions

File tree

tests/rust/tests/streamable_http/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@
88
mod auth_disable;
99
mod auth_oauth_e2e;
1010
mod gateway_notifications;
11+
mod network_advertising;
1112
mod notifications;
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
//! Network-bind advertising: when the gateway is bound to a non-loopback
2+
//! address (`network_bind = true`) the OAuth/MCP discovery metadata must
3+
//! advertise the host the client actually reached it on (the request `Host`
4+
//! header) so a remote client gets a resolvable URL instead of `localhost`.
5+
//! On a loopback bind the static base URL is used regardless of `Host`.
6+
//!
7+
//! Drives the real `oauth_metadata` / `resource_metadata` handlers over HTTP.
8+
9+
use axum::{routing::get, Router};
10+
use mcpmux_core::{DomainEvent, ServerDiscoveryService, ServerLogManager};
11+
use mcpmux_gateway::server::{
12+
oauth_metadata, resource_metadata, AppState, DependenciesBuilder, GatewayDependencies,
13+
GatewayState, ServiceContainer,
14+
};
15+
use mcpmux_storage::SqliteSpaceRepository;
16+
use std::sync::Arc;
17+
use tokio::sync::broadcast;
18+
use tokio_util::sync::CancellationToken;
19+
use uuid::Uuid;
20+
21+
use tests::db::TestDatabase;
22+
use tests::mocks::*;
23+
24+
struct Harness {
25+
base: String,
26+
ct: CancellationToken,
27+
}
28+
29+
impl Harness {
30+
/// Boot a gateway serving only the OAuth-discovery endpoints, with inbound
31+
/// auth enabled (so metadata is advertised) and the given `network_bind`.
32+
/// `public_base_url` stays unset so the Host-derivation path is exercised.
33+
async fn start(network_bind: bool) -> Self {
34+
let ct = CancellationToken::new();
35+
let space_id = Uuid::new_v4();
36+
37+
let test_db = TestDatabase::in_memory();
38+
let database = Arc::new(tokio::sync::Mutex::new(test_db.db));
39+
40+
let space_repo = Arc::new(SqliteSpaceRepository::new(database.clone()));
41+
let space = mcpmux_core::domain::Space {
42+
id: space_id,
43+
name: "Test Space".to_string(),
44+
icon: None,
45+
description: None,
46+
is_default: true,
47+
sort_order: 0,
48+
created_at: chrono::Utc::now(),
49+
updated_at: chrono::Utc::now(),
50+
};
51+
mcpmux_core::SpaceRepository::create(&*space_repo, &space)
52+
.await
53+
.expect("create space");
54+
mcpmux_core::SpaceRepository::set_default(&*space_repo, &space_id)
55+
.await
56+
.expect("set default");
57+
58+
let deps = DependenciesBuilder::new()
59+
.with_installed_server_repo(Arc::new(MockInstalledServerRepository::new()))
60+
.with_credential_repo(Arc::new(MockCredentialRepository::new()))
61+
.with_backend_oauth_repo(Arc::new(MockOutboundOAuthRepository::new()))
62+
.with_feature_repo(Arc::new(MockServerFeatureRepository::new())
63+
as Arc<dyn mcpmux_core::ServerFeatureRepository>)
64+
.with_feature_set_repo(Arc::new(MockFeatureSetRepository::new())
65+
as Arc<dyn mcpmux_core::FeatureSetRepository>)
66+
.with_server_discovery(Arc::new(ServerDiscoveryService::new(
67+
std::path::PathBuf::from("test-data"),
68+
std::path::PathBuf::from("test-spaces"),
69+
)))
70+
.with_log_manager(Arc::new(ServerLogManager::new(
71+
mcpmux_core::LogConfig::default(),
72+
)))
73+
.with_database(database)
74+
.build()
75+
.expect("build dependencies");
76+
let deps = GatewayDependencies {
77+
space_repo: space_repo as Arc<dyn mcpmux_core::SpaceRepository>,
78+
..deps
79+
};
80+
81+
let (event_tx, _) = broadcast::channel::<DomainEvent>(64);
82+
let mut gw_state = GatewayState::new(event_tx.clone());
83+
gw_state.set_base_url("http://127.0.0.1:0".to_string());
84+
gw_state.set_network_bind(network_bind);
85+
// public_base_url stays None; auth stays enabled so metadata is served.
86+
let gateway_state = Arc::new(tokio::sync::RwLock::new(gw_state));
87+
88+
let services = Arc::new(ServiceContainer::initialize(
89+
&deps,
90+
event_tx.clone(),
91+
gateway_state,
92+
));
93+
94+
let app_state = AppState {
95+
gateway_state: services.gateway_state.clone(),
96+
services: services.clone(),
97+
base_url: "http://127.0.0.1:0".to_string(),
98+
};
99+
let router = Router::new()
100+
.route(
101+
"/.well-known/oauth-authorization-server",
102+
get(oauth_metadata),
103+
)
104+
.route(
105+
"/.well-known/oauth-protected-resource/mcp",
106+
get(resource_metadata),
107+
)
108+
.with_state(app_state);
109+
110+
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
111+
.await
112+
.expect("bind");
113+
let port = listener.local_addr().unwrap().port();
114+
let ct_clone = ct.clone();
115+
tokio::spawn(async move {
116+
axum::serve(listener, router)
117+
.with_graceful_shutdown(async move { ct_clone.cancelled().await })
118+
.await
119+
.unwrap();
120+
});
121+
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
122+
123+
Self {
124+
base: format!("http://127.0.0.1:{port}"),
125+
ct,
126+
}
127+
}
128+
}
129+
130+
impl Drop for Harness {
131+
fn drop(&mut self) {
132+
self.ct.cancel();
133+
}
134+
}
135+
136+
#[tokio::test]
137+
async fn network_bind_advertises_the_request_host() {
138+
let h = Harness::start(true).await;
139+
let client = reqwest::Client::new();
140+
// The address a remote client reached us on (different from the loopback
141+
// socket we actually bound). The advertised metadata must reflect this.
142+
let lan = "mcpmux.lan:8080";
143+
144+
let meta: serde_json::Value = client
145+
.get(format!("{}/.well-known/oauth-authorization-server", h.base))
146+
.header(reqwest::header::HOST, lan)
147+
.send()
148+
.await
149+
.expect("request")
150+
.json()
151+
.await
152+
.expect("json");
153+
assert_eq!(meta["issuer"], format!("http://{lan}"));
154+
assert_eq!(
155+
meta["authorization_endpoint"],
156+
format!("http://{lan}/oauth/authorize")
157+
);
158+
assert_eq!(meta["token_endpoint"], format!("http://{lan}/oauth/token"));
159+
160+
let res: serde_json::Value = client
161+
.get(format!(
162+
"{}/.well-known/oauth-protected-resource/mcp",
163+
h.base
164+
))
165+
.header(reqwest::header::HOST, lan)
166+
.send()
167+
.await
168+
.expect("request")
169+
.json()
170+
.await
171+
.expect("json");
172+
assert_eq!(res["resource"], format!("http://{lan}/mcp"));
173+
assert_eq!(res["authorization_servers"][0], format!("http://{lan}"));
174+
}
175+
176+
#[tokio::test]
177+
async fn loopback_bind_ignores_request_host() {
178+
let h = Harness::start(false).await;
179+
let meta: serde_json::Value = reqwest::Client::new()
180+
.get(format!("{}/.well-known/oauth-authorization-server", h.base))
181+
.header(reqwest::header::HOST, "mcpmux.lan:8080")
182+
.send()
183+
.await
184+
.expect("request")
185+
.json()
186+
.await
187+
.expect("json");
188+
// network_bind = false → the configured base URL is advertised, Host ignored.
189+
assert_eq!(meta["issuer"], "http://127.0.0.1:0");
190+
}

0 commit comments

Comments
 (0)