diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index bc407863f03..a10a5a40da6 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -338,9 +338,6 @@ "apps": { "type": "boolean" }, - "apps_mcp_gateway": { - "type": "boolean" - }, "artifact": { "type": "boolean" }, @@ -1890,9 +1887,6 @@ "apps": { "type": "boolean" }, - "apps_mcp_gateway": { - "type": "boolean" - }, "artifact": { "type": "boolean" }, diff --git a/codex-rs/core/src/apps/render.rs b/codex-rs/core/src/apps/render.rs index da146f703b8..7cc07c0747a 100644 --- a/codex-rs/core/src/apps/render.rs +++ b/codex-rs/core/src/apps/render.rs @@ -4,7 +4,7 @@ use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG; pub(crate) fn render_apps_section() -> String { let body = format!( - "## Apps\nApps are mentioned in user messages in the format `[$app-name](app://{{connector_id}})`.\nAn app is equivalent to a set of MCP tools within the `{CODEX_APPS_MCP_SERVER_NAME}` MCP.\nWhen you see an app mention, the app's MCP tools are either available tools in the `{CODEX_APPS_MCP_SERVER_NAME}` MCP server, or the tools do not exist because the user has not installed the app.\nDo not additionally call list_mcp_resources for apps that are already mentioned." + "## Apps (Connectors)\nApps (Connectors) can be explicitly triggered in user messages in the format `[$app-name](app://{{connector_id}})`. Apps can also be implicitly triggered as long as the context suggests usage of available apps, the available apps will be listed by the `tool_search` tool.\nAn app is equivalent to a set of MCP tools within the `{CODEX_APPS_MCP_SERVER_NAME}` MCP.\nAn installed app's MCP tools are either provided to you already, or can be lazy-loaded through the `tool_search` tool.\nDo not additionally call list_mcp_resources or list_mcp_resource_templates for apps." ); format!("{APPS_INSTRUCTIONS_OPEN_TAG}\n{body}\n{APPS_INSTRUCTIONS_CLOSE_TAG}") } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 68bb4b438ee..b127f58fff6 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3936,12 +3936,13 @@ impl Session { server: &str, tool: &str, arguments: Option, + meta: Option, ) -> anyhow::Result { self.services .mcp_connection_manager .read() .await - .call_tool(server, tool, arguments) + .call_tool(server, tool, arguments, meta) .await } diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index c0e379593ce..7833c19686c 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -154,8 +154,6 @@ pub enum Feature { Plugins, /// Allow the model to invoke the built-in image generation tool. ImageGeneration, - /// Route apps MCP calls through the configured gateway. - AppsMcpGateway, /// Allow prompting and installing missing MCP dependencies. SkillMcpDependencyInstall, /// Prompt for missing skill env var dependencies. @@ -753,12 +751,6 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, - FeatureSpec { - id: Feature::AppsMcpGateway, - key: "apps_mcp_gateway", - stage: Stage::UnderDevelopment, - default_enabled: false, - }, FeatureSpec { id: Feature::SkillMcpDependencyInstall, key: "skill_mcp_dependency_install", diff --git a/codex-rs/core/src/mcp/mod.rs b/codex-rs/core/src/mcp/mod.rs index 3140f5bcff5..184f76e40f5 100644 --- a/codex-rs/core/src/mcp/mod.rs +++ b/codex-rs/core/src/mcp/mod.rs @@ -21,7 +21,6 @@ use crate::CodexAuth; use crate::config::Config; use crate::config::types::McpServerConfig; use crate::config::types::McpServerTransportConfig; -use crate::features::Feature; use crate::mcp::auth::compute_auth_statuses; use crate::mcp_connection_manager::McpConnectionManager; use crate::mcp_connection_manager::SandboxState; @@ -33,8 +32,6 @@ const MCP_TOOL_NAME_PREFIX: &str = "mcp"; const MCP_TOOL_NAME_DELIMITER: &str = "__"; pub(crate) const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps"; const CODEX_CONNECTORS_TOKEN_ENV_VAR: &str = "CODEX_CONNECTORS_TOKEN"; -const OPENAI_CONNECTORS_MCP_BASE_URL: &str = "https://api.openai.com"; -const OPENAI_CONNECTORS_MCP_PATH: &str = "/v1/connectors/gateways/flat/mcp"; #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ToolPluginProvenance { @@ -94,13 +91,6 @@ impl ToolPluginProvenance { } } -// Legacy vs new MCP gateway -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum CodexAppsMcpGateway { - LegacyMCPGateway, - MCPGateway, -} - fn codex_apps_mcp_bearer_token_env_var() -> Option { match env::var(CODEX_CONNECTORS_TOKEN_ENV_VAR) { Ok(value) if !value.trim().is_empty() => Some(CODEX_CONNECTORS_TOKEN_ENV_VAR.to_string()), @@ -135,14 +125,6 @@ fn codex_apps_mcp_http_headers(auth: Option<&CodexAuth>) -> Option CodexAppsMcpGateway { - if config.features.enabled(Feature::AppsMcpGateway) { - CodexAppsMcpGateway::MCPGateway - } else { - CodexAppsMcpGateway::LegacyMCPGateway - } -} - fn normalize_codex_apps_base_url(base_url: &str) -> String { let mut base_url = base_url.trim_end_matches('/').to_string(); if (base_url.starts_with("https://chatgpt.com") @@ -154,11 +136,7 @@ fn normalize_codex_apps_base_url(base_url: &str) -> String { base_url } -fn codex_apps_mcp_url_for_gateway(base_url: &str, gateway: CodexAppsMcpGateway) -> String { - if gateway == CodexAppsMcpGateway::MCPGateway { - return format!("{OPENAI_CONNECTORS_MCP_BASE_URL}{OPENAI_CONNECTORS_MCP_PATH}"); - } - +fn codex_apps_mcp_url_for_base_url(base_url: &str) -> String { let base_url = normalize_codex_apps_base_url(base_url); if base_url.contains("/backend-api") { format!("{base_url}/wham/apps") @@ -170,10 +148,7 @@ fn codex_apps_mcp_url_for_gateway(base_url: &str, gateway: CodexAppsMcpGateway) } pub(crate) fn codex_apps_mcp_url(config: &Config) -> String { - codex_apps_mcp_url_for_gateway( - &config.chatgpt_base_url, - selected_config_codex_apps_mcp_gateway(config), - ) + codex_apps_mcp_url_for_base_url(&config.chatgpt_base_url) } fn codex_apps_mcp_server_config(config: &Config, auth: Option<&CodexAuth>) -> McpServerConfig { diff --git a/codex-rs/core/src/mcp/mod_tests.rs b/codex-rs/core/src/mcp/mod_tests.rs index cdbcda2ea03..706f8ceb09c 100644 --- a/codex-rs/core/src/mcp/mod_tests.rs +++ b/codex-rs/core/src/mcp/mod_tests.rs @@ -1,6 +1,7 @@ use super::*; use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigBuilder; +use crate::features::Feature; use crate::plugins::AppConnectorId; use crate::plugins::PluginCapabilitySummary; use pretty_assertions::assert_eq; @@ -123,67 +124,27 @@ fn tool_plugin_provenance_collects_app_and_mcp_sources() { } #[test] -fn codex_apps_mcp_url_for_default_gateway_keeps_existing_paths() { +fn codex_apps_mcp_url_for_base_url_keeps_existing_paths() { assert_eq!( - codex_apps_mcp_url_for_gateway( - "https://chatgpt.com/backend-api", - CodexAppsMcpGateway::LegacyMCPGateway - ), + codex_apps_mcp_url_for_base_url("https://chatgpt.com/backend-api"), "https://chatgpt.com/backend-api/wham/apps" ); assert_eq!( - codex_apps_mcp_url_for_gateway( - "https://chat.openai.com", - CodexAppsMcpGateway::LegacyMCPGateway - ), + codex_apps_mcp_url_for_base_url("https://chat.openai.com"), "https://chat.openai.com/backend-api/wham/apps" ); assert_eq!( - codex_apps_mcp_url_for_gateway( - "http://localhost:8080/api/codex", - CodexAppsMcpGateway::LegacyMCPGateway - ), + codex_apps_mcp_url_for_base_url("http://localhost:8080/api/codex"), "http://localhost:8080/api/codex/apps" ); assert_eq!( - codex_apps_mcp_url_for_gateway( - "http://localhost:8080", - CodexAppsMcpGateway::LegacyMCPGateway - ), + codex_apps_mcp_url_for_base_url("http://localhost:8080"), "http://localhost:8080/api/codex/apps" ); } #[test] -fn codex_apps_mcp_url_for_gateway_uses_openai_connectors_gateway() { - let expected_url = format!("{OPENAI_CONNECTORS_MCP_BASE_URL}{OPENAI_CONNECTORS_MCP_PATH}"); - - assert_eq!( - codex_apps_mcp_url_for_gateway( - "https://chatgpt.com/backend-api", - CodexAppsMcpGateway::MCPGateway - ), - expected_url.as_str() - ); - assert_eq!( - codex_apps_mcp_url_for_gateway("https://chat.openai.com", CodexAppsMcpGateway::MCPGateway), - expected_url.as_str() - ); - assert_eq!( - codex_apps_mcp_url_for_gateway( - "http://localhost:8080/api/codex", - CodexAppsMcpGateway::MCPGateway - ), - expected_url.as_str() - ); - assert_eq!( - codex_apps_mcp_url_for_gateway("http://localhost:8080", CodexAppsMcpGateway::MCPGateway), - expected_url.as_str() - ); -} - -#[test] -fn codex_apps_mcp_url_uses_default_gateway_when_feature_is_disabled() { +fn codex_apps_mcp_url_uses_legacy_codex_apps_path() { let mut config = crate::config::test_config(); config.chatgpt_base_url = "https://chatgpt.com".to_string(); @@ -194,22 +155,7 @@ fn codex_apps_mcp_url_uses_default_gateway_when_feature_is_disabled() { } #[test] -fn codex_apps_mcp_url_uses_openai_connectors_gateway_when_feature_is_enabled() { - let mut config = crate::config::test_config(); - config.chatgpt_base_url = "https://chatgpt.com".to_string(); - config - .features - .enable(Feature::AppsMcpGateway) - .expect("test config should allow apps gateway"); - - assert_eq!( - codex_apps_mcp_url(&config), - format!("{OPENAI_CONNECTORS_MCP_BASE_URL}{OPENAI_CONNECTORS_MCP_PATH}") - ); -} - -#[test] -fn codex_apps_server_config_switches_gateway_with_flags() { +fn codex_apps_server_config_uses_legacy_codex_apps_path() { let mut config = crate::config::test_config(); config.chatgpt_base_url = "https://chatgpt.com".to_string(); @@ -231,22 +177,6 @@ fn codex_apps_server_config_switches_gateway_with_flags() { }; assert_eq!(url, "https://chatgpt.com/backend-api/wham/apps"); - - config - .features - .enable(Feature::AppsMcpGateway) - .expect("test config should allow apps gateway"); - servers = with_codex_apps_mcp(servers, true, None, &config); - let server = servers - .get(CODEX_APPS_MCP_SERVER_NAME) - .expect("codex apps should remain present when apps stays enabled"); - let url = match &server.transport { - McpServerTransportConfig::StreamableHttp { url, .. } => url, - _ => panic!("expected streamable http transport for codex apps"), - }; - - let expected_url = format!("{OPENAI_CONNECTORS_MCP_BASE_URL}{OPENAI_CONNECTORS_MCP_PATH}"); - assert_eq!(url, &expected_url); } #[tokio::test] diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 6e2174c4d33..74422d07409 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -1014,6 +1014,7 @@ impl McpConnectionManager { server: &str, tool: &str, arguments: Option, + meta: Option, ) -> Result { let client = self.client_by_name(server).await?; if !client.tool_filter.allows(tool) { @@ -1024,7 +1025,7 @@ impl McpConnectionManager { let result: rmcp::model::CallToolResult = client .client - .call_tool(tool.to_string(), arguments, client.tool_timeout) + .call_tool(tool.to_string(), arguments, meta, client.tool_timeout) .await .with_context(|| format!("tool call failed for `{server}/{tool}`"))?; diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 19982349bea..5f6a7a75069 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -117,6 +117,7 @@ pub(crate) async fn handle_mcp_tool_call( .counter("codex.mcp.call", 1, &[("status", status)]); return CallToolResult::from_result(result); } + let request_meta = build_mcp_tool_call_request_meta(&server, metadata.as_ref()); let tool_call_begin_event = EventMsg::McpToolCallBegin(McpToolCallBeginEvent { call_id: call_id.clone(), @@ -142,7 +143,12 @@ pub(crate) async fn handle_mcp_tool_call( let start = Instant::now(); let result = sess - .call_tool(&server, &tool_name, arguments_value.clone()) + .call_tool( + &server, + &tool_name, + arguments_value.clone(), + request_meta.clone(), + ) .await .map_err(|e| format!("tool call error: {e:?}")); let result = sanitize_mcp_tool_result_for_model( @@ -226,7 +232,7 @@ pub(crate) async fn handle_mcp_tool_call( let start = Instant::now(); // Perform the tool call. let result = sess - .call_tool(&server, &tool_name, arguments_value.clone()) + .call_tool(&server, &tool_name, arguments_value.clone(), request_meta) .await .map_err(|e| format!("tool call error: {e:?}")); let result = sanitize_mcp_tool_result_for_model( @@ -374,6 +380,24 @@ pub(crate) struct McpToolApprovalMetadata { connector_description: Option, tool_title: Option, tool_description: Option, + codex_apps_meta: Option>, +} + +const MCP_TOOL_CODEX_APPS_META_KEY: &str = "_codex_apps"; + +fn build_mcp_tool_call_request_meta( + server: &str, + metadata: Option<&McpToolApprovalMetadata>, +) -> Option { + if server != CODEX_APPS_MCP_SERVER_NAME { + return None; + } + + let codex_apps_meta = metadata.and_then(|metadata| metadata.codex_apps_meta.as_ref())?; + + Some(serde_json::json!({ + MCP_TOOL_CODEX_APPS_META_KEY: codex_apps_meta, + })) } #[derive(Clone, Copy)] @@ -750,6 +774,13 @@ pub(crate) async fn lookup_mcp_tool_metadata( connector_description, tool_title: tool_info.tool.title, tool_description: tool_info.tool.description.map(std::borrow::Cow::into_owned), + codex_apps_meta: tool_info + .tool + .meta + .as_ref() + .and_then(|meta| meta.get(MCP_TOOL_CODEX_APPS_META_KEY)) + .and_then(serde_json::Value::as_object) + .cloned(), }) } diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 62292b43f34..55f47f674f7 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -47,6 +47,7 @@ fn approval_metadata( connector_description: connector_description.map(str::to_string), tool_title: tool_title.map(str::to_string), tool_description: tool_description.map(str::to_string), + codex_apps_meta: None, } } @@ -415,6 +416,39 @@ fn sanitize_mcp_tool_result_for_model_preserves_image_when_supported() { assert_eq!(got, original); } +#[test] +fn codex_apps_tool_call_request_meta_includes_codex_apps_meta() { + let metadata = McpToolApprovalMetadata { + annotations: None, + connector_id: Some("calendar".to_string()), + connector_name: Some("Calendar".to_string()), + connector_description: Some("Manage events".to_string()), + tool_title: Some("Create Event".to_string()), + tool_description: Some("Create a calendar event.".to_string()), + codex_apps_meta: Some( + serde_json::json!({ + "resource_uri": "connector://calendar/tools/calendar_create_event", + "contains_mcp_source": true, + "connector_id": "calendar", + }) + .as_object() + .cloned() + .expect("_codex_apps metadata should be an object"), + ), + }; + + assert_eq!( + build_mcp_tool_call_request_meta(CODEX_APPS_MCP_SERVER_NAME, Some(&metadata)), + Some(serde_json::json!({ + MCP_TOOL_CODEX_APPS_META_KEY: { + "resource_uri": "connector://calendar/tools/calendar_create_event", + "contains_mcp_source": true, + "connector_id": "calendar", + }, + })) + ); +} + #[test] fn accepted_elicitation_content_converts_to_request_user_input_response() { let response = request_user_input_response_from_elicitation_content(Some(serde_json::json!( @@ -535,6 +569,7 @@ fn guardian_mcp_review_request_includes_annotations_when_present() { connector_description: None, tool_title: None, tool_description: None, + codex_apps_meta: None, }; let request = build_guardian_mcp_tool_review_request("call-1", &invocation, Some(&metadata)); @@ -856,6 +891,7 @@ async fn approve_mode_skips_when_annotations_do_not_require_approval() { connector_description: None, tool_title: Some("Read Only Tool".to_string()), tool_description: None, + codex_apps_meta: None, }; let decision = maybe_request_mcp_tool_approval( @@ -919,6 +955,7 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_for_model() { connector_description: Some("Manage events".to_string()), tool_title: Some("Dangerous Tool".to_string()), tool_description: Some("Performs a risky action.".to_string()), + codex_apps_meta: None, }; let decision = maybe_request_mcp_tool_approval( @@ -1021,6 +1058,7 @@ async fn approve_mode_routes_arc_ask_user_to_guardian_when_guardian_reviewer_is_ connector_description: Some("Manage events".to_string()), tool_title: Some("Dangerous Tool".to_string()), tool_description: Some("Performs a risky action.".to_string()), + codex_apps_meta: None, }; let decision = maybe_request_mcp_tool_approval( diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 2cf4e16d490..331a55f0cb4 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -5,6 +5,7 @@ use crate::client_common::tools::ToolSpec; use crate::config::AgentRoleConfig; use crate::features::Feature; use crate::features::Features; +use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp_connection_manager::ToolInfo; use crate::models_manager::collaboration_mode_presets::CollaborationModesConfig; use crate::original_image_detail::can_request_original_image_detail; @@ -1673,22 +1674,58 @@ fn create_tool_search_tool(app_tools: &HashMap) -> ToolSpec { }, ), ]); - let mut app_names = app_tools - .values() - .filter_map(|tool| tool.connector_name.clone()) - .collect::>(); - app_names.sort(); - app_names.dedup(); - let app_names = app_names.join(", "); - - let description = if app_names.is_empty() { - TOOL_SEARCH_DESCRIPTION_TEMPLATE - .replace("({{app_names}})", "(None currently enabled)") - .replace("{{app_names}}", "available apps") + let mut app_descriptions = BTreeMap::new(); + for tool in app_tools.values() { + if tool.server_name != CODEX_APPS_MCP_SERVER_NAME { + continue; + } + + let Some(connector_name) = tool + .connector_name + .as_deref() + .map(str::trim) + .filter(|connector_name| !connector_name.is_empty()) + else { + continue; + }; + + let connector_description = tool + .connector_description + .as_deref() + .map(str::trim) + .filter(|connector_description| !connector_description.is_empty()) + .map(str::to_string); + + app_descriptions + .entry(connector_name.to_string()) + .and_modify(|existing: &mut Option| { + if existing.is_none() { + *existing = connector_description.clone(); + } + }) + .or_insert(connector_description); + } + + let app_descriptions = if app_descriptions.is_empty() { + "None currently enabled.".to_string() } else { - TOOL_SEARCH_DESCRIPTION_TEMPLATE.replace("{{app_names}}", app_names.as_str()) + app_descriptions + .into_iter() + .map( + |(connector_name, connector_description)| match connector_description { + Some(connector_description) => { + format!("- {connector_name}: {connector_description}") + } + None => format!("- {connector_name}"), + }, + ) + .collect::>() + .join("\n") }; + let description = + TOOL_SEARCH_DESCRIPTION_TEMPLATE.replace("{{app_descriptions}}", app_descriptions.as_str()); + ToolSpec::ToolSearch { execution: "client".to_string(), description, diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index a5a904d2aae..c3c228703ae 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -1690,7 +1690,7 @@ fn test_build_specs_mcp_tools_sorted_by_name() { } #[test] -fn search_tool_description_includes_only_codex_apps_connector_names() { +fn search_tool_description_lists_each_codex_apps_connector_once() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::Apps); @@ -1736,7 +1736,45 @@ fn search_tool_description_includes_only_codex_apps_connector_names() { connector_id: Some("calendar".to_string()), connector_name: Some("Calendar".to_string()), plugin_display_names: Vec::new(), - connector_description: None, + connector_description: Some( + "Plan events and manage your calendar.".to_string(), + ), + }, + ), + ( + "mcp__codex_apps__calendar_list_events".to_string(), + ToolInfo { + server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool_name: "_list_events".to_string(), + tool_namespace: "mcp__codex_apps__calendar".to_string(), + tool: mcp_tool( + "calendar-list-events", + "List calendar events", + serde_json::json!({"type": "object"}), + ), + connector_id: Some("calendar".to_string()), + connector_name: Some("Calendar".to_string()), + plugin_display_names: Vec::new(), + connector_description: Some( + "Plan events and manage your calendar.".to_string(), + ), + }, + ), + ( + "mcp__codex_apps__gmail_search_threads".to_string(), + ToolInfo { + server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool_name: "_search_threads".to_string(), + tool_namespace: "mcp__codex_apps__gmail".to_string(), + tool: mcp_tool( + "gmail-search-threads", + "Search email threads", + serde_json::json!({"type": "object"}), + ), + connector_id: Some("gmail".to_string()), + connector_name: Some("Gmail".to_string()), + plugin_display_names: Vec::new(), + connector_description: Some("Find and summarize email threads.".to_string()), }, ), ( @@ -1762,7 +1800,14 @@ fn search_tool_description_includes_only_codex_apps_connector_names() { panic!("expected tool_search tool"); }; let description = description.as_str(); - assert!(description.contains("Calendar")); + assert!(description.contains("- Calendar: Plan events and manage your calendar.")); + assert!(description.contains("- Gmail: Find and summarize email threads.")); + assert_eq!( + description + .matches("- Calendar: Plan events and manage your calendar.") + .count(), + 1 + ); assert!(!description.contains("mcp__rmcp__echo")); } @@ -1874,8 +1919,56 @@ fn search_tool_description_handles_no_enabled_apps() { panic!("expected tool_search tool"); }; - assert!(description.contains("(None currently enabled)")); - assert!(!description.contains("{{app_names}}")); + assert!(description.contains("None currently enabled.")); + assert!(!description.contains("{{app_descriptions}}")); +} + +#[test] +fn search_tool_description_falls_back_to_connector_name_without_description() { + let model_info = search_capable_model_info(); + let mut features = Features::with_defaults(); + features.enable(Feature::Apps); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + + let (tools, _) = build_specs( + &tools_config, + None, + Some(HashMap::from([( + "mcp__codex_apps__calendar_create_event".to_string(), + ToolInfo { + server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool_name: "_create_event".to_string(), + tool_namespace: "mcp__codex_apps__calendar".to_string(), + tool: mcp_tool( + "calendar_create_event", + "Create calendar event", + serde_json::json!({"type": "object"}), + ), + connector_id: Some("calendar".to_string()), + connector_name: Some("Calendar".to_string()), + plugin_display_names: Vec::new(), + connector_description: None, + }, + )])), + &[], + ) + .build(); + let search_tool = find_tool(&tools, TOOL_SEARCH_TOOL_NAME); + let ToolSpec::ToolSearch { description, .. } = &search_tool.spec else { + panic!("expected tool_search tool"); + }; + + assert!(description.contains("- Calendar")); + assert!(!description.contains("- Calendar:")); } #[test] diff --git a/codex-rs/core/templates/search_tool/tool_description.md b/codex-rs/core/templates/search_tool/tool_description.md index db6b4e34aa8..6472011c207 100644 --- a/codex-rs/core/templates/search_tool/tool_description.md +++ b/codex-rs/core/templates/search_tool/tool_description.md @@ -2,5 +2,6 @@ Searches over apps/connectors tool metadata with BM25 and exposes matching tools for the next model call. -Tools of the apps ({{app_names}}) are hidden until you search for them with this tool (`tool_search`). -When the request needs one of these connectors and you don't already have the required tools from it, use this tool to load them. For the apps mentioned above, always prefer `tool_search` over `list_mcp_resources` or `list_mcp_resource_templates` for tool discovery. +You have access to all the tools of the following apps/connectors: +{{app_descriptions}} +Some of the tools may not have been provided to you upfront, and you should use this tool (`tool_search`) to search for the required tools and load them for the apps mentioned above. For the apps mentioned above, always use `tool_search` instead of `list_mcp_resources` or `list_mcp_resource_templates` for tool discovery. diff --git a/codex-rs/core/tests/common/apps_test_server.rs b/codex-rs/core/tests/common/apps_test_server.rs index 83ce020bef3..8ac60ffb13b 100644 --- a/codex-rs/core/tests/common/apps_test_server.rs +++ b/codex-rs/core/tests/common/apps_test_server.rs @@ -18,6 +18,9 @@ const CONNECTOR_DESCRIPTION: &str = "Plan events and manage your calendar."; const PROTOCOL_VERSION: &str = "2025-11-25"; const SERVER_NAME: &str = "codex-apps-test"; const SERVER_VERSION: &str = "1.0.0"; +pub const CALENDAR_CREATE_EVENT_RESOURCE_URI: &str = + "connector://calendar/tools/calendar_create_event"; +const CALENDAR_LIST_EVENTS_RESOURCE_URI: &str = "connector://calendar/tools/calendar_list_events"; #[derive(Clone)] pub struct AppsTestServer { @@ -175,7 +178,12 @@ impl Respond for CodexAppsJsonRpcResponder { "_meta": { "connector_id": CONNECTOR_ID, "connector_name": self.connector_name.clone(), - "connector_description": self.connector_description.clone() + "connector_description": self.connector_description.clone(), + "_codex_apps": { + "resource_uri": CALENDAR_CREATE_EVENT_RESOURCE_URI, + "contains_mcp_source": true, + "connector_id": CONNECTOR_ID + } } }, { @@ -192,7 +200,12 @@ impl Respond for CodexAppsJsonRpcResponder { "_meta": { "connector_id": CONNECTOR_ID, "connector_name": self.connector_name.clone(), - "connector_description": self.connector_description.clone() + "connector_description": self.connector_description.clone(), + "_codex_apps": { + "resource_uri": CALENDAR_LIST_EVENTS_RESOURCE_URI, + "contains_mcp_source": true, + "connector_id": CONNECTOR_ID + } } } ], @@ -214,6 +227,7 @@ impl Respond for CodexAppsJsonRpcResponder { .pointer("/params/arguments/starts_at") .and_then(Value::as_str) .unwrap_or_default(); + let codex_apps_meta = body.pointer("/params/_meta/_codex_apps").cloned(); ResponseTemplate::new(200).set_body_json(json!({ "jsonrpc": "2.0", @@ -223,6 +237,9 @@ impl Respond for CodexAppsJsonRpcResponder { "type": "text", "text": format!("called {tool_name} for {title} at {starts_at}") }], + "structuredContent": { + "_codex_apps": codex_apps_meta, + }, "isError": false } })) diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index a94ad9bf9c2..2fc5f8b9d10 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -943,10 +943,6 @@ async fn includes_apps_guidance_as_developer_message_for_chatgpt_auth() { .features .enable(Feature::Apps) .expect("test config should allow feature update"); - config - .features - .disable(Feature::AppsMcpGateway) - .expect("test config should allow feature update"); config.chatgpt_base_url = apps_base_url; }); let codex = builder @@ -971,7 +967,8 @@ async fn includes_apps_guidance_as_developer_message_for_chatgpt_auth() { let request = resp_mock.single_request(); let request_body = request.body_json(); let input = request_body["input"].as_array().expect("input array"); - let apps_snippet = "Apps are mentioned in user messages in the format"; + let apps_snippet = + "Apps (Connectors) can be explicitly triggered in user messages in the format"; let has_developer_apps_guidance = input.iter().any(|item| { item.get("role").and_then(|value| value.as_str()) == Some("developer") @@ -1034,10 +1031,6 @@ async fn omits_apps_guidance_for_api_key_auth_even_when_feature_enabled() { .features .enable(Feature::Apps) .expect("test config should allow feature update"); - config - .features - .disable(Feature::AppsMcpGateway) - .expect("test config should allow feature update"); config.chatgpt_base_url = apps_base_url; }); let codex = builder @@ -1062,7 +1055,8 @@ async fn omits_apps_guidance_for_api_key_auth_even_when_feature_enabled() { let request = resp_mock.single_request(); let request_body = request.body_json(); let input = request_body["input"].as_array().expect("input array"); - let apps_snippet = "Apps are mentioned in the prompt in the format"; + let apps_snippet = + "Apps (Connectors) can be explicitly triggered in user messages in the format"; let has_apps_guidance = input.iter().any(|item| { item.get("content") diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index 71a9f166a87..0eba6e32345 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -142,10 +142,6 @@ async fn build_apps_enabled_plugin_test_codex( .features .enable(Feature::Apps) .expect("test config should allow feature update"); - config - .features - .disable(Feature::AppsMcpGateway) - .expect("test config should allow feature update"); config.chatgpt_base_url = chatgpt_base_url; }); Ok(builder diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index d23d2f26698..485a216b4af 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -13,6 +13,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use core_test_support::apps_test_server::AppsTestServer; +use core_test_support::apps_test_server::CALENDAR_CREATE_EVENT_RESOURCE_URI; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -30,8 +31,9 @@ use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; -const SEARCH_TOOL_DESCRIPTION_SNIPPETS: [&str; 1] = [ - "Tools of the apps (Calendar) are hidden until you search for them with this tool (`tool_search`).", +const SEARCH_TOOL_DESCRIPTION_SNIPPETS: [&str; 2] = [ + "You have access to all the tools of the following apps/connectors", + "- Calendar: Plan events and manage your calendar.", ]; const TOOL_SEARCH_TOOL_NAME: &str = "tool_search"; const CALENDAR_CREATE_TOOL: &str = "mcp__codex_apps__calendar_create_event"; @@ -89,10 +91,6 @@ fn configure_apps(config: &mut Config, apps_base_url: &str) { .features .enable(Feature::Apps) .expect("test config should allow feature update"); - config - .features - .disable(Feature::AppsMcpGateway) - .expect("test config should allow feature update"); config.chatgpt_base_url = apps_base_url.to_string(); config.model = Some("gpt-5-codex".to_string()); @@ -404,6 +402,19 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - })), } ); + assert_eq!( + end.result + .as_ref() + .expect("tool call should succeed") + .structured_content, + Some(json!({ + "_codex_apps": { + "resource_uri": CALENDAR_CREATE_EVENT_RESOURCE_URI, + "contains_mcp_source": true, + "connector_id": "calendar", + }, + })) + ); wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index 97514ba20e7..9b2c99ecc52 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -700,6 +700,7 @@ impl RmcpClient { &self, name: String, arguments: Option, + meta: Option, timeout: Option, ) -> Result { self.refresh_oauth_if_needed().await; @@ -712,8 +713,17 @@ impl RmcpClient { } None => None, }; + let meta = match meta { + Some(Value::Object(map)) => Some(rmcp::model::Meta(map)), + Some(other) => { + return Err(anyhow!( + "MCP tool request _meta must be a JSON object, got {other}" + )); + } + None => None, + }; let rmcp_params = CallToolRequestParams { - meta: None, + meta, name: name.into(), arguments, task: None, diff --git a/codex-rs/rmcp-client/tests/streamable_http_recovery.rs b/codex-rs/rmcp-client/tests/streamable_http_recovery.rs index 4710fdf78a2..fb2fc96d20f 100644 --- a/codex-rs/rmcp-client/tests/streamable_http_recovery.rs +++ b/codex-rs/rmcp-client/tests/streamable_http_recovery.rs @@ -105,6 +105,7 @@ async fn call_echo_tool(client: &RmcpClient, message: &str) -> anyhow::Result