Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions codex-rs/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions codex-rs/codex-mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ pub use mcp::effective_mcp_servers_from_configured;
pub use mcp::host_owned_codex_apps_enabled;
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::PluginMcpServerPlacement;
pub use plugin_config::parse_plugin_mcp_config;

pub use mcp::McpServerStatusSnapshot;
pub use mcp::McpSnapshotDetail;
Expand Down Expand Up @@ -70,6 +74,7 @@ pub(crate) mod codex_apps;
pub(crate) mod connection_manager;
pub(crate) mod elicitation;
pub(crate) mod mcp;
mod plugin_config;
mod resource_client;
pub(crate) mod rmcp_client;
pub(crate) mod runtime;
Expand Down
232 changes: 232 additions & 0 deletions codex-rs/codex-mcp/src/plugin_config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
use codex_config::McpServerConfig;
use codex_config::McpServerEnvVar;
use codex_config::McpServerTransportConfig;
use serde::Deserialize;
use serde_json::Map as JsonMap;
use serde_json::Value as JsonValue;
use std::collections::BTreeMap;
use std::path::Component;
use std::path::Path;
use std::path::PathBuf;
use tracing::warn;

/// Placement applied while normalizing MCP servers declared by a plugin.
#[derive(Clone, Copy, Debug)]
pub enum PluginMcpServerPlacement<'a> {
/// Preserve declared placement, resolving a relative working directory below the plugin root.
Declared,
/// Bind stdio servers to one environment and default their working directory to the plugin root.
Environment { environment_id: &'a str },
Comment thread
jif-oai marked this conversation as resolved.
}

/// One plugin MCP server that could not be normalized into runtime configuration.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PluginMcpServerParseError {
pub name: String,
pub message: String,
}

/// Valid servers and per-server errors parsed from one plugin MCP file.
#[derive(Debug, Default, PartialEq)]
pub struct PluginMcpConfigParseOutcome {
pub servers: BTreeMap<String, McpServerConfig>,
pub errors: Vec<PluginMcpServerParseError>,
}

#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PluginMcpServersFile {
mcp_servers: BTreeMap<String, JsonValue>,
}

#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum PluginMcpFile {
McpServersObject(PluginMcpServersFile),
ServerMap(BTreeMap<String, JsonValue>),
}

impl PluginMcpFile {
fn into_mcp_servers(self) -> BTreeMap<String, JsonValue> {
match self {
Self::McpServersObject(file) => file.mcp_servers,
Self::ServerMap(mcp_servers) => mcp_servers,
}
}
}

/// Parses the two supported plugin MCP file shapes and normalizes each server.
///
/// Invalid individual servers are returned as errors without discarding valid
/// siblings. A malformed top-level document fails the whole parse.
pub fn parse_plugin_mcp_config(
plugin_root: &Path,
contents: &str,
placement: PluginMcpServerPlacement<'_>,
) -> Result<PluginMcpConfigParseOutcome, serde_json::Error> {
let parsed = serde_json::from_str::<PluginMcpFile>(contents)?;
let mut outcome = PluginMcpConfigParseOutcome::default();

for (name, config_value) in parsed.into_mcp_servers() {
match normalize_plugin_mcp_server(plugin_root, config_value, placement) {
Ok(config) => {
outcome.servers.insert(name, config);
}
Err(message) => outcome
.errors
.push(PluginMcpServerParseError { name, message }),
}
}

Ok(outcome)
}

fn normalize_plugin_mcp_server(
plugin_root: &Path,
value: JsonValue,
placement: PluginMcpServerPlacement<'_>,
) -> Result<McpServerConfig, String> {
let mut object = normalize_plugin_mcp_server_value(plugin_root, value, placement);
if let PluginMcpServerPlacement::Environment { environment_id } = placement {
object.insert(
"environment_id".to_string(),
JsonValue::String(environment_id.to_string()),
);
if object.contains_key("command") {
match object.remove("cwd") {
Some(JsonValue::String(cwd)) => object.insert(
"cwd".to_string(),
JsonValue::String(
executor_plugin_cwd(plugin_root, &cwd)?
.to_string_lossy()
.into_owned(),
),
),
Some(JsonValue::Null) | None => object.insert(
"cwd".to_string(),
JsonValue::String(plugin_root.to_string_lossy().into_owned()),
),
Some(value) => object.insert("cwd".to_string(), value),
};
}
}

let mut config = serde_json::from_value::<McpServerConfig>(JsonValue::Object(object))
.map_err(|err| err.to_string())?;
if matches!(placement, PluginMcpServerPlacement::Environment { .. }) {
bind_environment_env_vars(&mut config)?;
}
Ok(config)
}

fn executor_plugin_cwd(plugin_root: &Path, configured_cwd: &str) -> Result<PathBuf, String> {
let cwd = Path::new(configured_cwd);
if cwd.is_absolute() {
return Ok(cwd.to_path_buf());
}
if cwd.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
}) {
return Err(format!(
"relative cwd `{configured_cwd}` must remain within plugin root `{}`",
plugin_root.display()
));
}
Ok(plugin_root.join(cwd))
}

fn bind_environment_env_vars(config: &mut McpServerConfig) -> Result<(), String> {
let is_local_environment = config.is_local_environment();
let McpServerTransportConfig::Stdio { env_vars, .. } = &mut config.transport else {
return Ok(());
};
for env_var in env_vars {
match env_var {
McpServerEnvVar::Name(name) if !is_local_environment => {
*env_var = McpServerEnvVar::Config {
name: std::mem::take(name),
source: Some("remote".to_string()),
};
}
McpServerEnvVar::Name(_) => {}
McpServerEnvVar::Config { name, source } => {
match (is_local_environment, source.as_deref()) {
(true, None | Some("local")) | (false, Some("remote")) => {}
(true, Some("remote")) => {
return Err(format!(
"env_vars entry `{name}` cannot use source `remote` in a local environment"
));
}
(false, None) => *source = Some("remote".to_string()),
(false, Some("local")) => {
return Err(format!(
"env_vars entry `{name}` cannot use source `local` in an executor-owned plugin"
));
}
(_, Some(source)) => unreachable!("validated env_vars source `{source}`"),
}
}
}
}
Ok(())
}

fn normalize_plugin_mcp_server_value(
plugin_root: &Path,
value: JsonValue,
placement: PluginMcpServerPlacement<'_>,
) -> JsonMap<String, JsonValue> {
let mut object = match value {
JsonValue::Object(object) => object,
_ => return JsonMap::new(),
};

if let Some(JsonValue::String(transport_type)) = object.remove("type") {
match transport_type.as_str() {
"http" | "streamable_http" | "streamable-http" | "stdio" => {}
other => {
warn!(
plugin = %plugin_root.display(),
transport = other,
"plugin MCP server uses an unknown transport type"
);
}
}
}

if let Some(JsonValue::Object(mut oauth)) = object.remove("oauth") {
if oauth.remove("callbackPort").is_some() {
warn!(
plugin = %plugin_root.display(),
"plugin MCP server OAuth callbackPort is ignored; Codex uses global MCP OAuth callback settings"
);
}

if let Some(client_id) = oauth.remove("clientId") {
oauth.entry("client_id".to_string()).or_insert(client_id);
}

if !oauth.is_empty() {
object.insert("oauth".to_string(), JsonValue::Object(oauth));
}
}

if matches!(placement, PluginMcpServerPlacement::Declared)
&& let Some(JsonValue::String(cwd)) = object.get("cwd")
&& !Path::new(cwd).is_absolute()
{
object.insert(
"cwd".to_string(),
JsonValue::String(plugin_root.join(cwd).display().to_string()),
);
}

object
}

#[cfg(test)]
#[path = "plugin_config_tests.rs"]
mod tests;
Loading
Loading