Support MCP extensions in open plugins (#9471)

Signed-off-by: Douwe M Osinga <douwe@sidewalklabs.com>
Co-authored-by: Douwe M Osinga <douwe@sidewalklabs.com>
This commit is contained in:
Jack Amadeo
2026-06-17 14:52:54 -04:00
committed by GitHub
parent 1701f5279a
commit b0f5db2a07
24 changed files with 623 additions and 27 deletions
@@ -1080,6 +1080,7 @@ fn configure_stdio_extension() -> anyhow::Result<()> {
env_keys,
description,
timeout: Some(timeout),
cwd: None,
bundled: None,
available_tools: Vec::new(),
},
@@ -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(),
+8 -1
View File
@@ -411,6 +411,7 @@ async fn collect_extension_configs(
recipe: Option<&Recipe>,
session_id: &str,
) -> Result<Vec<ExtensionConfig>, ExtensionError> {
let recipe_extensions = recipe.and_then(|r| r.extensions.as_deref());
let configured_extensions: Vec<ExtensionConfig> = 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<ExtensionConfig> = 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)
+4
View File
@@ -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![],
}
+7 -1
View File
@@ -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);
+1
View File
@@ -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![],
},
+2
View File
@@ -356,6 +356,7 @@ fn mcp_server_to_extension_config(mcp_server: McpServer) -> Result<ExtensionConf
envs: Envs::new(stdio.env.into_iter().map(|e| (e.name, e.value)).collect()),
env_keys: vec![],
timeout,
cwd: None,
bundled: Some(false),
available_tools: vec![],
})
@@ -2954,6 +2955,7 @@ mod tests {
),
env_keys: vec![],
timeout: None,
cwd: None,
bundled: Some(false),
available_tools: vec![],
})
@@ -279,6 +279,7 @@ fn goose_extension_to_config(
envs: Envs::default(),
env_keys,
timeout,
cwd: None,
bundled,
available_tools: Vec::new(),
}
@@ -421,6 +422,7 @@ mod tests {
)])),
env_keys: vec!["SECRET_TOKEN".to_string()],
timeout: Some(42),
cwd: None,
bundled: None,
available_tools: vec![],
};
@@ -596,6 +598,7 @@ mod tests {
timeout,
bundled,
available_tools,
..
} = conversion.config
else {
panic!("expected stdio config");
@@ -519,6 +519,7 @@ fn apply_claude_desktop_candidate(
envs: Envs::new(server.env),
env_keys: Vec::new(),
timeout: Some(crate::config::DEFAULT_EXTENSION_TIMEOUT),
cwd: None,
bundled: None,
available_tools: Vec::new(),
},
+14 -1
View File
@@ -181,6 +181,8 @@ pub enum ExtensionConfig {
env_keys: Vec<String>,
timeout: Option<u64>,
#[serde(default)]
cwd: Option<String>,
#[serde(default)]
bundled: Option<bool>,
#[serde(default)]
available_tools: Vec<String>,
@@ -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![],
}
+6 -1
View File
@@ -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(),
+1
View File
@@ -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()],
},
+8 -2
View File
@@ -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 ---
+146 -8
View File
@@ -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<DiscoveredPlugin> {
discover_enabled_plugins_with_config(project_root, Config::global())
}
fn discover_enabled_plugins_with_config(
project_root: Option<&Path>,
config: &Config,
) -> Vec<DiscoveredPlugin> {
let scoped_settings = load_all_settings(project_root);
let mut found: HashMap<String, DiscoveredPlugin> = HashMap::new();
@@ -60,10 +77,46 @@ pub fn discover_enabled_plugins(project_root: Option<&Path>) -> Vec<DiscoveredPl
});
}
found
let enabled_by_settings: Vec<DiscoveredPlugin> = 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<DiscoveredPlugin>, config: &Config) -> Vec<DiscoveredPlugin> {
let mut entries: HashMap<String, PluginConfigEntry> =
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<DiscoveredPlugin> {
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::<Vec<_>>()
);
}
#[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<String, PluginConfigEntry> =
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<String, PluginConfigEntry> =
config.get_param(PLUGINS_CONFIG_KEY).unwrap();
assert!(entries.get(&key).is_some_and(|e| e.enabled));
}
}
@@ -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<String>,
pub name: Option<String>,
#[serde(default)]
version: Option<String>,
pub version: Option<String>,
#[serde(default)]
skills: Option<serde_json::Value>,
pub skills: Option<serde_json::Value>,
#[serde(default, rename = "mcpServers")]
pub mcp_servers: Option<serde_json::Value>,
}
#[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<PathBuf
dedupe_paths(dirs)
}
fn read_manifest(plugin_dir: &Path, source: &str) -> Result<OpenPluginsManifest> {
pub(in crate::plugins) fn read_manifest(
plugin_dir: &Path,
source: &str,
) -> Result<OpenPluginsManifest> {
let mut manifest = match manifest_path(plugin_dir) {
Some(manifest_path) => {
serde_json::from_str::<OpenPluginsManifest>(&fs::read_to_string(&manifest_path)?)
@@ -156,6 +162,7 @@ fn read_manifest(plugin_dir: &Path, source: &str) -> Result<OpenPluginsManifest>
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::<serde_json::Value>(&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<Vec<PathBuf>> {
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<String>,
exclusive: bool,
pub(in crate::plugins) struct ComponentPaths {
pub paths: Vec<String>,
pub exclusive: bool,
}
fn parse_component_paths(value: &serde_json::Value) -> Result<ComponentPaths> {
pub(in crate::plugins) fn parse_component_paths(
value: &serde_json::Value,
) -> Result<ComponentPaths> {
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<ComponentPaths> {
}
}
fn validate_relative_plugin_path(path: &str) -> Result<PathBuf> {
pub(in crate::plugins) fn validate_relative_plugin_path(path: &str) -> Result<PathBuf> {
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<PathBuf>) -> Vec<PathBuf> {
pub(in crate::plugins) fn dedupe_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
let mut seen = HashSet::new();
paths
.into_iter()
+335
View File
@@ -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<String, McpServerConfig>,
}
#[derive(Debug, Deserialize)]
struct McpServerConfig {
command: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: HashMap<String, String>,
#[serde(default)]
cwd: Option<String>,
}
pub fn enabled_plugin_mcp_servers(project_root: Option<&Path>) -> Vec<ExtensionConfig> {
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<Vec<ExtensionConfig>> {
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::<McpServersDocument>(&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<Vec<PathBuf>> {
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<HashMap<String, McpServerConfig>> {
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<String, McpServerConfig>,
) -> Vec<ExtensionConfig> {
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::<McpServersDocument>(value.clone())?;
validate_servers(document.mcp_servers)
}
fn validate_servers(servers: HashMap<String, McpServerConfig>) -> 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"));
}
}
+1
View File
@@ -1,5 +1,6 @@
pub mod discovery;
pub mod formats;
pub mod mcp_servers;
use crate::config::paths::Paths;
use crate::subprocess::SubprocessExt;
@@ -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![],
}],
+2
View File
@@ -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![],
},
@@ -20,6 +20,8 @@ enum RecipeExtensionConfigInternal {
env_keys: Vec<String>,
timeout: Option<u64>,
#[serde(default)]
cwd: Option<String>,
#[serde(default)]
bundled: Option<bool>,
#[serde(default)]
available_tools: Vec<String>,
@@ -122,6 +124,7 @@ impl From<RecipeExtensionConfigInternal> for ExtensionConfig {
envs,
env_keys,
timeout,
cwd,
bundled,
available_tools
},
+6 -1
View File
@@ -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?;
}
@@ -246,6 +246,7 @@ async fn test_replayed_session(
envs,
env_keys: vec![],
timeout: Some(30),
cwd: None,
bundled: Some(false),
available_tools: vec![],
};
+4
View File
@@ -4991,6 +4991,10 @@
"cmd": {
"type": "string"
},
"cwd": {
"type": "string",
"nullable": true
},
"description": {
"type": "string"
},
+1
View File
@@ -392,6 +392,7 @@ export type ExtensionConfig = {
available_tools?: Array<string>;
bundled?: boolean | null;
cmd: string;
cwd?: string | null;
description: string;
env_keys?: Array<string>;
envs?: Envs;