mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[codex] Support object-valued plugin MCP manifests (#28580)
## Summary
This fixes plugin manifest parsing for MCP servers declared as an object
directly in `plugin.json`.
Before this change, Codex modeled `mcpServers` as only a string path,
for example:
```json
{
"name": "counter-sample",
"version": "1.1.1",
"mcpServers": "./.mcp.json"
}
```
Some migrated plugins instead provide the server map directly in the
manifest:
```json
{
"name": "counter-sample",
"version": "1.1.1",
"description": "Plugin that declares MCP servers in the manifest",
"mcpServers": {
"counter": {
"type": "http",
"url": "https://sample.example/counter/mcp"
}
}
}
```
That object form previously failed during install/load with an error
like:
```text
failed to parse plugin manifest: invalid type: map, expected a string
```
## What changed
- Add a manifest representation for `mcpServers` as either
`Path(Resource)` or `Object(map)`.
- Parse `plugin.json` `mcpServers` as either a string path or an object.
- Route object-valued MCP server maps through the existing plugin MCP
config parser instead of adding a second parser.
- Apply existing per-plugin MCP server policy to object-valued MCP
servers the same way as file-backed MCP servers.
- Include object-valued MCP server names in plugin telemetry/capability
metadata.
- Support object-valued MCP config for executor plugins without
requiring a `.mcp.json` filesystem read.
- Update the bundled plugin-creator validator and `plugin-json-spec.md`
so generated-plugin validation accepts the same object-valued shape.
## Compatibility
Existing plugin manifests that use `"mcpServers": "./.mcp.json"`
continue to work. Plugins can now also use the object shape shown above.
## Tests
Added coverage for the new manifest attribute shape at the install,
normal load, telemetry, and executor-provider layers:
- `install_accepts_manifest_mcp_server_objects`
- `load_plugins_loads_manifest_mcp_server_objects`
- `plugin_telemetry_metadata_uses_manifest_mcp_server_objects`
- `reads_manifest_object_config_without_executor_file_system_access`
Also smoke-tested the plugin-creator validator against both supported
forms:
- `mcpServers` as a direct object in `plugin.json`
- `mcpServers` as `"./.mcp.json"` with a companion `.mcp.json`
## Validation
- `just test -p codex-plugin`
- `just test -p codex-core-plugins`
- `just test -p codex-mcp-extension`
- `just bazel-lock-update`
- `just bazel-lock-check`
- `just fmt`
- `git diff --check`
- Focused rename/object-form rerun: `just test -p codex-core-plugins
manager::tests::load_plugins_loads_manifest_mcp_server_objects
manager::tests::plugin_telemetry_metadata_uses_manifest_mcp_server_objects
store::tests::install_accepts_manifest_mcp_server_objects`
- Focused executor rerun: `just test -p codex-mcp-extension
executor_plugin::provider::tests::reads_manifest_object_config_without_executor_file_system_access`
- `python3
codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py
/private/tmp/codex-validator-object`
- `python3
codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py
/private/tmp/codex-validator-path`
This commit is contained in:
@@ -7,6 +7,7 @@ use codex_mcp::parse_plugin_mcp_config;
|
||||
use codex_plugin::PluginResourceLocator;
|
||||
use codex_plugin::ResolvedPlugin;
|
||||
use codex_plugin::ResolvedPluginLocation;
|
||||
use codex_plugin::manifest::PluginManifestMcpServers;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use std::io;
|
||||
@@ -56,26 +57,47 @@ async fn load_from_file_system(
|
||||
) -> Result<Vec<(String, McpServerConfig)>, ExecutorPluginMcpProviderError> {
|
||||
let ResolvedPluginLocation::Environment { environment_id, .. } = plugin.location();
|
||||
let plugin_id = plugin.selected_root_id();
|
||||
let (config_path, is_default) = match plugin.manifest().paths.mcp_servers.as_ref() {
|
||||
Some(PluginResourceLocator::Environment { path, .. }) => (path.clone(), false),
|
||||
None => (plugin_root.join(DEFAULT_MCP_CONFIG_FILE), true),
|
||||
};
|
||||
let config_uri = PathUri::from_abs_path(&config_path);
|
||||
|
||||
let contents = match file_system
|
||||
.read_file_text(&config_uri, /*sandbox*/ None)
|
||||
.await
|
||||
{
|
||||
Ok(contents) => contents,
|
||||
Err(source) if is_default && source.kind() == io::ErrorKind::NotFound => {
|
||||
return Ok(Vec::new());
|
||||
let (contents, config_path) = match plugin.manifest().paths.mcp_servers.as_ref() {
|
||||
Some(PluginManifestMcpServers::Path(PluginResourceLocator::Environment {
|
||||
path, ..
|
||||
})) => {
|
||||
let config_uri = PathUri::from_abs_path(path);
|
||||
(
|
||||
file_system
|
||||
.read_file_text(&config_uri, /*sandbox*/ None)
|
||||
.await
|
||||
.map_err(|source| ExecutorPluginMcpProviderError::ReadConfig {
|
||||
plugin_id: plugin_id.to_string(),
|
||||
path: path.clone(),
|
||||
source,
|
||||
})?,
|
||||
path.clone(),
|
||||
)
|
||||
}
|
||||
Err(source) => {
|
||||
return Err(ExecutorPluginMcpProviderError::ReadConfig {
|
||||
plugin_id: plugin_id.to_string(),
|
||||
path: config_path.clone(),
|
||||
source,
|
||||
});
|
||||
Some(PluginManifestMcpServers::Object(object_config)) => (
|
||||
object_config.clone(),
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
),
|
||||
None => {
|
||||
let config_path = plugin_root.join(DEFAULT_MCP_CONFIG_FILE);
|
||||
let config_uri = PathUri::from_abs_path(&config_path);
|
||||
let contents = match file_system
|
||||
.read_file_text(&config_uri, /*sandbox*/ None)
|
||||
.await
|
||||
{
|
||||
Ok(contents) => contents,
|
||||
Err(source) if source.kind() == io::ErrorKind::NotFound => {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Err(source) => {
|
||||
return Err(ExecutorPluginMcpProviderError::ReadConfig {
|
||||
plugin_id: plugin_id.to_string(),
|
||||
path: config_path.clone(),
|
||||
source,
|
||||
});
|
||||
}
|
||||
};
|
||||
(contents, config_path)
|
||||
}
|
||||
};
|
||||
let parsed = parse_plugin_mcp_config(
|
||||
|
||||
@@ -15,6 +15,7 @@ use codex_exec_server::ReadDirectoryEntry;
|
||||
use codex_exec_server::RemoveOptions;
|
||||
use codex_plugin::ResolvedPlugin;
|
||||
use codex_plugin::manifest::PluginManifest;
|
||||
use codex_plugin::manifest::PluginManifestMcpServers;
|
||||
use codex_plugin::manifest::PluginManifestPaths;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
@@ -144,7 +145,10 @@ async fn reads_declared_config_only_through_executor_file_system() {
|
||||
.expect("absolute plugin root");
|
||||
assert!(!plugin_root.as_path().exists());
|
||||
let config_path = plugin_root.join("config/mcp.json");
|
||||
let plugin = resolved_plugin(&plugin_root, Some(config_path.clone()));
|
||||
let plugin = resolved_plugin(
|
||||
&plugin_root,
|
||||
Some(PluginManifestMcpServers::Path(config_path.clone())),
|
||||
);
|
||||
let file_system = SyntheticExecutorFileSystem {
|
||||
config_path: config_path.clone(),
|
||||
config_contents: Some(MCP_CONFIG_CONTENTS),
|
||||
@@ -187,6 +191,60 @@ async fn reads_declared_config_only_through_executor_file_system() {
|
||||
assert_eq!(reads(&file_system), vec![config_path]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reads_manifest_object_config_without_executor_file_system_access() {
|
||||
let temp_dir = tempfile::tempdir().expect("tempdir");
|
||||
let plugin_root = AbsolutePathBuf::from_absolute_path_checked(temp_dir.path().join("plugin"))
|
||||
.expect("absolute plugin root");
|
||||
let config_path = plugin_root.join(DEFAULT_MCP_CONFIG_FILE);
|
||||
let plugin = resolved_plugin(
|
||||
&plugin_root,
|
||||
Some(PluginManifestMcpServers::Object(
|
||||
r#"{"counter":{"command":"counter-mcp","environment_id":"local"}}"#.to_string(),
|
||||
)),
|
||||
);
|
||||
let file_system = SyntheticExecutorFileSystem {
|
||||
config_path,
|
||||
config_contents: None,
|
||||
reads: Mutex::new(Vec::new()),
|
||||
};
|
||||
|
||||
let servers = load_from_file_system(&plugin, &plugin_root, &file_system)
|
||||
.await
|
||||
.expect("load manifest object executor MCP config");
|
||||
|
||||
assert_eq!(
|
||||
servers,
|
||||
vec![(
|
||||
"counter".to_string(),
|
||||
McpServerConfig {
|
||||
transport: McpServerTransportConfig::Stdio {
|
||||
command: "counter-mcp".to_string(),
|
||||
args: Vec::new(),
|
||||
env: None,
|
||||
env_vars: Vec::new(),
|
||||
cwd: Some(plugin_root.to_path_buf()),
|
||||
},
|
||||
environment_id: "executor-test".to_string(),
|
||||
enabled: true,
|
||||
required: false,
|
||||
supports_parallel_tool_calls: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
default_tools_approval_mode: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
)]
|
||||
);
|
||||
assert_eq!(reads(&file_system), Vec::new());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_default_config_is_empty() {
|
||||
let temp_dir = tempfile::tempdir().expect("tempdir");
|
||||
@@ -214,7 +272,10 @@ async fn malformed_declared_config_is_an_error() {
|
||||
let plugin_root = AbsolutePathBuf::from_absolute_path_checked(temp_dir.path().join("plugin"))
|
||||
.expect("absolute plugin root");
|
||||
let config_path = plugin_root.join("mcp.json");
|
||||
let plugin = resolved_plugin(&plugin_root, Some(config_path.clone()));
|
||||
let plugin = resolved_plugin(
|
||||
&plugin_root,
|
||||
Some(PluginManifestMcpServers::Path(config_path.clone())),
|
||||
);
|
||||
let file_system = SyntheticExecutorFileSystem {
|
||||
config_path: config_path.clone(),
|
||||
config_contents: Some("{not-json"),
|
||||
@@ -242,7 +303,7 @@ async fn malformed_declared_config_is_an_error() {
|
||||
|
||||
fn resolved_plugin(
|
||||
plugin_root: &AbsolutePathBuf,
|
||||
mcp_servers: Option<AbsolutePathBuf>,
|
||||
mcp_servers: Option<PluginManifestMcpServers<AbsolutePathBuf>>,
|
||||
) -> ResolvedPlugin {
|
||||
ResolvedPlugin::from_environment(
|
||||
"selected-root".to_string(),
|
||||
|
||||
Reference in New Issue
Block a user