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#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct PluginMcpServerParseError {
27 pub name: String,
28 pub message: String,
29}
30
31#[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
60pub 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
71pub 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, 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 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;