Files
codex/codex-rs/core-plugins/src/store_tests.rs
T
charlesgong-openaiandGitHub 1883dedc0e [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`
2026-06-16 19:22:57 -07:00

441 lines
14 KiB
Rust

use super::*;
use codex_plugin::PluginId;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
fn write_plugin_with_version(
root: &Path,
dir_name: &str,
manifest_name: &str,
manifest_version: Option<&str>,
) {
let plugin_root = root.join(dir_name);
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
fs::create_dir_all(plugin_root.join("skills")).unwrap();
let version = manifest_version
.map(|manifest_version| format!(r#","version":"{manifest_version}""#))
.unwrap_or_default();
fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
format!(r#"{{"name":"{manifest_name}"{version}}}"#),
)
.unwrap();
fs::write(plugin_root.join("skills/SKILL.md"), "skill").unwrap();
fs::write(plugin_root.join(".mcp.json"), r#"{"mcpServers":{}}"#).unwrap();
}
fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) {
write_plugin_with_version(
root,
dir_name,
manifest_name,
/*manifest_version*/ None,
);
}
#[test]
fn try_new_rejects_relative_codex_home() {
let err = PluginStore::try_new(PathBuf::from("relative"))
.expect_err("relative codex home should fail");
let err = err.to_string().replace('\\', "/");
assert_eq!(
err,
"failed to resolve plugin cache root: path is not absolute: relative/plugins/cache"
);
}
#[test]
fn install_copies_plugin_into_default_marketplace() {
let tmp = tempdir().unwrap();
write_plugin(tmp.path(), "sample-plugin", "sample-plugin");
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
let result = PluginStore::new(tmp.path().to_path_buf())
.install(
AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(),
plugin_id.clone(),
)
.unwrap();
let installed_path = tmp.path().join("plugins/cache/debug/sample-plugin/local");
assert_eq!(
result,
PluginInstallResult {
plugin_id,
plugin_version: "local".to_string(),
installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(),
}
);
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
assert!(installed_path.join("skills/SKILL.md").is_file());
}
#[test]
fn install_accepts_manifest_mcp_server_objects() {
let tmp = tempdir().unwrap();
let plugin_root = tmp.path().join("counter-sample");
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{
"name": "counter-sample",
"version": "1.1.1",
"mcpServers": {
"counter": {
"type": "http",
"url": "https://sample.example/counter/mcp"
}
}
}"#,
)
.unwrap();
let plugin_id = PluginId::new("counter-sample".to_string(), "debug".to_string()).unwrap();
let result = PluginStore::new(tmp.path().to_path_buf())
.install(
AbsolutePathBuf::try_from(plugin_root).unwrap(),
plugin_id.clone(),
)
.unwrap();
let installed_path = tmp.path().join("plugins/cache/debug/counter-sample/1.1.1");
assert_eq!(
result,
PluginInstallResult {
plugin_id,
plugin_version: "1.1.1".to_string(),
installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(),
}
);
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
}
#[test]
fn install_uses_manifest_name_for_destination_and_key() {
let tmp = tempdir().unwrap();
write_plugin(tmp.path(), "source-dir", "manifest-name");
let plugin_id = PluginId::new("manifest-name".to_string(), "market".to_string()).unwrap();
let result = PluginStore::new(tmp.path().to_path_buf())
.install(
AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(),
plugin_id.clone(),
)
.unwrap();
assert_eq!(
result,
PluginInstallResult {
plugin_id,
plugin_version: "local".to_string(),
installed_path: AbsolutePathBuf::try_from(
tmp.path().join("plugins/cache/market/manifest-name/local"),
)
.unwrap(),
}
);
}
#[test]
fn plugin_root_derives_path_from_key_and_version() {
let tmp = tempdir().unwrap();
let store = PluginStore::new(tmp.path().to_path_buf());
let plugin_id = PluginId::new("sample".to_string(), "debug".to_string()).unwrap();
assert_eq!(
store.plugin_root(&plugin_id, "local").as_path(),
tmp.path().join("plugins/cache/debug/sample/local")
);
}
#[test]
fn plugin_data_root_derives_path_from_key() {
let tmp = tempdir().unwrap();
let store = PluginStore::new(tmp.path().to_path_buf());
let plugin_id = PluginId::new("sample".to_string(), "debug".to_string()).unwrap();
assert_eq!(
store.plugin_data_root(&plugin_id).as_path(),
tmp.path().join("plugins/data/sample-debug")
);
}
#[test]
fn install_with_version_uses_requested_cache_version() {
let tmp = tempdir().unwrap();
write_plugin(tmp.path(), "sample-plugin", "sample-plugin");
let plugin_id =
PluginId::new("sample-plugin".to_string(), "openai-curated".to_string()).unwrap();
let plugin_version = "0123456789abcdef".to_string();
let result = PluginStore::new(tmp.path().to_path_buf())
.install_with_version(
AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(),
plugin_id.clone(),
plugin_version.clone(),
)
.unwrap();
let installed_path = tmp.path().join(format!(
"plugins/cache/openai-curated/sample-plugin/{plugin_version}"
));
assert_eq!(
result,
PluginInstallResult {
plugin_id,
plugin_version,
installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(),
}
);
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
}
#[test]
fn install_uses_manifest_version_when_present() {
let tmp = tempdir().unwrap();
write_plugin_with_version(
tmp.path(),
"sample-plugin",
"sample-plugin",
Some("1.2.3-beta+7"),
);
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
let result = PluginStore::new(tmp.path().to_path_buf())
.install(
AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(),
plugin_id.clone(),
)
.unwrap();
let installed_path = tmp
.path()
.join("plugins/cache/debug/sample-plugin/1.2.3-beta+7");
assert_eq!(
result,
PluginInstallResult {
plugin_id,
plugin_version: "1.2.3-beta+7".to_string(),
installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(),
}
);
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
}
#[test]
fn install_rejects_blank_manifest_version() {
let tmp = tempdir().unwrap();
write_plugin_with_version(tmp.path(), "sample-plugin", "sample-plugin", Some(" "));
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
let err = PluginStore::new(tmp.path().to_path_buf())
.install(
AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(),
plugin_id,
)
.expect_err("blank manifest version should be rejected");
let err = err.to_string().replace('\\', "/");
assert_eq!(
err,
"invalid plugin version in plugin.json: must not be blank"
);
}
#[test]
fn active_plugin_version_reads_version_directory_name() {
let tmp = tempdir().unwrap();
write_plugin(
&tmp.path().join("plugins/cache/debug"),
"sample-plugin/local",
"sample-plugin",
);
let store = PluginStore::new(tmp.path().to_path_buf());
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
assert_eq!(
store.active_plugin_version(&plugin_id),
Some("local".to_string())
);
assert_eq!(
store.active_plugin_root(&plugin_id).unwrap().as_path(),
tmp.path().join("plugins/cache/debug/sample-plugin/local")
);
}
#[test]
fn active_plugin_version_prefers_default_local_version_when_multiple_versions_exist() {
let tmp = tempdir().unwrap();
write_plugin(
&tmp.path().join("plugins/cache/debug"),
"sample-plugin/0123456789abcdef",
"sample-plugin",
);
write_plugin(
&tmp.path().join("plugins/cache/debug"),
"sample-plugin/local",
"sample-plugin",
);
let store = PluginStore::new(tmp.path().to_path_buf());
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
assert_eq!(
store.active_plugin_version(&plugin_id),
Some("local".to_string())
);
}
#[test]
fn active_plugin_version_returns_latest_version_when_default_is_missing() {
let tmp = tempdir().unwrap();
write_plugin(
&tmp.path().join("plugins/cache/debug"),
"sample-plugin/0123456789abcdef",
"sample-plugin",
);
write_plugin(
&tmp.path().join("plugins/cache/debug"),
"sample-plugin/fedcba9876543210",
"sample-plugin",
);
let store = PluginStore::new(tmp.path().to_path_buf());
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
assert_eq!(
store.active_plugin_version(&plugin_id),
Some("fedcba9876543210".to_string())
);
}
#[test]
fn active_plugin_version_compares_semver_versions_semantically() {
let tmp = tempdir().unwrap();
write_plugin(
&tmp.path().join("plugins/cache/debug"),
"sample-plugin/9.0.0",
"sample-plugin",
);
write_plugin(
&tmp.path().join("plugins/cache/debug"),
"sample-plugin/10.0.0",
"sample-plugin",
);
let store = PluginStore::new(tmp.path().to_path_buf());
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
assert_eq!(
store.active_plugin_version(&plugin_id),
Some("10.0.0".to_string())
);
}
#[test]
fn install_with_new_version_keeps_existing_plugin_root_and_prunes_old_versions() {
let tmp = tempdir().unwrap();
let store = PluginStore::new(tmp.path().to_path_buf());
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
write_plugin_with_version(tmp.path(), "v1", "sample-plugin", Some("1.0.0"));
store
.install(
AbsolutePathBuf::try_from(tmp.path().join("v1")).unwrap(),
plugin_id.clone(),
)
.unwrap();
write_plugin_with_version(tmp.path(), "v2", "sample-plugin", Some("2.0.0"));
store
.install(
AbsolutePathBuf::try_from(tmp.path().join("v2")).unwrap(),
plugin_id.clone(),
)
.unwrap();
assert_eq!(
store.active_plugin_version(&plugin_id),
Some("2.0.0".to_string())
);
assert!(
tmp.path()
.join("plugins/cache/debug/sample-plugin/2.0.0")
.is_dir()
);
assert!(
!tmp.path()
.join("plugins/cache/debug/sample-plugin/1.0.0")
.exists()
);
}
#[test]
fn old_plugin_version_would_stay_active_for_local_or_later_versions() {
assert!(old_plugin_version_would_stay_active(
DEFAULT_PLUGIN_VERSION,
"1.0.0"
));
assert!(old_plugin_version_would_stay_active("10.0.0", "9.0.0"));
assert!(!old_plugin_version_would_stay_active("1.0.0", "2.0.0"));
}
#[test]
fn plugin_root_rejects_path_separators_in_key_segments() {
let err = PluginId::parse("../../etc@debug").unwrap_err();
assert_eq!(
err.to_string(),
"invalid plugin name: only ASCII letters, digits, `_`, and `-` are allowed in `../../etc@debug`"
);
let err = PluginId::parse("sample@../../etc").unwrap_err();
assert_eq!(
err.to_string(),
"invalid marketplace name: only ASCII letters, digits, `_`, and `-` are allowed in `sample@../../etc`"
);
}
#[test]
fn install_rejects_manifest_names_with_path_separators() {
let tmp = tempdir().unwrap();
write_plugin(tmp.path(), "source-dir", "../../etc");
let err = PluginStore::new(tmp.path().to_path_buf())
.install(
AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(),
PluginId::new("source-dir".to_string(), "debug".to_string()).unwrap(),
)
.unwrap_err();
assert_eq!(
err.to_string(),
"invalid plugin name: only ASCII letters, digits, `_`, and `-` are allowed"
);
}
#[test]
fn install_rejects_marketplace_names_with_path_separators() {
let err = PluginId::new("sample-plugin".to_string(), "../../etc".to_string()).unwrap_err();
assert_eq!(
err.to_string(),
"invalid marketplace name: only ASCII letters, digits, `_`, and `-` are allowed"
);
}
#[test]
fn install_rejects_manifest_names_that_do_not_match_marketplace_plugin_name() {
let tmp = tempdir().unwrap();
write_plugin(tmp.path(), "source-dir", "manifest-name");
let err = PluginStore::new(tmp.path().to_path_buf())
.install(
AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(),
PluginId::new("different-name".to_string(), "debug".to_string()).unwrap(),
)
.unwrap_err();
assert_eq!(
err.to_string(),
"plugin.json name `manifest-name` does not match marketplace plugin name `different-name`"
);
}