mirror of
https://github.com/openai/codex.git
synced 2026-08-23 13:09:46 +00:00
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:
532
codex-rs/codex-mcp/src/agent_plugin_config.rs
Normal file
532
codex-rs/codex-mcp/src/agent_plugin_config.rs
Normal 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(),
|
||||
))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user