1pub use auth::McpAuthStatusEntry;
2pub use auth::McpOAuthLoginConfig;
3pub use auth::McpOAuthLoginSupport;
4pub use auth::McpOAuthScopesSource;
5pub use auth::ResolvedMcpOAuthScopes;
6pub use auth::compute_auth_statuses;
7pub use auth::discover_supported_scopes;
8pub use auth::discover_supported_scopes_with_http_client;
9pub use auth::oauth_login_support;
10pub use auth::oauth_login_support_with_http_client;
11pub use auth::resolve_oauth_scopes;
12pub use auth::should_retry_without_scopes;
13
14pub(crate) mod auth;
15
16use std::collections::HashMap;
17use std::collections::HashSet;
18use std::env;
19use std::path::PathBuf;
20use std::time::Duration;
21
22use codex_config::Constrained;
23use codex_config::McpServerAuth;
24use codex_config::McpServerConfig;
25use codex_config::McpServerTransportConfig;
26use codex_config::types::AppToolApproval;
27use codex_config::types::AuthKeyringBackendKind;
28use codex_config::types::OAuthCredentialsStoreMode;
29use codex_connectors::ConnectorRuntimeManager;
30use codex_connectors::ConnectorSnapshot;
31use codex_connectors::connector_runtime_context_key;
32use codex_login::CodexAuth;
33use codex_model_provider::CHATGPT_CODEX_BASE_URL;
34use codex_protocol::mcp::McpServerInfo;
35use codex_protocol::mcp::Resource;
36use codex_protocol::mcp::ResourceTemplate;
37use codex_protocol::mcp::Tool;
38use codex_protocol::models::PermissionProfile;
39use codex_protocol::protocol::AskForApproval;
40use codex_protocol::protocol::McpAuthStatus;
41use rmcp::model::ElicitationCapability;
42use rmcp::model::ReadResourceRequestParams;
43use rmcp::model::ReadResourceResult;
44use serde_json::Value;
45use tokio_util::sync::CancellationToken;
46
47use crate::ResolvedMcpCatalog;
48use crate::connection_manager::McpConnectionManager;
49use crate::runtime::McpRuntimeContext;
50use crate::server::EffectiveMcpServer;
51use crate::tools::ToolInfo;
52
53pub const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps";
54const DEFAULT_CODEX_APPS_MCP_PRODUCT_SKU: &str = "codex";
55const MCP_TOOL_NAME_PREFIX: &str = "mcp";
56const MCP_TOOL_NAME_DELIMITER: &str = "__";
57const CODEX_CONNECTORS_TOKEN_ENV_VAR: &str = "CODEX_CONNECTORS_TOKEN";
58
59#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
60pub enum McpSnapshotDetail {
61 #[default]
62 Full,
63 ToolsAndAuthOnly,
64}
65
66impl McpSnapshotDetail {
67 fn include_resources(self) -> bool {
68 matches!(self, Self::Full)
69 }
70}
71
72pub fn qualified_mcp_tool_name_prefix(server_name: &str) -> String {
73 sanitize_responses_api_tool_name(&format!(
74 "{MCP_TOOL_NAME_PREFIX}{MCP_TOOL_NAME_DELIMITER}{server_name}{MCP_TOOL_NAME_DELIMITER}"
75 ))
76}
77
78pub fn mcp_permission_prompt_is_auto_approved(
81 approval_policy: AskForApproval,
82 permission_profile: &PermissionProfile,
83 context: McpPermissionPromptAutoApproveContext,
84) -> bool {
85 if context.tool_approval_mode == Some(AppToolApproval::Approve) {
86 return true;
87 }
88
89 if approval_policy != AskForApproval::Never {
90 return false;
91 }
92
93 match permission_profile {
94 PermissionProfile::Disabled | PermissionProfile::External { .. } => true,
95 PermissionProfile::Managed { file_system, .. } => {
96 file_system.to_sandbox_policy().has_full_disk_write_access()
97 }
98 }
99}
100
101#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
102pub struct McpPermissionPromptAutoApproveContext {
103 pub tool_approval_mode: Option<AppToolApproval>,
104}
105
106#[derive(Debug, Clone)]
116pub struct McpConfig {
117 pub chatgpt_base_url: String,
119 pub apps_mcp_product_sku: Option<String>,
121 pub codex_home: PathBuf,
123 pub mcp_oauth_credentials_store_mode: OAuthCredentialsStoreMode,
125 pub auth_keyring_backend_kind: AuthKeyringBackendKind,
127 pub mcp_oauth_callback_port: Option<u16>,
129 pub mcp_oauth_callback_url: Option<String>,
131 pub skill_mcp_dependency_install_enabled: bool,
133 pub approval_policy: Constrained<AskForApproval>,
135 pub codex_linux_sandbox_exe: Option<PathBuf>,
137 pub use_legacy_landlock: bool,
139 pub apps_enabled: bool,
144 pub prefix_mcp_tool_names: bool,
147 pub client_elicitation_capability: ElicitationCapability,
149 pub mcp_server_catalog: ResolvedMcpCatalog,
151 pub connector_snapshot: ConnectorSnapshot,
154}
155
156#[derive(Debug, Clone, Default, PartialEq, Eq)]
157pub struct ToolPluginProvenance {
158 plugin_display_names_by_connector_id: HashMap<String, Vec<String>>,
159 plugin_display_names_by_mcp_server_name: HashMap<String, Vec<String>>,
160 plugin_ids_by_mcp_server_name: HashMap<String, String>,
161 selected_plugin_mcp_server_names: HashSet<String>,
162}
163
164impl ToolPluginProvenance {
165 pub fn plugin_display_names_for_connector_id(&self, connector_id: &str) -> &[String] {
166 self.plugin_display_names_by_connector_id
167 .get(connector_id)
168 .map(Vec::as_slice)
169 .unwrap_or(&[])
170 }
171
172 pub fn plugin_display_names_for_mcp_server_name(&self, server_name: &str) -> &[String] {
173 self.plugin_display_names_by_mcp_server_name
174 .get(server_name)
175 .map(Vec::as_slice)
176 .unwrap_or(&[])
177 }
178
179 pub fn plugin_id_for_mcp_server_name(&self, server_name: &str) -> Option<&str> {
180 self.plugin_ids_by_mcp_server_name
181 .get(server_name)
182 .map(String::as_str)
183 }
184
185 pub(crate) fn is_selected_plugin_mcp_server(&self, server_name: &str) -> bool {
186 self.selected_plugin_mcp_server_names.contains(server_name)
187 }
188
189 fn from_config(config: &McpConfig) -> Self {
190 let mut tool_plugin_provenance = Self::default();
191 for connector_id in config.connector_snapshot.connector_ids() {
192 tool_plugin_provenance
193 .plugin_display_names_by_connector_id
194 .insert(
195 connector_id.0.clone(),
196 config
197 .connector_snapshot
198 .plugin_display_names_for_connector_id(&connector_id.0)
199 .to_vec(),
200 );
201 }
202
203 for (server_name, attribution) in config
204 .mcp_server_catalog
205 .plugin_attributions_by_server_name()
206 {
207 tool_plugin_provenance
208 .plugin_display_names_by_mcp_server_name
209 .insert(
210 server_name.clone(),
211 vec![attribution.display_name().to_string()],
212 );
213 tool_plugin_provenance
214 .plugin_ids_by_mcp_server_name
215 .insert(server_name, attribution.plugin_id().to_string());
216 }
217 tool_plugin_provenance
218 .selected_plugin_mcp_server_names
219 .extend(
220 config
221 .mcp_server_catalog
222 .selected_plugin_server_names()
223 .map(str::to_string),
224 );
225
226 for plugin_names in tool_plugin_provenance
227 .plugin_display_names_by_connector_id
228 .values_mut()
229 .chain(
230 tool_plugin_provenance
231 .plugin_display_names_by_mcp_server_name
232 .values_mut(),
233 )
234 {
235 plugin_names.sort_unstable();
236 plugin_names.dedup();
237 }
238 tool_plugin_provenance
239 }
240}
241
242pub fn host_owned_codex_apps_enabled(config: &McpConfig, auth: Option<&CodexAuth>) -> bool {
243 config.apps_enabled && auth.is_some_and(CodexAuth::uses_codex_backend)
244}
245
246pub fn configured_mcp_servers(config: &McpConfig) -> HashMap<String, McpServerConfig> {
247 config.mcp_server_catalog.configured_servers()
248}
249
250pub fn effective_mcp_servers(
251 config: &McpConfig,
252 auth: Option<&CodexAuth>,
253) -> HashMap<String, EffectiveMcpServer> {
254 effective_mcp_servers_from_configured(configured_mcp_servers(config), config, auth)
255}
256
257pub fn effective_mcp_servers_from_configured(
262 configured_servers: HashMap<String, McpServerConfig>,
263 config: &McpConfig,
264 auth: Option<&CodexAuth>,
265) -> HashMap<String, EffectiveMcpServer> {
266 let chatgpt_origin = url::Url::parse(CHATGPT_CODEX_BASE_URL)
267 .ok()
268 .map(|url| url.origin());
269 let mut servers = configured_servers
270 .into_iter()
271 .map(|(name, mut server)| {
272 match server.auth.clone() {
273 McpServerAuth::ChatGpt => {
274 let server_origin = match &server.transport {
275 McpServerTransportConfig::StreamableHttp { url, .. } => {
276 url::Url::parse(url)
277 .ok()
278 .filter(|url| matches!(url.scheme(), "http" | "https"))
279 .map(|url| url.origin())
280 }
281 McpServerTransportConfig::Stdio { .. } => None,
282 };
283 if server_origin.as_ref() != chatgpt_origin.as_ref() {
284 server.auth = McpServerAuth::OAuth;
285 }
286 }
287 McpServerAuth::OAuth => {}
288 }
289 (name, EffectiveMcpServer::configured(server))
290 })
291 .collect::<HashMap<_, _>>();
292 if !host_owned_codex_apps_enabled(config, auth) {
293 servers.remove(CODEX_APPS_MCP_SERVER_NAME);
294 }
295 servers
296}
297
298pub fn tool_plugin_provenance(config: &McpConfig) -> ToolPluginProvenance {
299 ToolPluginProvenance::from_config(config)
300}
301
302pub async fn read_mcp_resource(
303 config: &McpConfig,
304 auth: Option<&CodexAuth>,
305 runtime_context: McpRuntimeContext,
306 codex_apps_tools_cache: ConnectorRuntimeManager<ToolInfo>,
307 tool_catalog_cache: crate::McpToolCatalogCache,
308 server: &str,
309 uri: &str,
310) -> anyhow::Result<ReadResourceResult> {
311 let mut mcp_servers = effective_mcp_servers(config, auth);
312 mcp_servers.retain(|name, _| name == server);
313 let cancel_token = CancellationToken::new();
314 let manager = McpConnectionManager::new(
315 &mcp_servers,
316 config.mcp_oauth_credentials_store_mode,
317 config.auth_keyring_backend_kind,
318 &config.approval_policy,
319 String::new(),
320 None,
321 cancel_token.clone(),
322 PermissionProfile::default(),
323 runtime_context,
324 config.codex_home.clone(),
325 codex_apps_tools_cache,
326 tool_catalog_cache,
327 connector_runtime_context_key(auth),
328 config.prefix_mcp_tool_names,
329 config.client_elicitation_capability.clone(),
330 false,
331 tool_plugin_provenance(config),
332 auth,
333 None,
334 None,
335 None,
336 crate::elicitation::ElicitationRequestRouter::default(),
337 )
338 .await;
339
340 let result = manager
341 .read_resource(server, ReadResourceRequestParams::new(uri))
342 .await;
343 cancel_token.cancel();
344 result
345}
346
347#[derive(Debug, Clone)]
348pub struct McpServerStatusSnapshot {
349 pub server_infos: HashMap<String, McpServerInfo>,
350 pub tools_by_server: HashMap<String, HashMap<String, Tool>>,
351 pub resources: HashMap<String, Vec<Resource>>,
352 pub resource_templates: HashMap<String, Vec<ResourceTemplate>>,
353 pub auth_statuses: HashMap<String, McpAuthStatus>,
354 pub server_names: Vec<String>,
355}
356
357pub async fn collect_mcp_server_status_snapshot_with_detail(
358 config: &McpConfig,
359 auth: Option<&CodexAuth>,
360 submit_id: String,
361 runtime_context: McpRuntimeContext,
362 codex_apps_tools_cache: ConnectorRuntimeManager<ToolInfo>,
363 tool_catalog_cache: crate::McpToolCatalogCache,
364 detail: McpSnapshotDetail,
365) -> McpServerStatusSnapshot {
366 let mcp_servers = effective_mcp_servers(config, auth);
367 let tool_plugin_provenance = tool_plugin_provenance(config);
368 if mcp_servers.is_empty() {
369 return McpServerStatusSnapshot {
370 server_infos: HashMap::new(),
371 tools_by_server: HashMap::new(),
372 resources: HashMap::new(),
373 resource_templates: HashMap::new(),
374 auth_statuses: HashMap::new(),
375 server_names: Vec::new(),
376 };
377 }
378
379 let auth_status_entries = compute_auth_statuses(
380 mcp_servers.iter(),
381 config.mcp_oauth_credentials_store_mode,
382 config.auth_keyring_backend_kind,
383 auth,
384 &runtime_context,
385 )
386 .await;
387
388 let server_names = mcp_servers.keys().cloned().collect();
389
390 let cancel_token = CancellationToken::new();
391 let mcp_connection_manager = McpConnectionManager::new(
392 &mcp_servers,
393 config.mcp_oauth_credentials_store_mode,
394 config.auth_keyring_backend_kind,
395 &config.approval_policy,
396 submit_id,
397 None,
398 cancel_token.clone(),
399 PermissionProfile::default(),
400 runtime_context,
401 config.codex_home.clone(),
402 codex_apps_tools_cache,
403 tool_catalog_cache,
404 connector_runtime_context_key(auth),
405 config.prefix_mcp_tool_names,
406 config.client_elicitation_capability.clone(),
407 false,
408 tool_plugin_provenance,
409 auth,
410 None,
411 None,
412 None,
413 crate::elicitation::ElicitationRequestRouter::default(),
414 )
415 .await;
416
417 let snapshot = collect_mcp_server_status_snapshot_from_manager(
418 &mcp_connection_manager,
419 auth_status_entries,
420 server_names,
421 detail,
422 )
423 .await;
424
425 cancel_token.cancel();
426
427 snapshot
428}
429
430pub(crate) fn sanitize_responses_api_tool_name(name: &str) -> String {
434 let mut sanitized = String::with_capacity(name.len());
435 for c in name.chars() {
436 if c.is_ascii_alphanumeric() || c == '_' {
437 sanitized.push(c);
438 } else {
439 sanitized.push('_');
440 }
441 }
442
443 if sanitized.is_empty() {
444 "_".to_string()
445 } else {
446 sanitized
447 }
448}
449
450fn codex_apps_mcp_bearer_token_env_var() -> Option<String> {
451 match env::var(CODEX_CONNECTORS_TOKEN_ENV_VAR) {
452 Ok(value) if !value.trim().is_empty() => Some(CODEX_CONNECTORS_TOKEN_ENV_VAR.to_string()),
453 Ok(_) => None,
454 Err(env::VarError::NotPresent) => None,
455 Err(env::VarError::NotUnicode(_)) => Some(CODEX_CONNECTORS_TOKEN_ENV_VAR.to_string()),
456 }
457}
458
459fn normalize_codex_apps_base_url(base_url: &str) -> String {
460 let mut base_url = base_url.trim_end_matches('/').to_string();
461 if (base_url.starts_with("https://chatgpt.com")
462 || base_url.starts_with("https://chat.openai.com"))
463 && !base_url.contains("/backend-api")
464 {
465 base_url = format!("{base_url}/backend-api");
466 }
467 base_url
468}
469
470fn codex_apps_mcp_url_for_base_url(base_url: &str) -> String {
471 let base_url = normalize_codex_apps_base_url(base_url);
472 let base_url = if base_url.contains("/backend-api") || base_url.contains("/api/codex") {
473 base_url
474 } else {
475 format!("{base_url}/api/codex")
476 };
477 format!("{base_url}/ps/mcp")
478}
479
480pub fn codex_apps_mcp_server_config(
481 chatgpt_base_url: &str,
482 apps_mcp_product_sku: Option<&str>,
483 originator: Option<&str>,
484) -> McpServerConfig {
485 mcp_server_config_for_url(
486 codex_apps_mcp_url_for_base_url(chatgpt_base_url),
487 apps_mcp_product_sku,
488 originator,
489 McpServerAuth::ChatGpt,
490 )
491}
492
493pub fn hosted_plugin_runtime_mcp_server_config(
495 chatgpt_base_url: &str,
496 apps_mcp_product_sku: Option<&str>,
497 originator: Option<&str>,
498) -> McpServerConfig {
499 codex_apps_mcp_server_config(chatgpt_base_url, apps_mcp_product_sku, originator)
500}
501
502fn mcp_server_config_for_url(
503 url: String,
504 apps_mcp_product_sku: Option<&str>,
505 originator: Option<&str>,
506 auth_mode: McpServerAuth,
507) -> McpServerConfig {
508 let product_sku = apps_mcp_product_sku.unwrap_or(DEFAULT_CODEX_APPS_MCP_PRODUCT_SKU);
509 let mut http_headers =
510 HashMap::from([("X-OpenAI-Product-Sku".to_string(), product_sku.to_string())]);
511 if let Some(originator) = originator {
512 http_headers.insert("originator".to_string(), originator.to_string());
513 }
514
515 McpServerConfig {
516 transport: McpServerTransportConfig::StreamableHttp {
517 url,
518 bearer_token_env_var: codex_apps_mcp_bearer_token_env_var(),
519 http_headers: Some(http_headers),
520 env_http_headers: None,
521 },
522 auth: auth_mode,
523 environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
524 enabled: true,
525 required: false,
526 supports_parallel_tool_calls: false,
527 disabled_reason: None,
528 startup_timeout_sec: Some(Duration::from_secs(30)),
529 tool_timeout_sec: None,
530 default_tools_approval_mode: None,
531 enabled_tools: None,
532 disabled_tools: None,
533 scopes: None,
534 oauth: None,
535 oauth_resource: None,
536 tools: HashMap::new(),
537 }
538}
539
540fn protocol_tool_from_rmcp_tool(name: &str, tool: &rmcp::model::Tool) -> Option<Tool> {
541 match serde_json::to_value(tool) {
542 Ok(value) => match Tool::from_mcp_value(value) {
543 Ok(tool) => Some(tool),
544 Err(err) => {
545 tracing::warn!("Failed to convert MCP tool '{name}': {err}");
546 None
547 }
548 },
549 Err(err) => {
550 tracing::warn!("Failed to serialize MCP tool '{name}': {err}");
551 None
552 }
553 }
554}
555
556fn auth_statuses_from_entries(
557 auth_status_entries: &HashMap<String, crate::mcp::auth::McpAuthStatusEntry>,
558) -> HashMap<String, McpAuthStatus> {
559 auth_status_entries
560 .iter()
561 .map(|(name, entry)| (name.clone(), McpAuthStatus::from(entry.auth_state)))
562 .collect::<HashMap<_, _>>()
563}
564
565fn convert_mcp_resources(
566 resources: HashMap<String, Vec<rmcp::model::Resource>>,
567) -> HashMap<String, Vec<Resource>> {
568 resources
569 .into_iter()
570 .map(|(name, resources)| {
571 let resources = resources
572 .into_iter()
573 .filter_map(|resource| match serde_json::to_value(resource) {
574 Ok(value) => match Resource::from_mcp_value(value.clone()) {
575 Ok(resource) => Some(resource),
576 Err(err) => {
577 let (uri, resource_name) = match value {
578 Value::Object(obj) => (
579 obj.get("uri")
580 .and_then(|v| v.as_str().map(ToString::to_string)),
581 obj.get("name")
582 .and_then(|v| v.as_str().map(ToString::to_string)),
583 ),
584 _ => (None, None),
585 };
586
587 tracing::warn!(
588 "Failed to convert MCP resource (uri={uri:?}, name={resource_name:?}): {err}"
589 );
590 None
591 }
592 },
593 Err(err) => {
594 tracing::warn!("Failed to serialize MCP resource: {err}");
595 None
596 }
597 })
598 .collect::<Vec<_>>();
599 (name, resources)
600 })
601 .collect::<HashMap<_, _>>()
602}
603
604fn convert_mcp_resource_templates(
605 resource_templates: HashMap<String, Vec<rmcp::model::ResourceTemplate>>,
606) -> HashMap<String, Vec<ResourceTemplate>> {
607 resource_templates
608 .into_iter()
609 .map(|(name, templates)| {
610 let templates = templates
611 .into_iter()
612 .filter_map(|template| match serde_json::to_value(template) {
613 Ok(value) => match ResourceTemplate::from_mcp_value(value.clone()) {
614 Ok(template) => Some(template),
615 Err(err) => {
616 let (uri_template, template_name) = match value {
617 Value::Object(obj) => (
618 obj.get("uriTemplate")
619 .or_else(|| obj.get("uri_template"))
620 .and_then(|v| v.as_str().map(ToString::to_string)),
621 obj.get("name")
622 .and_then(|v| v.as_str().map(ToString::to_string)),
623 ),
624 _ => (None, None),
625 };
626
627 tracing::warn!(
628 "Failed to convert MCP resource template (uri_template={uri_template:?}, name={template_name:?}): {err}"
629 );
630 None
631 }
632 },
633 Err(err) => {
634 tracing::warn!("Failed to serialize MCP resource template: {err}");
635 None
636 }
637 })
638 .collect::<Vec<_>>();
639 (name, templates)
640 })
641 .collect::<HashMap<_, _>>()
642}
643
644async fn collect_mcp_server_status_snapshot_from_manager(
645 mcp_connection_manager: &McpConnectionManager,
646 auth_status_entries: HashMap<String, crate::mcp::auth::McpAuthStatusEntry>,
647 server_names: Vec<String>,
648 detail: McpSnapshotDetail,
649) -> McpServerStatusSnapshot {
650 let ((server_infos, tools), resources, resource_templates) = tokio::join!(
651 async {
652 let server_infos = mcp_connection_manager.list_available_server_infos().await;
653 let tools = mcp_connection_manager.list_all_tools().await;
654 (server_infos, tools)
655 },
656 async {
657 if detail.include_resources() {
658 mcp_connection_manager.list_all_resources(|_| true).await
659 } else {
660 HashMap::new()
661 }
662 },
663 async {
664 if detail.include_resources() {
665 mcp_connection_manager
666 .list_all_resource_templates(|_| true)
667 .await
668 } else {
669 HashMap::new()
670 }
671 },
672 );
673
674 let mut tools_by_server = HashMap::<String, HashMap<String, Tool>>::new();
675 for tool_info in tools {
676 let raw_tool_name = tool_info.tool.name.to_string();
677 let Some(tool) = protocol_tool_from_rmcp_tool(&raw_tool_name, &tool_info.tool) else {
678 continue;
679 };
680 let tool_name = tool.name.clone();
681 tools_by_server
682 .entry(tool_info.server_name)
683 .or_default()
684 .insert(tool_name, tool);
685 }
686
687 McpServerStatusSnapshot {
688 server_infos,
689 tools_by_server,
690 resources: convert_mcp_resources(resources),
691 resource_templates: convert_mcp_resource_templates(resource_templates),
692 auth_statuses: auth_statuses_from_entries(&auth_status_entries),
693 server_names,
694 }
695}
696
697#[cfg(test)]
698#[path = "mod_tests.rs"]
699pub(crate) mod tests;