From bd12b3a9ecc88af606434335eaf905ea4a22dc44 Mon Sep 17 00:00:00 2001 From: jacobzhou-oai Date: Mon, 3 Aug 2026 20:31:39 +0000 Subject: [PATCH] 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 --- codex-rs/codex-mcp/src/agent_plugin_config.rs | 532 ++++++++++++++++++ codex-rs/codex-mcp/src/lib.rs | 1 + codex-rs/codex-mcp/src/plugin_config.rs | 5 + codex-rs/codex-mcp/src/plugin_config_tests.rs | 492 ++++++++++++++++ .../rmcp-client/src/http_client_adapter.rs | 2 +- .../src/http_client_adapter_tests.rs | 21 + 6 files changed, 1052 insertions(+), 1 deletion(-) create mode 100644 codex-rs/codex-mcp/src/agent_plugin_config.rs diff --git a/codex-rs/codex-mcp/src/agent_plugin_config.rs b/codex-rs/codex-mcp/src/agent_plugin_config.rs new file mode 100644 index 000000000000..d2a445566d72 --- /dev/null +++ b/codex-rs/codex-mcp/src/agent_plugin_config.rs @@ -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, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type", deny_unknown_fields)] +enum AgentPluginMcpServer { + #[serde(rename = "stdio")] + Stdio { + command: String, + #[serde(default)] + args: Vec, + #[serde(default)] + env: BTreeMap, + cwd: Option, + }, + #[serde(rename = "streamable-http")] + StreamableHttp { + url: String, + headers: Option>, + }, + #[serde(rename = "sse")] + Sse { + #[serde(rename = "url")] + _url: String, + #[serde(rename = "headers")] + _headers: Option>, + }, +} + +/// 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 { + 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 { + 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 { + 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::(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, + mut env: BTreeMap, + cwd: Option, + plugin_root: &Path, + plugin_data_root: &Path, +) -> Result, 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, 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>, +) -> Result, 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) -> 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) -> 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 { + 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 { + 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 { + 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 { + let mut existing = path.to_path_buf(); + let mut missing_components = Vec::::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) -> serde_json::Error { + serde_json::Error::io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + message.into(), + )) +} diff --git a/codex-rs/codex-mcp/src/lib.rs b/codex-rs/codex-mcp/src/lib.rs index 3a7265a347b4..ecc9cf7d73eb 100644 --- a/codex-rs/codex-mcp/src/lib.rs +++ b/codex-rs/codex-mcp/src/lib.rs @@ -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; diff --git a/codex-rs/codex-mcp/src/plugin_config.rs b/codex-rs/codex-mcp/src/plugin_config.rs index 4f74a97d160e..1982c931c7a0 100644 --- a/codex-rs/codex-mcp/src/plugin_config.rs +++ b/codex-rs/codex-mcp/src/plugin_config.rs @@ -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 { diff --git a/codex-rs/codex-mcp/src/plugin_config_tests.rs b/codex-rs/codex-mcp/src/plugin_config_tests.rs index 0a5337273bc6..612c6fe97848 100644 --- a/codex-rs/codex-mcp/src/plugin_config_tests.rs +++ b/codex-rs/codex-mcp/src/plugin_config_tests.rs @@ -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![ + "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!["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!["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!["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!["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, diff --git a/codex-rs/rmcp-client/src/http_client_adapter.rs b/codex-rs/rmcp-client/src/http_client_adapter.rs index 6b034286b7ab..23220cdf5343 100644 --- a/codex-rs/rmcp-client/src/http_client_adapter.rs +++ b/codex-rs/rmcp-client/src/http_client_adapter.rs @@ -593,7 +593,7 @@ fn protocol_headers(headers: &HeaderMap) -> Vec { .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() diff --git a/codex-rs/rmcp-client/src/http_client_adapter_tests.rs b/codex-rs/rmcp-client/src/http_client_adapter_tests.rs index 261eff9c9ec1..a615beec4eb3 100644 --- a/codex-rs/rmcp-client/src/http_client_adapter_tests.rs +++ b/codex-rs/rmcp-client/src/http_client_adapter_tests.rs @@ -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() {