Skip to main content

codex_mcp/
plugin_config.rs

1use codex_config::McpServerConfig;
2use codex_config::McpServerEnvVar;
3use codex_config::McpServerTransportConfig;
4use codex_utils_path_uri::LegacyAppPathString;
5use codex_utils_path_uri::PathUri;
6use serde::Deserialize;
7use serde_json::Map as JsonMap;
8use serde_json::Value as JsonValue;
9use std::collections::BTreeMap;
10use std::path::Path;
11use tracing::warn;
12
13#[derive(Clone, Copy, Debug)]
14enum PluginMcpSource<'a> {
15    Host {
16        root: &'a Path,
17    },
18    Environment {
19        root: &'a PathUri,
20        environment_id: &'a str,
21    },
22}
23
24/// One plugin MCP server that could not be normalized into runtime configuration.
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct PluginMcpServerParseError {
27    pub name: String,
28    pub message: String,
29}
30
31/// Valid servers and per-server errors parsed from one plugin MCP file.
32#[derive(Debug, Default, PartialEq)]
33pub struct PluginMcpConfigParseOutcome {
34    pub servers: BTreeMap<String, McpServerConfig>,
35    pub errors: Vec<PluginMcpServerParseError>,
36}
37
38#[derive(Debug, Default, Deserialize)]
39#[serde(rename_all = "camelCase")]
40struct PluginMcpServersFile {
41    mcp_servers: BTreeMap<String, JsonValue>,
42}
43
44#[derive(Debug, Deserialize)]
45#[serde(untagged)]
46enum PluginMcpFile {
47    McpServersObject(PluginMcpServersFile),
48    ServerMap(BTreeMap<String, JsonValue>),
49}
50
51impl PluginMcpFile {
52    fn into_mcp_servers(self) -> BTreeMap<String, JsonValue> {
53        match self {
54            Self::McpServersObject(file) => file.mcp_servers,
55            Self::ServerMap(mcp_servers) => mcp_servers,
56        }
57    }
58}
59
60/// Parses the two supported plugin MCP file shapes and normalizes each server.
61///
62/// Invalid individual servers are returned as errors without discarding valid
63/// siblings. A malformed top-level document fails the whole parse.
64pub fn parse_plugin_mcp_config(
65    plugin_root: &Path,
66    contents: &str,
67) -> Result<PluginMcpConfigParseOutcome, serde_json::Error> {
68    parse_plugin_mcp_config_from(contents, PluginMcpSource::Host { root: plugin_root })
69}
70
71/// Parses executor-owned plugin MCP config without interpreting the plugin root
72/// as a path on the orchestrator host.
73pub fn parse_executor_plugin_mcp_config(
74    plugin_root: &PathUri,
75    contents: &str,
76    environment_id: &str,
77) -> Result<PluginMcpConfigParseOutcome, serde_json::Error> {
78    parse_plugin_mcp_config_from(
79        contents,
80        PluginMcpSource::Environment {
81            root: plugin_root,
82            environment_id,
83        },
84    )
85}
86
87impl PluginMcpSource<'_> {
88    fn display(self) -> String {
89        match self {
90            Self::Host { root } => root.display().to_string(),
91            Self::Environment { root, .. } => root.to_string(),
92        }
93    }
94}
95
96fn parse_plugin_mcp_config_from(
97    contents: &str,
98    source: PluginMcpSource<'_>,
99) -> Result<PluginMcpConfigParseOutcome, serde_json::Error> {
100    let parsed = serde_json::from_str::<PluginMcpFile>(contents)?;
101    let mut outcome = PluginMcpConfigParseOutcome::default();
102
103    for (name, config_value) in parsed.into_mcp_servers() {
104        match normalize_plugin_mcp_server(config_value, source) {
105            Ok(config) => {
106                outcome.servers.insert(name, config);
107            }
108            Err(message) => outcome
109                .errors
110                .push(PluginMcpServerParseError { name, message }),
111        }
112    }
113
114    Ok(outcome)
115}
116
117fn normalize_plugin_mcp_server(
118    value: JsonValue,
119    source: PluginMcpSource<'_>,
120) -> Result<McpServerConfig, String> {
121    let mut object = normalize_plugin_mcp_server_value(value, source);
122    if let PluginMcpSource::Environment {
123        root,
124        environment_id,
125    } = source
126    {
127        object.insert(
128            "environment_id".to_string(),
129            JsonValue::String(environment_id.to_string()),
130        );
131        if object.contains_key("command") {
132            match object.remove("cwd") {
133                Some(JsonValue::String(cwd)) => object.insert(
134                    "cwd".to_string(),
135                    JsonValue::String(environment_cwd(root, Some(&cwd))?.into_string()),
136                ),
137                Some(JsonValue::Null) | None => object.insert(
138                    "cwd".to_string(),
139                    JsonValue::String(
140                        environment_cwd(root, /*configured_cwd*/ None)?.into_string(),
141                    ),
142                ),
143                Some(value) => object.insert("cwd".to_string(), value),
144            };
145        }
146    }
147
148    let mut config = serde_json::from_value::<McpServerConfig>(JsonValue::Object(object))
149        .map_err(|err| err.to_string())?;
150    if matches!(source, PluginMcpSource::Environment { .. }) {
151        bind_environment_env_vars(&mut config)?;
152    }
153    Ok(config)
154}
155
156fn environment_cwd(
157    root: &PathUri,
158    configured_cwd: Option<&str>,
159) -> Result<LegacyAppPathString, String> {
160    let Some(configured_cwd) = configured_cwd else {
161        return Ok(root.clone().into());
162    };
163    let cwd = PathUri::parse(configured_cwd)
164        .or_else(|_| root.join(configured_cwd))
165        .map_err(|err| format!("invalid cwd `{configured_cwd}`: {err}"))?;
166    if !cwd.starts_with(root) {
167        return Err(format!(
168            "cwd `{configured_cwd}` must remain within plugin root `{root}`"
169        ));
170    }
171    Ok(cwd.into())
172}
173
174fn bind_environment_env_vars(config: &mut McpServerConfig) -> Result<(), String> {
175    let is_local_environment = config.is_local_environment();
176    let env_vars = match &mut config.transport {
177        McpServerTransportConfig::Stdio { env_vars, .. } => env_vars,
178        // Never resolve executor-owned environment references in the host process.
179        // Remove this rejection once the owning executor resolves these fields.
180        McpServerTransportConfig::StreamableHttp {
181            bearer_token_env_var,
182            env_http_headers,
183            ..
184        } => {
185            if is_local_environment {
186                return Ok(());
187            }
188            if bearer_token_env_var.is_some() {
189                return Err(
190                    "`bearer_token_env_var` requires executor-side environment resolution for an executor-owned HTTP MCP"
191                        .to_string(),
192                );
193            }
194            if env_http_headers
195                .as_ref()
196                .is_some_and(|headers| !headers.is_empty())
197            {
198                return Err(
199                    "`env_http_headers` requires executor-side environment resolution for an executor-owned HTTP MCP"
200                        .to_string(),
201                );
202            }
203            return Ok(());
204        }
205    };
206    for env_var in env_vars {
207        match env_var {
208            McpServerEnvVar::Name(name) if !is_local_environment => {
209                *env_var = McpServerEnvVar::Config {
210                    name: std::mem::take(name),
211                    source: Some("remote".to_string()),
212                };
213            }
214            McpServerEnvVar::Name(_) => {}
215            McpServerEnvVar::Config { name, source } => {
216                match (is_local_environment, source.as_deref()) {
217                    (true, None | Some("local")) | (false, Some("remote")) => {}
218                    (true, Some("remote")) => {
219                        return Err(format!(
220                            "env_vars entry `{name}` cannot use source `remote` in a local environment"
221                        ));
222                    }
223                    (false, None) => *source = Some("remote".to_string()),
224                    (false, Some("local")) => {
225                        return Err(format!(
226                            "env_vars entry `{name}` cannot use source `local` in an executor-owned plugin"
227                        ));
228                    }
229                    (_, Some(source)) => unreachable!("validated env_vars source `{source}`"),
230                }
231            }
232        }
233    }
234    Ok(())
235}
236
237fn normalize_plugin_mcp_server_value(
238    value: JsonValue,
239    source: PluginMcpSource<'_>,
240) -> JsonMap<String, JsonValue> {
241    let mut object = match value {
242        JsonValue::Object(object) => object,
243        _ => return JsonMap::new(),
244    };
245
246    if let Some(JsonValue::String(transport_type)) = object.remove("type") {
247        match transport_type.as_str() {
248            "http" | "streamable_http" | "streamable-http" | "stdio" => {}
249            other => {
250                let plugin_display = source.display();
251                warn!(
252                    plugin = %plugin_display,
253                    transport = other,
254                    "plugin MCP server uses an unknown transport type"
255                );
256            }
257        }
258    }
259
260    if let Some(JsonValue::Object(mut oauth)) = object.remove("oauth") {
261        if oauth.remove("callbackPort").is_some() {
262            let plugin_display = source.display();
263            warn!(
264                plugin = %plugin_display,
265                "plugin MCP server OAuth callbackPort is ignored; Codex uses global MCP OAuth callback settings"
266            );
267        }
268
269        if let Some(client_id) = oauth.remove("clientId") {
270            oauth.entry("client_id".to_string()).or_insert(client_id);
271        }
272
273        if !oauth.is_empty() {
274            object.insert("oauth".to_string(), JsonValue::Object(oauth));
275        }
276    }
277
278    if let PluginMcpSource::Host { root } = source
279        && let Some(JsonValue::String(cwd)) = object.get("cwd")
280        && !Path::new(cwd).is_absolute()
281    {
282        object.insert(
283            "cwd".to_string(),
284            JsonValue::String(root.join(cwd).display().to_string()),
285        );
286    }
287
288    object
289}
290
291#[cfg(test)]
292#[path = "plugin_config_tests.rs"]
293mod tests;