Skip to content

Commit e38a390

Browse files
Merge pull request #8 from crimsonsunset/feat/workspace-machine-binding
feat(workspaces): machine-scoped bindings and multi-device gateway routing
2 parents 425b44a + a86178a commit e38a390

168 files changed

Lines changed: 11389 additions & 3288 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Copy to `.env` in the repo root (gitignored). Used by remote tunnel smoke scripts
2+
# and Playwright admin tests — never commit real values.
3+
4+
# Cloudflare Access service token (Zero Trust → Access → Service Auth)
5+
MCPMUX_CF_ACCESS_CLIENT_ID=
6+
MCPMUX_CF_ACCESS_CLIENT_SECRET=
7+
8+
# Optional tunnel hostnames for `pnpm remote:smoke`
9+
MCPMUX_REMOTE_GATEWAY_URL=https://mcp.example.com
10+
MCPMUX_REMOTE_ADMIN_URL=https://mux.example.com
11+
12+
# Playwright admin parity against loopback admin with CF trust enabled
13+
# MCPMUX_ADMIN_CF_JWT=
14+
# MCPMUX_ADMIN_CF_TRUST_ENABLED=1
15+
# MCPMUX_ADMIN_BASE_URL=http://localhost:45819

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ coverage/
6060
# Test reports & assets
6161
test-results/
6262
playwright-report/
63+
.playwright-mcp/
6364
blob-report/
6465
html-report/
6566
tests/e2e/reports/

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"rust-analyzer.rustfmt.extraArgs": [
77
"+nightly"
88
],
9+
"rust-analyzer.cargo.targetDir": true,
910
"files.eol": "\n",
1011
"files.insertFinalNewline": true,
1112
"files.trimTrailingWhitespace": true,

AGENTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ Run everything from `mcp-mux/`:
3232
| `pnpm setup` | First-time dev environment setup (PowerShell on Windows). |
3333
| `pnpm dev` | Tauri desktop dev mode (Rust + React hot-reload). |
3434
| `pnpm dev:web` | Web UI only via Vite — no Rust, no Tauri shell. |
35+
| `pnpm dev:admin` | Full stack + web admin: `tauri dev` with admin enabled, opens browser at `:1420` once the gateway is stable. Primary dev driver. |
36+
| `pnpm dev:web:admin` | Browser-only admin UI; auto-starts the backend detached if it isn't already up. |
37+
| `pnpm dev:stop` | Quit McpMux.app and free the dev ports (`:1420`, `:45818`, `:45819`). |
38+
| `pnpm dev:rebuild` | Force `cargo build --workspace` (debug) without launching — recovery for stale binaries. |
3539
| `pnpm build` | Production Tauri build for the current platform. |
3640
| `pnpm validate` | Full correctness gate — runs the items below in sequence. |
3741
| `pnpm lint` | ESLint (recursive) + `cargo clippy --workspace -- -D warnings`. |
@@ -40,6 +44,8 @@ Run everything from `mcp-mux/`:
4044
| `pnpm format:check` | Formatting check (no writes). |
4145
| `pnpm typecheck` | Recursive TypeScript typecheck. |
4246

47+
**Hot-reload while developing:** `pnpm dev:admin` keeps the desktop window, the `:1420` browser tab (Vite HMR), and the CF tunnel / `:45819` static UI in sync. TS/CSS changes hot-reload instantly on `:1420`; the same edits trigger `vite build --watch` to rebuild `apps/desktop/dist/` for `:45819` and remote tunnel tabs (hard-refresh those after each rebuild, ~10s). Rust changes in any workspace crate trigger `tauri dev` to recompile and restart the backend automatically. Rust is compiled, so "auto-reload" means recompile + process restart, not live patching. After a stalled restart, orphaned backend, or stale binary, recover with `pnpm dev:stop && pnpm dev:rebuild && pnpm dev:admin`. The repo's `.vscode/settings.json` sets `rust-analyzer.cargo.targetDir` so the editor's `cargo check` doesn't fight `tauri dev` over `target/` (avoids double compiles). When `.env` carries CF Access credentials the admin runs with `cf_access=true`; the dev scripts' health probes send `adminCfProbeHeaders()` so `/api/v1/health` doesn't 401. One-off production bundle: `pnpm build:web:admin`.
48+
4349
**Before claiming a change is done**, run `pnpm validate` (or the relevant subset) — it mirrors what CI enforces.
4450

4551
## Testing

Cargo.lock

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ url = "2.5"
5656
urlencoding = "2.1"
5757
dotenvy = "0.15"
5858
os_pipe = "1"
59+
hostname = "0.4"
5960

6061
# MCP Protocol
6162
# NOTE: Never use local path dependency - E:\one-mcp\rust-sdk is for source lookup only

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,10 @@ pnpm build # Production build
225225
pnpm test # Run all tests
226226
```
227227

228+
**Remote access (optional):** MCP and web admin can be exposed via Cloudflare Tunnel + Access on separate hostnames (`45818` / `45819` stay loopback). User-facing overview: [`docs/guide/remote-access.mdx`](docs/guide/remote-access.mdx).
229+
230+
**Web admin (browser UI over HTTP):** `pnpm dev:admin` or `pnpm dev:web:admin` — see [`AGENTS.md`](AGENTS.md) for dev commands and port layout (`:1420` / `:45818` / `:45819`).
231+
228232
**Prerequisites:** Rust 1.75+, Node.js 18+, pnpm 9+. Linux also needs `gnome-keyring libsecret-1-dev librsvg2-dev pkg-config`.
229233

230234
Built with **Tauri 2** (Rust + React 19), **Axum** for the gateway, **ring** for encryption, **rmcp** for MCP.

apps/desktop/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,15 @@
2424
"@tauri-apps/plugin-opener": "^2",
2525
"@tauri-apps/plugin-process": "^2",
2626
"@tauri-apps/plugin-updater": "^2",
27+
"emoji-picker-element": "^1.29.1",
2728
"i18next": "^26.3.1",
2829
"immer": "^11.0.1",
2930
"lucide-react": "^0.561.0",
3031
"posthog-js": "^1.387.0",
3132
"react": "^19.1.0",
3233
"react-dom": "^19.1.0",
3334
"react-i18next": "^17.0.8",
35+
"wouter": "^3.10.0",
3436
"zustand": "^5.0.9"
3537
},
3638
"devDependencies": {

apps/desktop/src-tauri/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ notify = { version = "7", default-features = false, features = [
4646
"macos_fsevent",
4747
] }
4848
notify-debouncer-mini = "0.5"
49+
hostname.workspace = true
4950

5051
# Internal crates (path-only, no version needed)
5152
mcpmux-core.workspace = true

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

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
//! IPC commands for controlling the local MCP gateway server.
44
55
use crate::commands::server_manager::ServerManagerState;
6+
use crate::services::ui_events::OAUTH_CONSENT_REQUEST_CHANNEL;
67
use crate::AppState;
78
use mcpmux_core::service::{allocate_dynamic_port, is_port_available};
89
use mcpmux_core::DomainEvent;
10+
use mcpmux_gateway::admin::ui_events::AdminUiEventBus;
911
use mcpmux_gateway::{
1012
ConnectionContext, ConnectionResult, FeatureService, InstalledServerInfo, OAuthCompleteEvent,
1113
PoolService, ResolvedTransport, ServerKey, ServerManager,
@@ -82,6 +84,18 @@ pub struct GatewayAppState {
8284
/// Surfaced to the desktop Workspaces tab so users can see + act on
8385
/// every folder connected clients are currently operating in.
8486
pub session_roots: Option<Arc<mcpmux_gateway::services::SessionRootsRegistry>>,
87+
/// FeatureSet resolver for live machine-identity hot-reload.
88+
pub feature_set_resolver: Option<Arc<mcpmux_gateway::services::FeatureSetResolverService>>,
89+
}
90+
91+
/// Hot-reload the resolver's local machine identity after settings persist.
92+
pub(crate) async fn hot_reload_local_machine_id(
93+
gateway_state: &Arc<RwLock<GatewayAppState>>,
94+
id: Option<Uuid>,
95+
) {
96+
if let Some(resolver) = gateway_state.read().await.feature_set_resolver.as_ref() {
97+
resolver.set_local_machine_id(id).await;
98+
}
8599
}
86100

87101
/// Gracefully shuts down a running gateway and waits for the axum task
@@ -410,7 +424,17 @@ pub fn start_domain_event_bridge(
410424
// window forward BEFORE emitting so the popup animates into a
411425
// visible window instead of rendering behind another app.
412426
if matches!(event, DomainEvent::WorkspaceNeedsBinding { .. }) {
413-
focus_main_window(&app_handle_clone);
427+
let has_web_admin = {
428+
let admin_state: tauri::State<
429+
'_,
430+
Arc<tokio::sync::RwLock<crate::services::AdminServerState>>,
431+
> = app_handle_clone.state();
432+
let guard = admin_state.read().await;
433+
guard.event_hub.has_sse_subscribers()
434+
};
435+
if !has_web_admin {
436+
focus_main_window(&app_handle_clone);
437+
}
414438
}
415439

416440
// Map domain events to UI channels
@@ -792,7 +816,6 @@ fn map_domain_event_to_ui(event: &DomainEvent) -> (&'static str, serde_json::Val
792816
session_id,
793817
space_id,
794818
workspace_root,
795-
collision_client_id,
796819
space_locked,
797820
} => (
798821
"workspace-needs-binding",
@@ -801,7 +824,6 @@ fn map_domain_event_to_ui(event: &DomainEvent) -> (&'static str, serde_json::Val
801824
"session_id": session_id,
802825
"space_id": space_id,
803826
"workspace_root": workspace_root,
804-
"collision_client_id": collision_client_id,
805827
"space_locked": space_locked,
806828
}),
807829
),
@@ -861,6 +883,30 @@ fn map_domain_event_to_ui(event: &DomainEvent) -> (&'static str, serde_json::Val
861883
}
862884
}
863885

886+
/// Wire OAuth consent notifications to the desktop webview and admin SSE bus.
887+
pub async fn wire_consent_ui_notifications(
888+
app_handle: &AppHandle,
889+
gateway_state: &Arc<RwLock<mcpmux_gateway::GatewayState>>,
890+
ui_bus: Option<Arc<AdminUiEventBus>>,
891+
) {
892+
let app = app_handle.clone();
893+
let ui_bus_for_hook = ui_bus.clone();
894+
let hook: mcpmux_gateway::ConsentUiNotifier = Arc::new(move |request_id: &str| {
895+
crate::services::ui_events::emit_ui_channel(
896+
&app,
897+
ui_bus_for_hook.as_deref(),
898+
OAUTH_CONSENT_REQUEST_CHANNEL,
899+
serde_json::json!({ "requestId": request_id }),
900+
);
901+
});
902+
903+
gateway_state.write().await.set_consent_ui_hook(hook);
904+
info!(
905+
"[Gateway] Consent UI wired (Tauri + SSE={})",
906+
ui_bus.is_some()
907+
);
908+
}
909+
864910
/// Create Gateway dependencies from app state using DI builder pattern
865911
///
866912
/// Centralizes dependency construction following Dependency Injection principles.
@@ -1057,6 +1103,7 @@ pub async fn start_gateway(
10571103
let server_manager = server.server_manager();
10581104
let grant_service = server.grant_service();
10591105
let session_roots = server.session_roots();
1106+
let feature_set_resolver = server.feature_set_resolver();
10601107

10611108
// Seed the system-wide inbound-auth toggle into the running gateway from
10621109
// persisted settings (default: auth required). Live changes go through
@@ -1122,6 +1169,7 @@ pub async fn start_gateway(
11221169
state.grant_service = Some(grant_service);
11231170
state.approval_broker = Some(approval_broker);
11241171
state.session_roots = Some(session_roots);
1172+
state.feature_set_resolver = Some(feature_set_resolver);
11251173
info!(
11261174
"[Gateway] Started — url={}, event_emitter={}, grant_service={}",
11271175
url,
@@ -1159,6 +1207,12 @@ pub async fn start_gateway(
11591207
drop(guard);
11601208
let guard2 = admin_guard_clone.read().await;
11611209
register_gateway_sse(&guard2, &gw_state_clone).await;
1210+
wire_consent_ui_notifications(
1211+
&app_handle,
1212+
&gw_state_clone,
1213+
Some(guard2.ui_event_bus.clone()),
1214+
)
1215+
.await;
11621216
}
11631217
}
11641218

0 commit comments

Comments
 (0)