From b0f5db2a07c3b447a5e34e2acba0ebcab8b1abe4 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Wed, 17 Jun 2026 14:52:54 -0400 Subject: [PATCH] Support MCP extensions in open plugins (#9471) Signed-off-by: Douwe M Osinga Co-authored-by: Douwe M Osinga --- crates/goose-cli/src/commands/configure.rs | 1 + .../goose-cli/src/recipes/secret_discovery.rs | 2 + crates/goose-cli/src/session/builder.rs | 9 +- crates/goose-cli/src/session/mod.rs | 4 + crates/goose-server/src/routes/agent.rs | 8 +- crates/goose/src/acp/provider.rs | 1 + crates/goose/src/acp/server.rs | 2 + crates/goose/src/acp/server/extensions.rs | 3 + crates/goose/src/acp/server/onboarding.rs | 1 + crates/goose/src/agents/extension.rs | 15 +- crates/goose/src/agents/extension_manager.rs | 7 +- crates/goose/src/config/extensions.rs | 1 + crates/goose/src/gateway/handler.rs | 10 +- crates/goose/src/plugins/discovery.rs | 154 +++++++- .../goose/src/plugins/formats/open_plugins.rs | 77 +++- crates/goose/src/plugins/mcp_servers.rs | 335 ++++++++++++++++++ crates/goose/src/plugins/mod.rs | 1 + crates/goose/src/providers/claude_code.rs | 1 + crates/goose/src/providers/codex.rs | 2 + .../src/recipe/recipe_extension_adapter.rs | 3 + crates/goose/src/scheduler.rs | 7 +- crates/goose/tests/mcp_integration_test.rs | 1 + ui/desktop/openapi.json | 4 + ui/desktop/src/api/types.gen.ts | 1 + 24 files changed, 623 insertions(+), 27 deletions(-) create mode 100644 crates/goose/src/plugins/mcp_servers.rs diff --git a/crates/goose-cli/src/commands/configure.rs b/crates/goose-cli/src/commands/configure.rs index ce6cb63ac..b7cce307f 100644 --- a/crates/goose-cli/src/commands/configure.rs +++ b/crates/goose-cli/src/commands/configure.rs @@ -1080,6 +1080,7 @@ fn configure_stdio_extension() -> anyhow::Result<()> { env_keys, description, timeout: Some(timeout), + cwd: None, bundled: None, available_tools: Vec::new(), }, diff --git a/crates/goose-cli/src/recipes/secret_discovery.rs b/crates/goose-cli/src/recipes/secret_discovery.rs index 3a9a17f8e..29a102ee3 100644 --- a/crates/goose-cli/src/recipes/secret_discovery.rs +++ b/crates/goose-cli/src/recipes/secret_discovery.rs @@ -178,6 +178,7 @@ mod tests { envs: Envs::new(HashMap::new()), env_keys: vec!["SLACK_TOKEN".to_string()], timeout: None, + cwd: None, description: "slack-mcp".to_string(), bundled: None, available_tools: Vec::new(), @@ -275,6 +276,7 @@ mod tests { envs: Envs::new(HashMap::new()), env_keys: vec!["API_KEY".to_string()], // Same original key, different extension timeout: None, + cwd: None, description: "service-b".to_string(), bundled: None, available_tools: Vec::new(), diff --git a/crates/goose-cli/src/session/builder.rs b/crates/goose-cli/src/session/builder.rs index c5c1d228e..6945e6e09 100644 --- a/crates/goose-cli/src/session/builder.rs +++ b/crates/goose-cli/src/session/builder.rs @@ -411,6 +411,7 @@ async fn collect_extension_configs( recipe: Option<&Recipe>, session_id: &str, ) -> Result, ExtensionError> { + let recipe_extensions = recipe.and_then(|r| r.extensions.as_deref()); let configured_extensions: Vec = if session_config.resume { EnabledExtensionsState::for_session( &agent.config.session_manager, @@ -421,7 +422,7 @@ async fn collect_extension_configs( } else if session_config.no_profile { Vec::new() } else { - resolve_extensions_for_new_session(recipe.and_then(|r| r.extensions.as_deref()), None) + resolve_extensions_for_new_session(recipe_extensions, None) }; let cli_flag_extensions = parse_cli_flag_extensions( @@ -431,6 +432,12 @@ async fn collect_extension_configs( ); let mut all: Vec = configured_extensions; + if !session_config.no_profile && !session_config.resume && recipe_extensions.is_none() { + let project_root = std::env::current_dir().ok(); + all.extend(goose::plugins::mcp_servers::enabled_plugin_mcp_servers( + project_root.as_deref(), + )); + } all.extend(cli_flag_extensions.into_iter().map(|(_, cfg)| cfg)); Ok(all) diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 1fb7860bd..0a4f1ceab 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -316,6 +316,7 @@ impl CliSession { env_keys: Vec::new(), description: goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string(), timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT), + cwd: None, bundled: None, available_tools: Vec::new(), }) @@ -2346,6 +2347,7 @@ mod tests { env_keys: vec![], description: goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string(), timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT), + cwd: None, bundled: None, available_tools: vec![], } @@ -2361,6 +2363,7 @@ mod tests { env_keys: vec![], description: goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string(), timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT), + cwd: None, bundled: None, available_tools: vec![], } @@ -2376,6 +2379,7 @@ mod tests { env_keys: vec![], description: goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string(), timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT), + cwd: None, bundled: None, available_tools: vec![], } diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index 081a11654..3df4f5c4a 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -267,8 +267,14 @@ async fn start_agent( let recipe_extensions = original_recipe .as_ref() .and_then(|r| r.extensions.as_deref()); - let extensions_to_use = + let has_extension_overrides = extension_overrides.is_some(); + let mut extensions_to_use = resolve_extensions_for_new_session(recipe_extensions, extension_overrides); + if recipe_extensions.is_none() && !has_extension_overrides { + extensions_to_use.extend(goose::plugins::mcp_servers::enabled_plugin_mcp_servers( + Some(&PathBuf::from(&working_dir)), + )); + } let mut extension_data = session.extension_data.clone(); let extensions_state = EnabledExtensionsState::new(extensions_to_use); diff --git a/crates/goose/src/acp/provider.rs b/crates/goose/src/acp/provider.rs index 16bca02d5..2bce0e561 100644 --- a/crates/goose/src/acp/provider.rs +++ b/crates/goose/src/acp/provider.rs @@ -1716,6 +1716,7 @@ mod tests { envs: Envs::new([("GITHUB_PERSONAL_ACCESS_TOKEN".into(), "ghp_xxxxxxxxxxxx".into())].into()), env_keys: vec![], timeout: None, + cwd: None, bundled: Some(false), available_tools: vec![], }, diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index c506d1788..ef2e28c9c 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -356,6 +356,7 @@ fn mcp_server_to_extension_config(mcp_server: McpServer) -> Result, timeout: Option, #[serde(default)] + cwd: Option, + #[serde(default)] bundled: Option, #[serde(default)] available_tools: Vec, @@ -333,6 +335,7 @@ impl ExtensionConfig { env_keys: Vec::new(), description: description.into(), timeout: Some(timeout.into()), + cwd: None, bundled: None, available_tools: Vec::new(), } @@ -366,6 +369,7 @@ impl ExtensionConfig { envs, env_keys, timeout, + cwd, description, bundled, available_tools, @@ -378,6 +382,7 @@ impl ExtensionConfig { args: args.into_iter().map(Into::into).collect(), description, timeout, + cwd, bundled, available_tools, }, @@ -443,6 +448,7 @@ impl ExtensionConfig { envs, env_keys, timeout, + cwd, bundled, available_tools, } => { @@ -452,9 +458,10 @@ impl ExtensionConfig { description, cmd, args, - envs: Envs::new(merged), + envs: Envs::new(merged.clone()), env_keys: vec![], timeout, + cwd: cwd.map(|s| substitute_env_vars(&s, &merged)), bundled, available_tools, }) @@ -731,6 +738,7 @@ available_tools: [] envs: extension::Envs::default(), env_keys: vec![], timeout: None, + cwd: None, bundled: None, available_tools: vec![], }, @@ -742,6 +750,7 @@ available_tools: [] envs: extension::Envs::default(), env_keys: vec![], timeout: None, + cwd: None, bundled: None, available_tools: vec![], } @@ -756,6 +765,7 @@ available_tools: [] envs: extension::Envs::default(), env_keys: vec!["MY_SECRET".into()], timeout: None, + cwd: None, bundled: None, available_tools: vec![], }, @@ -771,6 +781,7 @@ available_tools: [] }), env_keys: vec![], timeout: None, + cwd: None, bundled: None, available_tools: vec![], } @@ -858,6 +869,7 @@ available_tools: [] }), env_keys: vec!["MY_SECRET".into()], timeout: None, + cwd: None, bundled: None, available_tools: vec![], }, @@ -873,6 +885,7 @@ available_tools: [] }), env_keys: vec![], timeout: None, + cwd: None, bundled: None, available_tools: vec![], } diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index 840dd7e55..f5bff6f8a 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -1001,11 +1001,16 @@ impl ExtensionManager { envs, env_keys, timeout, + cwd, .. } => { let config = Config::global(); let mut all_envs = merge_environments(envs, env_keys, &sanitized_name, config).await?; + let process_working_dir = cwd + .as_deref() + .map(PathBuf::from) + .unwrap_or_else(|| effective_working_dir.clone()); if let Some(sid) = session_id { all_envs.insert("AGENT_SESSION_ID".to_string(), sid.to_string()); @@ -1041,7 +1046,7 @@ impl ExtensionManager { command, timeout, self.provider.clone(), - &effective_working_dir, + &process_working_dir, container.map(|c| c.id().to_string()), self.client_name.clone(), self.mcp_client_capabilities(), diff --git a/crates/goose/src/config/extensions.rs b/crates/goose/src/config/extensions.rs index 85a596f87..3a81e9154 100644 --- a/crates/goose/src/config/extensions.rs +++ b/crates/goose/src/config/extensions.rs @@ -482,6 +482,7 @@ extensions: envs: Default::default(), env_keys: Vec::new(), timeout: Some(120), + cwd: None, bundled: None, available_tools: vec!["run".to_string()], }, diff --git a/crates/goose/src/gateway/handler.rs b/crates/goose/src/gateway/handler.rs index bce4ffdaa..31fdde38c 100644 --- a/crates/goose/src/gateway/handler.rs +++ b/crates/goose/src/gateway/handler.rs @@ -174,7 +174,10 @@ impl GatewayHandler { } // Store default extensions so load_extensions_from_session works. - let extensions = get_enabled_extensions(); + let mut extensions = get_enabled_extensions(); + extensions.extend(crate::plugins::mcp_servers::enabled_plugin_mcp_servers( + Some(&session.working_dir), + )); let extensions_state = EnabledExtensionsState::new(extensions); let mut extension_data = session.extension_data.clone(); if let Err(e) = extensions_state.to_extension_data(&mut extension_data) { @@ -220,7 +223,10 @@ impl GatewayHandler { // --- current global config --- let current_provider = config.get_goose_provider().ok(); let current_model_name = config.get_goose_model().ok(); - let current_extensions = get_enabled_extensions(); + let mut current_extensions = get_enabled_extensions(); + current_extensions.extend(crate::plugins::mcp_servers::enabled_plugin_mcp_servers( + Some(&session.working_dir), + )); let current_mode = config.get_goose_mode().unwrap_or_default(); // --- what the session has --- diff --git a/crates/goose/src/plugins/discovery.rs b/crates/goose/src/plugins/discovery.rs index 76815be44..cdd80a652 100644 --- a/crates/goose/src/plugins/discovery.rs +++ b/crates/goose/src/plugins/discovery.rs @@ -1,10 +1,20 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; +use crate::config::Config; use crate::plugins::plugin_install_dir; +const PLUGINS_CONFIG_KEY: &str = "plugins"; + +/// Per-plugin entry stored under the `plugins` map in `config.yaml`, keyed by +/// the plugin's filesystem path. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PluginConfigEntry { + enabled: bool, +} + /// A plugin found on disk and not disabled by any settings file. #[derive(Debug, Clone)] pub struct DiscoveredPlugin { @@ -40,6 +50,13 @@ enum SettingsScope { /// `project_root`, when supplied, enables project + local scope settings and /// project-scope `.agents/plugins/` lookups. pub fn discover_enabled_plugins(project_root: Option<&Path>) -> Vec { + discover_enabled_plugins_with_config(project_root, Config::global()) +} + +fn discover_enabled_plugins_with_config( + project_root: Option<&Path>, + config: &Config, +) -> Vec { let scoped_settings = load_all_settings(project_root); let mut found: HashMap = HashMap::new(); @@ -60,10 +77,46 @@ pub fn discover_enabled_plugins(project_root: Option<&Path>) -> Vec = found .into_values() .filter(|plugin| is_enabled(&plugin.name, &scoped_settings)) - .collect() + .collect(); + + filter_by_config(enabled_by_settings, config) +} + +/// Apply the `plugins` map in `config.yaml`. Newly discovered plugins are added +/// to the map with `enabled: true`; plugins explicitly set to `enabled: false` +/// are dropped. +fn filter_by_config(plugins: Vec, config: &Config) -> Vec { + let mut entries: HashMap = + config.get_param(PLUGINS_CONFIG_KEY).unwrap_or_default(); + + let mut dirty = false; + let mut enabled = Vec::new(); + for plugin in plugins { + let key = plugin.root.to_string_lossy().to_string(); + match entries.get(&key) { + Some(entry) => { + if entry.enabled { + enabled.push(plugin); + } + } + None => { + entries.insert(key, PluginConfigEntry { enabled: true }); + dirty = true; + enabled.push(plugin); + } + } + } + + if dirty { + if let Err(e) = config.set_param(PLUGINS_CONFIG_KEY, entries) { + tracing::warn!(error = %e, "Failed to persist plugin config entries"); + } + } + + enabled } fn is_enabled(plugin_name: &str, scoped_settings: &[(SettingsScope, PluginSettings)]) -> bool { @@ -197,13 +250,22 @@ mod tests { std::fs::write(dir.join("settings.local.json"), contents).unwrap(); } + fn test_config(dir: &Path) -> Config { + Config::new(dir.join("config.yaml"), "goose-discovery-test").unwrap() + } + + fn discover(project: &Path) -> Vec { + let cfg_dir = tempfile::tempdir().unwrap(); + discover_enabled_plugins_with_config(Some(project), &test_config(cfg_dir.path())) + } + #[test] fn finds_project_scope_plugin() { let tmp = tempfile::tempdir().unwrap(); let project = tmp.path(); write_plugin_dir(&project.join(".agents").join("plugins"), "demo"); - let found = discover_enabled_plugins(Some(project)); + let found = discover(project); let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect(); assert!(names.contains(&"demo"), "got: {names:?}"); let demo = found.iter().find(|p| p.name == "demo").unwrap(); @@ -221,7 +283,7 @@ mod tests { r#"{"disabledPlugins":["demo"]}"#, ); - let found = discover_enabled_plugins(Some(project)); + let found = discover(project); assert!(found.iter().all(|p| p.name != "demo")); } @@ -237,7 +299,7 @@ mod tests { r#"{"enabledPlugins":["demo"]}"#, ); - let found = discover_enabled_plugins(Some(project)); + let found = discover(project); let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect(); assert!(names.contains(&"demo"), "got: {names:?}"); assert!(names.contains(&"other"), "got: {names:?}"); @@ -258,7 +320,7 @@ mod tests { r#"{"enabledPlugins":["demo"]}"#, ); - let found = discover_enabled_plugins(Some(project)); + let found = discover(project); assert!( found.iter().any(|p| p.name == "demo"), "local scope should win; got: {:?}", @@ -285,7 +347,7 @@ mod tests { let prev = std::env::var("GOOSE_PATH_ROOT").ok(); unsafe { std::env::set_var("GOOSE_PATH_ROOT", fake_home.path()) }; - let found = discover_enabled_plugins(Some(project)); + let found = discover(project); match prev { Some(v) => unsafe { std::env::set_var("GOOSE_PATH_ROOT", v) }, None => unsafe { std::env::remove_var("GOOSE_PATH_ROOT") }, @@ -297,4 +359,80 @@ mod tests { found.iter().map(|p| &p.name).collect::>() ); } + + #[test] + fn newly_discovered_plugin_is_added_to_config_as_enabled() { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path(); + write_plugin_dir(&project.join(".agents").join("plugins"), "demo"); + + let cfg_dir = tempfile::tempdir().unwrap(); + let config = test_config(cfg_dir.path()); + + let found = discover_enabled_plugins_with_config(Some(project), &config); + assert!(found.iter().any(|p| p.name == "demo")); + + let entries: HashMap = + config.get_param(PLUGINS_CONFIG_KEY).unwrap(); + let key = project + .join(".agents") + .join("plugins") + .join("demo") + .to_string_lossy() + .to_string(); + assert!( + entries.get(&key).is_some_and(|e| e.enabled), + "got: {entries:?}" + ); + } + + #[test] + fn disabled_in_config_drops_plugin() { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path(); + write_plugin_dir(&project.join(".agents").join("plugins"), "demo"); + + let cfg_dir = tempfile::tempdir().unwrap(); + let config = test_config(cfg_dir.path()); + let key = project + .join(".agents") + .join("plugins") + .join("demo") + .to_string_lossy() + .to_string(); + let entries = HashMap::from([(key, PluginConfigEntry { enabled: false })]); + config.set_param(PLUGINS_CONFIG_KEY, entries).unwrap(); + + let found = discover_enabled_plugins_with_config(Some(project), &config); + assert!(found.iter().all(|p| p.name != "demo")); + } + + #[test] + fn enabled_in_config_keeps_plugin_without_modifying_config() { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path(); + write_plugin_dir(&project.join(".agents").join("plugins"), "demo"); + + let cfg_dir = tempfile::tempdir().unwrap(); + let config = test_config(cfg_dir.path()); + let key = project + .join(".agents") + .join("plugins") + .join("demo") + .to_string_lossy() + .to_string(); + config + .set_param( + PLUGINS_CONFIG_KEY, + HashMap::from([(key.clone(), PluginConfigEntry { enabled: true })]), + ) + .unwrap(); + + let found = discover_enabled_plugins_with_config(Some(project), &config); + assert!(found.iter().any(|p| p.name == "demo")); + + let entries: HashMap = + config.get_param(PLUGINS_CONFIG_KEY).unwrap(); + assert!(entries.get(&key).is_some_and(|e| e.enabled)); + } } diff --git a/crates/goose/src/plugins/formats/open_plugins.rs b/crates/goose/src/plugins/formats/open_plugins.rs index 2ad3362db..4cf8ce3bb 100644 --- a/crates/goose/src/plugins/formats/open_plugins.rs +++ b/crates/goose/src/plugins/formats/open_plugins.rs @@ -18,16 +18,18 @@ const MANIFESTS: [&str; 3] = [ ]; const FORMAT: &str = "open-plugins"; -const COMPONENT_MARKERS: &[&str] = &["hooks/hooks.json", "commands", "agents"]; +const COMPONENT_MARKERS: &[&str] = &["hooks/hooks.json", "commands", "agents", ".mcp.json"]; #[derive(Debug, Deserialize)] -struct OpenPluginsManifest { +pub struct OpenPluginsManifest { #[serde(default)] - name: Option, + pub name: Option, #[serde(default)] - version: Option, + pub version: Option, #[serde(default)] - skills: Option, + pub skills: Option, + #[serde(default, rename = "mcpServers")] + pub mcp_servers: Option, } #[derive(Debug)] @@ -88,6 +90,7 @@ fn install_from_manifest( } let skills = find_agent_skills(checkout_dir, manifest.skills.as_ref())?; + validate_mcp_servers(checkout_dir, manifest.mcp_servers.as_ref())?; copy_dir_all(checkout_dir, &destination)?; @@ -146,7 +149,10 @@ pub(in crate::plugins) fn installed_skill_dirs(plugin_dir: &Path) -> Vec Result { +pub(in crate::plugins) fn read_manifest( + plugin_dir: &Path, + source: &str, +) -> Result { let mut manifest = match manifest_path(plugin_dir) { Some(manifest_path) => { serde_json::from_str::(&fs::read_to_string(&manifest_path)?) @@ -156,6 +162,7 @@ fn read_manifest(plugin_dir: &Path, source: &str) -> Result name: None, version: None, skills: None, + mcp_servers: None, }, }; @@ -240,6 +247,50 @@ fn namespaced_component_name(plugin_name: &str, component_name: &str) -> String format!("{plugin_name}:{component_name}") } +fn validate_mcp_servers( + plugin_dir: &Path, + mcp_servers_config: Option<&serde_json::Value>, +) -> Result<()> { + if let Some(value) = mcp_servers_config { + crate::plugins::mcp_servers::validate_mcp_servers_manifest_value(value)?; + } + + for path in mcp_config_paths_for_validation(plugin_dir, mcp_servers_config)? { + if !path.is_file() { + continue; + } + let value = serde_json::from_str::(&fs::read_to_string(&path)?) + .with_context(|| format!("Failed to parse {}", path.display()))?; + crate::plugins::mcp_servers::validate_mcp_server_document(&value)?; + } + + Ok(()) +} + +fn mcp_config_paths_for_validation( + plugin_dir: &Path, + config: Option<&serde_json::Value>, +) -> Result> { + let custom_paths = config + .filter(|value| { + !value + .as_object() + .is_some_and(|object| object.contains_key("mcpServers")) + }) + .map(parse_component_paths) + .transpose()? + .unwrap_or_default(); + + let mut paths = Vec::new(); + if !custom_paths.exclusive { + paths.push(plugin_dir.join(".mcp.json")); + } + for path in custom_paths.paths { + paths.push(plugin_dir.join(validate_relative_plugin_path(&path)?)); + } + Ok(dedupe_paths(paths)) +} + fn find_agent_skills( plugin_dir: &Path, skills_config: Option<&serde_json::Value>, @@ -283,12 +334,14 @@ fn skill_root_directories( } #[derive(Default)] -struct ComponentPaths { - paths: Vec, - exclusive: bool, +pub(in crate::plugins) struct ComponentPaths { + pub paths: Vec, + pub exclusive: bool, } -fn parse_component_paths(value: &serde_json::Value) -> Result { +pub(in crate::plugins) fn parse_component_paths( + value: &serde_json::Value, +) -> Result { match value { serde_json::Value::String(path) => Ok(ComponentPaths { paths: vec![path.clone()], @@ -332,7 +385,7 @@ fn parse_component_paths(value: &serde_json::Value) -> Result { } } -fn validate_relative_plugin_path(path: &str) -> Result { +pub(in crate::plugins) fn validate_relative_plugin_path(path: &str) -> Result { if !path.starts_with("./") { bail!( "Open Plugins component paths must start with './': {}", @@ -460,7 +513,7 @@ fn build_skill_md(name: &str, body: &str) -> String { ) } -fn dedupe_paths(paths: Vec) -> Vec { +pub(in crate::plugins) fn dedupe_paths(paths: Vec) -> Vec { let mut seen = HashSet::new(); paths .into_iter() diff --git a/crates/goose/src/plugins/mcp_servers.rs b/crates/goose/src/plugins/mcp_servers.rs new file mode 100644 index 000000000..b5fa6cee9 --- /dev/null +++ b/crates/goose/src/plugins/mcp_servers.rs @@ -0,0 +1,335 @@ +use crate::agents::extension::{Envs, ExtensionConfig}; +use crate::config::{DEFAULT_EXTENSION_DESCRIPTION, DEFAULT_EXTENSION_TIMEOUT}; +use crate::plugins::discovery::discover_enabled_plugins; +use crate::plugins::formats::open_plugins; +use anyhow::{bail, Context, Result}; +use fs_err as fs; +use serde::Deserialize; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use tracing::warn; + +const DEFAULT_MCP_CONFIG: &str = ".mcp.json"; +const PLUGIN_ROOT: &str = "${PLUGIN_ROOT}"; + +#[derive(Debug, Deserialize)] +struct McpServersDocument { + #[serde(default, rename = "mcpServers")] + mcp_servers: HashMap, +} + +#[derive(Debug, Deserialize)] +struct McpServerConfig { + command: String, + #[serde(default)] + args: Vec, + #[serde(default)] + env: HashMap, + #[serde(default)] + cwd: Option, +} + +pub fn enabled_plugin_mcp_servers(project_root: Option<&Path>) -> Vec { + let mut configs = Vec::new(); + for plugin in discover_enabled_plugins(project_root) { + match plugin_mcp_servers(&plugin.name, &plugin.root) { + Ok(plugin_configs) => configs.extend(plugin_configs), + Err(err) => warn!( + plugin = %plugin.name, + root = %plugin.root.display(), + error = %err, + "Failed to load plugin MCP servers; skipping", + ), + } + } + configs +} + +pub fn plugin_mcp_servers(plugin_name: &str, plugin_root: &Path) -> Result> { + let manifest = open_plugins::read_manifest(plugin_root, "")?; + let mut configs = Vec::new(); + let mut seen = HashSet::new(); + + for path in mcp_config_paths(plugin_root, manifest.mcp_servers.as_ref())? { + if !seen.insert(path.clone()) || !path.is_file() { + continue; + } + + let document = serde_json::from_str::(&fs::read_to_string(&path)?) + .with_context(|| format!("Failed to parse {}", path.display()))?; + configs.extend(document_to_extension_configs( + plugin_name, + plugin_root, + document.mcp_servers, + )); + } + + if let Some(value) = manifest + .mcp_servers + .as_ref() + .filter(|value| is_inline_config(value)) + { + let servers = parse_inline_servers(value)?; + configs.extend(document_to_extension_configs( + plugin_name, + plugin_root, + servers, + )); + } + + Ok(configs) +} + +fn mcp_config_paths( + plugin_root: &Path, + config: Option<&serde_json::Value>, +) -> Result> { + let custom_paths = config + .filter(|value| !is_inline_config(value)) + .map(open_plugins::parse_component_paths) + .transpose()? + .unwrap_or_default(); + + let mut paths = Vec::new(); + if !custom_paths.exclusive { + paths.push(plugin_root.join(DEFAULT_MCP_CONFIG)); + } + + for path in custom_paths.paths { + paths.push(plugin_root.join(open_plugins::validate_relative_plugin_path(&path)?)); + } + + Ok(open_plugins::dedupe_paths(paths)) +} + +fn is_inline_config(value: &serde_json::Value) -> bool { + value.as_object().is_some_and(|object| { + !object + .keys() + .all(|key| matches!(key.as_str(), "paths" | "exclusive")) + }) +} + +fn parse_inline_servers(value: &serde_json::Value) -> Result> { + serde_json::from_value(value.clone()) + .with_context(|| "Failed to parse inline Open Plugins MCP servers") +} + +fn document_to_extension_configs( + plugin_name: &str, + plugin_root: &Path, + servers: HashMap, +) -> Vec { + let mut entries: Vec<_> = servers.into_iter().collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + + entries + .into_iter() + .map(|(server_name, server)| { + server_to_extension_config(plugin_name, plugin_root, server_name, server) + }) + .collect() +} + +fn server_to_extension_config( + plugin_name: &str, + plugin_root: &Path, + server_name: String, + server: McpServerConfig, +) -> ExtensionConfig { + let root = plugin_root.to_string_lossy(); + let mut env = HashMap::from([("PLUGIN_ROOT".to_string(), root.to_string())]); + env.extend( + server + .env + .into_iter() + .map(|(key, value)| (key, expand_plugin_root(&value, &root))), + ); + + ExtensionConfig::Stdio { + name: format!("{plugin_name}:{server_name}"), + description: DEFAULT_EXTENSION_DESCRIPTION.to_string(), + cmd: expand_plugin_root(&server.command, &root), + args: server + .args + .into_iter() + .map(|arg| expand_plugin_root(&arg, &root)) + .collect(), + envs: Envs::new(env), + env_keys: Vec::new(), + timeout: Some(DEFAULT_EXTENSION_TIMEOUT), + cwd: server.cwd.map(|cwd| expand_plugin_root(&cwd, &root)), + bundled: Some(false), + available_tools: Vec::new(), + } +} + +fn expand_plugin_root(value: &str, plugin_root: &str) -> String { + value.replace(PLUGIN_ROOT, plugin_root) +} + +pub fn validate_mcp_servers_manifest_value(value: &serde_json::Value) -> Result<()> { + if is_inline_config(value) { + validate_servers(parse_inline_servers(value)?)?; + return Ok(()); + } + + open_plugins::parse_component_paths(value)?; + Ok(()) +} + +pub fn validate_mcp_server_document(value: &serde_json::Value) -> Result<()> { + let document = serde_json::from_value::(value.clone())?; + validate_servers(document.mcp_servers) +} + +fn validate_servers(servers: HashMap) -> Result<()> { + for (name, server) in servers { + if server.command.trim().is_empty() { + bail!( + "Open Plugins MCP server '{}' command must not be empty", + name + ); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agents::extension::ExtensionConfig; + + #[test] + fn loads_default_mcp_json_with_plugin_root_expansion() { + let plugin = tempfile::tempdir().unwrap(); + fs::write( + plugin.path().join(DEFAULT_MCP_CONFIG), + r#"{ + "mcpServers": { + "database": { + "command": "${PLUGIN_ROOT}/servers/db-server", + "args": ["--config", "${PLUGIN_ROOT}/config.json"], + "env": {"DB_PATH": "${PLUGIN_ROOT}/data"}, + "cwd": "${PLUGIN_ROOT}" + } + } + }"#, + ) + .unwrap(); + + let configs = plugin_mcp_servers("test-plugin", plugin.path()).unwrap(); + assert_eq!(configs.len(), 1); + let ExtensionConfig::Stdio { + name, + cmd, + args, + envs, + cwd, + .. + } = &configs[0] + else { + panic!("expected stdio config"); + }; + + assert_eq!(name, "test-plugin:database"); + assert_eq!( + cmd, + plugin + .path() + .join("servers/db-server") + .to_string_lossy() + .as_ref() + ); + assert_eq!( + args, + &vec![ + "--config".to_string(), + plugin + .path() + .join("config.json") + .to_string_lossy() + .to_string() + ] + ); + assert_eq!( + envs.get_env().get("DB_PATH"), + Some(&plugin.path().join("data").to_string_lossy().to_string()) + ); + assert_eq!( + cwd.as_deref(), + Some(plugin.path().to_string_lossy().as_ref()) + ); + } + + #[test] + fn loads_inline_manifest_mcp_servers() { + let plugin = tempfile::tempdir().unwrap(); + fs::create_dir_all(plugin.path().join(".plugin")).unwrap(); + fs::write( + plugin.path().join(".plugin/plugin.json"), + r#"{ + "name": "test-plugin", + "mcpServers": { + "api": {"command": "npx", "args": ["@company/mcp-server"]} + } + }"#, + ) + .unwrap(); + + let configs = plugin_mcp_servers("test-plugin", plugin.path()).unwrap(); + assert_eq!(configs.len(), 1); + assert_eq!(configs[0].name(), "test-plugin:api"); + } + + #[test] + fn manifest_paths_supplement_default_config() { + let plugin = tempfile::tempdir().unwrap(); + fs::create_dir_all(plugin.path().join(".plugin")).unwrap(); + fs::write( + plugin.path().join(".plugin/plugin.json"), + r#"{"name":"test-plugin","mcpServers":"./custom-mcp.json"}"#, + ) + .unwrap(); + fs::write( + plugin.path().join(DEFAULT_MCP_CONFIG), + r#"{"mcpServers":{"default":{"command":"default-server"}}}"#, + ) + .unwrap(); + fs::write( + plugin.path().join("custom-mcp.json"), + r#"{"mcpServers":{"custom":{"command":"custom-server"}}}"#, + ) + .unwrap(); + + let names: Vec<_> = plugin_mcp_servers("test-plugin", plugin.path()) + .unwrap() + .into_iter() + .map(|config| config.name()) + .collect(); + + assert_eq!(names, vec!["test-plugin:default", "test-plugin:custom"]); + } + + #[test] + fn validates_manifest_mcp_servers_value() { + validate_mcp_servers_manifest_value(&serde_json::json!({ + "api": {"command": "npx"} + })) + .unwrap(); + validate_mcp_servers_manifest_value(&serde_json::json!({ + "paths": ["./mcp.json"], + "exclusive": true + })) + .unwrap(); + } + + #[test] + fn rejects_inline_mcp_server_with_empty_command() { + let error = validate_mcp_servers_manifest_value(&serde_json::json!({ + "api": {"command": ""} + })) + .unwrap_err(); + assert!(error.to_string().contains("command must not be empty")); + } +} diff --git a/crates/goose/src/plugins/mod.rs b/crates/goose/src/plugins/mod.rs index 44805d766..eabe5d8f8 100644 --- a/crates/goose/src/plugins/mod.rs +++ b/crates/goose/src/plugins/mod.rs @@ -1,5 +1,6 @@ pub mod discovery; pub mod formats; +pub mod mcp_servers; use crate::config::paths::Paths; use crate::subprocess::SubprocessExt; diff --git a/crates/goose/src/providers/claude_code.rs b/crates/goose/src/providers/claude_code.rs index 3899a642e..0fb412220 100644 --- a/crates/goose/src/providers/claude_code.rs +++ b/crates/goose/src/providers/claude_code.rs @@ -1128,6 +1128,7 @@ mod tests { envs: Envs::new([("API_KEY".into(), "secret".into())].into()), env_keys: vec![], timeout: None, + cwd: None, bundled: Some(false), available_tools: vec![], }], diff --git a/crates/goose/src/providers/codex.rs b/crates/goose/src/providers/codex.rs index 094c3e10b..91809d346 100644 --- a/crates/goose/src/providers/codex.rs +++ b/crates/goose/src/providers/codex.rs @@ -780,6 +780,7 @@ mod tests { env_keys: vec![], description: "Lookup".into(), timeout: Some(30), + cwd: None, bundled: None, available_tools: vec![], }, @@ -836,6 +837,7 @@ mod tests { env_keys: vec![], description: String::new(), timeout: None, + cwd: None, bundled: None, available_tools: vec![], }, diff --git a/crates/goose/src/recipe/recipe_extension_adapter.rs b/crates/goose/src/recipe/recipe_extension_adapter.rs index 1e1504734..2d88c7aa3 100644 --- a/crates/goose/src/recipe/recipe_extension_adapter.rs +++ b/crates/goose/src/recipe/recipe_extension_adapter.rs @@ -20,6 +20,8 @@ enum RecipeExtensionConfigInternal { env_keys: Vec, timeout: Option, #[serde(default)] + cwd: Option, + #[serde(default)] bundled: Option, #[serde(default)] available_tools: Vec, @@ -122,6 +124,7 @@ impl From for ExtensionConfig { envs, env_keys, timeout, + cwd, bundled, available_tools }, diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs index 3e646bed3..4cee2b1fc 100644 --- a/crates/goose/src/scheduler.rs +++ b/crates/goose/src/scheduler.rs @@ -848,7 +848,12 @@ async fn execute_job( ) .await?; - let extensions = resolve_extensions_for_new_session(recipe.extensions.as_deref(), None); + let mut extensions = resolve_extensions_for_new_session(recipe.extensions.as_deref(), None); + if recipe.extensions.is_none() { + extensions.extend(crate::plugins::mcp_servers::enabled_plugin_mcp_servers( + std::env::current_dir().ok().as_deref(), + )); + } for ext in &extensions { agent.add_extension(ext.clone(), &session.id).await?; } diff --git a/crates/goose/tests/mcp_integration_test.rs b/crates/goose/tests/mcp_integration_test.rs index 859383d7b..0426592c1 100644 --- a/crates/goose/tests/mcp_integration_test.rs +++ b/crates/goose/tests/mcp_integration_test.rs @@ -246,6 +246,7 @@ async fn test_replayed_session( envs, env_keys: vec![], timeout: Some(30), + cwd: None, bundled: Some(false), available_tools: vec![], }; diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 953ff66ae..93466b85c 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -4991,6 +4991,10 @@ "cmd": { "type": "string" }, + "cwd": { + "type": "string", + "nullable": true + }, "description": { "type": "string" }, diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index b174c1b96..1aeb78722 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -392,6 +392,7 @@ export type ExtensionConfig = { available_tools?: Array; bundled?: boolean | null; cmd: string; + cwd?: string | null; description: string; env_keys?: Array; envs?: Envs;