From add19151956a0c21f1a3c1ffe21ebaab40445df6 Mon Sep 17 00:00:00 2001 From: laopan <147567034@qq.com> Date: Sat, 27 Jun 2026 23:40:21 +0800 Subject: [PATCH 1/2] feat(plugins): add MCP server merging from enabled plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add MCP server merging logic to load_config_with_workspace: · Merge MCP servers from enabled plugins into the main MCP config · Plugin MCP server names are qualified with plugin name: - · Relative cwd paths are resolved relative to the plugin directory · Only enabled plugins contribute their MCP servers This is Stage 3 of the plugin system per review feedback on PR #3699. No prompt injection — that follows in a separate PR (Stage 4, paused). --- crates/tui/src/mcp.rs | 37 ++++++++++++++++++++++++++++++ crates/tui/src/plugins/manifest.rs | 12 +++------- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/crates/tui/src/mcp.rs b/crates/tui/src/mcp.rs index a104f93ed0..bbc60c8fbb 100644 --- a/crates/tui/src/mcp.rs +++ b/crates/tui/src/mcp.rs @@ -2825,9 +2825,46 @@ 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(mut config: McpConfig) -> Result { + let plugins = crate::plugins::try_with_registry(|r| r.list_enabled()).unwrap_or_default(); + + for (plugin_name, plugin) in plugins { + if let Some(mcp_servers) = &plugin.manifest.mcp_servers { + for (server_name, mut server_config) in mcp_servers { + let qualified_name = format!("{}-{}", plugin_name, server_name); + + 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/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>, From 6a0211e2dd4c9a79010fcc659eac25102f33378c Mon Sep 17 00:00:00 2001 From: Hunter B Date: Sat, 27 Jun 2026 19:26:56 -0700 Subject: [PATCH 2/2] fix(plugins): merge plugin MCP servers with owned config Clone enabled plugin entries while the registry lock is held, then merge their MCP servers into the workspace-aware config using owned server configs. This keeps plugin info rendering aligned with the shared MCP config shape and avoids borrowed data escaping the registry lock.\n\nAdds coverage for plugin-qualified MCP server names and plugin-relative stdio cwd resolution.\n\nMaintainer follow-up for PR #3710 by @pkeging. Signed-off-by: Hunter B --- crates/tui/src/commands/groups/plugins/mod.rs | 16 +++--- crates/tui/src/mcp.rs | 22 ++++++-- crates/tui/src/mcp/tests.rs | 50 +++++++++++++++++++ 3 files changed, 75 insertions(+), 13 deletions(-) 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 bbc60c8fbb..ccf4327a02 100644 --- a/crates/tui/src/mcp.rs +++ b/crates/tui/src/mcp.rs @@ -2831,13 +2831,27 @@ pub fn load_config_with_workspace(global_path: &Path, workspace: &Path) -> Resul Ok(merged) } -fn merge_plugin_mcp_servers(mut config: McpConfig) -> Result { - let plugins = crate::plugins::try_with_registry(|r| r.list_enabled()).unwrap_or_default(); - +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, mut server_config) in 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( 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();