[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:
charlesgong-openai
2026-06-16 19:22:57 -07:00
committed by GitHub
Unverified
parent 6f77491e95
commit 1883dedc0e
10 changed files with 472 additions and 75 deletions
+89 -33
View File
@@ -2,6 +2,7 @@ use crate::app_mcp_routing::apply_app_mcp_routing_policy;
use crate::app_mcp_routing::apps_route_available;
use crate::is_openai_curated_marketplace_name;
use crate::manifest::PluginManifestHooks;
use crate::manifest::PluginManifestMcpServers;
use crate::manifest::PluginManifestPaths;
use crate::manifest::load_plugin_manifest;
use crate::marketplace::MarketplacePluginSource;
@@ -724,25 +725,12 @@ async fn load_plugin(
let has_enabled_skills = resolved_skills.has_enabled_skills();
loaded_plugin.disabled_skill_paths = resolved_skills.disabled_skill_paths;
loaded_plugin.has_enabled_skills = has_enabled_skills;
let mut mcp_servers = HashMap::new();
for mcp_config_path in plugin_mcp_config_paths(plugin_root.as_path(), manifest_paths) {
let plugin_mcp =
load_mcp_servers_from_file(plugin_root.as_path(), &mcp_config_path).await;
for (name, mut config) in plugin_mcp.mcp_servers {
if let Some(policy) = plugin.mcp_servers.get(&name) {
apply_plugin_mcp_server_policy(&mut config, policy);
}
if mcp_servers.insert(name.clone(), config).is_some() {
warn!(
plugin = %plugin_root.display(),
path = %mcp_config_path.display(),
server = name,
"plugin MCP file overwrote an earlier server definition"
);
}
}
}
loaded_plugin.mcp_servers = mcp_servers;
loaded_plugin.mcp_servers = load_plugin_mcp_servers_from_manifest(
plugin_root.as_path(),
manifest_paths,
Some(&plugin.mcp_servers),
)
.await;
loaded_plugin.apps = load_plugin_apps(plugin_root.as_path()).await;
}
PluginLoadScope::HooksOnly => {}
@@ -853,7 +841,7 @@ fn plugin_mcp_config_paths(
plugin_root: &Path,
manifest_paths: &PluginManifestPaths,
) -> Vec<AbsolutePathBuf> {
if let Some(path) = &manifest_paths.mcp_servers {
if let Some(PluginManifestMcpServers::Path(path)) = &manifest_paths.mcp_servers {
return vec![path.clone()];
}
default_mcp_config_paths(plugin_root)
@@ -1094,15 +1082,14 @@ pub async fn plugin_telemetry_metadata_from_root(
let manifest_paths = &manifest.paths;
let has_skills = !plugin_skill_roots(plugin_root, manifest_paths).is_empty();
let mut mcp_server_names = Vec::new();
for path in plugin_mcp_config_paths(plugin_root.as_path(), manifest_paths) {
mcp_server_names.extend(
load_mcp_servers_from_file(plugin_root.as_path(), &path)
.await
.mcp_servers
.into_keys(),
);
}
let mut mcp_server_names = load_plugin_mcp_servers_from_manifest(
plugin_root.as_path(),
manifest_paths,
/*plugin_policy*/ None,
)
.await
.into_keys()
.collect::<Vec<_>>();
mcp_server_names.sort_unstable();
mcp_server_names.dedup();
@@ -1151,11 +1138,49 @@ async fn load_declared_plugin_mcp_servers(plugin_root: &Path) -> HashMap<String,
return HashMap::new();
};
load_plugin_mcp_servers_from_manifest(plugin_root, &manifest.paths, /*plugin_policy*/ None)
.await
}
async fn load_plugin_mcp_servers_from_manifest(
plugin_root: &Path,
manifest_paths: &PluginManifestPaths,
plugin_policy: Option<&HashMap<String, PluginMcpServerConfig>>,
) -> HashMap<String, McpServerConfig> {
let mut mcp_servers = HashMap::new();
for mcp_config_path in plugin_mcp_config_paths(plugin_root, &manifest.paths) {
let plugin_mcp = load_mcp_servers_from_file(plugin_root, &mcp_config_path).await;
for (name, config) in plugin_mcp.mcp_servers {
mcp_servers.entry(name).or_insert(config);
match &manifest_paths.mcp_servers {
Some(PluginManifestMcpServers::Object(object_servers)) => {
let plugin_mcp = load_mcp_servers_from_manifest_object(plugin_root, object_servers);
for (name, mut config) in plugin_mcp.mcp_servers {
if let Some(policy) = plugin_policy.and_then(|policy| policy.get(&name)) {
apply_plugin_mcp_server_policy(&mut config, policy);
}
if mcp_servers.insert(name.clone(), config).is_some() {
warn!(
plugin = %plugin_root.display(),
server = name,
"plugin manifest MCP object overwrote an earlier server definition"
);
}
}
}
Some(PluginManifestMcpServers::Path(_)) | None => {
for mcp_config_path in plugin_mcp_config_paths(plugin_root, manifest_paths) {
let plugin_mcp = load_mcp_servers_from_file(plugin_root, &mcp_config_path).await;
for (name, mut config) in plugin_mcp.mcp_servers {
if let Some(policy) = plugin_policy.and_then(|policy| policy.get(&name)) {
apply_plugin_mcp_server_policy(&mut config, policy);
}
if mcp_servers.insert(name.clone(), config).is_some() {
warn!(
plugin = %plugin_root.display(),
path = %mcp_config_path.display(),
server = name,
"plugin MCP file overwrote an earlier server definition"
);
}
}
}
}
}
@@ -1212,6 +1237,37 @@ async fn load_mcp_servers_from_file(
}
}
fn load_mcp_servers_from_manifest_object(
plugin_root: &Path,
object_config: &str,
) -> PluginMcpDiscovery {
let parsed = match parse_plugin_mcp_config(
plugin_root,
object_config,
PluginMcpServerPlacement::Declared,
) {
Ok(parsed) => parsed,
Err(err) => {
warn!(
plugin = %plugin_root.display(),
"failed to parse plugin manifest MCP object: {err}"
);
return PluginMcpDiscovery::default();
}
};
for error in parsed.errors {
warn!(
plugin = %plugin_root.display(),
server = error.name,
error = error.message,
"failed to parse plugin manifest MCP object server"
);
}
PluginMcpDiscovery {
mcp_servers: parsed.servers.into_iter().collect(),
}
}
#[derive(Debug, Default)]
struct PluginMcpDiscovery {
mcp_servers: HashMap<String, McpServerConfig>,