Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion apps/desktop/src-tauri/src/commands/gateway.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<String>, String> {
let trimmed = raw.trim();
Expand Down Expand Up @@ -189,6 +190,33 @@ pub(crate) async fn load_public_base_url(app_state: &AppState) -> Option<String>
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<dyn mcpmux_core::AppSettingsRepository>,
) -> 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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<bool, String> {
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.
Expand Down Expand Up @@ -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");
}
}
8 changes: 7 additions & 1 deletion apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
122 changes: 122 additions & 0 deletions apps/desktop/src/features/settings/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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<boolean>('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);
Expand Down Expand Up @@ -785,6 +815,98 @@ export function SettingsPage() {
</div>
</div>

<div className="border-t border-[rgb(var(--border-subtle))] pt-4">
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 flex-1 items-start gap-3">
<Network className="mt-0.5 h-5 w-5 flex-shrink-0 text-[rgb(var(--muted))]" />
<div className="min-w-0">
<label className="text-sm font-medium">
Allow access from other devices
</label>
<p className="mt-1 text-xs text-[rgb(var(--muted))]">
Bind the gateway to all network interfaces (
<span className="font-mono">0.0.0.0</span>) so other machines on your
network can connect to the same MCP servers. Off keeps it on{' '}
<span className="font-mono">127.0.0.1</span> (this machine only).
Restart the gateway to apply.
</p>
</div>
</div>
<Switch
checked={networkAccess}
onCheckedChange={updateNetworkAccess}
disabled={savingNetworkAccess}
data-testid="network-access-switch"
/>
</div>

{networkAccess ? (
<div
className={`mt-3 flex items-start gap-2 rounded-lg border p-3 text-xs ${
authDisabled
? 'border-red-300 bg-red-50 dark:border-red-700/60 dark:bg-red-900/20'
: 'border-amber-300 bg-amber-50 dark:border-amber-700/60 dark:bg-amber-900/20'
}`}
data-testid="network-access-warning"
>
<AlertCircle
className={`mt-0.5 h-4 w-4 flex-shrink-0 ${
authDisabled
? 'text-red-600 dark:text-red-400'
: 'text-amber-600 dark:text-amber-400'
}`}
/>
<div className="flex-1">
{authDisabled ? (
<>
<p className="font-semibold text-red-800 dark:text-red-200">
Exposed without authentication
</p>
<p className="mt-0.5 text-red-700 dark:text-red-300">
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.
</p>
</>
) : (
<>
<p className="font-semibold text-amber-800 dark:text-amber-200">
Reachable on your network
</p>
<p className="mt-0.5 text-amber-700 dark:text-amber-300">
Connecting clients still need to be approved, but traffic is plain
HTTP — only enable this on a network you trust. From another device,
replace <span className="font-mono">localhost</span> with this
machine's LAN IP, e.g.{' '}
<span className="font-mono">
http://192.168.1.x:
{portSettings.activePort ?? portSettings.defaultPort}/mcp
</span>
.
</p>
<p className="mt-1 text-amber-700 dark:text-amber-300">
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.
</p>
</>
)}
</div>
<Button
variant="secondary"
size="sm"
onClick={handleRestartGateway}
data-testid="network-access-restart-btn"
>
Restart gateway
</Button>
</div>
) : null}
</div>

{publicUrlSettings?.activePublicBaseUrl &&
(publicUrlSettings.configuredPublicBaseUrl ?? publicUrlSettings.localBaseUrl) &&
publicUrlSettings.activePublicBaseUrl !==
Expand Down
14 changes: 13 additions & 1 deletion crates/mcpmux-gateway/src/mcp/oauth_middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading
Loading