Add Agent Plugins MCP config parsing (#36796)

## What changed

- Add `parse_agent_plugin_mcp_config` to translate Agent Plugins v1 `mcp.json` files into Codex MCP server configuration.
- Normalize `stdio` and streamable HTTP transports, including `PLUGIN_ROOT` and `PLUGIN_DATA` expansion, contained plugin paths, secure endpoint validation, and filtering of client-owned HTTP headers.
- Keep valid sibling servers when another server is invalid, while returning per-server parse errors.
- Preserve UTF-8 HTTP header values when forwarding streamable HTTP protocol headers.

## Testing

- Cover transport mapping, placeholder expansion, path containment, schema and field validation, per-server errors, platform-specific environment handling, and UTF-8 headers.

GitOrigin-RevId: ed4ab0fcf495afbb381ce48beb93989629444c56
This commit is contained in:
jacobzhou-oai
2026-08-03 20:31:39 +00:00
committed by copyberry
parent 3149fa4b99
commit bd12b3a9ec
6 changed files with 1052 additions and 1 deletions

View File

@@ -0,0 +1,532 @@
use super::PluginMcpConfigParseOutcome;
use super::PluginMcpServerParseError;
use codex_config::McpServerConfig;
use serde::Deserialize;
use serde_json::Map as JsonMap;
use serde_json::Value as JsonValue;
use std::collections::BTreeMap;
use std::ffi::OsString;
use std::path::Path;
use std::path::PathBuf;
use url::Host;
// Published Agent Plugins v1 MCP schema:
// https://github.com/agentplugins/agent-plugins-spec/blob/main/schemas/1.0.0/mcp.schema.json
const AGENT_PLUGIN_MCP_SCHEMA_URI: &str = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";
const SUPPORTED_AGENT_PLUGIN_MCP_SCHEMA_URIS: &[&str] = &[AGENT_PLUGIN_MCP_SCHEMA_URI];
const PLUGIN_ROOT_VARIABLE: &str = "PLUGIN_ROOT";
const PLUGIN_DATA_VARIABLE: &str = "PLUGIN_DATA";
const CLIENT_OWNED_HTTP_HEADERS: &[&str] = &[
"accept",
"authorization",
"connection",
"content-encoding",
"content-length",
"content-type",
"host",
"last-event-id",
"mcp-protocol-version",
"mcp-session-id",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"user-agent",
];
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct AgentPluginMcpFile {
#[serde(rename = "$schema")]
schema: String,
mcp_servers: BTreeMap<String, JsonValue>,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", deny_unknown_fields)]
enum AgentPluginMcpServer {
#[serde(rename = "stdio")]
Stdio {
command: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: BTreeMap<String, String>,
cwd: Option<String>,
},
#[serde(rename = "streamable-http")]
StreamableHttp {
url: String,
headers: Option<BTreeMap<String, String>>,
},
#[serde(rename = "sse")]
Sse {
#[serde(rename = "url")]
_url: String,
#[serde(rename = "headers")]
_headers: Option<BTreeMap<String, String>>,
},
}
/// Translates an Agent Plugins `mcp.json` into Codex MCP configuration.
pub fn parse_agent_plugin_mcp_config(
plugin_root: &Path,
plugin_data_root: &Path,
contents: &str,
) -> Result<PluginMcpConfigParseOutcome, serde_json::Error> {
parse_agent_plugin_mcp_config_from(contents, plugin_root, plugin_data_root)
}
fn parse_agent_plugin_mcp_config_from(
contents: &str,
plugin_root: &Path,
plugin_data_root: &Path,
) -> Result<PluginMcpConfigParseOutcome, serde_json::Error> {
let AgentPluginMcpFile {
schema,
mcp_servers,
} = serde_json::from_str(contents)?;
if !SUPPORTED_AGENT_PLUGIN_MCP_SCHEMA_URIS.contains(&schema.as_str()) {
return Err(plugin_mcp_json_error(format!(
"unsupported Agent Plugins MCP schema `{schema}`; supported schemas: {}",
SUPPORTED_AGENT_PLUGIN_MCP_SCHEMA_URIS.join(", ")
)));
}
let mut outcome = PluginMcpConfigParseOutcome::default();
for (name, value) in mcp_servers {
match normalize_agent_plugin_mcp_server(value, plugin_root, plugin_data_root) {
Ok(config) => {
outcome.servers.insert(name, config);
}
Err(message) => outcome
.errors
.push(PluginMcpServerParseError { name, message }),
}
}
Ok(outcome)
}
fn normalize_agent_plugin_mcp_server(
value: JsonValue,
plugin_root: &Path,
plugin_data_root: &Path,
) -> Result<McpServerConfig, String> {
let object = value
.as_object()
.ok_or_else(|| "Agent Plugins MCP server must be an object".to_string())?;
match object.get("type").and_then(JsonValue::as_str) {
Some("stdio") => reject_explicit_null(object, "cwd")?,
Some("streamable-http" | "sse") => reject_explicit_null(object, "headers")?,
_ => {}
}
let server =
serde_json::from_value::<AgentPluginMcpServer>(value).map_err(|err| err.to_string())?;
let object = match server {
AgentPluginMcpServer::Stdio {
command,
args,
env,
cwd,
} => normalize_agent_plugin_stdio_server(
command,
args,
env,
cwd,
plugin_root,
plugin_data_root,
)?,
AgentPluginMcpServer::StreamableHttp { url, headers } => {
normalize_agent_plugin_http_server(url, headers)?
}
AgentPluginMcpServer::Sse { .. } => {
return Err("Agent Plugins legacy SSE transport is not supported by Codex".to_string());
}
};
serde_json::from_value(JsonValue::Object(object)).map_err(|err| err.to_string())
}
fn normalize_agent_plugin_stdio_server(
mut command: String,
mut args: Vec<String>,
mut env: BTreeMap<String, String>,
cwd: Option<String>,
plugin_root: &Path,
plugin_data_root: &Path,
) -> Result<JsonMap<String, JsonValue>, String> {
#[cfg(windows)]
let has_windows_path_prefix = matches!(
Path::new(&command).components().next(),
Some(std::path::Component::Prefix(_))
);
#[cfg(not(windows))]
let has_windows_path_prefix = false;
let is_bare_command = !command.is_empty()
&& !command.contains('/')
&& !command.contains('\\')
&& !has_windows_path_prefix;
let is_plugin_relative_command =
command.starts_with("./") && is_portable_relative_path(&command);
if !is_bare_command && !is_plugin_relative_command {
return Err(
"Agent Plugins stdio command must be a bare executable name or a contained `./` path"
.to_string(),
);
}
for reserved in [PLUGIN_ROOT_VARIABLE, PLUGIN_DATA_VARIABLE] {
if env
.keys()
.any(|name| environment_variable_names_match(name, reserved))
{
return Err(format!(
"Agent Plugins stdio `env` cannot override reserved variable `{reserved}`"
));
}
}
#[cfg(windows)]
{
let mut normalized_env = BTreeMap::new();
for (name, value) in env {
let normalized_name = name.to_ascii_uppercase();
if normalized_env.insert(normalized_name, value).is_some() {
return Err(format!(
"duplicate case-insensitive Agent Plugins environment variable `{name}`"
));
}
}
env = normalized_env;
}
let root_path = absolute_plugin_path(plugin_root)?;
let data_root_path = absolute_plugin_path(plugin_data_root)?;
let root = host_path_string(&root_path);
let data_root = host_path_string(&data_root_path);
if command.starts_with("./") {
command = host_path_string(&resolve_contained_host_path(
&command, &root_path, &root_path,
)?);
}
for arg in &mut args {
*arg = expand_agent_plugin_placeholders(arg, &root, &data_root);
}
for value in env.values_mut() {
*value = expand_agent_plugin_placeholders(value, &root, &data_root);
}
let cwd = cwd.as_deref().unwrap_or("${PLUGIN_ROOT}");
let Some(cwd_root) = parse_agent_plugin_cwd(cwd) else {
return Err(
"Agent Plugins stdio `cwd` must be a contained `./`, `${PLUGIN_ROOT}`, or `${PLUGIN_DATA}` path"
.to_string(),
);
};
let cwd = expand_agent_plugin_placeholders(cwd, &root, &data_root);
let cwd_root = match cwd_root {
AgentPluginCwdRoot::Package => &root_path,
AgentPluginCwdRoot::Data => &data_root_path,
};
env.insert(PLUGIN_ROOT_VARIABLE.to_string(), root);
env.insert(PLUGIN_DATA_VARIABLE.to_string(), data_root);
Ok(JsonMap::from_iter([
("command".to_string(), JsonValue::String(command)),
(
"args".to_string(),
JsonValue::Array(args.into_iter().map(JsonValue::String).collect()),
),
("env".to_string(), string_map_value(env)),
(
"cwd".to_string(),
JsonValue::String(host_path_string(&resolve_contained_host_path(
&cwd, cwd_root, cwd_root,
)?)),
),
]))
}
fn reject_explicit_null(object: &JsonMap<String, JsonValue>, field: &str) -> Result<(), String> {
if object.get(field).is_some_and(JsonValue::is_null) {
return Err(format!(
"Agent Plugins MCP `{field}` must use its declared type when present"
));
}
Ok(())
}
fn environment_variable_names_match(left: &str, right: &str) -> bool {
if cfg!(windows) {
left.eq_ignore_ascii_case(right)
} else {
left == right
}
}
fn normalize_agent_plugin_http_server(
url: String,
mut headers: Option<BTreeMap<String, String>>,
) -> Result<JsonMap<String, JsonValue>, String> {
validate_agent_plugin_url(&url)?;
if let Some(configured_headers) = headers.as_mut() {
validate_agent_plugin_headers(configured_headers)?;
configured_headers.retain(|name, _| {
!CLIENT_OWNED_HTTP_HEADERS
.iter()
.any(|owned| name.eq_ignore_ascii_case(owned))
});
}
let mut object = JsonMap::from_iter([("url".to_string(), JsonValue::String(url))]);
if let Some(headers) = headers.filter(|headers| !headers.is_empty()) {
object.insert("http_headers".to_string(), string_map_value(headers));
}
Ok(object)
}
fn validate_agent_plugin_url(raw_url: &str) -> Result<(), String> {
if raw_url.is_empty() {
return Err("Agent Plugins HTTP server requires a non-empty `url`".to_string());
}
let parsed = url::Url::parse(raw_url)
.map_err(|err| format!("invalid Agent Plugins MCP URL `{raw_url}`: {err}"))?;
if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
return Err("Agent Plugins MCP URL must be absolute HTTP or HTTPS".to_string());
}
if !parsed.username().is_empty() || parsed.password().is_some() || parsed.fragment().is_some() {
return Err(
"Agent Plugins MCP URL must not contain user information or a fragment".to_string(),
);
}
let is_loopback = match parsed.host() {
Some(Host::Domain(host)) => host == "localhost",
Some(Host::Ipv4(address)) => address.is_loopback(),
Some(Host::Ipv6(address)) => address.is_loopback(),
None => false,
};
if parsed.scheme() == "http" && !is_loopback {
return Err("non-loopback Agent Plugins MCP endpoints must use HTTPS".to_string());
}
Ok(())
}
fn validate_agent_plugin_headers(headers: &BTreeMap<String, String>) -> Result<(), String> {
let mut seen = std::collections::HashSet::new();
for (name, value) in headers {
if !seen.insert(name.to_ascii_lowercase()) {
return Err(format!(
"duplicate case-insensitive Agent Plugins HTTP header `{name}`"
));
}
if !is_valid_http_header_name(name) {
return Err(format!("invalid Agent Plugins HTTP header name `{name}`"));
}
if value
.bytes()
.any(|byte| (byte < 32 && byte != b'\t') || byte == 127)
{
return Err(format!(
"invalid Agent Plugins HTTP header value for `{name}`"
));
}
}
Ok(())
}
fn string_map_value(values: BTreeMap<String, String>) -> JsonValue {
JsonValue::Object(
values
.into_iter()
.map(|(name, value)| (name, JsonValue::String(value)))
.collect(),
)
}
#[derive(Clone, Copy, Debug)]
enum AgentPluginCwdRoot {
Package,
Data,
}
fn parse_agent_plugin_cwd(value: &str) -> Option<AgentPluginCwdRoot> {
if value == "./" {
return Some(AgentPluginCwdRoot::Package);
}
if let Some(relative) = value.strip_prefix("./")
&& is_portable_path_suffix(relative)
{
return Some(AgentPluginCwdRoot::Package);
}
for (placeholder, root) in [
("${PLUGIN_ROOT}", AgentPluginCwdRoot::Package),
("${PLUGIN_DATA}", AgentPluginCwdRoot::Data),
] {
if value == placeholder {
return Some(root);
}
if let Some(relative) = value.strip_prefix(&format!("{placeholder}/"))
&& (relative.is_empty() || is_portable_path_suffix(relative))
{
return Some(root);
}
}
None
}
fn expand_agent_plugin_placeholders(value: &str, plugin_root: &str, plugin_data: &str) -> String {
const ROOT: &str = "${PLUGIN_ROOT}";
const DATA: &str = "${PLUGIN_DATA}";
let mut output = String::with_capacity(value.len());
let mut remaining = value;
loop {
let next = match (remaining.find(ROOT), remaining.find(DATA)) {
(Some(root), Some(data)) if root <= data => Some((root, ROOT, plugin_root)),
(Some(_), Some(data)) => Some((data, DATA, plugin_data)),
(Some(root), None) => Some((root, ROOT, plugin_root)),
(None, Some(data)) => Some((data, DATA, plugin_data)),
(None, None) => None,
};
let Some((index, placeholder, replacement)) = next else {
output.push_str(remaining);
break;
};
output.push_str(&remaining[..index]);
output.push_str(replacement);
remaining = &remaining[index + placeholder.len()..];
}
output
}
fn absolute_plugin_path(path: &Path) -> Result<PathBuf, String> {
let absolute = if path.is_absolute() {
Ok(path.to_path_buf())
} else {
std::env::current_dir()
.map(|cwd| cwd.join(path))
.map_err(|err| format!("failed to resolve plugin path: {err}"))
}?;
resolve_existing_path_prefix(&absolute)
}
fn resolve_contained_host_path(
value: &str,
root: &Path,
allowed_root: &Path,
) -> Result<PathBuf, String> {
let value = Path::new(value);
let path = if value.is_absolute() {
value.to_path_buf()
} else {
root.join(value)
};
let path = resolve_existing_path_prefix(&path)?;
if !path.starts_with(allowed_root) {
return Err(format!(
"expanded path `{}` must remain within `{}`",
value.display(),
allowed_root.display()
));
}
Ok(path)
}
fn resolve_existing_path_prefix(path: &Path) -> Result<PathBuf, String> {
let mut existing = path.to_path_buf();
let mut missing_components = Vec::<OsString>::new();
loop {
match std::fs::canonicalize(&existing) {
Ok(mut resolved) => {
for component in missing_components.iter().rev() {
resolved.push(component);
}
return Ok(lexical_normalize(&resolved));
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
if std::fs::symlink_metadata(&existing)
.is_ok_and(|metadata| metadata.file_type().is_symlink())
{
return Err(format!(
"failed to resolve symlinked path `{}`",
path.display()
));
}
let Some(component) = existing.components().next_back() else {
return Err(format!(
"failed to resolve path `{}`: {err}",
path.display()
));
};
if matches!(
component,
std::path::Component::Prefix(_) | std::path::Component::RootDir
) {
return Err(format!(
"failed to resolve path `{}`: {err}",
path.display()
));
}
missing_components.push(component.as_os_str().to_os_string());
if !existing.pop() {
return Err(format!(
"failed to resolve path `{}`: {err}",
path.display()
));
}
}
Err(err) => {
return Err(format!(
"failed to resolve path `{}`: {err}",
path.display()
));
}
}
}
}
fn host_path_string(path: &Path) -> String {
let rendered = path.to_string_lossy();
#[cfg(windows)]
if let Some(path) = rendered.strip_prefix(r"\\?\") {
return path
.strip_prefix(r"UNC\")
.map(|path| format!(r"\\{path}"))
.unwrap_or_else(|| path.to_string());
}
rendered.into_owned()
}
fn is_portable_relative_path(value: &str) -> bool {
value
.strip_prefix("./")
.is_some_and(is_portable_path_suffix)
}
fn is_portable_path_suffix(value: &str) -> bool {
!value.is_empty() && !value.contains('\\')
}
fn is_valid_http_header_name(name: &str) -> bool {
!name.is_empty()
&& name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&byte))
}
fn lexical_normalize(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
normalized.pop();
}
component => normalized.push(component.as_os_str()),
}
}
normalized
}
fn plugin_mcp_json_error(message: impl Into<String>) -> serde_json::Error {
serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
message.into(),
))
}

View File

@@ -57,6 +57,7 @@ pub use mcp::hosted_plugin_runtime_mcp_server_config;
pub use mcp::tool_plugin_provenance;
pub use plugin_config::PluginMcpConfigParseOutcome;
pub use plugin_config::PluginMcpServerParseError;
pub use plugin_config::parse_agent_plugin_mcp_config;
pub use plugin_config::parse_executor_plugin_mcp_config;
pub use plugin_config::parse_plugin_mcp_config;

View File

@@ -10,6 +10,11 @@ use std::collections::BTreeMap;
use std::path::Path;
use tracing::warn;
#[path = "agent_plugin_config.rs"]
mod agent_plugin_config;
pub use agent_plugin_config::parse_agent_plugin_mcp_config;
#[derive(Clone, Copy, Debug)]
enum PluginMcpSource<'a> {
Host {

View File

@@ -1,5 +1,6 @@
use super::PluginMcpConfigParseOutcome;
use super::PluginMcpServerParseError;
use super::parse_agent_plugin_mcp_config;
use super::parse_executor_plugin_mcp_config;
use super::parse_plugin_mcp_config;
use codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID;
@@ -25,6 +26,497 @@ fn plugin_root_uri(plugin_root: &Path) -> PathUri {
PathUri::from_host_native_path(plugin_root).expect("plugin root URI")
}
#[test]
fn agent_plugin_placeholder_expansion_is_single_pass() {
let plugin_root = plugin_root().join("${PLUGIN_DATA}");
let plugin_data_root = plugin_root
.parent()
.expect("plugin root parent")
.join("plugin-data");
let outcome = parse_agent_plugin_mcp_config(
&plugin_root,
&plugin_data_root,
r#"{
"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
"mcpServers":{"demo":{
"type":"stdio",
"command":"python",
"args":["${PLUGIN_ROOT}:${PLUGIN_DATA}"]
}}
}"#,
)
.expect("parse Agent Plugins MCP config");
let McpServerTransportConfig::Stdio { args, .. } = &outcome.servers["demo"].transport else {
panic!("expected stdio transport");
};
assert_eq!(
args,
&vec![format!(
"{}:{}",
plugin_root.display(),
plugin_data_root.display()
)]
);
}
#[test]
fn agent_plugin_mcp_expands_reserved_paths_and_maps_transports() {
let plugin_root = plugin_root();
let plugin_data_root = plugin_root.parent().expect("parent").join("plugin-data");
let outcome = parse_agent_plugin_mcp_config(
&plugin_root,
&plugin_data_root,
r#"{
"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
"mcpServers": {
"local": {
"type":"stdio",
"command":"python",
"args":["${PLUGIN_ROOT}/server.py", "${PLUGIN_DATA}/state.json"],
"env":{"CACHE":"${PLUGIN_DATA}/cache"},
"cwd":"${PLUGIN_ROOT}/scripts"
},
"remote": {
"type":"streamable-http",
"url":"https://example.com/mcp",
"headers":{"X-Plugin":"demo"}
}
}
}"#,
)
.expect("parse Agent Plugins MCP config");
assert!(outcome.errors.is_empty());
let local = outcome.servers.get("local").expect("local server");
let McpServerTransportConfig::Stdio { args, env, cwd, .. } = &local.transport else {
panic!("expected stdio transport");
};
assert_eq!(
args,
&vec![
format!("{}/server.py", plugin_root.display()),
format!("{}/state.json", plugin_data_root.display()),
]
);
assert_eq!(
env.as_ref().expect("environment").get("PLUGIN_ROOT"),
Some(&plugin_root.display().to_string())
);
assert_eq!(
env.as_ref().expect("environment").get("PLUGIN_DATA"),
Some(&plugin_data_root.display().to_string())
);
assert_eq!(
cwd.as_ref(),
Some(&LegacyAppPathString::from_path(
&plugin_root.join("scripts")
))
);
let remote = outcome.servers.get("remote").expect("remote server");
let McpServerTransportConfig::StreamableHttp { http_headers, .. } = &remote.transport else {
panic!("expected HTTP transport");
};
assert_eq!(
http_headers
.as_ref()
.and_then(|headers| headers.get("X-Plugin")),
Some(&"demo".to_string())
);
}
#[test]
fn agent_plugin_mcp_handles_portable_path_and_http_edge_cases() {
let plugin_root = plugin_root();
let plugin_data_root = plugin_root.parent().expect("parent").join("plugin-data");
let outcome = parse_agent_plugin_mcp_config(
&plugin_root,
&plugin_data_root,
r#"{
"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
"mcpServers": {
"contained":{"type":"stdio","command":"./bin/../server","cwd":"${PLUGIN_ROOT}/work/../data"},
"redundant-separator":{"type":"stdio","command":".//bin/server"},
"root-slash":{"type":"stdio","command":"python","cwd":"${PLUGIN_ROOT}/"},
"data-slash":{"type":"stdio","command":"python","cwd":"${PLUGIN_DATA}/"},
"headers":{"type":"streamable-http","url":"https://example.com/mcp","headers":{"aUtHoRiZaTiOn":"public-package-value","Content-Length":"0","HOST":"other.example.com","Proxy-Authorization":"public-package-value","Transfer-Encoding":"chunked","uSeR-aGeNt":"plugin-agent/1.0","X-Plugin":"demo","X-Plugin-Name":"café"}},
"loopback":{"type":"streamable-http","url":"http://[::1]/mcp"}
}
}"#,
)
.expect("parse Agent Plugins MCP config");
assert!(outcome.errors.is_empty());
assert_eq!(
outcome.servers.keys().collect::<Vec<_>>(),
vec![
"contained",
"data-slash",
"headers",
"loopback",
"redundant-separator",
"root-slash"
]
);
let McpServerTransportConfig::Stdio { command, cwd, .. } =
&outcome.servers["contained"].transport
else {
panic!("expected stdio transport");
};
assert_eq!(command, &plugin_root.join("server").display().to_string());
assert_eq!(
cwd.as_ref(),
Some(&LegacyAppPathString::from_path(&plugin_root.join("data")))
);
let McpServerTransportConfig::Stdio { command, .. } =
&outcome.servers["redundant-separator"].transport
else {
panic!("expected stdio transport");
};
assert_eq!(
command,
&plugin_root.join("bin").join("server").display().to_string()
);
for (server_name, expected_cwd) in [
("root-slash", plugin_root.as_path()),
("data-slash", plugin_data_root.as_path()),
] {
let McpServerTransportConfig::Stdio { cwd, .. } = &outcome.servers[server_name].transport
else {
panic!("expected stdio transport");
};
assert_eq!(
cwd.as_ref(),
Some(&LegacyAppPathString::from_path(expected_cwd))
);
}
let McpServerTransportConfig::StreamableHttp { http_headers, .. } =
&outcome.servers["headers"].transport
else {
panic!("expected HTTP transport");
};
assert_eq!(
http_headers,
&Some(HashMap::from([
("X-Plugin".to_string(), "demo".to_string()),
("X-Plugin-Name".to_string(), "café".to_string()),
]))
);
}
#[test]
fn agent_plugin_mcp_skips_invalid_server_without_disabling_siblings() {
let plugin_root = plugin_root();
let outcome = parse_agent_plugin_mcp_config(
&plugin_root,
&plugin_root.join("data"),
r#"{
"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
"mcpServers": {
"valid":{"type":"stdio","command":"python"},
"reserved":{"type":"stdio","command":"python","env":{"PLUGIN_ROOT":"bad"}}
}
}"#,
)
.expect("parse Agent Plugins MCP config");
assert_eq!(outcome.servers.keys().collect::<Vec<_>>(), vec!["valid"]);
assert_eq!(outcome.errors.len(), 1);
assert_eq!(outcome.errors[0].name, "reserved");
assert!(
outcome.errors[0]
.message
.contains("reserved variable `PLUGIN_ROOT`")
);
}
#[test]
fn agent_plugin_mcp_preserves_server_named_mcp_servers() {
let plugin_root = plugin_root();
let outcome = parse_agent_plugin_mcp_config(
&plugin_root,
&plugin_root.join("data"),
r#"{
"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
"mcpServers": {
"mcpServers":{"type":"stdio","command":"first"},
"sibling":{"type":"stdio","command":"second"}
}
}"#,
)
.expect("parse Agent Plugins MCP config");
assert!(outcome.errors.is_empty());
assert_eq!(
outcome.servers.keys().collect::<Vec<_>>(),
vec!["mcpServers", "sibling"]
);
}
#[test]
fn agent_plugin_mcp_preserves_arbitrary_server_names() {
let plugin_root = plugin_root();
let outcome = parse_agent_plugin_mcp_config(
&plugin_root,
&plugin_root.join("data"),
r#"{
"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
"mcpServers":{"agent.smoke / local":{"type":"stdio","command":"python"}}
}"#,
)
.expect("parse Agent Plugins MCP config");
assert!(outcome.errors.is_empty());
assert!(outcome.servers.contains_key("agent.smoke / local"));
}
#[test]
fn agent_plugin_mcp_rejects_explicit_null_optional_fields() {
let plugin_root = plugin_root();
let outcome = parse_agent_plugin_mcp_config(
&plugin_root,
&plugin_root.join("data"),
r#"{
"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
"mcpServers":{
"cwd":{"type":"stdio","command":"python","cwd":null},
"headers":{"type":"streamable-http","url":"https://example.com/mcp","headers":null}
}
}"#,
)
.expect("parse Agent Plugins MCP config");
assert!(outcome.servers.is_empty());
assert_eq!(outcome.errors.len(), 2);
assert!(
outcome
.errors
.iter()
.any(|error| error.message.contains("`cwd`"))
);
assert!(
outcome
.errors
.iter()
.any(|error| error.message.contains("`headers`"))
);
}
#[cfg(windows)]
#[test]
fn agent_plugin_mcp_rejects_reserved_environment_aliases_case_insensitively() {
let plugin_root = plugin_root();
let outcome = parse_agent_plugin_mcp_config(
&plugin_root,
&plugin_root.join("data"),
r#"{
"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
"mcpServers":{"reserved":{"type":"stdio","command":"python","env":{"plugin_root":"bad"}}}
}"#,
)
.expect("parse Agent Plugins MCP config");
assert!(outcome.servers.is_empty());
assert_eq!(outcome.errors.len(), 1);
}
#[cfg(windows)]
#[test]
fn agent_plugin_mcp_overlays_windows_environment_case_insensitively() {
let plugin_root = plugin_root();
let outcome = parse_agent_plugin_mcp_config(
&plugin_root,
&plugin_root.join("data"),
r#"{
"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
"mcpServers":{
"configured":{"type":"stdio","command":"python","env":{"Path":"configured"}},
"duplicate":{"type":"stdio","command":"python","env":{"PATH":"one","Path":"two"}}
}
}"#,
)
.expect("parse Agent Plugins MCP config");
assert_eq!(
outcome.servers.keys().collect::<Vec<_>>(),
vec!["configured"]
);
assert_eq!(outcome.errors.len(), 1);
let McpServerTransportConfig::Stdio { env, .. } = &outcome.servers["configured"].transport
else {
panic!("expected stdio transport");
};
assert_eq!(
env.as_ref().and_then(|env| env.get("PATH")),
Some(&"configured".to_string())
);
}
#[cfg(unix)]
#[test]
fn agent_plugin_mcp_resolves_root_before_collapsing_parent_components() {
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().expect("temporary directory");
let base = temp.path().join("base");
let outside = temp.path().join("outside");
let outside_directory = outside.join("directory");
let resolved_plugin_root = outside.join("plugin");
let plugin_data_root = temp.path().join("plugin-data");
std::fs::create_dir_all(&base).expect("create base directory");
std::fs::create_dir_all(&outside_directory).expect("create symlink target");
std::fs::create_dir_all(&resolved_plugin_root).expect("create resolved plugin root");
std::fs::create_dir_all(&plugin_data_root).expect("create plugin data root");
let canonical_plugin_root = resolved_plugin_root
.canonicalize()
.expect("canonical plugin root");
symlink(&outside_directory, base.join("link")).expect("create root symlink");
let plugin_root = base.join("link/../plugin");
let outcome = parse_agent_plugin_mcp_config(
&plugin_root,
&plugin_data_root,
r#"{
"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
"mcpServers":{"demo":{"type":"stdio","command":"python"}}
}"#,
)
.expect("parse Agent Plugins MCP config");
assert!(outcome.errors.is_empty());
let McpServerTransportConfig::Stdio { env, cwd, .. } = &outcome.servers["demo"].transport
else {
panic!("expected stdio transport");
};
assert_eq!(
env.as_ref().and_then(|env| env.get("PLUGIN_ROOT")),
Some(&canonical_plugin_root.display().to_string())
);
assert_eq!(
cwd.as_ref(),
Some(&LegacyAppPathString::from_path(&canonical_plugin_root))
);
}
#[cfg(unix)]
#[test]
fn agent_plugin_mcp_rejects_missing_descendant_below_escaping_symlink() {
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().expect("temporary directory");
let plugin_root = temp.path().join("plugin");
let plugin_data_root = temp.path().join("plugin-data");
let outside = temp.path().join("outside");
std::fs::create_dir_all(&plugin_root).expect("create plugin root");
std::fs::create_dir_all(&plugin_data_root).expect("create plugin data root");
std::fs::create_dir_all(&outside).expect("create outside directory");
symlink(&outside, plugin_root.join("link")).expect("create escaping symlink");
let outcome = parse_agent_plugin_mcp_config(
&plugin_root,
&plugin_data_root,
r#"{
"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
"mcpServers":{"escape":{"type":"stdio","command":"./link/missing"}}
}"#,
)
.expect("parse Agent Plugins MCP config");
assert!(outcome.servers.is_empty());
assert_eq!(outcome.errors.len(), 1);
assert!(outcome.errors[0].message.contains("must remain within"));
}
#[test]
fn agent_plugin_mcp_enforces_closed_transport_and_path_semantics() {
let plugin_root = plugin_root();
let outcome = parse_agent_plugin_mcp_config(
&plugin_root,
&plugin_root.join("data"),
r#"{
"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
"mcpServers": {
"valid":{"type":"stdio","command":"python"},
"command":{"type":"stdio","command":"../server"},
"escape":{"type":"stdio","command":"./../server"},
"cwd":{"type":"stdio","command":"python","cwd":"${PLUGIN_ROOT}/../outside"},
"backslash":{"type":"stdio","command":"./scripts\\..\\outside"},
"remote":{"type":"streamable-http","url":"http://example.com/mcp"},
"header":{"type":"streamable-http","url":"https://example.com/mcp","headers":{"X-Demo":"one","x-demo":"two"}},
"sse":{"type":"sse","url":"https://example.com/sse"},
"unknown":{"type":"stdio","command":"python","future":true}
}
}"#,
)
.expect("parse Agent Plugins MCP config");
assert_eq!(outcome.servers.keys().collect::<Vec<_>>(), vec!["valid"]);
assert_eq!(outcome.errors.len(), 8);
}
#[cfg(windows)]
#[test]
fn agent_plugin_mcp_rejects_drive_relative_windows_command() {
let plugin_root = plugin_root();
let outcome = parse_agent_plugin_mcp_config(
&plugin_root,
&plugin_root.join("data"),
r#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{"drive-relative":{"type":"stdio","command":"C:server.exe"}}}"#,
)
.expect("parse Agent Plugins MCP config");
assert!(outcome.servers.is_empty());
assert_eq!(outcome.errors.len(), 1);
}
#[test]
fn agent_plugin_mcp_treats_args_and_env_as_opaque_after_expansion() {
let plugin_root = plugin_root();
let data_root = plugin_root.join("data");
let outcome = parse_agent_plugin_mcp_config(
&plugin_root,
&data_root,
r#"{
"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
"mcpServers":{"demo":{
"type":"stdio",
"command":"python",
"args":["${PLUGIN_ROOT}/../opaque"],
"env":{"OPAQUE":"${PLUGIN_DATA}/../opaque"}
}}
}"#,
)
.expect("parse Agent Plugins MCP config");
assert!(outcome.errors.is_empty());
let McpServerTransportConfig::Stdio { args, env, .. } = &outcome.servers["demo"].transport
else {
panic!("expected stdio transport");
};
assert_eq!(args, &vec![format!("{}/../opaque", plugin_root.display())]);
assert_eq!(
env.as_ref().and_then(|env| env.get("OPAQUE")),
Some(&format!("{}/../opaque", data_root.display()))
);
}
#[test]
fn agent_plugin_mcp_rejects_unsupported_schema() {
let plugin_root = plugin_root();
let error = parse_agent_plugin_mcp_config(
&plugin_root,
&plugin_root.join("data"),
r#"{"$schema":"https://agent-plugins.org/schemas/2.0.0/mcp.schema.json","mcpServers":{}}"#,
)
.expect_err("unsupported schema");
assert!(
error
.to_string()
.contains("unsupported Agent Plugins MCP schema")
);
}
fn stdio_server(
command: &str,
environment_id: &str,

View File

@@ -593,7 +593,7 @@ fn protocol_headers(headers: &HeaderMap) -> Vec<HttpHeader> {
.filter_map(|(name, value)| {
Some(HttpHeader {
name: name.as_str().to_string(),
value: value.to_str().ok()?.to_string(),
value: std::str::from_utf8(value.as_bytes()).ok()?.to_string(),
})
})
.collect()

View File

@@ -1,8 +1,29 @@
use std::io::ErrorKind;
use http::HeaderMap;
use http::HeaderValue;
use pretty_assertions::assert_eq;
use super::HttpHeader;
use super::SseEventSizeLimit;
use super::protocol_headers;
#[test]
fn protocol_headers_preserve_utf8_values() {
let mut headers = HeaderMap::new();
headers.insert(
"x-plugin-name",
HeaderValue::from_str("café").expect("valid HTTP field value"),
);
assert_eq!(
protocol_headers(&headers),
vec![HttpHeader {
name: "x-plugin-name".to_string(),
value: "café".to_string(),
}]
);
}
#[test]
fn lf_terminators_reset_the_event_limit() {