diff --git a/crates/tui/src/commands/groups/plugins/mod.rs b/crates/tui/src/commands/groups/plugins/mod.rs index b03728d84e..45cf7b1cb6 100644 --- a/crates/tui/src/commands/groups/plugins/mod.rs +++ b/crates/tui/src/commands/groups/plugins/mod.rs @@ -156,18 +156,16 @@ fn plugin_info(_app: &App, name: &str) -> CommandResult { if let Some(mcp_servers) = &plugin.manifest.mcp_servers { out.push_str(&format!("MCP servers: {}\n", mcp_servers.len())); for (server_name, server) in mcp_servers { - out.push_str(&format!(" - {}: {}\n", server_name, server.command)); - if let Some(args) = &server.args { - out.push_str(&format!(" args: {}\n", args.join(" "))); + let command = server.command.as_deref().unwrap_or(""); + out.push_str(&format!(" - {}: {}\n", server_name, command)); + if !server.args.is_empty() { + out.push_str(&format!(" args: {}\n", server.args.join(" "))); } - if let Some(env) = &server.env { - out.push_str(&format!(" env vars: {}\n", env.len())); + if !server.env.is_empty() { + out.push_str(&format!(" env vars: {}\n", server.env.len())); } if let Some(cwd) = &server.cwd { - out.push_str(&format!(" cwd: {}\n", cwd)); - } - if let Some(sandbox) = server.sandbox { - out.push_str(&format!(" sandbox: {}\n", sandbox)); + out.push_str(&format!(" cwd: {}\n", cwd.display())); } } } diff --git a/crates/tui/src/mcp.rs b/crates/tui/src/mcp.rs index a104f93ed0..ccf4327a02 100644 --- a/crates/tui/src/mcp.rs +++ b/crates/tui/src/mcp.rs @@ -2825,9 +2825,60 @@ pub fn load_config_with_workspace(global_path: &Path, workspace: &Path) -> Resul } } merged.servers.extend(project.servers); + + merged = merge_plugin_mcp_servers(merged)?; + Ok(merged) } +fn merge_plugin_mcp_servers(config: McpConfig) -> Result { + let plugins = crate::plugins::try_with_registry(|r| { + r.list_enabled() + .into_iter() + .map(|(name, plugin)| (name.clone(), plugin.clone())) + .collect::>() + }) + .unwrap_or_default(); + + merge_plugin_mcp_servers_from_plugins(config, plugins) +} + +fn merge_plugin_mcp_servers_from_plugins( + mut config: McpConfig, + plugins: impl IntoIterator, +) -> Result { + for (plugin_name, plugin) in plugins { + if let Some(mcp_servers) = &plugin.manifest.mcp_servers { + for (server_name, server_config) in mcp_servers { + let qualified_name = format!("{}-{}", plugin_name, server_name); + let mut server_config = server_config.clone(); + + if server_config.command.is_some() && server_config.url.is_none() { + server_config.cwd = Some(resolve_plugin_mcp_cwd( + &plugin.base_path, + server_config.cwd.as_deref(), + )?); + } + + config.servers.insert(qualified_name, server_config); + } + } + } + + Ok(config) +} + +fn resolve_plugin_mcp_cwd(plugin_path: &Path, cwd: Option<&Path>) -> Result { + let cwd = match cwd { + Some(cwd) if cwd.is_relative() => normalize_path_components(&plugin_path.join(cwd)), + Some(cwd) => normalize_path_components(cwd), + None => plugin_path.to_path_buf(), + }; + Ok(cwd + .canonicalize() + .unwrap_or_else(|_| normalize_path_components(&cwd))) +} + fn workspace_allows_project_mcp_config(workspace: &Path) -> bool { crate::config::is_workspace_trusted(workspace) } diff --git a/crates/tui/src/mcp/tests.rs b/crates/tui/src/mcp/tests.rs index 43e1b34b6e..83004936b4 100644 --- a/crates/tui/src/mcp/tests.rs +++ b/crates/tui/src/mcp/tests.rs @@ -465,6 +465,56 @@ fn workspace_manager_snapshot_counts_global_and_project_servers() { ); } +#[test] +fn plugin_mcp_servers_are_qualified_and_resolve_relative_cwd() { + let dir = tempfile::tempdir().unwrap(); + let plugin_base = dir.path().join("plugins").join("fleet"); + fs::create_dir_all(&plugin_base).unwrap(); + + let manifest = toml::from_str::( + r#" +[plugin] +name = "fleet" + +[mcp_servers.local] +command = "node" +args = ["server.js"] +cwd = "servers/local" + +[mcp_servers.remote] +url = "https://example.invalid/mcp" +"#, + ) + .unwrap(); + let plugin = crate::plugins::manifest::LoadedPlugin { + manifest, + base_path: plugin_base.clone(), + enabled: true, + }; + let mut config = McpConfig::default(); + config.servers.insert( + "global".to_string(), + serde_json::from_str(r#"{"command":"node","args":["global.js"]}"#).unwrap(), + ); + + let cfg = + merge_plugin_mcp_servers_from_plugins(config, vec![("fleet".to_string(), plugin)]).unwrap(); + + assert!(cfg.servers.contains_key("global")); + + let local = cfg.servers.get("fleet-local").unwrap(); + assert_eq!(local.command.as_deref(), Some("node")); + assert_eq!(local.args, vec!["server.js"]); + assert_eq!( + local.cwd.as_deref(), + Some(plugin_base.join("servers/local").as_path()) + ); + + let remote = cfg.servers.get("fleet-remote").unwrap(); + assert_eq!(remote.url.as_deref(), Some("https://example.invalid/mcp")); + assert!(remote.cwd.is_none()); +} + #[test] fn workspace_mcp_config_ignores_project_file_until_workspace_trusted() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/tui/src/plugins/manifest.rs b/crates/tui/src/plugins/manifest.rs index 3c3ab5fa8f..5586fbcd3e 100644 --- a/crates/tui/src/plugins/manifest.rs +++ b/crates/tui/src/plugins/manifest.rs @@ -3,10 +3,13 @@ use std::path::{Path, PathBuf}; use serde::Deserialize; +use crate::mcp::McpServerConfig; + #[derive(Debug, Clone, Deserialize)] pub struct PluginManifest { pub plugin: PluginMeta, pub skills: Option, + #[serde(default)] pub mcp_servers: Option>, pub when: Option, } @@ -24,15 +27,6 @@ pub struct PluginSkills { pub path: Option, } -#[derive(Debug, Clone, Deserialize)] -pub struct McpServerConfig { - pub command: String, - pub args: Option>, - pub env: Option>, - pub cwd: Option, - pub sandbox: Option, -} - #[derive(Debug, Clone, Deserialize)] pub struct PluginWhen { pub os: Option>,