diff --git a/crates/executors/src/command.rs b/crates/executors/src/command.rs index f8263e8f..28971e09 100644 --- a/crates/executors/src/command.rs +++ b/crates/executors/src/command.rs @@ -62,6 +62,8 @@ pub struct AgentProfile { pub agent: BaseCodingAgent, /// Command builder configuration pub command: CommandBuilder, + /// Optional profile-specific MCP config file path (absolute; supports leading ~). Overrides the default `BaseCodingAgent` config path + pub mcp_config_path: Option, } impl AgentProfile { @@ -75,6 +77,7 @@ impl AgentProfile { "--verbose", "--output-format=stream-json", ]), + mcp_config_path: None, } } @@ -88,6 +91,7 @@ impl AgentProfile { "--verbose", "--output-format=stream-json", ]), + mcp_config_path: None, } } @@ -103,6 +107,7 @@ impl AgentProfile { "--output-format=stream-json", ], ), + mcp_config_path: None, } } @@ -112,6 +117,7 @@ impl AgentProfile { agent: BaseCodingAgent::Amp, command: CommandBuilder::new("npx -y @sourcegraph/amp@0.0.1752148945-gd8844f") .params(vec!["--format=jsonl"]), + mcp_config_path: None, } } @@ -120,6 +126,7 @@ impl AgentProfile { label: "gemini".to_string(), agent: BaseCodingAgent::Gemini, command: CommandBuilder::new("npx -y @google/gemini-cli@latest").params(vec!["--yolo"]), + mcp_config_path: None, } } @@ -132,6 +139,17 @@ impl AgentProfile { "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check", ]), + mcp_config_path: None, + } + } + + pub fn qwen_code() -> Self { + Self { + label: "qwen-code".to_string(), + agent: BaseCodingAgent::Gemini, + command: CommandBuilder::new("npx -y @qwen-code/qwen-code@latest") + .params(vec!["--yolo"]), + mcp_config_path: Some("~/.qwen/settings.json".to_string()), } } @@ -141,6 +159,7 @@ impl AgentProfile { agent: BaseCodingAgent::Opencode, command: CommandBuilder::new("npx -y opencode-ai@latest run") .params(vec!["--print-logs"]), + mcp_config_path: None, } } } @@ -179,6 +198,7 @@ impl AgentProfiles { AgentProfile::gemini(), AgentProfile::codex(), AgentProfile::opencode(), + AgentProfile::qwen_code(), ], } } @@ -240,98 +260,45 @@ mod tests { use super::*; #[test] - fn test_command_builder() { - let builder = CommandBuilder::new("npx claude").params(vec!["--verbose", "--json"]); - assert_eq!(builder.build_initial(), "npx claude --verbose --json"); - assert_eq!( - builder.build_follow_up(&["--resume".to_string(), "session123".to_string()]), - "npx claude --verbose --json --resume session123" - ); - } + fn default_profiles_have_expected_base_and_noninteractive_or_json_flags() { + // Build default profiles and make lookup by label easy + let profiles = AgentProfiles::from_defaults().to_map(); - #[test] - fn test_default_profiles() { - let profiles = AgentProfiles::from_defaults(); - assert!(profiles.profiles.len() == 7); + let get_profile_command = |label: &str| { + profiles + .get(label) + .map(|p| p.command.build_initial()) + .unwrap_or_else(|| panic!("Profile not found: {label}")) + }; - let claude_profile = profiles.get_profile("claude-code").unwrap(); - assert_eq!(claude_profile.agent, BaseCodingAgent::ClaudeCode); - assert!( - claude_profile - .command - .build_initial() - .contains("claude-code") - ); - assert!( - claude_profile - .command - .build_initial() - .contains("--dangerously-skip-permissions") - ); + let claude_code_command = get_profile_command("claude-code"); + assert!(claude_code_command.contains("npx -y @anthropic-ai/claude-code@latest")); + assert!(claude_code_command.contains("-p")); + assert!(claude_code_command.contains("--dangerously-skip-permissions")); - let amp_profile = profiles.get_profile("amp").unwrap(); - assert_eq!(amp_profile.agent, BaseCodingAgent::Amp); - assert!(amp_profile.command.build_initial().contains("amp")); - assert!( - amp_profile - .command - .build_initial() - .contains("--format=jsonl") - ); + let claude_code_router_command = get_profile_command("claude-code-router"); + assert!(claude_code_router_command.contains("npx -y @musistudio/claude-code-router code")); + assert!(claude_code_router_command.contains("-p")); + assert!(claude_code_router_command.contains("--dangerously-skip-permissions")); - let gemini_profile = profiles.get_profile("gemini").unwrap(); - assert_eq!(gemini_profile.agent, BaseCodingAgent::Gemini); - assert!(gemini_profile.command.build_initial().contains("gemini")); - assert!(gemini_profile.command.build_initial().contains("--yolo")); + let amp_command = get_profile_command("amp"); + assert!(amp_command.contains("npx -y @sourcegraph/amp@0.0.1752148945-gd8844f")); + assert!(amp_command.contains("--format=jsonl")); - let codex_profile = profiles.get_profile("codex").unwrap(); - assert_eq!(codex_profile.agent, BaseCodingAgent::Codex); - assert!(codex_profile.command.build_initial().contains("codex")); - assert!(codex_profile.command.build_initial().contains("--json")); + let gemini_command = get_profile_command("gemini"); + assert!(gemini_command.contains("npx -y @google/gemini-cli@latest")); + assert!(gemini_command.contains("--yolo")); - let opencode_profile = profiles.get_profile("opencode").unwrap(); - assert_eq!(opencode_profile.agent, BaseCodingAgent::Opencode); - assert!( - opencode_profile - .command - .build_initial() - .contains("opencode-ai") - ); - assert!(opencode_profile.command.build_initial().contains("run")); - assert!( - opencode_profile - .command - .build_initial() - .contains("--print-logs") - ); + let codex_command = get_profile_command("codex"); + assert!(codex_command.contains("npx -y @openai/codex exec")); + assert!(codex_command.contains("--json")); - let claude_code_router_profile = profiles.get_profile("claude-code-router").unwrap(); - assert_eq!( - claude_code_router_profile.agent, - BaseCodingAgent::ClaudeCode - ); - assert!( - claude_code_router_profile - .command - .build_initial() - .contains("@musistudio/claude-code-router") - ); - assert!( - claude_code_router_profile - .command - .build_initial() - .contains("--dangerously-skip-permissions") - ); - } + let qwen_code_command = get_profile_command("qwen-code"); + assert!(qwen_code_command.contains("npx -y @qwen-code/qwen-code@latest")); + assert!(qwen_code_command.contains("--yolo")); - #[test] - fn test_profiles_for_agent() { - let profiles = AgentProfiles::from_defaults(); - - let claude_profiles = profiles.get_profiles_for_agent(&BaseCodingAgent::ClaudeCode); - assert_eq!(claude_profiles.len(), 3); // default, plan mode, and claude-code-router - - let amp_profiles = profiles.get_profiles_for_agent(&BaseCodingAgent::Amp); - assert_eq!(amp_profiles.len(), 1); + let opencode_command = get_profile_command("opencode"); + assert!(opencode_command.contains("npx -y opencode-ai@latest run")); + assert!(opencode_command.contains("--print-logs")); } } diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index df2d22cb..2cc3c50d 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -46,6 +46,7 @@ git2 = "0.18" mime_guess = "2.0" rust-embed = "8.2" octocrab = "0.44" +dirs = "5.0" [dev-dependencies] tempfile = "3.8" diff --git a/crates/server/src/routes/config.rs b/crates/server/src/routes/config.rs index 2779b386..e6e25eaf 100644 --- a/crates/server/src/routes/config.rs +++ b/crates/server/src/routes/config.rs @@ -15,7 +15,7 @@ use serde_json::Value; use services::services::config::{save_config_to_file, Config, SoundFile}; use tokio::fs; use ts_rs::TS; -use utils::{assets::config_path, response::ApiResponse}; +use utils::{assets::config_path, path::expand_tilde, response::ApiResponse}; use crate::{ error::ApiError, @@ -115,6 +115,7 @@ async fn get_sound(Path(sound): Path) -> Result { #[derive(Debug, Deserialize)] struct McpServerQuery { base_coding_agent: Option, + mcp_config_path: Option, } async fn get_mcp_servers( @@ -138,13 +139,17 @@ async fn get_mcp_servers( ))); } - // Get the config file path for this executor - let config_path = match agent.config_path() { - Some(path) => path, - None => { - return Ok(ResponseJson(ApiResponse::error( - "Could not determine config file path", - ))); + // Resolve supplied config path or agent default + let config_path = if let Some(path_str) = &query.mcp_config_path { + expand_tilde(path_str) + } else { + match agent.config_path() { + Some(path) => path, + None => { + return Ok(ResponseJson(ApiResponse::error( + "Could not determine config file path", + ))) + } } }; @@ -185,13 +190,17 @@ async fn update_mcp_servers( ))); } - // Get the config file path for this executor - let config_path = match agent.config_path() { - Some(path) => path, - None => { - return Ok(ResponseJson(ApiResponse::error( - "Could not determine config file path", - ))); + // Resolve supplied config path or agent default + let config_path = if let Some(path_str) = &query.mcp_config_path { + expand_tilde(path_str) + } else { + match agent.config_path() { + Some(path) => path, + None => { + return Ok(ResponseJson(ApiResponse::error( + "Could not determine config file path", + ))) + } } }; diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml index 420c11bd..379aeeaa 100644 --- a/crates/utils/Cargo.toml +++ b/crates/utils/Cargo.toml @@ -27,3 +27,4 @@ base64 = "0.22" tokio = { workspace = true } futures = "0.3.31" tokio-stream = { version = "0.1.17", features = ["sync"] } +shellexpand = "3.1.1" diff --git a/crates/utils/src/path.rs b/crates/utils/src/path.rs index ec0c865a..d611fc7d 100644 --- a/crates/utils/src/path.rs +++ b/crates/utils/src/path.rs @@ -88,6 +88,11 @@ pub fn get_vibe_kanban_temp_dir() -> std::path::PathBuf { } } +/// Expand leading ~ to user's home directory. +pub fn expand_tilde(path_str: &str) -> std::path::PathBuf { + shellexpand::tilde(path_str).as_ref().into() +} + #[cfg(test)] mod tests { use super::*; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index c57a8efc..41d353ed 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -553,20 +553,25 @@ export const templatesApi = { // MCP Servers APIs export const mcpServersApi = { - load: async (executor: string): Promise => { - const response = await makeRequest( - `/api/mcp-config?base_coding_agent=${encodeURIComponent(executor)}` - ); + load: async (executor: string, mcpConfigPath?: string): Promise => { + const params = new URLSearchParams(); + params.set('base_coding_agent', executor); + if (mcpConfigPath) params.set('mcp_config_path', mcpConfigPath); + const response = await makeRequest(`/api/mcp-config?${params.toString()}`); return handleApiResponse(response); }, - save: async (executor: string, serversConfig: any): Promise => { - const response = await makeRequest( - `/api/mcp-config?base_coding_agent=${encodeURIComponent(executor)}`, - { - method: 'POST', - body: JSON.stringify(serversConfig), - } - ); + save: async ( + executor: string, + mcpConfigPath: string | undefined, + serversConfig: any + ): Promise => { + const params = new URLSearchParams(); + params.set('base_coding_agent', executor); + if (mcpConfigPath) params.set('mcp_config_path', mcpConfigPath); + const response = await makeRequest(`/api/mcp-config?${params.toString()}`, { + method: 'POST', + body: JSON.stringify(serversConfig), + }); if (!response.ok) { const errorData = await response.json(); console.error('[API Error] Failed to save MCP servers', { diff --git a/frontend/src/pages/McpServers.tsx b/frontend/src/pages/McpServers.tsx index bc388144..3cf45353 100644 --- a/frontend/src/pages/McpServers.tsx +++ b/frontend/src/pages/McpServers.tsx @@ -63,8 +63,11 @@ export function McpServers() { setMcpConfigPath(''); try { - // Load MCP servers for the selected profile's base agent - const result = await mcpServersApi.load(profile.agent); + // Load MCP servers for the selected profile/agent + const result = await mcpServersApi.load( + profile.agent, + profile.mcp_config_path || undefined + ); // Handle new response format with servers and config_path const data = result || {}; const servers = data.servers || {}; @@ -160,7 +163,11 @@ export function McpServers() { // Extract just the servers object for the API - backend will handle nesting/format const mcpServersConfig = strategy.extractServersForApi(fullConfig); - await mcpServersApi.save(selectedProfile.agent, mcpServersConfig); + await mcpServersApi.save( + selectedProfile.agent, + mcpConfigPath || undefined, + mcpServersConfig + ); // Show success feedback setSuccess(true); diff --git a/shared/types.ts b/shared/types.ts index 3a9c860c..c7115858 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -102,7 +102,11 @@ agent: BaseCodingAgent, /** * Command builder configuration */ -command: CommandBuilder, }; +command: CommandBuilder, +/** + * Optional profile-specific MCP config file path (absolute; supports leading ~). Overrides the default `BaseCodingAgent` config path + */ +mcp_config_path: string | null, }; export type AgentProfiles = { profiles: Array, };