Skip to content

Commit 6f81378

Browse files
feat: support configurable public gateway base URL (#192)
Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com> Co-authored-by: its-mash <maa.ashik00@gmail.com>
1 parent 47c787c commit 6f81378

8 files changed

Lines changed: 827 additions & 208 deletions

File tree

apps/desktop/src-tauri/src/commands/gateway.rs

Lines changed: 223 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,11 @@ pub struct PendingPortConflict {
5353
pub struct GatewayAppState {
5454
/// Gateway running flag
5555
pub running: bool,
56-
/// Gateway URL
56+
/// Gateway URL advertised to clients.
5757
pub url: Option<String>,
58+
/// Locally bound port. This remains the actual listener port even when
59+
/// `url` is a public tunnel origin such as https://mcp.example.com.
60+
pub bound_port: Option<u16>,
5861
/// Gateway task + graceful-shutdown signal. `shutdown()` + awaiting
5962
/// `task` (with a timeout) lets the OS reclaim the listener socket
6063
/// cleanly; `.abort()` alone can leave an orphaned kernel-level bind.
@@ -121,6 +124,79 @@ pub(crate) async fn shutdown_gateway_handle(mut handle: mcpmux_gateway::GatewayS
121124
/// a fresh client connection automatically draws the user's eye to the
122125
/// mcpmux app instead of the dialog rendering invisibly under another
123126
/// window.
127+
const GATEWAY_PUBLIC_BASE_URL_KEY: &str = "gateway.public_base_url";
128+
129+
pub(crate) fn normalize_public_base_url(raw: &str) -> Result<Option<String>, String> {
130+
let trimmed = raw.trim();
131+
if trimmed.is_empty() {
132+
return Ok(None);
133+
}
134+
135+
let parsed = url::Url::parse(trimmed).map_err(|e| format!("Invalid public base URL: {}", e))?;
136+
137+
if parsed.scheme() != "https" {
138+
return Err("Public base URL must start with https://".to_string());
139+
}
140+
if parsed.host_str().is_none() {
141+
return Err("Public base URL must include a host".to_string());
142+
}
143+
if !parsed.username().is_empty() || parsed.password().is_some() {
144+
return Err("Public base URL must not include credentials".to_string());
145+
}
146+
if parsed.query().is_some() || parsed.fragment().is_some() {
147+
return Err("Public base URL must not include a query string or fragment".to_string());
148+
}
149+
if parsed.path() != "/" && !parsed.path().is_empty() {
150+
return Err(
151+
"Public base URL must be an origin only, for example https://mcp.example.com"
152+
.to_string(),
153+
);
154+
}
155+
156+
let origin = match parsed.port() {
157+
Some(port) => format!(
158+
"{}://{}:{}",
159+
parsed.scheme(),
160+
parsed.host_str().unwrap(),
161+
port
162+
),
163+
None => format!("{}://{}", parsed.scheme(), parsed.host_str().unwrap()),
164+
};
165+
Ok(Some(origin.trim_end_matches('/').to_string()))
166+
}
167+
168+
pub(crate) async fn load_public_base_url_from_repo(
169+
settings_repository: &Arc<dyn mcpmux_core::AppSettingsRepository>,
170+
) -> Option<String> {
171+
settings_repository
172+
.get(GATEWAY_PUBLIC_BASE_URL_KEY)
173+
.await
174+
.ok()
175+
.flatten()
176+
.and_then(|value| match normalize_public_base_url(&value) {
177+
Ok(normalized) => normalized,
178+
Err(e) => {
179+
warn!(
180+
"[Gateway] Ignoring invalid persisted public base URL: {}",
181+
e
182+
);
183+
None
184+
}
185+
})
186+
}
187+
188+
pub(crate) async fn load_public_base_url(app_state: &AppState) -> Option<String> {
189+
load_public_base_url_from_repo(&app_state.settings_repository).await
190+
}
191+
192+
pub(crate) fn advertised_base_url(public_base_url: Option<&str>, port: u16) -> String {
193+
public_base_url
194+
.map(str::trim)
195+
.filter(|url| !url.is_empty())
196+
.map(|url| url.trim_end_matches('/').to_string())
197+
.unwrap_or_else(|| format!("http://localhost:{}", port))
198+
}
199+
124200
pub(crate) fn focus_main_window<R: tauri::Runtime>(app: &tauri::AppHandle<R>) {
125201
use tauri::Manager;
126202
let Some(window) = app.get_webview_window("main") else {
@@ -896,9 +972,11 @@ pub async fn start_gateway(
896972
));
897973
};
898974

899-
let url = format!("http://localhost:{}", final_port);
975+
let public_base_url = load_public_base_url(&app_state).await;
976+
let url = advertised_base_url(public_base_url.as_deref(), final_port);
977+
let local_url = format!("http://localhost:{}", final_port);
900978

901-
info!("Starting gateway on {}", url);
979+
info!("Starting gateway on {} (advertising {})", local_url, url);
902980

903981
// Create dependencies using DI builder pattern
904982
let dependencies = create_gateway_dependencies(&app_state, app_handle.clone())?;
@@ -907,6 +985,7 @@ pub async fn start_gateway(
907985
let config = mcpmux_gateway::GatewayConfig {
908986
host: "127.0.0.1".to_string(), // Bind address must be IP
909987
port: final_port,
988+
public_base_url: public_base_url.clone(),
910989
enable_cors: true,
911990
};
912991

@@ -978,6 +1057,7 @@ pub async fn start_gateway(
9781057
);
9791058
state.running = true;
9801059
state.url = Some(url.clone());
1060+
state.bound_port = Some(final_port);
9811061
state.handle = Some(handle);
9821062
state.gateway_state = Some(gw_state);
9831063
state.pool_service = Some(pool_service);
@@ -1028,6 +1108,7 @@ pub async fn stop_gateway(
10281108
let handle = state.handle.take();
10291109
state.running = false;
10301110
state.url = None;
1111+
state.bound_port = None;
10311112
handle
10321113
};
10331114

@@ -1075,7 +1156,9 @@ pub async fn get_gateway_port_settings(
10751156

10761157
let active_port = {
10771158
let state = gateway_state.read().await;
1078-
state.url.as_deref().and_then(parse_port_from_url)
1159+
state
1160+
.bound_port
1161+
.or_else(|| state.url.as_deref().and_then(parse_port_from_url))
10791162
};
10801163

10811164
Ok(GatewayPortSettings {
@@ -1166,6 +1249,94 @@ pub async fn set_gateway_auth_disabled(
11661249
Ok(disabled)
11671250
}
11681251

1252+
/// Public URL configuration response.
1253+
///
1254+
/// `configured_public_base_url` is the persisted external origin advertised in
1255+
/// OAuth metadata. `active_public_base_url` is the URL currently advertised by
1256+
/// the running gateway, and `local_base_url` is the localhost listener.
1257+
#[derive(Debug, Serialize)]
1258+
#[serde(rename_all = "camelCase")]
1259+
pub struct GatewayPublicUrlSettings {
1260+
pub configured_public_base_url: Option<String>,
1261+
pub active_public_base_url: Option<String>,
1262+
pub local_base_url: Option<String>,
1263+
}
1264+
1265+
#[tauri::command]
1266+
pub async fn get_gateway_public_url_settings(
1267+
gateway_state: State<'_, Arc<RwLock<GatewayAppState>>>,
1268+
app_state: State<'_, AppState>,
1269+
) -> Result<GatewayPublicUrlSettings, String> {
1270+
let configured_public_base_url = load_public_base_url(&app_state).await;
1271+
let (active_public_base_url, local_base_url) = {
1272+
let state = gateway_state.read().await;
1273+
(
1274+
if state.running {
1275+
state.url.clone()
1276+
} else {
1277+
None
1278+
},
1279+
state
1280+
.bound_port
1281+
.map(|port| format!("http://localhost:{}", port)),
1282+
)
1283+
};
1284+
1285+
Ok(GatewayPublicUrlSettings {
1286+
configured_public_base_url,
1287+
active_public_base_url,
1288+
local_base_url,
1289+
})
1290+
}
1291+
1292+
/// Persist the public base URL advertised in OAuth metadata.
1293+
///
1294+
/// Pass `None` or an empty string to return to local-only localhost metadata.
1295+
/// Non-empty values must be HTTPS origins, e.g. `https://mcp.example.com`.
1296+
#[tauri::command]
1297+
pub async fn set_gateway_public_base_url(
1298+
public_base_url: Option<String>,
1299+
app_state: State<'_, AppState>,
1300+
) -> Result<(), String> {
1301+
let normalized = match public_base_url.as_deref() {
1302+
Some(raw) => normalize_public_base_url(raw)?,
1303+
None => None,
1304+
};
1305+
1306+
match normalized {
1307+
Some(url) => {
1308+
app_state
1309+
.settings_repository
1310+
.set(GATEWAY_PUBLIC_BASE_URL_KEY, &url)
1311+
.await
1312+
.map_err(|e| e.to_string())?;
1313+
info!("[Gateway] Persisted public base URL: {}", url);
1314+
}
1315+
None => {
1316+
app_state
1317+
.settings_repository
1318+
.delete(GATEWAY_PUBLIC_BASE_URL_KEY)
1319+
.await
1320+
.map_err(|e| e.to_string())?;
1321+
info!("[Gateway] Cleared public base URL — reverting to local-only metadata");
1322+
}
1323+
}
1324+
1325+
Ok(())
1326+
}
1327+
1328+
#[tauri::command]
1329+
pub async fn reset_gateway_public_base_url(app_state: State<'_, AppState>) -> Result<(), String> {
1330+
app_state
1331+
.settings_repository
1332+
.delete(GATEWAY_PUBLIC_BASE_URL_KEY)
1333+
.await
1334+
.map_err(|e| e.to_string())?;
1335+
1336+
info!("[Gateway] Cleared public base URL — reverting to local-only metadata");
1337+
Ok(())
1338+
}
1339+
11691340
/// Which port source a startup attempt would use.
11701341
///
11711342
/// Kept as a string-valued enum for clean JSON serialization to the UI.
@@ -1266,6 +1437,7 @@ pub async fn restart_gateway(
12661437
let handle = state.handle.take();
12671438
state.running = false;
12681439
state.url = None;
1440+
state.bound_port = None;
12691441
handle
12701442
};
12711443
if let Some(h) = handle {
@@ -1753,3 +1925,50 @@ pub struct PoolStatsResponse {
17531925
pub connected_instances: usize,
17541926
pub total_space_server_mappings: usize,
17551927
}
1928+
1929+
#[cfg(test)]
1930+
mod public_base_url_tests {
1931+
use super::{advertised_base_url, normalize_public_base_url};
1932+
1933+
#[test]
1934+
fn normalize_accepts_https_origin_and_trims_trailing_slash() {
1935+
assert_eq!(
1936+
normalize_public_base_url("https://mcp.example.com/").unwrap(),
1937+
Some("https://mcp.example.com".to_string())
1938+
);
1939+
assert_eq!(
1940+
normalize_public_base_url("https://mcp.example.com:8443").unwrap(),
1941+
Some("https://mcp.example.com:8443".to_string())
1942+
);
1943+
}
1944+
1945+
#[test]
1946+
fn normalize_treats_blank_as_none() {
1947+
assert_eq!(normalize_public_base_url("").unwrap(), None);
1948+
assert_eq!(normalize_public_base_url(" ").unwrap(), None);
1949+
}
1950+
1951+
#[test]
1952+
fn normalize_rejects_unsafe_or_non_origin_urls() {
1953+
// Non-https, credentials, query/fragment, non-root path, and garbage all rejected.
1954+
assert!(normalize_public_base_url("http://mcp.example.com").is_err());
1955+
assert!(normalize_public_base_url("https://user:pass@mcp.example.com").is_err());
1956+
assert!(normalize_public_base_url("https://mcp.example.com/?x=1").is_err());
1957+
assert!(normalize_public_base_url("https://mcp.example.com/#frag").is_err());
1958+
assert!(normalize_public_base_url("https://mcp.example.com/mcp").is_err());
1959+
assert!(normalize_public_base_url("not a url").is_err());
1960+
}
1961+
1962+
#[test]
1963+
fn advertised_base_url_falls_back_to_localhost() {
1964+
assert_eq!(advertised_base_url(None, 45818), "http://localhost:45818");
1965+
assert_eq!(
1966+
advertised_base_url(Some(" "), 45818),
1967+
"http://localhost:45818"
1968+
);
1969+
assert_eq!(
1970+
advertised_base_url(Some("https://mcp.example.com/"), 45818),
1971+
"https://mcp.example.com"
1972+
);
1973+
}
1974+
}

apps/desktop/src-tauri/src/lib.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -419,8 +419,10 @@ pub fn run() {
419419
}
420420

421421
let final_port = preferred_port;
422-
let url = format!("http://localhost:{}", final_port);
423-
info!("Auto-starting gateway on {}", url);
422+
let public_base_url = crate::commands::gateway::load_public_base_url_from_repo(&settings_repo).await;
423+
let url = crate::commands::gateway::advertised_base_url(public_base_url.as_deref(), final_port);
424+
let local_url = format!("http://localhost:{}", final_port);
425+
info!("Auto-starting gateway on {} (advertising {})", local_url, url);
424426

425427
// Load JWT signing secret (DPAPI on Windows, keychain elsewhere)
426428
let jwt_secret = match mcpmux_storage::create_jwt_secret_provider(&app_data_dir) {
@@ -469,6 +471,7 @@ pub fn run() {
469471
let config = mcpmux_gateway::GatewayConfig {
470472
host: "127.0.0.1".to_string(), // Bind address must be IP
471473
port: final_port,
474+
public_base_url: public_base_url.clone(),
472475
enable_cors: true,
473476
};
474477

@@ -525,6 +528,7 @@ pub fn run() {
525528
let mut state = gw_state_clone.write().await;
526529
state.running = true;
527530
state.url = Some(url.clone());
531+
state.bound_port = Some(final_port);
528532
state.handle = Some(handle);
529533
state.gateway_state = Some(gw_inner_state);
530534
state.pool_service = Some(pool_service);
@@ -951,6 +955,9 @@ pub fn run() {
951955
commands::reset_gateway_port,
952956
commands::get_gateway_auth_disabled,
953957
commands::set_gateway_auth_disabled,
958+
commands::get_gateway_public_url_settings,
959+
commands::set_gateway_public_base_url,
960+
commands::reset_gateway_public_base_url,
954961
commands::probe_gateway_start,
955962
commands::take_pending_port_conflict,
956963
commands::start_gateway,
@@ -1026,6 +1033,7 @@ pub fn run() {
10261033
let mut state = gw_state.write().await;
10271034
state.running = false;
10281035
state.url = None;
1036+
state.bound_port = None;
10291037
state.handle.take()
10301038
};
10311039
if let Some(h) = handle {

apps/desktop/src/components/ConnectionCard.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export function ConnectionCard() {
5454
const displayUrl = status.url ?? FALLBACK_URL;
5555
const mcpUrl = `${displayUrl}/mcp`;
5656
const port = extractPort(status.url);
57+
const isPublicEndpoint = displayUrl.startsWith('https://');
5758

5859
const reloadStatus = useCallback(async () => {
5960
try {
@@ -148,13 +149,15 @@ export function ConnectionCard() {
148149
{status.running && (
149150
<span className="inline-flex items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-[rgb(var(--muted))] px-1.5 py-0.5 rounded-md bg-[rgb(var(--surface))] border border-[rgb(var(--border-subtle))]">
150151
<Lock className="h-2.5 w-2.5" />
151-
Local only
152+
{isPublicEndpoint ? 'Public tunnel' : 'Local only'}
152153
</span>
153154
)}
154155
</div>
155156
<p className="text-xs text-[rgb(var(--muted))] mt-0.5 truncate">
156157
{status.running
157-
? 'Accepting IDE connections on this device.'
158+
? isPublicEndpoint
159+
? 'Advertising the public tunnel endpoint for remote MCP clients.'
160+
: 'Accepting IDE connections on this device.'
158161
: 'Start the gateway to let IDEs connect through McpMux.'}
159162
</p>
160163
</div>

0 commit comments

Comments
 (0)