From 8c203cb34c12876d5dd348a0347f38f234ac637e Mon Sep 17 00:00:00 2001 From: Arya Date: Sat, 28 Feb 2026 23:17:14 +0800 Subject: [PATCH 1/4] fix: harden oauth/sync flows and unblock lint --- .github/workflows/build.yml | 6 +- .gitignore | 2 +- .../src-tauri/src/clients/antigravity.rs | 6 +- .../src-tauri/src/clients/claude_code.rs | 7 +- .../src-tauri/src/clients/claude_desktop.rs | 7 +- .../src-tauri/src/clients/jetbrains.rs | 5 +- apps/desktop/src-tauri/src/clients/mod.rs | 7 +- apps/desktop/src-tauri/src/clients/vscode.rs | 6 +- .../desktop/src-tauri/src/clients/windsurf.rs | 7 +- .../src-tauri/src/commands/activity.rs | 7 +- .../src-tauri/src/commands/detection.rs | 9 +- apps/desktop/src-tauri/src/commands/import.rs | 16 +- apps/desktop/src-tauri/src/commands/logo.rs | 10 +- apps/desktop/src-tauri/src/commands/oauth.rs | 8 +- .../src-tauri/src/commands/registry.rs | 153 +- .../desktop/src-tauri/src/commands/servers.rs | 101 +- apps/desktop/src-tauri/src/commands/stacks.rs | 67 +- apps/desktop/src-tauri/src/commands/sync.rs | 149 +- apps/desktop/src-tauri/src/config/backup.rs | 19 +- apps/desktop/src-tauri/src/config/mod.rs | 29 +- .../src-tauri/src/config/normalizer.rs | 9 +- .../src-tauri/src/config/serializer.rs | 21 +- apps/desktop/src-tauri/src/file_guard.rs | 81 + apps/desktop/src-tauri/src/lib.rs | 39 +- apps/desktop/src-tauri/src/oauth/mod.rs | 606 ++- apps/desktop/src-tauri/src/watcher/mod.rs | 25 +- apps/desktop/src/App.tsx | 8 + apps/desktop/src/globals.d.ts | 3 + apps/desktop/src/hooks/useAutoSync.ts | 79 + apps/desktop/src/stores/configStore.ts | 4 + apps/desktop/src/views/StacksView.tsx | 28 +- apps/web/.eslintrc.json | 3 + apps/web/app/share/page.tsx | 22 +- apps/web/components/Nav.tsx | 5 +- apps/web/package.json | 20 +- mise.toml | 28 + nx.json | 30 + package.json | 9 +- pnpm-lock.yaml | 3479 ++++++++++++++++- 39 files changed, 4741 insertions(+), 379 deletions(-) create mode 100644 apps/desktop/src-tauri/src/file_guard.rs create mode 100644 apps/desktop/src/globals.d.ts create mode 100644 apps/desktop/src/hooks/useAutoSync.ts create mode 100644 apps/web/.eslintrc.json create mode 100644 mise.toml create mode 100644 nx.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 631b253..42ffa93 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,8 +22,6 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 10 - uses: actions/setup-node@v4 with: @@ -43,7 +41,7 @@ jobs: - platform: macos-latest target: aarch64-apple-darwin label: apple-silicon - - platform: macos-13 + - platform: macos-latest target: x86_64-apple-darwin label: intel @@ -52,8 +50,6 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 10 - uses: actions/setup-node@v4 with: diff --git a/.gitignore b/.gitignore index d99c40d..46bd21b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,4 @@ target/ .env .env.local *.log -.turbo/ +.nx/ diff --git a/apps/desktop/src-tauri/src/clients/antigravity.rs b/apps/desktop/src-tauri/src/clients/antigravity.rs index dd17f81..6c658a5 100644 --- a/apps/desktop/src-tauri/src/clients/antigravity.rs +++ b/apps/desktop/src-tauri/src/clients/antigravity.rs @@ -90,7 +90,11 @@ impl ClientAdapter for AntigravityAdapter { let path = Self::get_config_path() .ok_or_else(|| anyhow::anyhow!("Cannot determine config path for Antigravity"))?; - let format = if Self::is_mcp_json(&path) { "vscode-mcp" } else { "vscode" }; + let format = if Self::is_mcp_json(&path) { + "vscode-mcp" + } else { + "vscode" + }; let current_content = match existing_content { Some(c) => Some(c.to_string()), diff --git a/apps/desktop/src-tauri/src/clients/claude_code.rs b/apps/desktop/src-tauri/src/clients/claude_code.rs index e7f4cf2..bf419f9 100644 --- a/apps/desktop/src-tauri/src/clients/claude_code.rs +++ b/apps/desktop/src-tauri/src/clients/claude_code.rs @@ -80,8 +80,11 @@ impl ClientAdapter for ClaudeCodeAdapter { } }; - let output = - serializer::serialize_to_client_format("claude-code", servers, current_content.as_deref())?; + let output = serializer::serialize_to_client_format( + "claude-code", + servers, + current_content.as_deref(), + )?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; diff --git a/apps/desktop/src-tauri/src/clients/claude_desktop.rs b/apps/desktop/src-tauri/src/clients/claude_desktop.rs index 8a4abe1..1ca0b3e 100644 --- a/apps/desktop/src-tauri/src/clients/claude_desktop.rs +++ b/apps/desktop/src-tauri/src/clients/claude_desktop.rs @@ -82,8 +82,11 @@ impl ClientAdapter for ClaudeDesktopAdapter { } }; - let output = - serializer::serialize_to_client_format("claude-desktop", servers, current_content.as_deref())?; + let output = serializer::serialize_to_client_format( + "claude-desktop", + servers, + current_content.as_deref(), + )?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; diff --git a/apps/desktop/src-tauri/src/clients/jetbrains.rs b/apps/desktop/src-tauri/src/clients/jetbrains.rs index 692bd71..a6509e5 100644 --- a/apps/desktop/src-tauri/src/clients/jetbrains.rs +++ b/apps/desktop/src-tauri/src/clients/jetbrains.rs @@ -11,7 +11,10 @@ impl JetBrainsAdapter { /// We search for common JetBrains IDEs on macOS. fn get_config_path() -> Option { let home = dirs::home_dir()?; - let app_support = home.join("Library").join("Application Support").join("JetBrains"); + let app_support = home + .join("Library") + .join("Application Support") + .join("JetBrains"); if !app_support.exists() { return None; diff --git a/apps/desktop/src-tauri/src/clients/mod.rs b/apps/desktop/src-tauri/src/clients/mod.rs index 4ef0836..0e27d60 100644 --- a/apps/desktop/src-tauri/src/clients/mod.rs +++ b/apps/desktop/src-tauri/src/clients/mod.rs @@ -36,8 +36,11 @@ pub trait ClientAdapter: Send + Sync { /// Write MCP server configurations to this client's config, /// optionally merging with existing content. - fn write_servers(&self, servers: &[McpServerConfig], existing_content: Option<&str>) - -> Result<()>; + fn write_servers( + &self, + servers: &[McpServerConfig], + existing_content: Option<&str>, + ) -> Result<()>; } /// Information about a detected client. diff --git a/apps/desktop/src-tauri/src/clients/vscode.rs b/apps/desktop/src-tauri/src/clients/vscode.rs index 166b007..b5aa502 100644 --- a/apps/desktop/src-tauri/src/clients/vscode.rs +++ b/apps/desktop/src-tauri/src/clients/vscode.rs @@ -89,7 +89,11 @@ impl ClientAdapter for VSCodeAdapter { let path = Self::get_config_path() .ok_or_else(|| anyhow::anyhow!("Cannot determine config path for VS Code"))?; - let format = if Self::is_mcp_json(&path) { "vscode-mcp" } else { "vscode" }; + let format = if Self::is_mcp_json(&path) { + "vscode-mcp" + } else { + "vscode" + }; let current_content = match existing_content { Some(c) => Some(c.to_string()), diff --git a/apps/desktop/src-tauri/src/clients/windsurf.rs b/apps/desktop/src-tauri/src/clients/windsurf.rs index 1624a7c..ae82f05 100644 --- a/apps/desktop/src-tauri/src/clients/windsurf.rs +++ b/apps/desktop/src-tauri/src/clients/windsurf.rs @@ -82,8 +82,11 @@ impl ClientAdapter for WindsurfAdapter { } }; - let output = - serializer::serialize_to_client_format("windsurf", servers, current_content.as_deref())?; + let output = serializer::serialize_to_client_format( + "windsurf", + servers, + current_content.as_deref(), + )?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; diff --git a/apps/desktop/src-tauri/src/commands/activity.rs b/apps/desktop/src-tauri/src/commands/activity.rs index 3970fee..4104d33 100644 --- a/apps/desktop/src-tauri/src/commands/activity.rs +++ b/apps/desktop/src-tauri/src/commands/activity.rs @@ -2,7 +2,12 @@ use crate::config::{self, ActivityEntry}; #[tauri::command] pub async fn get_activity() -> Result, String> { - let cfg = config::read_config().map_err(|e| e.to_string())?; + let mut cfg = config::read_config().map_err(|e| e.to_string())?; + let pruned = config::prune_activity_entries(&mut cfg.activity); + if pruned > 0 { + config::write_config(&cfg).map_err(|e| e.to_string())?; + } + let mut entries = cfg.activity; entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); Ok(entries) diff --git a/apps/desktop/src-tauri/src/commands/detection.rs b/apps/desktop/src-tauri/src/commands/detection.rs index cf6d945..050d22d 100644 --- a/apps/desktop/src-tauri/src/commands/detection.rs +++ b/apps/desktop/src-tauri/src/commands/detection.rs @@ -20,7 +20,10 @@ pub async fn detect_clients() -> Result, String> { let detected = adapter.detect(); let (server_count, server_names) = if detected { match adapter.read_servers() { - Ok(servers) => (servers.len(), servers.iter().map(|s| s.name.clone()).collect()), + Ok(servers) => ( + servers.len(), + servers.iter().map(|s| s.name.clone()).collect(), + ), Err(_) => (0, Vec::new()), } } else { @@ -38,7 +41,9 @@ pub async fn detect_clients() -> Result, String> { display_name: adapter.display_name().to_string(), icon: adapter.icon().to_string(), detected, - config_path: adapter.config_path().map(|p| p.to_string_lossy().to_string()), + config_path: adapter + .config_path() + .map(|p| p.to_string_lossy().to_string()), server_count, server_names, last_synced_at, diff --git a/apps/desktop/src-tauri/src/commands/import.rs b/apps/desktop/src-tauri/src/commands/import.rs index 121dc5b..cba3a76 100644 --- a/apps/desktop/src-tauri/src/commands/import.rs +++ b/apps/desktop/src-tauri/src/commands/import.rs @@ -3,11 +3,14 @@ use crate::config::{self, ImportResult}; #[tauri::command] pub async fn import_from_client(client_id: String) -> Result { - let adapter = clients::get_adapter(&client_id) - .ok_or_else(|| format!("Unknown client: {}", client_id))?; + let adapter = + clients::get_adapter(&client_id).ok_or_else(|| format!("Unknown client: {}", client_id))?; if !adapter.detect() { - return Err(format!("Client '{}' is not installed or not detected", client_id)); + return Err(format!( + "Client '{}' is not installed or not detected", + client_id + )); } let client_servers = adapter.read_servers().map_err(|e| e.to_string())?; @@ -18,9 +21,10 @@ pub async fn import_from_client(client_id: String) -> Result Option { } // Strategy 2: Try well-known icon file names derived from the app name - let app_name = bundle - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or(""); + let app_name = bundle.file_stem().and_then(|s| s.to_str()).unwrap_or(""); if !app_name.is_empty() { let candidate = resources.join(format!("{}.icns", app_name)); if candidate.exists() { @@ -260,7 +257,10 @@ fn try_icon_from_url_domain(url: &str) -> Option { // Use Google's favicon service for the domain // This returns a real favicon for any public domain let base_domain = extract_base_domain(host); - Some(format!("https://www.google.com/s2/favicons?domain={}&sz=64", base_domain)) + Some(format!( + "https://www.google.com/s2/favicons?domain={}&sz=64", + base_domain + )) } /// Extract the registrable domain from a hostname diff --git a/apps/desktop/src-tauri/src/commands/oauth.rs b/apps/desktop/src-tauri/src/commands/oauth.rs index 90f92c8..0c6859d 100644 --- a/apps/desktop/src-tauri/src/commands/oauth.rs +++ b/apps/desktop/src-tauri/src/commands/oauth.rs @@ -33,10 +33,10 @@ pub async fn start_oauth_flow( /// Check the authentication status for a server. #[tauri::command] pub async fn check_auth_status(server_id: String) -> Result { - let username = format!("{}:oauth_token", server_id); - let entry = keyring::Entry::new("conductor", &username).map_err(|e| e.to_string())?; - - let authenticated = entry.get_password().is_ok(); + let authenticated = crate::oauth::get_valid_oauth_token(&server_id) + .await + .map(|token| token.is_some()) + .unwrap_or(false); let provider = if authenticated { let provider_key = format!("{}:oauth_provider", server_id); diff --git a/apps/desktop/src-tauri/src/commands/registry.rs b/apps/desktop/src-tauri/src/commands/registry.rs index 7bdbb49..b19fa5b 100644 --- a/apps/desktop/src-tauri/src/commands/registry.rs +++ b/apps/desktop/src-tauri/src/commands/registry.rs @@ -50,14 +50,16 @@ pub struct RegistryServer { impl From for RegistryServer { fn from(raw: RawRegistryServer) -> Self { let qn = raw.qualified_name.unwrap_or_default(); - let dn = raw.display_name.unwrap_or_else(|| { - qn.split('/').last().unwrap_or(&qn).to_string() - }); + let dn = raw + .display_name + .unwrap_or_else(|| qn.split('/').last().unwrap_or(&qn).to_string()); RegistryServer { id: qn.clone(), qualified_name: qn, display_name: dn, - description: raw.description.unwrap_or_else(|| "No description".to_string()), + description: raw + .description + .unwrap_or_else(|| "No description".to_string()), icon_url: raw.icon_url, homepage: raw.homepage, verified: raw.verified.unwrap_or(false), @@ -101,10 +103,7 @@ pub async fn get_popular_servers() -> Result, String> { .map_err(|e| format!("Failed to query registry: {}", e))?; if !response.status().is_success() { - return Err(format!( - "Registry returned status {}", - response.status() - )); + return Err(format!("Registry returned status {}", response.status())); } let body = response.text().await.map_err(|e| e.to_string())?; @@ -140,23 +139,21 @@ pub async fn search_registry(query: String) -> Result, Strin .map_err(|e| format!("Failed to query registry: {}", e))?; if !response.status().is_success() { - return Err(format!( - "Registry returned status {}", - response.status() - )); + return Err(format!("Registry returned status {}", response.status())); } let body = response.text().await.map_err(|e| e.to_string())?; // Try parsing as the expected { servers: [...] } wrapper - let raw_servers: Vec = match serde_json::from_str::(&body) { - Ok(resp) => resp.servers, - Err(_) => { - // Try parsing as a direct array - serde_json::from_str(&body) - .map_err(|e| format!("Failed to parse registry response: {}", e))? - } - }; + let raw_servers: Vec = + match serde_json::from_str::(&body) { + Ok(resp) => resp.servers, + Err(_) => { + // Try parsing as a direct array + serde_json::from_str(&body) + .map_err(|e| format!("Failed to parse registry response: {}", e))? + } + }; Ok(raw_servers.into_iter().map(RegistryServer::from).collect()) } @@ -206,61 +203,69 @@ pub async fn install_from_registry(registry_id: String) -> Result { - let pkg_name = &server_info.qualified_name; - let pkg = if pkg_name.is_empty() { ®istry_id } else { pkg_name }; - ( - TransportType::Stdio, - Some("npx".to_string()), - vec![ - "-y".to_string(), - "@smithery/cli@latest".to_string(), - "run".to_string(), - pkg.to_string(), - ], - None, - ) - } - Some("sse") | Some("streamable-http") => { - let t = if conn.connection_type.as_deref() == Some("streamable-http") { - TransportType::StreamableHttp - } else { - TransportType::Sse - }; - (t, None, Vec::new(), conn.url.clone()) - } - _ => { - let pkg_name = &server_info.qualified_name; - let pkg = if pkg_name.is_empty() { ®istry_id } else { pkg_name }; - ( - TransportType::Stdio, - Some("npx".to_string()), - vec![ - "-y".to_string(), - "@smithery/cli@latest".to_string(), - "run".to_string(), - pkg.to_string(), - ], - None, - ) - } + let (transport, command, args, server_url) = if let Some(conn) = server_info.connections.first() + { + match conn.connection_type.as_deref() { + Some("stdio") => { + let pkg_name = &server_info.qualified_name; + let pkg = if pkg_name.is_empty() { + ®istry_id + } else { + pkg_name + }; + ( + TransportType::Stdio, + Some("npx".to_string()), + vec![ + "-y".to_string(), + "@smithery/cli@latest".to_string(), + "run".to_string(), + pkg.to_string(), + ], + None, + ) } - } else { - ( - TransportType::Stdio, - Some("npx".to_string()), - vec![ - "-y".to_string(), - "@smithery/cli@latest".to_string(), - "run".to_string(), - registry_id.clone(), - ], - None, - ) - }; + Some("sse") | Some("streamable-http") => { + let t = if conn.connection_type.as_deref() == Some("streamable-http") { + TransportType::StreamableHttp + } else { + TransportType::Sse + }; + (t, None, Vec::new(), conn.url.clone()) + } + _ => { + let pkg_name = &server_info.qualified_name; + let pkg = if pkg_name.is_empty() { + ®istry_id + } else { + pkg_name + }; + ( + TransportType::Stdio, + Some("npx".to_string()), + vec![ + "-y".to_string(), + "@smithery/cli@latest".to_string(), + "run".to_string(), + pkg.to_string(), + ], + None, + ) + } + } + } else { + ( + TransportType::Stdio, + Some("npx".to_string()), + vec![ + "-y".to_string(), + "@smithery/cli@latest".to_string(), + "run".to_string(), + registry_id.clone(), + ], + None, + ) + }; // Check for name collision let mut cfg = config::read_config().map_err(|e| e.to_string())?; diff --git a/apps/desktop/src-tauri/src/commands/servers.rs b/apps/desktop/src-tauri/src/commands/servers.rs index b956198..ad7327c 100644 --- a/apps/desktop/src-tauri/src/commands/servers.rs +++ b/apps/desktop/src-tauri/src/commands/servers.rs @@ -1,6 +1,6 @@ -use crate::config::{self, McpServerConfig, TransportType, log_activity}; +use crate::config::{self, log_activity, McpServerConfig, TransportType}; use serde::Deserialize; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; fn now_iso() -> String { chrono::Utc::now().to_rfc3339() @@ -39,7 +39,10 @@ pub async fn add_server(request: AddServerRequest) -> Result Result Result Result Result { +pub async fn update_server( + server_id: String, + request: UpdateServerRequest, +) -> Result { let mut cfg = config::read_config().map_err(|e| e.to_string())?; let server = cfg @@ -137,7 +153,7 @@ pub async fn update_server(server_id: String, request: UpdateServerRequest) -> R server.url = if u.is_empty() { None } else { Some(u) }; } if let Some(sek) = request.secret_env_keys { - server.secret_env_keys = sek; + server.secret_env_keys = normalize_secret_env_keys(&sek); } if let Some(iu) = request.icon_url { server.icon_url = if iu.is_empty() { None } else { Some(iu) }; @@ -146,6 +162,9 @@ pub async fn update_server(server_id: String, request: UpdateServerRequest) -> R server.enabled = en; } + let normalized_secret_keys = normalize_secret_env_keys(&server.secret_env_keys); + validate_secret_env_keys(&server.id, &server.env, &normalized_secret_keys)?; + server.secret_env_keys = normalized_secret_keys; server.updated_at = Some(now_iso()); let updated = server.clone(); @@ -171,7 +190,13 @@ pub async fn delete_server(server_id: String) -> Result<(), String> { config::write_config(&cfg).map_err(|e| e.to_string())?; - log_activity("delete", &format!("Deleted server {}", server_id), None, None, Some(server_id)); + log_activity( + "delete", + &format!("Deleted server {}", server_id), + None, + None, + Some(server_id), + ); Ok(()) } @@ -192,7 +217,63 @@ pub async fn toggle_server(server_id: String, enabled: bool) -> Result Vec { + let mut seen = HashSet::new(); + let mut normalized = Vec::new(); + + for key in keys { + let trimmed = key.trim(); + if trimmed.is_empty() { + continue; + } + if seen.insert(trimmed.to_string()) { + normalized.push(trimmed.to_string()); + } + } + + normalized.sort(); + normalized +} + +fn validate_secret_env_keys( + server_id: &str, + env: &HashMap, + secret_env_keys: &[String], +) -> Result<(), String> { + let missing: Vec = secret_env_keys + .iter() + .filter(|key| { + !env.contains_key((*key).as_str()) && !secret_exists_in_keychain(server_id, key) + }) + .cloned() + .collect(); + + if !missing.is_empty() { + return Err(format!( + "secretEnvKeys contains keys without values in env or keychain: {}", + missing.join(", ") + )); + } + + Ok(()) +} + +fn secret_exists_in_keychain(server_id: &str, key: &str) -> bool { + let username = format!("{}:{}", server_id, key); + keyring::Entry::new("conductor", &username) + .ok() + .and_then(|entry| entry.get_password().ok()) + .map(|v| !v.trim().is_empty()) + .unwrap_or(false) +} diff --git a/apps/desktop/src-tauri/src/commands/stacks.rs b/apps/desktop/src-tauri/src/commands/stacks.rs index 361253f..a06c9ce 100644 --- a/apps/desktop/src-tauri/src/commands/stacks.rs +++ b/apps/desktop/src-tauri/src/commands/stacks.rs @@ -1,5 +1,6 @@ use crate::config::{self, McpServerConfig}; use serde::{Deserialize, Serialize}; +use std::collections::HashSet; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -28,13 +29,29 @@ pub async fn export_stack( .filter(|s| server_ids.contains(&s.id)) .cloned() .map(|mut s| { - // Strip secrets and sensitive env vars before export - for key in &s.secret_env_keys { - s.env.remove(key); + // Strip secrets and sensitive env vars before export. + // This is defensive: if users forgot to mark a key as secret, + // we still redact obvious credential-like values. + let mut secret_keys: HashSet = s.secret_env_keys.iter().cloned().collect(); + let env_keys: Vec = s.env.keys().cloned().collect(); + for key in env_keys { + let redact = secret_keys.contains(&key) + || looks_sensitive_env_key(&key) + || s.env + .get(&key) + .map(|v| looks_sensitive_env_value(v)) + .unwrap_or(false); + if redact { + s.env.remove(&key); + secret_keys.insert(key); + } } + s.secret_env_keys = secret_keys.into_iter().collect(); + s.secret_env_keys.sort(); // Generate fresh IDs for exported servers s.id = uuid::Uuid::new_v4().to_string(); s.source = Some("stack".to_string()); + s.registry_id = None; s }) .collect(); @@ -120,7 +137,10 @@ pub async fn delete_saved_stack(stack_id: String) -> Result<(), String> { /// Fetch a stack from a URL and return it. #[tauri::command] pub async fn get_stack_from_url(url: String) -> Result { - let client = reqwest::Client::new(); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .map_err(|e| e.to_string())?; let response = client .get(&url) .header("Accept", "application/json") @@ -138,3 +158,42 @@ pub async fn get_stack_from_url(url: String) -> Result { Ok(stack) } + +fn looks_sensitive_env_key(key: &str) -> bool { + let upper = key.to_ascii_uppercase(); + let sensitive_markers = [ + "SECRET", + "TOKEN", + "PASSWORD", + "PRIVATE", + "API_KEY", + "ACCESS_KEY", + "AUTH", + "CREDENTIAL", + ]; + sensitive_markers + .iter() + .any(|marker| upper.contains(marker)) +} + +fn looks_sensitive_env_value(value: &str) -> bool { + let trimmed = value.trim(); + if trimmed.is_empty() || trimmed.len() < 20 { + return false; + } + + let looks_structured_secret = trimmed.starts_with("sk-") + || trimmed.starts_with("ghp_") + || trimmed.starts_with("github_pat_") + || trimmed.starts_with("xox") + || trimmed.starts_with("AIza") + || trimmed.starts_with("ya29.") + || trimmed.starts_with("Bearer "); + + let no_spaces = !trimmed.contains(char::is_whitespace); + let high_entropy_hint = trimmed.chars().any(|c| c.is_ascii_digit()) + && trimmed.chars().any(|c| c.is_ascii_uppercase()) + && trimmed.chars().any(|c| c.is_ascii_lowercase()); + + looks_structured_secret || (no_spaces && high_entropy_hint) +} diff --git a/apps/desktop/src-tauri/src/commands/sync.rs b/apps/desktop/src-tauri/src/commands/sync.rs index 7cada22..7f78ac8 100644 --- a/apps/desktop/src-tauri/src/commands/sync.rs +++ b/apps/desktop/src-tauri/src/commands/sync.rs @@ -1,19 +1,25 @@ use crate::clients; -use crate::config::{self, McpServerConfig, SyncResult}; +use crate::config::{self, backup, McpServerConfig, SyncResult}; +use std::collections::HashSet; +use std::path::Path; #[tauri::command] pub async fn sync_to_client( client_id: String, server_ids: Option>, ) -> Result { - let adapter = clients::get_adapter(&client_id) - .ok_or_else(|| format!("Unknown client: {}", client_id))?; + let adapter = + clients::get_adapter(&client_id).ok_or_else(|| format!("Unknown client: {}", client_id))?; let cfg = config::read_config().map_err(|e| e.to_string())?; // If no server_ids provided, sync all enabled servers let ids_to_sync = server_ids.unwrap_or_else(|| { - cfg.servers.iter().filter(|s| s.enabled).map(|s| s.id.clone()).collect() + cfg.servers + .iter() + .filter(|s| s.enabled) + .map(|s| s.id.clone()) + .collect() }); let servers_to_sync: Vec = cfg @@ -32,19 +38,45 @@ pub async fn sync_to_client( }); } - // Inject secrets from keychain into env vars - let enriched_servers: Vec = servers_to_sync - .into_iter() - .map(|mut server| { - inject_secrets(&mut server); - server - }) - .collect(); + // Inject secrets from keychain and OAuth access token into env vars. + let mut enriched_servers: Vec = Vec::with_capacity(servers_to_sync.len()); + for mut server in servers_to_sync { + inject_secrets(&mut server) + .await + .map_err(|e| e.to_string())?; + enriched_servers.push(server); + } let count = enriched_servers.len(); + let config_path = adapter.config_path(); + let existing_content = config_path + .as_ref() + .and_then(|path| read_existing_content(path).ok().flatten()); - match adapter.write_servers(&enriched_servers, None) { + match adapter.write_servers(&enriched_servers, existing_content.as_deref()) { Ok(()) => { + if let Err(verify_err) = verify_written_servers(&*adapter, &enriched_servers) { + let rollback_err = + rollback_client_config(config_path.as_ref(), existing_content.as_deref()); + let error = match rollback_err { + Some(rb_err) => format!( + "Sync verification failed: {}. Rollback also failed: {}", + verify_err, rb_err + ), + None => format!( + "Sync verification failed: {}. Rolled back client config.", + verify_err + ), + }; + + return Ok(SyncResult { + client_id, + success: false, + servers_written: 0, + error: Some(error), + }); + } + // Log activity config::log_activity( "sync", @@ -79,12 +111,21 @@ pub async fn sync_to_client( error: None, }) } - Err(e) => Ok(SyncResult { - client_id, - success: false, - servers_written: 0, - error: Some(e.to_string()), - }), + Err(e) => { + let rollback_err = + rollback_client_config(config_path.as_ref(), existing_content.as_deref()); + let error = match rollback_err { + Some(rb_err) => format!("{} (rollback failed: {})", e, rb_err), + None => e.to_string(), + }; + + Ok(SyncResult { + client_id, + success: false, + servers_written: 0, + error: Some(error), + }) + } } } @@ -107,7 +148,8 @@ pub async fn sync_to_all_clients() -> Result, String> { continue; } - let result = sync_to_client(adapter.id().to_string(), Some(enabled_server_ids.clone())).await; + let result = + sync_to_client(adapter.id().to_string(), Some(enabled_server_ids.clone())).await; match result { Ok(r) => results.push(r), Err(e) => results.push(SyncResult { @@ -122,7 +164,7 @@ pub async fn sync_to_all_clients() -> Result, String> { Ok(results) } -fn inject_secrets(server: &mut McpServerConfig) { +async fn inject_secrets(server: &mut McpServerConfig) -> anyhow::Result<()> { // Inject secret env vars from keychain for key in &server.secret_env_keys { let username = format!("{}:{}", server.id, key); @@ -133,17 +175,60 @@ fn inject_secrets(server: &mut McpServerConfig) { } } - // Also inject OAuth token if one exists for this server. - // Many MCP servers expect an OAUTH_TOKEN env var for authentication. - // This ensures users don't need to re-authenticate in each client. - let token_key = format!("{}:oauth_token", server.id); - if let Ok(entry) = keyring::Entry::new("conductor", &token_key) { - if let Ok(token) = entry.get_password() { - // Only inject if the server doesn't already have it as a secret_env_key - // (to avoid overwriting user-specified keys) - if !server.secret_env_keys.iter().any(|k| k == "OAUTH_TOKEN") { - server.env.insert("OAUTH_TOKEN".to_string(), token); - } + // Inject OAuth token if one exists and the server hasn't set OAUTH_TOKEN itself. + // This avoids silently overwriting user-provided env values. + if !server.env.contains_key("OAUTH_TOKEN") { + if let Some(token) = crate::oauth::get_valid_oauth_token(&server.id).await? { + server.env.insert("OAUTH_TOKEN".to_string(), token); + } + } + + Ok(()) +} + +fn verify_written_servers( + adapter: &dyn crate::clients::ClientAdapter, + expected_servers: &[McpServerConfig], +) -> anyhow::Result<()> { + let actual_servers = adapter.read_servers()?; + let actual_names: HashSet<&str> = actual_servers.iter().map(|s| s.name.as_str()).collect(); + + for server in expected_servers { + if !actual_names.contains(server.name.as_str()) { + anyhow::bail!( + "Client config verification failed for '{}': missing server '{}'", + adapter.id(), + server.name + ); } } + + Ok(()) +} + +fn read_existing_content(path: &Path) -> anyhow::Result> { + if !path.exists() { + return Ok(None); + } + let content = std::fs::read_to_string(path)?; + Ok(Some(content)) +} + +fn rollback_client_config( + path: Option<&std::path::PathBuf>, + previous_content: Option<&str>, +) -> Option { + let Some(path) = path else { + return None; + }; + + let result = if let Some(content) = previous_content { + backup::atomic_write(path, content) + } else if path.exists() { + std::fs::remove_file(path).map_err(anyhow::Error::from) + } else { + Ok(()) + }; + + result.err().map(|e| e.to_string()) } diff --git a/apps/desktop/src-tauri/src/config/backup.rs b/apps/desktop/src-tauri/src/config/backup.rs index fa8cefa..3cc3ee7 100644 --- a/apps/desktop/src-tauri/src/config/backup.rs +++ b/apps/desktop/src-tauri/src/config/backup.rs @@ -7,9 +7,11 @@ use std::path::Path; /// 2. If the target file exists, creates a timestamped .bak backup. /// 3. Renames the temporary file to the target path. pub fn atomic_write(path: &Path, content: &str) -> Result<()> { - let parent = path - .parent() - .ok_or_else(|| anyhow::anyhow!("Cannot determine parent directory of {}", path.display()))?; + let _write_guard = crate::file_guard::acquire_internal_write(path)?; + + let parent = path.parent().ok_or_else(|| { + anyhow::anyhow!("Cannot determine parent directory of {}", path.display()) + })?; // Ensure parent directory exists if !parent.exists() { @@ -33,10 +35,7 @@ pub fn atomic_write(path: &Path, content: &str) -> Result<()> { .file_stem() .and_then(|s| s.to_str()) .unwrap_or("config"); - let extension = path - .extension() - .and_then(|s| s.to_str()) - .unwrap_or("json"); + let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("json"); let backup_name = format!("{}_{}.{}.bak", file_stem, timestamp, extension); let backup_path = parent.join(backup_name); @@ -47,6 +46,12 @@ pub fn atomic_write(path: &Path, content: &str) -> Result<()> { e ); // Non-fatal: we still proceed with the write + } else if let Err(e) = std::fs::read(&backup_path) { + eprintln!( + "Warning: Backup verification failed at {}: {}", + backup_path.display(), + e + ); } // Clean up old backups (keep last 5) diff --git a/apps/desktop/src-tauri/src/config/mod.rs b/apps/desktop/src-tauri/src/config/mod.rs index 1a07bff..459c42d 100644 --- a/apps/desktop/src-tauri/src/config/mod.rs +++ b/apps/desktop/src-tauri/src/config/mod.rs @@ -5,6 +5,8 @@ pub mod serializer; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +const ACTIVITY_RETENTION_DAYS: i64 = 30; + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub enum TransportType { @@ -93,9 +95,15 @@ pub struct AppSettings { pub error_notifications: bool, } -fn default_true() -> bool { true } -fn default_sync_delay() -> u32 { 5 } -fn default_backup_retention() -> u32 { 30 } +fn default_true() -> bool { + true +} +fn default_sync_delay() -> u32 { + 5 +} +fn default_backup_retention() -> u32 { + 30 +} impl Default for AppSettings { fn default() -> Self { @@ -167,7 +175,8 @@ pub struct ImportResult { /// Returns the path to the Conductor master config file. pub fn master_config_path() -> anyhow::Result { - let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?; + let home = + dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?; let config_dir = home.join(".conductor"); if !config_dir.exists() { std::fs::create_dir_all(&config_dir)?; @@ -213,6 +222,7 @@ pub fn log_activity( server_id, }; cfg.activity.push(entry); + prune_activity_entries(&mut cfg.activity); // Cap at 200 entries, remove oldest first if cfg.activity.len() > 200 { let drain = cfg.activity.len() - 200; @@ -221,3 +231,14 @@ pub fn log_activity( let _ = write_config(&cfg); } } + +pub fn prune_activity_entries(entries: &mut Vec) -> usize { + let cutoff = chrono::Utc::now() - chrono::Duration::days(ACTIVITY_RETENTION_DAYS); + let before = entries.len(); + entries.retain(|entry| { + chrono::DateTime::parse_from_rfc3339(&entry.timestamp) + .map(|ts| ts.with_timezone(&chrono::Utc) >= cutoff) + .unwrap_or(true) + }); + before.saturating_sub(entries.len()) +} diff --git a/apps/desktop/src-tauri/src/config/normalizer.rs b/apps/desktop/src-tauri/src/config/normalizer.rs index 8d6fb76..0ea56be 100644 --- a/apps/desktop/src-tauri/src/config/normalizer.rs +++ b/apps/desktop/src-tauri/src/config/normalizer.rs @@ -194,10 +194,7 @@ fn parse_jetbrains_config(raw: &str) -> Result> { || node.tag_name().name() == "server" || node.tag_name().name() == "mcpServer" { - let name = node - .attribute("name") - .unwrap_or("unnamed") - .to_string(); + let name = node.attribute("name").unwrap_or("unnamed").to_string(); let command = node.attribute("command").map(|s| s.to_string()); let args_str = node.attribute("args").unwrap_or(""); let args: Vec = if args_str.is_empty() { @@ -266,7 +263,9 @@ fn parse_codex_config(raw: &str) -> Result> { if let Some(mcp_item) = value.get("mcp_servers") { if let Some(mcp_table) = mcp_item.as_table() { // Check if this is a table of subtables (named format) vs other - let has_subtables = mcp_table.iter().any(|(_, v)| v.is_table() || v.is_inline_table()); + let has_subtables = mcp_table + .iter() + .any(|(_, v)| v.is_table() || v.is_inline_table()); if has_subtables { for (name, server_item) in mcp_table.iter() { if let Some(table) = server_item.as_table() { diff --git a/apps/desktop/src-tauri/src/config/serializer.rs b/apps/desktop/src-tauri/src/config/serializer.rs index b88716a..6c55cb7 100644 --- a/apps/desktop/src-tauri/src/config/serializer.rs +++ b/apps/desktop/src-tauri/src/config/serializer.rs @@ -75,7 +75,11 @@ fn serialize_vscode(servers: &[McpServerConfig], existing_content: Option<&str>) // Merge: keep client-specific servers, add/overwrite Conductor servers let mut merged = serde_json::Map::new(); - if let Some(existing) = root.get("mcp").and_then(|m| m.get("servers")).and_then(|v| v.as_object()) { + if let Some(existing) = root + .get("mcp") + .and_then(|m| m.get("servers")) + .and_then(|v| v.as_object()) + { for (name, value) in existing { if !conductor_names.contains(name.as_str()) { merged.insert(name.clone(), value.clone()); @@ -92,7 +96,10 @@ fn serialize_vscode(servers: &[McpServerConfig], existing_content: Option<&str>) } /// VS Code mcp.json format: top-level "servers" key. -fn serialize_vscode_mcp(servers: &[McpServerConfig], existing_content: Option<&str>) -> Result { +fn serialize_vscode_mcp( + servers: &[McpServerConfig], + existing_content: Option<&str>, +) -> Result { let mut root: serde_json::Value = if let Some(content) = existing_content { serde_json::from_str(content).unwrap_or_else(|_| serde_json::json!({})) } else { @@ -183,7 +190,11 @@ fn serialize_jetbrains(servers: &[McpServerConfig]) -> Result { // Write XML declaration writer - .write_event(Event::Decl(quick_xml::events::BytesDecl::new("1.0", Some("UTF-8"), None))) + .write_event(Event::Decl(quick_xml::events::BytesDecl::new( + "1.0", + Some("UTF-8"), + None, + ))) .context("Failed to write XML declaration")?; // Root element @@ -316,7 +327,9 @@ fn serialize_codex(servers: &[McpServerConfig], existing_content: Option<&str>) } /// Convert a slice of server configs into a JSON object for standard formats. -fn servers_to_json_object(servers: &[McpServerConfig]) -> serde_json::Map { +fn servers_to_json_object( + servers: &[McpServerConfig], +) -> serde_json::Map { let mut map = serde_json::Map::new(); for server in servers { diff --git a/apps/desktop/src-tauri/src/file_guard.rs b/apps/desktop/src-tauri/src/file_guard.rs new file mode 100644 index 0000000..401fa65 --- /dev/null +++ b/apps/desktop/src-tauri/src/file_guard.rs @@ -0,0 +1,81 @@ +use anyhow::Result; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::{Condvar, LazyLock, Mutex}; +use std::time::{Duration, Instant}; + +const SUPPRESSION_WINDOW: Duration = Duration::from_secs(2); + +struct GuardState { + active_writes: HashSet, + suppressed_until: HashMap, +} + +impl GuardState { + fn new() -> Self { + Self { + active_writes: HashSet::new(), + suppressed_until: HashMap::new(), + } + } + + fn cleanup_expired(&mut self) { + let now = Instant::now(); + self.suppressed_until.retain(|_, until| *until > now); + } +} + +static WRITE_STATE: LazyLock<(Mutex, Condvar)> = + LazyLock::new(|| (Mutex::new(GuardState::new()), Condvar::new())); + +pub struct InternalWriteGuard { + path: PathBuf, +} + +impl Drop for InternalWriteGuard { + fn drop(&mut self) { + let (lock, cvar) = &*WRITE_STATE; + if let Ok(mut state) = lock.lock() { + state.active_writes.remove(&self.path); + state + .suppressed_until + .insert(self.path.clone(), Instant::now() + SUPPRESSION_WINDOW); + cvar.notify_all(); + } + } +} + +/// Acquire an exclusive write lock for a config path. +/// This serializes Conductor-owned writes to the same file and lets the watcher +/// suppress self-generated file events. +pub fn acquire_internal_write(path: &Path) -> Result { + let canonical = path.to_path_buf(); + let (lock, cvar) = &*WRITE_STATE; + + let mut state = lock + .lock() + .map_err(|_| anyhow::anyhow!("Failed to lock internal write state"))?; + + loop { + state.cleanup_expired(); + if !state.active_writes.contains(&canonical) { + state.active_writes.insert(canonical.clone()); + return Ok(InternalWriteGuard { path: canonical }); + } + state = cvar + .wait(state) + .map_err(|_| anyhow::anyhow!("Failed waiting for internal write lock"))?; + } +} + +/// Returns true if the path is currently being written by Conductor or +/// has just been written and should be ignored by the watcher. +pub fn is_internal_write(path: &Path) -> bool { + let (lock, _) = &*WRITE_STATE; + let Ok(mut state) = lock.lock() else { + return false; + }; + state.cleanup_expired(); + let key = path.to_path_buf(); + state.active_writes.contains(&key) || state.suppressed_until.contains_key(&key) +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 7c667af..6a8d410 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ pub mod clients; pub mod commands; pub mod config; pub mod errors; +pub mod file_guard; pub mod oauth; pub mod watcher; @@ -70,27 +71,35 @@ pub fn run() { // Setup system tray let tray = app.tray_by_id("main"); if let Some(tray) = tray { - let show_item = - tauri::menu::MenuItem::with_id(app, "show", "Show Conductor", true, None::<&str>)?; - let quit_item = - tauri::menu::MenuItem::with_id(app, "quit", "Quit Conductor", true, None::<&str>)?; + let show_item = tauri::menu::MenuItem::with_id( + app, + "show", + "Show Conductor", + true, + None::<&str>, + )?; + let quit_item = tauri::menu::MenuItem::with_id( + app, + "quit", + "Quit Conductor", + true, + None::<&str>, + )?; let menu = tauri::menu::Menu::with_items(app, &[&show_item, &quit_item])?; tray.set_menu(Some(menu))?; let app_handle = app.handle().clone(); - tray.on_menu_event(move |_tray, event| { - match event.id().as_ref() { - "show" => { - if let Some(window) = app_handle.get_webview_window("main") { - let _ = window.show(); - let _ = window.set_focus(); - } + tray.on_menu_event(move |_tray, event| match event.id().as_ref() { + "show" => { + if let Some(window) = app_handle.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); } - "quit" => { - std::process::exit(0); - } - _ => {} } + "quit" => { + std::process::exit(0); + } + _ => {} }); } diff --git a/apps/desktop/src-tauri/src/oauth/mod.rs b/apps/desktop/src-tauri/src/oauth/mod.rs index bf1d83f..320f562 100644 --- a/apps/desktop/src-tauri/src/oauth/mod.rs +++ b/apps/desktop/src-tauri/src/oauth/mod.rs @@ -3,22 +3,60 @@ use axum::extract::Query; use axum::response::Html; use axum::routing::get; use axum::Router; +use chrono::{DateTime, Duration as ChronoDuration, Utc}; +use serde_json::Value; use std::collections::HashMap; use std::sync::Arc; use tauri::Emitter; use tokio::sync::oneshot; use tokio::time::{timeout, Duration}; +use url::Url; + +#[derive(Debug, Clone)] +enum TokenRequestStyle { + Form, + JsonBasicAuth, +} + +#[derive(Debug, Clone)] +struct ProviderSpec { + auth_url: String, + token_url: String, + scope: Option, + auth_extra_params: Vec<(String, String)>, + token_request_style: TokenRequestStyle, +} + +#[derive(Debug, Clone)] +struct OAuthClientCredentials { + client_id: String, + client_secret: String, +} + +#[derive(Debug, Clone)] +struct OAuthCallbackContext { + server_id: String, + provider: String, + expected_state: String, + redirect_uri: String, + provider_spec: ProviderSpec, + credentials: OAuthClientCredentials, +} + +#[derive(Debug, Clone)] +struct OAuthTokenBundle { + access_token: String, + refresh_token: Option, + expires_at: Option>, +} /// Start a temporary OAuth callback server on a random port. /// Returns the authorization URL that the caller should open in a browser. -/// The server listens for the callback, stores tokens in keychain, -/// emits a Tauri event, and shuts down after callback or 5-minute timeout. pub async fn start_oauth_server( app_handle: tauri::AppHandle, server_id: &str, provider: &str, ) -> Result { - // Bind to a random available port let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .context("Failed to bind to random port")?; @@ -28,35 +66,37 @@ pub async fn start_oauth_server( .port(); let redirect_uri = format!("http://localhost:{}/callback", port); + let provider_spec = provider_spec(provider)?; + let credentials = resolve_client_credentials(server_id, provider)?; + let state = uuid::Uuid::new_v4().to_string(); + let auth_url = build_auth_url(&provider_spec, &credentials, &redirect_uri, &state)?; - // Build the authorization URL based on the provider - let auth_url = build_auth_url(provider, &redirect_uri)?; - - let server_id_owned = server_id.to_string(); - let provider_owned = provider.to_string(); + let callback_ctx = OAuthCallbackContext { + server_id: server_id.to_string(), + provider: provider.to_string(), + expected_state: state, + redirect_uri, + provider_spec, + credentials, + }; - // Create a shutdown channel let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); let shutdown_tx = Arc::new(tokio::sync::Mutex::new(Some(shutdown_tx))); - let server_id_for_handler = server_id_owned.clone(); - let provider_for_handler = provider_owned.clone(); let app_handle_for_handler = app_handle.clone(); + let callback_ctx_for_handler = callback_ctx.clone(); let shutdown_for_handler = shutdown_tx.clone(); - // Build the axum router with a single callback route let app = Router::new().route( "/callback", get(move |query: Query>| { - let server_id = server_id_for_handler.clone(); - let provider = provider_for_handler.clone(); let app_handle = app_handle_for_handler.clone(); + let callback_ctx = callback_ctx_for_handler.clone(); let shutdown = shutdown_for_handler.clone(); async move { - let result = handle_callback(&server_id, &provider, &query, &app_handle).await; + let result = handle_callback(&callback_ctx, &query, &app_handle).await; - // Trigger shutdown after handling the callback if let Some(tx) = shutdown.lock().await.take() { let _ = tx.send(()); } @@ -69,76 +109,139 @@ pub async fn start_oauth_server( }), ); - // Spawn the server with a 5-minute timeout tokio::spawn(async move { let server = axum::serve(listener, app); - let graceful = server.with_graceful_shutdown(async { let _ = shutdown_rx.await; }); - - // 5-minute timeout — pass the future itself, not an awaited value let _ = timeout(Duration::from_secs(300), graceful).await; }); Ok(auth_url) } -/// Build the authorization URL for the given provider. -fn build_auth_url(provider: &str, redirect_uri: &str) -> Result { - let encoded_redirect = urlencoding::encode(redirect_uri); +pub async fn get_valid_oauth_token(server_id: &str) -> Result> { + let current_token = get_keyring_value(server_id, "oauth_token"); + let Some(token) = current_token else { + return Ok(None); + }; - let url = match provider { - "github" => { - format!( - "https://github.com/login/oauth/authorize?client_id=conductor_app&redirect_uri={}&scope=repo", - encoded_redirect - ) - } - "google" => { - format!( - "https://accounts.google.com/o/oauth2/v2/auth?client_id=conductor_app&redirect_uri={}&response_type=code&scope=openid+email+profile", - encoded_redirect - ) - } - "notion" => { - format!( - "https://api.notion.com/v1/oauth/authorize?client_id=conductor_app&redirect_uri={}&response_type=code", - encoded_redirect - ) - } - "slack" => { - format!( - "https://slack.com/oauth/v2/authorize?client_id=conductor_app&redirect_uri={}&scope=chat:write", - encoded_redirect - ) - } - "linear" => { - format!( - "https://linear.app/oauth/authorize?client_id=conductor_app&redirect_uri={}&response_type=code&scope=read", - encoded_redirect - ) - } + let expires_at = get_keyring_value(server_id, "oauth_expires") + .as_deref() + .and_then(parse_rfc3339_utc); + + // Keep a small leeway to avoid pushing a nearly-expired token to clients. + let refresh_needed = expires_at + .map(|ts| ts <= Utc::now() + ChronoDuration::seconds(60)) + .unwrap_or(false); + + if !refresh_needed { + return Ok(Some(token)); + } + + let refreshed = refresh_access_token(server_id).await?; + Ok(Some(refreshed.access_token)) +} + +fn provider_spec(provider: &str) -> Result { + let normalized = provider.trim().to_lowercase(); + let spec = match normalized.as_str() { + "github" => ProviderSpec { + auth_url: "https://github.com/login/oauth/authorize".to_string(), + token_url: "https://github.com/login/oauth/access_token".to_string(), + scope: Some("repo".to_string()), + auth_extra_params: vec![], + token_request_style: TokenRequestStyle::Form, + }, + "google" => ProviderSpec { + auth_url: "https://accounts.google.com/o/oauth2/v2/auth".to_string(), + token_url: "https://oauth2.googleapis.com/token".to_string(), + scope: Some("openid email profile".to_string()), + auth_extra_params: vec![ + ("access_type".to_string(), "offline".to_string()), + ("prompt".to_string(), "consent".to_string()), + ], + token_request_style: TokenRequestStyle::Form, + }, + "notion" => ProviderSpec { + auth_url: "https://api.notion.com/v1/oauth/authorize".to_string(), + token_url: "https://api.notion.com/v1/oauth/token".to_string(), + scope: None, + auth_extra_params: vec![], + token_request_style: TokenRequestStyle::JsonBasicAuth, + }, + "slack" => ProviderSpec { + auth_url: "https://slack.com/oauth/v2/authorize".to_string(), + token_url: "https://slack.com/api/oauth.v2.access".to_string(), + scope: Some("chat:write".to_string()), + auth_extra_params: vec![], + token_request_style: TokenRequestStyle::Form, + }, + "linear" => ProviderSpec { + auth_url: "https://linear.app/oauth/authorize".to_string(), + token_url: "https://api.linear.app/oauth/token".to_string(), + scope: Some("read".to_string()), + auth_extra_params: vec![], + token_request_style: TokenRequestStyle::Form, + }, _ => { - // Generic OAuth URL — the provider name is used as a base URL hint - format!( - "https://{}/oauth/authorize?client_id=conductor_app&redirect_uri={}&response_type=code", - provider, encoded_redirect - ) + let base = if normalized.starts_with("http://") || normalized.starts_with("https://") { + normalized + } else { + format!("https://{}", normalized) + }; + let mut auth_url = Url::parse(&base) + .with_context(|| format!("Invalid provider URL '{}'", provider))?; + auth_url.set_path("/oauth/authorize"); + let mut token_url = Url::parse(&base) + .with_context(|| format!("Invalid provider URL '{}'", provider))?; + token_url.set_path("/oauth/token"); + + ProviderSpec { + auth_url: auth_url.to_string(), + token_url: token_url.to_string(), + scope: None, + auth_extra_params: vec![], + token_request_style: TokenRequestStyle::Form, + } } }; - Ok(url) + Ok(spec) +} + +fn build_auth_url( + provider_spec: &ProviderSpec, + credentials: &OAuthClientCredentials, + redirect_uri: &str, + state: &str, +) -> Result { + let mut url = Url::parse(&provider_spec.auth_url) + .with_context(|| format!("Invalid OAuth auth URL '{}'", provider_spec.auth_url))?; + + { + let mut pairs = url.query_pairs_mut(); + pairs.append_pair("client_id", &credentials.client_id); + pairs.append_pair("redirect_uri", redirect_uri); + pairs.append_pair("response_type", "code"); + pairs.append_pair("state", state); + + if let Some(scope) = &provider_spec.scope { + pairs.append_pair("scope", scope); + } + for (k, v) in &provider_spec.auth_extra_params { + pairs.append_pair(k, v); + } + } + + Ok(url.to_string()) } -/// Handle the OAuth callback: extract code/token, store in keychain, emit event. async fn handle_callback( - server_id: &str, - provider: &str, + ctx: &OAuthCallbackContext, query: &HashMap, app_handle: &tauri::AppHandle, ) -> Result<()> { - // Check for error response if let Some(error) = query.get("error") { let description = query .get("error_description") @@ -147,39 +250,33 @@ async fn handle_callback( anyhow::bail!("OAuth error: {}", description); } - // Get the authorization code + let returned_state = query + .get("state") + .ok_or_else(|| anyhow::anyhow!("Missing OAuth state in callback"))?; + if returned_state != &ctx.expected_state { + anyhow::bail!("OAuth state mismatch"); + } + let code = query .get("code") .ok_or_else(|| anyhow::anyhow!("No authorization code in callback"))?; - // Store the auth code as the token (in a real implementation, - // you would exchange this for an access token) - let token_key = format!("{}:oauth_token", server_id); - let entry = keyring::Entry::new("conductor", &token_key) - .context("Failed to create keyring entry")?; - entry - .set_password(code) - .context("Failed to store token in keychain")?; - - // Store the provider - let provider_key = format!("{}:oauth_provider", server_id); - if let Ok(entry) = keyring::Entry::new("conductor", &provider_key) { - let _ = entry.set_password(provider); - } + let bundle = exchange_code_for_tokens( + &ctx.provider_spec, + &ctx.credentials, + code, + &ctx.redirect_uri, + Some(&ctx.expected_state), + ) + .await?; - // Store expiry (default: 1 hour from now) - let expires_at = chrono::Utc::now() + chrono::Duration::hours(1); - let expires_key = format!("{}:oauth_expires", server_id); - if let Ok(entry) = keyring::Entry::new("conductor", &expires_key) { - let _ = entry.set_password(&expires_at.to_rfc3339()); - } + store_oauth_bundle(&ctx.server_id, &ctx.provider, bundle)?; - // Emit Tauri event let _ = app_handle.emit( "oauth-callback-received", serde_json::json!({ - "serverId": server_id, - "provider": provider, + "serverId": ctx.server_id, + "provider": ctx.provider, "success": true }), ); @@ -187,6 +284,339 @@ async fn handle_callback( Ok(()) } +async fn refresh_access_token(server_id: &str) -> Result { + let provider = get_keyring_value(server_id, "oauth_provider") + .ok_or_else(|| anyhow::anyhow!("Missing OAuth provider"))?; + let refresh_token = get_keyring_value(server_id, "oauth_refresh") + .ok_or_else(|| anyhow::anyhow!("OAuth token expired and no refresh token is available"))?; + + let spec = provider_spec(&provider)?; + let credentials = resolve_client_credentials(server_id, &provider)?; + let bundle = refresh_with_provider(&spec, &credentials, &refresh_token).await?; + store_oauth_bundle(server_id, &provider, bundle.clone())?; + Ok(bundle) +} + +async fn exchange_code_for_tokens( + spec: &ProviderSpec, + credentials: &OAuthClientCredentials, + code: &str, + redirect_uri: &str, + state: Option<&str>, +) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(20)) + .build() + .context("Failed to build OAuth HTTP client")?; + + let response = match spec.token_request_style { + TokenRequestStyle::Form => { + let mut form = vec![ + ("grant_type", "authorization_code".to_string()), + ("client_id", credentials.client_id.clone()), + ("client_secret", credentials.client_secret.clone()), + ("code", code.to_string()), + ("redirect_uri", redirect_uri.to_string()), + ]; + if let Some(state) = state { + form.push(("state", state.to_string())); + } + + client + .post(&spec.token_url) + .header("Accept", "application/json") + .form(&form) + .send() + .await + .context("OAuth token exchange request failed")? + } + TokenRequestStyle::JsonBasicAuth => { + let mut payload = serde_json::json!({ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + }); + if let Some(state) = state { + payload["state"] = Value::String(state.to_string()); + } + + client + .post(&spec.token_url) + .basic_auth(&credentials.client_id, Some(&credentials.client_secret)) + .header("Accept", "application/json") + .json(&payload) + .send() + .await + .context("OAuth token exchange request failed")? + } + }; + + parse_oauth_token_response(response).await +} + +async fn refresh_with_provider( + spec: &ProviderSpec, + credentials: &OAuthClientCredentials, + refresh_token: &str, +) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(20)) + .build() + .context("Failed to build OAuth HTTP client")?; + + let response = match spec.token_request_style { + TokenRequestStyle::Form => { + let form = vec![ + ("grant_type", "refresh_token".to_string()), + ("client_id", credentials.client_id.clone()), + ("client_secret", credentials.client_secret.clone()), + ("refresh_token", refresh_token.to_string()), + ]; + client + .post(&spec.token_url) + .header("Accept", "application/json") + .form(&form) + .send() + .await + .context("OAuth refresh request failed")? + } + TokenRequestStyle::JsonBasicAuth => { + let payload = serde_json::json!({ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + }); + client + .post(&spec.token_url) + .basic_auth(&credentials.client_id, Some(&credentials.client_secret)) + .header("Accept", "application/json") + .json(&payload) + .send() + .await + .context("OAuth refresh request failed")? + } + }; + + let mut bundle = parse_oauth_token_response(response).await?; + if bundle.refresh_token.is_none() { + bundle.refresh_token = Some(refresh_token.to_string()); + } + Ok(bundle) +} + +async fn parse_oauth_token_response(response: reqwest::Response) -> Result { + let status = response.status(); + let body_text = response + .text() + .await + .context("Failed reading OAuth token response")?; + + let body: Value = serde_json::from_str(&body_text) + .with_context(|| format!("Invalid OAuth token response: {}", body_text))?; + + if !status.is_success() { + let error = body + .get("error_description") + .and_then(|v| v.as_str()) + .or_else(|| body.get("error").and_then(|v| v.as_str())) + .unwrap_or("unknown OAuth error"); + anyhow::bail!("OAuth token request failed: {}", error); + } + + if body.get("ok").and_then(|v| v.as_bool()) == Some(false) { + let error = body + .get("error") + .and_then(|v| v.as_str()) + .unwrap_or("unknown OAuth error"); + anyhow::bail!("OAuth token request failed: {}", error); + } + + let access_token = body + .get("access_token") + .and_then(|v| v.as_str()) + .or_else(|| { + body.get("authed_user") + .and_then(|u| u.get("access_token")) + .and_then(|v| v.as_str()) + }) + .ok_or_else(|| anyhow::anyhow!("OAuth token response missing access_token"))? + .to_string(); + + let refresh_token = body + .get("refresh_token") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let expires_at = if let Some(ts) = body.get("expires_at").and_then(|v| v.as_str()) { + parse_rfc3339_utc(ts) + } else if let Some(epoch_secs) = body.get("expires_at").and_then(|v| v.as_i64()) { + DateTime::from_timestamp(epoch_secs, 0) + } else if let Some(expires_in) = body.get("expires_in").and_then(|v| v.as_i64()) { + Some(Utc::now() + ChronoDuration::seconds(expires_in.max(0))) + } else if let Some(expires_in) = body + .get("authed_user") + .and_then(|u| u.get("expires_in")) + .and_then(|v| v.as_i64()) + { + Some(Utc::now() + ChronoDuration::seconds(expires_in.max(0))) + } else { + None + }; + + Ok(OAuthTokenBundle { + access_token, + refresh_token, + expires_at, + }) +} + +fn resolve_client_credentials(server_id: &str, provider: &str) -> Result { + let provider_key = sanitize_provider_key(provider); + let server = read_server(server_id); + + let client_id = resolve_credential_value( + server_id, + server.as_ref(), + &[ + format!("OAUTH_{}_CLIENT_ID", provider_key), + "OAUTH_CLIENT_ID".to_string(), + "CLIENT_ID".to_string(), + ], + ) + .ok_or_else(|| { + anyhow::anyhow!( + "Missing OAuth client ID for provider '{}'. Set OAUTH_{}_CLIENT_ID or OAUTH_CLIENT_ID.", + provider, + provider_key + ) + })?; + + let client_secret = resolve_credential_value( + server_id, + server.as_ref(), + &[ + format!("OAUTH_{}_CLIENT_SECRET", provider_key), + "OAUTH_CLIENT_SECRET".to_string(), + "CLIENT_SECRET".to_string(), + ], + ) + .ok_or_else(|| { + anyhow::anyhow!( + "Missing OAuth client secret for provider '{}'. Set OAUTH_{}_CLIENT_SECRET or OAUTH_CLIENT_SECRET.", + provider, + provider_key + ) + })?; + + Ok(OAuthClientCredentials { + client_id, + client_secret, + }) +} + +fn read_server(server_id: &str) -> Option { + crate::config::read_config() + .ok() + .and_then(|cfg| cfg.servers.into_iter().find(|s| s.id == server_id)) +} + +fn resolve_credential_value( + server_id: &str, + server: Option<&crate::config::McpServerConfig>, + candidate_keys: &[String], +) -> Option { + for key in candidate_keys { + let keychain_username = format!("{}:{}", server_id, key); + if let Ok(entry) = keyring::Entry::new("conductor", &keychain_username) { + if let Ok(value) = entry.get_password() { + if !value.trim().is_empty() { + return Some(value); + } + } + } + } + + if let Some(server) = server { + for key in candidate_keys { + if let Some(value) = server.env.get(key) { + if !value.trim().is_empty() { + return Some(value.clone()); + } + } + } + } + + for key in candidate_keys { + if let Ok(value) = std::env::var(key) { + if !value.trim().is_empty() { + return Some(value); + } + } + } + + None +} + +fn sanitize_provider_key(provider: &str) -> String { + provider + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() { + c.to_ascii_uppercase() + } else { + '_' + } + }) + .collect() +} + +fn parse_rfc3339_utc(value: &str) -> Option> { + chrono::DateTime::parse_from_rfc3339(value) + .ok() + .map(|dt| dt.with_timezone(&Utc)) +} + +fn get_keyring_value(server_id: &str, suffix: &str) -> Option { + let username = format!("{}:{}", server_id, suffix); + keyring::Entry::new("conductor", &username) + .ok() + .and_then(|entry| entry.get_password().ok()) +} + +fn set_keyring_value(server_id: &str, suffix: &str, value: &str) -> Result<()> { + let username = format!("{}:{}", server_id, suffix); + let entry = + keyring::Entry::new("conductor", &username).context("Failed to create keyring entry")?; + entry + .set_password(value) + .context("Failed to store value in keychain") +} + +fn delete_keyring_value(server_id: &str, suffix: &str) { + let username = format!("{}:{}", server_id, suffix); + if let Ok(entry) = keyring::Entry::new("conductor", &username) { + let _ = entry.delete_credential(); + } +} + +fn store_oauth_bundle(server_id: &str, provider: &str, bundle: OAuthTokenBundle) -> Result<()> { + set_keyring_value(server_id, "oauth_token", &bundle.access_token)?; + set_keyring_value(server_id, "oauth_provider", provider)?; + + if let Some(refresh) = bundle.refresh_token { + set_keyring_value(server_id, "oauth_refresh", &refresh)?; + } else { + delete_keyring_value(server_id, "oauth_refresh"); + } + + if let Some(expires_at) = bundle.expires_at { + set_keyring_value(server_id, "oauth_expires", &expires_at.to_rfc3339())?; + } else { + delete_keyring_value(server_id, "oauth_expires"); + } + + Ok(()) +} + fn success_html() -> String { r#" diff --git a/apps/desktop/src-tauri/src/watcher/mod.rs b/apps/desktop/src-tauri/src/watcher/mod.rs index fdc66cb..44a7318 100644 --- a/apps/desktop/src-tauri/src/watcher/mod.rs +++ b/apps/desktop/src-tauri/src/watcher/mod.rs @@ -10,16 +10,19 @@ use tokio::time::{Duration, Instant}; /// Start watching all detected client config files for changes. /// Emits "client-config-changed" Tauri events with 500ms debounce. +/// Only emits for actual MCP config files, not other files in the same directory. pub async fn start_watching(app_handle: tauri::AppHandle) -> Result<()> { let adapters = get_all_adapters(); - // Collect parent directories of all config files that exist + // Collect actual config file paths and their parent directories let mut watch_dirs: HashSet = HashSet::new(); + let mut config_files: HashSet = HashSet::new(); for adapter in &adapters { if let Some(config_path) = adapter.config_path() { if let Some(parent) = config_path.parent() { if parent.exists() { watch_dirs.insert(parent.to_path_buf()); + config_files.insert(config_path); } } } @@ -62,6 +65,19 @@ pub async fn start_watching(app_handle: tauri::AppHandle) -> Result<()> { while let Some(event) = rx.recv().await { match event.kind { EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_) => { + // Filter to only actual MCP config files + let changed_paths: Vec = event + .paths + .iter() + .filter(|p| config_files.contains(p.as_path())) + .filter(|p| !crate::file_guard::is_internal_write(p.as_path())) + .map(|p| p.to_string_lossy().to_string()) + .collect(); + + if changed_paths.is_empty() { + continue; + } + let mut last = last_event_clone.lock().await; let now = Instant::now(); @@ -75,13 +91,6 @@ pub async fn start_watching(app_handle: tauri::AppHandle) -> Result<()> { *last = Some(now); drop(last); // Release lock before emitting - // Determine which client config changed - let changed_paths: Vec = event - .paths - .iter() - .map(|p| p.to_string_lossy().to_string()) - .collect(); - let _ = app_handle_clone.emit("client-config-changed", &changed_paths); } } diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 6dc280b..1012783 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -11,6 +11,7 @@ import { Toaster } from "sonner"; import { useUIStore } from "@/stores/uiStore"; import { useConfigStore } from "@/stores/configStore"; import { useClientStore } from "@/stores/clientStore"; +import { useAutoSync } from "@/hooks/useAutoSync"; export function App() { const activeView = useUIStore((s) => s.activeView); @@ -18,12 +19,19 @@ export function App() { const setCommandPaletteOpen = useUIStore((s) => s.setCommandPaletteOpen); const fetchServers = useConfigStore((s) => s.fetchServers); const detectClients = useClientStore((s) => s.detectClients); + const { triggerAutoSync } = useAutoSync(); useEffect(() => { fetchServers(); detectClients(); }, [fetchServers, detectClients]); + // Expose triggerAutoSync globally so stores can call it + useEffect(() => { + window.__conductorAutoSync = triggerAutoSync; + return () => { delete window.__conductorAutoSync; }; + }, [triggerAutoSync]); + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "k") { diff --git a/apps/desktop/src/globals.d.ts b/apps/desktop/src/globals.d.ts new file mode 100644 index 0000000..3f767b5 --- /dev/null +++ b/apps/desktop/src/globals.d.ts @@ -0,0 +1,3 @@ +interface Window { + __conductorAutoSync?: () => void; +} diff --git a/apps/desktop/src/hooks/useAutoSync.ts b/apps/desktop/src/hooks/useAutoSync.ts new file mode 100644 index 0000000..6066323 --- /dev/null +++ b/apps/desktop/src/hooks/useAutoSync.ts @@ -0,0 +1,79 @@ +import { useEffect, useRef } from "react"; +import { listen } from "@tauri-apps/api/event"; +import * as tauri from "@/lib/tauri"; +import { useClientStore } from "@/stores/clientStore"; +import { toast } from "sonner"; +import type { AppSettings } from "@conductor/types"; + +/** + * Listens for "client-config-changed" events from the file watcher + * and triggers auto-sync when enabled in settings. + * + * Also provides `triggerAutoSync()` for use after config mutations. + */ +export function useAutoSync() { + const settingsRef = useRef(null); + const syncTimerRef = useRef | undefined>(undefined); + const syncToAllClients = useClientStore((s) => s.syncToAllClients); + const detectClients = useClientStore((s) => s.detectClients); + + // Load settings on mount and refresh periodically + useEffect(() => { + const loadSettings = () => { + tauri.getSettings().then((s) => { + settingsRef.current = s; + }).catch(() => {}); + }; + + loadSettings(); + const interval = setInterval(loadSettings, 10_000); + return () => clearInterval(interval); + }, []); + + // Listen for file watcher events from the Rust backend + useEffect(() => { + const unlisten = listen("client-config-changed", (event) => { + const settings = settingsRef.current; + if (!settings) return; + + // Notify about external config changes + if (settings.notifyExternal) { + const paths = event.payload; + const fileNames = paths.map((p) => p.split("/").pop() || p); + toast.info("External config change detected", { + description: fileNames.join(", "), + }); + } + + // Refresh client detection to pick up external changes + detectClients(); + }); + + return () => { + unlisten.then((fn) => fn()); + }; + }, [detectClients]); + + // Return a function that configStore can call after mutations + return { + triggerAutoSync: () => { + const settings = settingsRef.current; + if (!settings?.autoSync) return; + + // Clear any pending sync timer + if (syncTimerRef.current) { + clearTimeout(syncTimerRef.current); + } + + const delay = (settings.syncDelay ?? 5) * 1000; + + if (delay === 0) { + syncToAllClients(); + } else { + syncTimerRef.current = setTimeout(() => { + syncToAllClients(); + }, delay); + } + }, + }; +} diff --git a/apps/desktop/src/stores/configStore.ts b/apps/desktop/src/stores/configStore.ts index cabb908..11b4e9c 100644 --- a/apps/desktop/src/stores/configStore.ts +++ b/apps/desktop/src/stores/configStore.ts @@ -46,6 +46,7 @@ export const useConfigStore = create((set, get) => ({ toast.success("Server added", { description: `${server.displayName || server.name} has been added.`, }); + window.__conductorAutoSync?.(); return server; } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -61,6 +62,7 @@ export const useConfigStore = create((set, get) => ({ servers: state.servers.map((s) => (s.id === serverId ? updated : s)), })); toast.success("Server updated"); + window.__conductorAutoSync?.(); return updated; } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -79,6 +81,7 @@ export const useConfigStore = create((set, get) => ({ toast.success("Server deleted", { description: `${server?.displayName || server?.name || "Server"} has been removed.`, }); + window.__conductorAutoSync?.(); return true; } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -100,6 +103,7 @@ export const useConfigStore = create((set, get) => ({ set((state) => ({ servers: state.servers.map((s) => (s.id === serverId ? updated : s)), })); + window.__conductorAutoSync?.(); } catch (err) { // Roll back set({ servers: prev }); diff --git a/apps/desktop/src/views/StacksView.tsx b/apps/desktop/src/views/StacksView.tsx index 5fd4dca..1061f52 100644 --- a/apps/desktop/src/views/StacksView.tsx +++ b/apps/desktop/src/views/StacksView.tsx @@ -483,13 +483,27 @@ function ExportedStackCard({ }; const handleShareLink = async () => { - const encoded = btoa(unescape(encodeURIComponent(json))); - const isDev = window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1"; - const base = isDev - ? "http://localhost:3000" - : "https://conductormcp.dev"; - const url = `${base}/share#${encoded}`; - await navigator.clipboard.writeText(url); + // Strip fields that aren't needed for sharing to minimize URL size + const minimalStack = { + ...stack, + servers: stack.servers.map(({ name, displayName, transport, command, args, url, env, description }) => ({ + name, displayName, transport, command, args, url, env, + ...(description ? { description } : {}), + })), + }; + const minJson = JSON.stringify(minimalStack); + const bytes = new TextEncoder().encode(minJson); + const binStr = Array.from(bytes, (b) => String.fromCodePoint(b)).join(""); + const encoded = btoa(binStr); + + const shareUrl = `https://conductormcp.dev/share#${encoded}`; + if (shareUrl.length > 32000) { + toast.warning("Stack too large for share link", { + description: "Use \"Copy JSON\" to share this stack instead.", + }); + return; + } + await navigator.clipboard.writeText(shareUrl); toast.success("Share link copied to clipboard"); }; diff --git a/apps/web/.eslintrc.json b/apps/web/.eslintrc.json new file mode 100644 index 0000000..957cd15 --- /dev/null +++ b/apps/web/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals"] +} diff --git a/apps/web/app/share/page.tsx b/apps/web/app/share/page.tsx index 92a4f9a..580813e 100644 --- a/apps/web/app/share/page.tsx +++ b/apps/web/app/share/page.tsx @@ -1,5 +1,6 @@ "use client"; +import Link from "next/link"; import { useEffect, useState } from "react"; import { Layers, Server, Copy, Check, Download, ExternalLink } from "lucide-react"; @@ -74,12 +75,9 @@ export default function SharePage() {

This link doesn't contain a valid MCP stack. It may have been corrupted or is incomplete.

- + Go to Conductor - + ); @@ -98,16 +96,16 @@ export default function SharePage() { {/* Nav */} @@ -204,17 +202,17 @@ export default function SharePage() {

What is this?

This is an MCP server stack shared via{" "} - Conductor + Conductor . Conductor is a free, open-source config manager that lets you define your MCP servers once and sync them across Claude Desktop, Cursor, VS Code, Windsurf, and more.

- Learn more - + diff --git a/apps/web/components/Nav.tsx b/apps/web/components/Nav.tsx index 9d8ccb7..1461369 100644 --- a/apps/web/components/Nav.tsx +++ b/apps/web/components/Nav.tsx @@ -1,5 +1,6 @@ "use client"; +import Link from "next/link"; import { useState } from "react"; import { Menu, X, Github, Download } from "lucide-react"; @@ -16,7 +17,7 @@ export function Nav() {