@@ -53,8 +53,11 @@ pub struct PendingPortConflict {
5353pub 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+
124200pub ( 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 {
0 commit comments