diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index f3e5f8d4f1..21c5aa6b43 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -67,11 +67,26 @@ pub fn log_plugin_load_errors(outcome: &PluginLoadOutcome) { #[derive(Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] -struct PluginMcpFile { - #[serde(default)] +struct PluginMcpServersFile { mcp_servers: HashMap, } +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum PluginMcpFile { + McpServersObject(PluginMcpServersFile), + ServerMap(HashMap), +} + +impl PluginMcpFile { + fn into_mcp_servers(self) -> HashMap { + match self { + Self::McpServersObject(file) => file.mcp_servers, + Self::ServerMap(mcp_servers) => mcp_servers, + } + } +} + #[derive(Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] struct PluginAppFile { @@ -775,7 +790,7 @@ async fn load_mcp_servers_from_file( }; normalize_plugin_mcp_servers( plugin_root, - parsed.mcp_servers, + parsed.into_mcp_servers(), mcp_config_path.to_string_lossy().as_ref(), ) } @@ -988,6 +1003,82 @@ mod tests { use super::*; use pretty_assertions::assert_eq; + #[test] + fn plugin_mcp_file_supports_mcp_servers_object_format() { + let parsed = serde_json::from_str::( + r#"{ + "mcpServers": { + "sample": { + "command": "sample-mcp" + } + } +}"#, + ) + .expect("parse wrapped plugin mcp config") + .into_mcp_servers(); + + assert_eq!( + parsed, + HashMap::from([( + "sample".to_string(), + serde_json::json!({ + "command": "sample-mcp" + }), + )]) + ); + } + + #[test] + fn plugin_mcp_file_supports_mcp_servers_object_format_with_metadata() { + let parsed = serde_json::from_str::( + r#"{ + "$schema": "https://example.com/plugin-mcp.schema.json", + "mcpServers": { + "sample": { + "command": "sample-mcp" + } + } +}"#, + ) + .expect("parse plugin mcp config with metadata") + .into_mcp_servers(); + + assert_eq!( + parsed, + HashMap::from([( + "sample".to_string(), + serde_json::json!({ + "command": "sample-mcp" + }), + )]) + ); + } + + #[test] + fn plugin_mcp_file_supports_top_level_server_map_format() { + let parsed = serde_json::from_str::( + r#"{ + "linear": { + "type": "http", + "url": "https://mcp.linear.app/mcp" + } +}"#, + ) + .expect("parse flat plugin mcp config") + .into_mcp_servers(); + + assert_eq!( + parsed, + HashMap::from([( + "linear".to_string(), + serde_json::json!({ + "type": "http", + "url": "https://mcp.linear.app/mcp" + }), + )]) + ); + } + #[test] fn materialize_git_subdir_uses_sparse_checkout() { let codex_home = tempfile::tempdir().expect("create codex home");