From c0e1f00a1cbcf090b3976ab28acd934acb5384e0 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Thu, 12 Feb 2026 23:06:20 +0800 Subject: [PATCH 1/3] feat: support default values for input definitions Add `default` field to domain InputDefinition so server definitions can specify fallback values for inputs not provided by the user. Transport resolution now merges defaults before resolving placeholders, with user-provided values always taking priority. Co-Authored-By: Claude Opus 4.6 --- crates/mcpmux-core/src/domain/config.rs | 131 ++++++++ crates/mcpmux-core/src/domain/server.rs | 1 + .../src/pool/transport/resolution.rs | 310 +++++++++++++++++- 3 files changed, 434 insertions(+), 8 deletions(-) diff --git a/crates/mcpmux-core/src/domain/config.rs b/crates/mcpmux-core/src/domain/config.rs index b2789fb7..d306edcd 100644 --- a/crates/mcpmux-core/src/domain/config.rs +++ b/crates/mcpmux-core/src/domain/config.rs @@ -254,6 +254,7 @@ impl UserServerEntry { required: true, secret: true, description: None, + default: None, placeholder: None, obtain_url: None, obtain_instructions: None, @@ -444,6 +445,7 @@ mod tests { required: false, secret: false, description: Some("Custom description".to_string()), + default: None, placeholder: None, obtain_url: None, obtain_instructions: None, @@ -649,4 +651,133 @@ mod tests { // Explicit OAuth should not be overridden assert!(matches!(def.auth, Some(AuthConfig::Oauth))); } + + #[test] + fn test_input_default_value_parsed_from_json() { + let json = r#"{ + "mcpServers": { + "test-server": { + "command": "node", + "args": ["server.js"], + "env": { + "LOG_LEVEL": "${input:LOG_LEVEL}" + }, + "metadata": { + "inputs": [ + { + "id": "LOG_LEVEL", + "label": "Log Level", + "type": "text", + "required": false, + "secret": false, + "default": "info" + } + ] + } + } + } + }"#; + + let config: UserSpaceConfig = serde_json::from_str(json).unwrap(); + let definitions = + config.to_server_definitions("test-space", PathBuf::from("/test/path.json")); + + assert_eq!(definitions.len(), 1); + let inputs = &definitions[0].transport.metadata().inputs; + assert_eq!(inputs.len(), 1); + assert_eq!(inputs[0].id, "LOG_LEVEL"); + assert_eq!(inputs[0].default, Some("info".to_string())); + } + + #[test] + fn test_explicit_input_with_default_takes_precedence_over_autodiscovery() { + let entry = UserServerEntry { + command: Some("node".to_string()), + args: None, + env: Some(HashMap::from([( + "LOG_LEVEL".to_string(), + "${input:LOG_LEVEL}".to_string(), + )])), + url: None, + headers: None, + name: None, + description: None, + icon: None, + alias: None, + auth: None, + metadata: Some(UserServerMetadata { + inputs: Some(vec![InputDefinition { + id: "LOG_LEVEL".to_string(), + label: "Log Level".to_string(), + r#type: "text".to_string(), + required: false, + secret: false, + description: None, + default: Some("info".to_string()), + placeholder: None, + obtain_url: None, + obtain_instructions: None, + }]), + publisher: None, + }), + }; + + let (_, inputs) = entry.resolve_transport_and_inputs(); + + assert_eq!(inputs.len(), 1); + assert_eq!(inputs[0].id, "LOG_LEVEL"); + assert_eq!(inputs[0].default, Some("info".to_string())); + // Should use explicit definition's type, not auto-discovered "password" + assert_eq!(inputs[0].r#type, "text"); + assert!(!inputs[0].required); + assert!(!inputs[0].secret); + } + + #[test] + fn test_auto_discovered_inputs_have_no_default() { + let entry = UserServerEntry { + command: Some("node".to_string()), + args: None, + env: Some(HashMap::from([( + "API_KEY".to_string(), + "${input:API_KEY}".to_string(), + )])), + url: None, + headers: None, + name: None, + description: None, + icon: None, + alias: None, + auth: None, + metadata: None, + }; + + let (_, inputs) = entry.resolve_transport_and_inputs(); + + assert_eq!(inputs.len(), 1); + assert_eq!(inputs[0].id, "API_KEY"); + assert_eq!(inputs[0].default, None); + } + + #[test] + fn test_input_default_serializes_roundtrip() { + let input = InputDefinition { + id: "PORT".to_string(), + label: "Port".to_string(), + r#type: "number".to_string(), + required: false, + secret: false, + description: None, + default: Some("8080".to_string()), + placeholder: None, + obtain_url: None, + obtain_instructions: None, + }; + + let json = serde_json::to_string(&input).unwrap(); + let deserialized: InputDefinition = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.id, "PORT"); + assert_eq!(deserialized.default, Some("8080".to_string())); + } } diff --git a/crates/mcpmux-core/src/domain/server.rs b/crates/mcpmux-core/src/domain/server.rs index 379e9063..02670db6 100644 --- a/crates/mcpmux-core/src/domain/server.rs +++ b/crates/mcpmux-core/src/domain/server.rs @@ -146,6 +146,7 @@ pub struct InputDefinition { #[serde(default)] pub secret: bool, pub description: Option, + pub default: Option, pub placeholder: Option, // Additional helpful metadata for acquiring credentials diff --git a/crates/mcpmux-gateway/src/pool/transport/resolution.rs b/crates/mcpmux-gateway/src/pool/transport/resolution.rs index 7024ba82..9a47b8a7 100644 --- a/crates/mcpmux-gateway/src/pool/transport/resolution.rs +++ b/crates/mcpmux-gateway/src/pool/transport/resolution.rs @@ -10,6 +10,29 @@ use std::path::Path; const MCP_STATE_DIR_ENV: &str = "MCP_STATE_DIR"; +/// Build a merged input_values map that includes defaults for any inputs +/// not explicitly provided by the user. +fn merge_input_defaults( + registry_transport: &RegistryConfig, + user_values: &HashMap, +) -> HashMap { + let mut merged = user_values.clone(); + let metadata = registry_transport.metadata(); + for input in &metadata.inputs { + if !merged.contains_key(&input.id) { + if let Some(ref default_val) = input.default { + tracing::debug!( + "[TransportResolution] Using default for input '{}': '{}'", + input.id, + default_val + ); + merged.insert(input.id.clone(), default_val.clone()); + } + } + } + merged +} + /// Build transport config from registry transport and installed server pub fn build_transport_config( registry_transport: &RegistryConfig, @@ -23,14 +46,17 @@ pub fn build_transport_config( installed.input_values.len() ); + // Merge user-provided values with defaults from input definitions + let effective_values = merge_input_defaults(registry_transport, &installed.input_values); + match registry_transport { RegistryConfig::Stdio { command, args, env, .. } => { - let resolved_command = resolve_placeholders(command, &installed.input_values); + let resolved_command = resolve_placeholders(command, &effective_values); let mut resolved_args: Vec = args .iter() - .map(|arg| resolve_placeholders(arg, &installed.input_values)) + .map(|arg| resolve_placeholders(arg, &effective_values)) .collect(); // Append user's extra args @@ -41,7 +67,7 @@ pub fn build_transport_config( // 1. Start with registry env for (k, v) in env { - let resolved_value = resolve_placeholders(v, &installed.input_values); + let resolved_value = resolve_placeholders(v, &effective_values); tracing::debug!( "[TransportResolution] Registry env: {}={} → {}", k, @@ -51,12 +77,12 @@ pub fn build_transport_config( resolved_env.insert(k.clone(), resolved_value); } - // 2. Add input values directly as env vars + // 2. Add input values (user-provided + defaults) directly as env vars tracing::debug!( "[TransportResolution] Adding {} input values as direct env vars", - installed.input_values.len() + effective_values.len() ); - resolved_env.extend(installed.input_values.clone()); + resolved_env.extend(effective_values.clone()); // 3. Apply user's env overrides resolved_env.extend(installed.env_overrides.clone()); @@ -76,12 +102,12 @@ pub fn build_transport_config( } } RegistryConfig::Http { url, headers, .. } => { - let resolved_url = resolve_placeholders(url, &installed.input_values); + let resolved_url = resolve_placeholders(url, &effective_values); // Resolve headers from registry let mut resolved_headers: HashMap = headers .iter() - .map(|(k, v)| (k.clone(), resolve_placeholders(v, &installed.input_values))) + .map(|(k, v)| (k.clone(), resolve_placeholders(v, &effective_values))) .collect(); // Add user's extra headers @@ -127,3 +153,271 @@ fn resolve_placeholders(template: &str, input_values: &HashMap) } result } + +#[cfg(test)] +mod tests { + use super::*; + use mcpmux_core::{InputDefinition, TransportMetadata}; + + fn make_installed(input_values: HashMap) -> InstalledServer { + InstalledServer::new("test-space", "test-server").with_inputs(input_values) + } + + fn make_input(id: &str, default: Option<&str>) -> InputDefinition { + InputDefinition { + id: id.to_string(), + label: id.to_string(), + r#type: "text".to_string(), + required: default.is_none(), + secret: false, + description: None, + default: default.map(|s| s.to_string()), + placeholder: None, + obtain_url: None, + obtain_instructions: None, + } + } + + #[test] + fn test_default_used_when_user_provides_no_value() { + let transport = RegistryConfig::Stdio { + command: "node".to_string(), + args: vec!["server.js".to_string()], + env: HashMap::from([( + "LOG_LEVEL".to_string(), + "${input:LOG_LEVEL}".to_string(), + )]), + metadata: TransportMetadata { + inputs: vec![make_input("LOG_LEVEL", Some("info"))], + }, + }; + + let installed = make_installed(HashMap::new()); // No user values + + let resolved = build_transport_config(&transport, &installed, None); + + match resolved { + ResolvedTransport::Stdio { env, .. } => { + // Default should be used for placeholder resolution + assert_eq!(env.get("LOG_LEVEL"), Some(&"info".to_string())); + } + _ => panic!("Expected Stdio transport"), + } + } + + #[test] + fn test_user_value_overrides_default() { + let transport = RegistryConfig::Stdio { + command: "node".to_string(), + args: vec![], + env: HashMap::from([( + "LOG_LEVEL".to_string(), + "${input:LOG_LEVEL}".to_string(), + )]), + metadata: TransportMetadata { + inputs: vec![make_input("LOG_LEVEL", Some("info"))], + }, + }; + + let installed = make_installed(HashMap::from([( + "LOG_LEVEL".to_string(), + "debug".to_string(), + )])); + + let resolved = build_transport_config(&transport, &installed, None); + + match resolved { + ResolvedTransport::Stdio { env, .. } => { + // User value should win over default + assert_eq!(env.get("LOG_LEVEL"), Some(&"debug".to_string())); + } + _ => panic!("Expected Stdio transport"), + } + } + + #[test] + fn test_default_resolves_in_args() { + let transport = RegistryConfig::Stdio { + command: "node".to_string(), + args: vec![ + "--port".to_string(), + "${input:PORT}".to_string(), + ], + env: HashMap::new(), + metadata: TransportMetadata { + inputs: vec![make_input("PORT", Some("8080"))], + }, + }; + + let installed = make_installed(HashMap::new()); + + let resolved = build_transport_config(&transport, &installed, None); + + match resolved { + ResolvedTransport::Stdio { args, .. } => { + assert_eq!(args[0], "--port"); + assert_eq!(args[1], "8080"); + } + _ => panic!("Expected Stdio transport"), + } + } + + #[test] + fn test_default_resolves_in_command() { + let transport = RegistryConfig::Stdio { + command: "${input:BINARY_PATH}".to_string(), + args: vec![], + env: HashMap::new(), + metadata: TransportMetadata { + inputs: vec![make_input("BINARY_PATH", Some("/usr/local/bin/mcp"))], + }, + }; + + let installed = make_installed(HashMap::new()); + + let resolved = build_transport_config(&transport, &installed, None); + + match resolved { + ResolvedTransport::Stdio { command, .. } => { + assert_eq!(command, "/usr/local/bin/mcp"); + } + _ => panic!("Expected Stdio transport"), + } + } + + #[test] + fn test_default_resolves_in_http_url() { + let transport = RegistryConfig::Http { + url: "https://api.example.com/${input:API_VERSION}/mcp".to_string(), + headers: HashMap::new(), + metadata: TransportMetadata { + inputs: vec![make_input("API_VERSION", Some("v2"))], + }, + }; + + let installed = make_installed(HashMap::new()); + + let resolved = build_transport_config(&transport, &installed, None); + + match resolved { + ResolvedTransport::Http { url, .. } => { + assert_eq!(url, "https://api.example.com/v2/mcp"); + } + _ => panic!("Expected Http transport"), + } + } + + #[test] + fn test_default_resolves_in_http_headers() { + let transport = RegistryConfig::Http { + url: "https://api.example.com/mcp".to_string(), + headers: HashMap::from([( + "X-Api-Key".to_string(), + "${input:API_KEY}".to_string(), + )]), + metadata: TransportMetadata { + inputs: vec![make_input("API_KEY", Some("default-key"))], + }, + }; + + let installed = make_installed(HashMap::new()); + + let resolved = build_transport_config(&transport, &installed, None); + + match resolved { + ResolvedTransport::Http { headers, .. } => { + assert_eq!(headers.get("X-Api-Key"), Some(&"default-key".to_string())); + } + _ => panic!("Expected Http transport"), + } + } + + #[test] + fn test_multiple_defaults_some_overridden() { + let transport = RegistryConfig::Stdio { + command: "node".to_string(), + args: vec![], + env: HashMap::from([ + ("LOG_LEVEL".to_string(), "${input:LOG_LEVEL}".to_string()), + ("PORT".to_string(), "${input:PORT}".to_string()), + ("API_KEY".to_string(), "${input:API_KEY}".to_string()), + ]), + metadata: TransportMetadata { + inputs: vec![ + make_input("LOG_LEVEL", Some("info")), + make_input("PORT", Some("3000")), + make_input("API_KEY", None), // No default + ], + }, + }; + + // User provides PORT and API_KEY, but not LOG_LEVEL + let installed = make_installed(HashMap::from([ + ("PORT".to_string(), "9090".to_string()), + ("API_KEY".to_string(), "secret123".to_string()), + ])); + + let resolved = build_transport_config(&transport, &installed, None); + + match resolved { + ResolvedTransport::Stdio { env, .. } => { + // LOG_LEVEL: default used + assert_eq!(env.get("LOG_LEVEL"), Some(&"info".to_string())); + // PORT: user value wins + assert_eq!(env.get("PORT"), Some(&"9090".to_string())); + // API_KEY: user value used + assert_eq!(env.get("API_KEY"), Some(&"secret123".to_string())); + } + _ => panic!("Expected Stdio transport"), + } + } + + #[test] + fn test_no_default_leaves_placeholder_unresolved() { + let transport = RegistryConfig::Stdio { + command: "node".to_string(), + args: vec![], + env: HashMap::from([( + "API_KEY".to_string(), + "${input:API_KEY}".to_string(), + )]), + metadata: TransportMetadata { + inputs: vec![make_input("API_KEY", None)], + }, + }; + + let installed = make_installed(HashMap::new()); + + let resolved = build_transport_config(&transport, &installed, None); + + match resolved { + ResolvedTransport::Stdio { env, .. } => { + // Without user value or default, placeholder stays unresolved in the env template + assert_eq!(env.get("API_KEY"), Some(&"${input:API_KEY}".to_string())); + } + _ => panic!("Expected Stdio transport"), + } + } + + #[test] + fn test_merge_input_defaults_only_fills_missing() { + let transport = RegistryConfig::Stdio { + command: "node".to_string(), + args: vec![], + env: HashMap::new(), + metadata: TransportMetadata { + inputs: vec![ + make_input("A", Some("default_a")), + make_input("B", Some("default_b")), + ], + }, + }; + + let user_values = HashMap::from([("A".to_string(), "user_a".to_string())]); + + let merged = merge_input_defaults(&transport, &user_values); + + assert_eq!(merged.get("A"), Some(&"user_a".to_string())); + assert_eq!(merged.get("B"), Some(&"default_b".to_string())); + } +} From 0562cdd57426ac19b50e7f488615c4536dcebcb2 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Fri, 13 Feb 2026 09:50:45 +0800 Subject: [PATCH 2/3] style: fix cargo fmt formatting in resolution tests Co-Authored-By: Claude Opus 4.6 Signed-off-by: Mohammod Al Amin Ashik --- .../src/pool/transport/resolution.rs | 25 ++++--------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/crates/mcpmux-gateway/src/pool/transport/resolution.rs b/crates/mcpmux-gateway/src/pool/transport/resolution.rs index 9a47b8a7..da0fbd18 100644 --- a/crates/mcpmux-gateway/src/pool/transport/resolution.rs +++ b/crates/mcpmux-gateway/src/pool/transport/resolution.rs @@ -183,10 +183,7 @@ mod tests { let transport = RegistryConfig::Stdio { command: "node".to_string(), args: vec!["server.js".to_string()], - env: HashMap::from([( - "LOG_LEVEL".to_string(), - "${input:LOG_LEVEL}".to_string(), - )]), + env: HashMap::from([("LOG_LEVEL".to_string(), "${input:LOG_LEVEL}".to_string())]), metadata: TransportMetadata { inputs: vec![make_input("LOG_LEVEL", Some("info"))], }, @@ -210,10 +207,7 @@ mod tests { let transport = RegistryConfig::Stdio { command: "node".to_string(), args: vec![], - env: HashMap::from([( - "LOG_LEVEL".to_string(), - "${input:LOG_LEVEL}".to_string(), - )]), + env: HashMap::from([("LOG_LEVEL".to_string(), "${input:LOG_LEVEL}".to_string())]), metadata: TransportMetadata { inputs: vec![make_input("LOG_LEVEL", Some("info"))], }, @@ -239,10 +233,7 @@ mod tests { fn test_default_resolves_in_args() { let transport = RegistryConfig::Stdio { command: "node".to_string(), - args: vec![ - "--port".to_string(), - "${input:PORT}".to_string(), - ], + args: vec!["--port".to_string(), "${input:PORT}".to_string()], env: HashMap::new(), metadata: TransportMetadata { inputs: vec![make_input("PORT", Some("8080"))], @@ -311,10 +302,7 @@ mod tests { fn test_default_resolves_in_http_headers() { let transport = RegistryConfig::Http { url: "https://api.example.com/mcp".to_string(), - headers: HashMap::from([( - "X-Api-Key".to_string(), - "${input:API_KEY}".to_string(), - )]), + headers: HashMap::from([("X-Api-Key".to_string(), "${input:API_KEY}".to_string())]), metadata: TransportMetadata { inputs: vec![make_input("API_KEY", Some("default-key"))], }, @@ -377,10 +365,7 @@ mod tests { let transport = RegistryConfig::Stdio { command: "node".to_string(), args: vec![], - env: HashMap::from([( - "API_KEY".to_string(), - "${input:API_KEY}".to_string(), - )]), + env: HashMap::from([("API_KEY".to_string(), "${input:API_KEY}".to_string())]), metadata: TransportMetadata { inputs: vec![make_input("API_KEY", None)], }, From baccd37be8795d047126f6deb3c521b8ed66a0f8 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Fri, 13 Feb 2026 09:51:12 +0800 Subject: [PATCH 3/3] docs: add mcp-servers validation commands to CLAUDE.md Co-Authored-By: Claude Opus 4.6 Signed-off-by: Mohammod Al Amin Ashik --- CLAUDE.md | 167 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..f1154360 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,167 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Codebase Search + +This repository is indexed for semantic code search via the `claude-context` MCP server. Prefer `mcp__claude-context__search_code` (with path `D:\mcpmux`) over Grep/Glob when looking for implementations, understanding how features work, or finding related code across the codebase. Use Grep/Glob for exact string matches or filename patterns. If search returns an indexing error, re-index with `mcp__claude-context__index_codebase` first. + +## What is McpMux + +McpMux is a desktop app + local gateway that lets users configure MCP servers once and connect every AI client (Cursor, Claude Desktop, VS Code, Windsurf) through a single `localhost:45818` endpoint. Credentials are encrypted in the OS keychain instead of plain-text JSON files. + +## Repository Structure + +This is a multi-project workspace with 6 independent projects (not a unified monorepo): + +| Project | Tech | Purpose | +|---------|------|---------| +| `mcp-mux/` | Tauri 2 (Rust + React 19), pnpm workspace | Desktop app + local MCP gateway | +| `mcpmux.bundler/` | Cloudflare Worker | GitHub webhook receiver that processes server definitions into D1/R2 | +| `mcpmux.serverhub.api/` | Cloudflare Worker (Hono) | REST API for server registry discovery (KV -> D1 -> R2 fallback chain) | +| `mcpmux.discover.ui/` | Next.js 16, shadcn/ui, Tailwind 4 | Web UI for browsing the server registry | +| `mcp-servers/` | JSON + AJV validation | Community MCP server definitions repository | +| `mcpmux.space/` | Docs | Documentation and design space | + +## mcp-mux (Desktop App) - Main Project + +### Build & Dev Commands + +All commands run from `mcp-mux/`: + +```bash +pnpm setup # First-time dev environment setup (PowerShell) +pnpm dev # Tauri desktop app dev mode (Rust + React hot-reload) +pnpm dev:web # Web UI only (Vite, no Rust) +pnpm build # Production Tauri build (all platforms) +``` + +### Testing + +```bash +pnpm test # All tests (Rust + TypeScript) +pnpm test:rust # cargo nextest run --workspace +pnpm test:rust:unit # cargo nextest run --workspace --lib +pnpm test:rust:int # cargo nextest run -p tests +pnpm test:rust:doc # cargo test --workspace --doc +pnpm test:ts # vitest run -c tests/ts/vitest.config.ts +pnpm test:ts:watch # vitest watch mode +pnpm test:e2e # WebDriver IO desktop E2E (needs MCPMUX_REGISTRY_URL) +pnpm test:e2e:file # Single E2E spec: pnpm test:e2e:file -- tests/e2e/specs/foo.ts +pnpm test:e2e:grep # E2E by name: pnpm test:e2e:grep -- "test name" +pnpm test:e2e:web # Playwright web UI E2E +pnpm test:coverage # cargo llvm-cov + vitest coverage +``` + +### Linting & Validation + +```bash +pnpm validate # Full check: cargo fmt + clippy + check + eslint + typecheck +pnpm lint # ESLint (recursive) + cargo clippy --workspace -- -D warnings +pnpm lint:fix # Auto-fix lint issues +pnpm format # prettier --write . && cargo fmt --all +pnpm format:check # Check formatting without modifying +pnpm typecheck # TypeScript type checking (recursive) +``` + +### Rust Crate Architecture + +The Cargo workspace has 4 library crates + 1 app crate + 1 test crate: + +- **mcpmux-core** (`crates/mcpmux-core/`) - Domain layer: entities (Space, InstalledServer, FeatureSet, Client), repository traits, domain services, application services with event emission, and the central EventBus +- **mcpmux-gateway** (`crates/mcpmux-gateway/`) - Axum HTTP gateway: routes MCP calls to correct servers, manages OAuth 2.1+PKCE token refresh, filters tools/resources/prompts based on FeatureSets, per-client access key auth, server connection pooling +- **mcpmux-storage** (`crates/mcpmux-storage/`) - SQLite persistence with AES-256-GCM field-level encryption via ring, typed credential rows (per-token encryption), DPAPI key storage on Windows (`keychain_dpapi.rs`), OS keychain on macOS/Linux via keyring crate, zeroize for secure memory clearing +- **mcpmux-mcp** (`crates/mcpmux-mcp/`) - MCP protocol client management using rmcp SDK +- **apps/desktop/src-tauri** - Tauri 2 app shell, Tauri commands, system tray, deep-link handler (`mcpmux://`) +- **tests/rust** - Integration test crate + +Key patterns: event-driven architecture (EventBus), repository pattern (trait-based storage abstraction), service layer pattern with DI via ApplicationServices builders. + +### Frontend Architecture + +- **Entry**: `apps/desktop/src/main.tsx` -> `App.tsx` +- **State**: Zustand store (`stores/appStore.ts`) +- **Hooks**: `useServerManager` (server CRUD), `useSpaces` (workspace switching), `useDomainEvents` (Rust event listeners), `useDataSync` (data synchronization) +- **UI**: React 19 + Tailwind CSS + Lucide icons + Monaco Editor (config editing) +- **Path aliases**: `@/` -> `src/`, `@mcpmux/ui` -> shared UI package + +### Data Flow + +``` +AI Clients -> McpMux Gateway (localhost:45818/mcp) -> MCP Servers (stdio/HTTP) + | + Authenticates (access keys) + Routes (per-space server config) + Filters (FeatureSet permissions) + OAuth token refresh (automatic) + Credentials (DPAPI files on Windows / OS Keychain on macOS+Linux + encrypted SQLite) +``` + +### Prerequisites + +Rust 1.75+, Node.js 20+, pnpm 9+. Linux: `gnome-keyring libsecret-1-dev librsvg2-dev pkg-config`. + +### Code Style + +- Rust: 100 char max width, 4-space indent, `clippy` with `avoid-breaking-exported-api = false` +- TypeScript/JSX: Prettier with single quotes, 2-space indent, 100 char width, trailing commas (es5), Tailwind CSS plugin +- Commits require `Signed-off-by` line (use `git commit -s`) + +## mcp-servers (Server Definitions Repository) + +All commands run from `mcp-servers/` (sibling repo): + +```bash +pnpm install # Install dependencies (vitest, ajv, glob) +pnpm validate:all # Validate all server definitions against JSON schema +pnpm validate # Validate specific server definition file(s) +pnpm build # Build registry bundle (bundle/bundle.json) +pnpm test # Run all tests (schema validation, consistency, categories, bundle) +``` + +The JSON schema at `schemas/server-definition.schema.json` defines the structure for server definitions in `servers/`. Input definitions support: `id`, `label`, `type`, `required`, `secret`, `description`, `default`, `placeholder`, and `obtain` (with `url`, `instructions`, `button_label`). + +## Cloudflare Workers + +Each worker is an independent project with its own `package.json`: + +**mcpmux.serverhub.api/** - Hono-based registry API: +```bash +cd mcpmux.serverhub.api && pnpm dev # wrangler dev +cd mcpmux.serverhub.api && pnpm test # vitest (cloudflare pool) +``` + +**mcpmux.bundler/** - Webhook-triggered bundle processor: +```bash +cd mcpmux.bundler && pnpm dev # wrangler dev +cd mcpmux.bundler && pnpm test # vitest run +``` + +## mcpmux.discover.ui (Discovery Web UI) + +```bash +cd mcpmux.discover.ui && pnpm dev # next dev +cd mcpmux.discover.ui && pnpm test # vitest +cd mcpmux.discover.ui && pnpm test:e2e # playwright +``` + +## Important Patterns + +### Child Process Platform Flags + +When spawning child processes (e.g., stdio MCP servers), **always** use `configure_child_process_platform()` from `mcpmux_gateway::pool::transport`. This applies: +- **Windows**: `CREATE_NO_WINDOW` (`0x08000000`) — prevents visible console windows in release builds (where `windows_subsystem = "windows"` means the app is a GUI subsystem process) +- **Unix**: `process_group(0)` — isolates child from parent terminal signals (SIGINT, SIGTSTP) + +Note: `tokio::process::Command` already exposes `creation_flags()` (Windows) and `process_group()` (Unix) natively — do **not** import `std::os::unix::process::CommandExt` or `std::os::windows::process::CommandExt` as the traits are unused with Tokio's Command. + +### Cross-Platform CI Awareness + +The pre-commit hook runs `cargo clippy --workspace -- -D warnings` locally, but `#[cfg(unix)]` / `#[cfg(windows)]` blocks are only compiled on the matching platform. **CI runs on Linux**, so: +- `#[cfg(unix)]` code is only linted in CI, not on a Windows dev machine +- `#[cfg(windows)]` code is only linted locally on Windows, not in CI +- Always validate that platform-conditional code compiles correctly on both platforms before pushing + +## CI + +GitHub Actions runs on push/PR to main: Rust format + clippy + check, ESLint + typecheck, cargo nextest, vitest, desktop E2E (Windows/macOS/Linux), web E2E (Playwright). Releases use release-please for semantic versioning with multi-platform Tauri builds.