From d3e8545a945a2495fa20c589f8b36de010ed4977 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Fri, 13 Mar 2026 16:21:12 -0700 Subject: [PATCH 1/6] update --- codex-rs/core/src/codex.rs | 3 +- codex-rs/core/src/mcp_connection_manager.rs | 3 +- codex-rs/core/src/mcp_tool_call.rs | 40 ++++++++++++++++++- codex-rs/core/src/mcp_tool_call_tests.rs | 24 +++++++++++ .../core/tests/common/apps_test_server.rs | 15 ++++++- codex-rs/core/tests/suite/search_tool.rs | 10 +++++ codex-rs/rmcp-client/src/rmcp_client.rs | 12 +++++- .../tests/streamable_http_recovery.rs | 1 + 8 files changed, 101 insertions(+), 7 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 10e8bebf89d..75b4621d9fc 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3894,12 +3894,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/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 19997f039a8..4b17e6d24bc 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 74303121363..3e933065ede 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -116,6 +116,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()); if let Some(decision) = maybe_request_mcp_tool_approval( &sess, @@ -145,7 +146,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( @@ -231,7 +237,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( @@ -379,6 +385,27 @@ struct McpToolApprovalMetadata { connector_description: Option, tool_title: Option, tool_description: Option, + resource_uri: Option, +} + +const MCP_TOOL_RESOURCE_URI_META_KEY: &str = "resource_uri"; + +fn build_mcp_tool_call_request_meta( + server: &str, + metadata: Option<&McpToolApprovalMetadata>, +) -> Option { + let resource_uri = if server == CODEX_APPS_MCP_SERVER_NAME { + metadata + .and_then(|metadata| metadata.resource_uri.as_deref()) + .map(str::trim) + .filter(|resource_uri| !resource_uri.is_empty()) + } else { + None + }?; + + Some(serde_json::json!({ + MCP_TOOL_RESOURCE_URI_META_KEY: resource_uri, + })) } #[derive(Clone, Copy)] @@ -742,6 +769,15 @@ 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), + resource_uri: tool_info + .tool + .meta + .as_ref() + .and_then(|meta| meta.get(MCP_TOOL_RESOURCE_URI_META_KEY)) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|resource_uri| !resource_uri.is_empty()) + .map(str::to_string), }) } diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 5e8cb7f873b..0dc6a72804a 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -40,6 +40,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), + resource_uri: None, } } @@ -408,6 +409,26 @@ fn sanitize_mcp_tool_result_for_model_preserves_image_when_supported() { assert_eq!(got, original); } +#[test] +fn codex_apps_tool_call_request_meta_includes_resource_uri() { + 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()), + resource_uri: Some("connector://calendar/tools/calendar_create_event".to_string()), + }; + + assert_eq!( + build_mcp_tool_call_request_meta(CODEX_APPS_MCP_SERVER_NAME, Some(&metadata)), + Some(serde_json::json!({ + MCP_TOOL_RESOURCE_URI_META_KEY: "connector://calendar/tools/calendar_create_event", + })) + ); +} + #[test] fn accepted_elicitation_content_converts_to_request_user_input_response() { let response = request_user_input_response_from_elicitation_content(Some(serde_json::json!( @@ -526,6 +547,7 @@ fn guardian_mcp_review_request_includes_annotations_when_present() { connector_description: None, tool_title: None, tool_description: None, + resource_uri: None, }; let request = build_guardian_mcp_tool_review_request(&invocation, Some(&metadata)); @@ -829,6 +851,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, + resource_uri: None, }; let decision = maybe_request_mcp_tool_approval( @@ -892,6 +915,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()), + resource_uri: None, }; let decision = maybe_request_mcp_tool_approval( diff --git a/codex-rs/core/tests/common/apps_test_server.rs b/codex-rs/core/tests/common/apps_test_server.rs index 83ce020bef3..f1703e1817e 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,8 @@ 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(), + "resource_uri": CALENDAR_CREATE_EVENT_RESOURCE_URI } }, { @@ -192,7 +196,8 @@ 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(), + "resource_uri": CALENDAR_LIST_EVENTS_RESOURCE_URI } } ], @@ -214,6 +219,9 @@ impl Respond for CodexAppsJsonRpcResponder { .pointer("/params/arguments/starts_at") .and_then(Value::as_str) .unwrap_or_default(); + let resource_uri = body + .pointer("/params/_meta/resource_uri") + .and_then(Value::as_str); ResponseTemplate::new(200).set_body_json(json!({ "jsonrpc": "2.0", @@ -223,6 +231,9 @@ impl Respond for CodexAppsJsonRpcResponder { "type": "text", "text": format!("called {tool_name} for {title} at {starts_at}") }], + "structuredContent": { + "resource_uri": resource_uri, + }, "isError": false } })) diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index e7f0a60cb7a..c37d26914a7 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; @@ -404,6 +405,15 @@ 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!({ + "resource_uri": CALENDAR_CREATE_EVENT_RESOURCE_URI, + })) + ); 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 Date: Fri, 13 Mar 2026 22:12:07 -0700 Subject: [PATCH 2/6] update --- codex-rs/core/src/mcp_tool_call.rs | 29 ++++++++----------- codex-rs/core/src/mcp_tool_call_tests.rs | 27 ++++++++++++----- .../core/tests/common/apps_test_server.rs | 18 ++++++++---- codex-rs/core/tests/suite/search_tool.rs | 6 +++- 4 files changed, 49 insertions(+), 31 deletions(-) diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 3e933065ede..50564677406 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -385,26 +385,23 @@ struct McpToolApprovalMetadata { connector_description: Option, tool_title: Option, tool_description: Option, - resource_uri: Option, + codex_apps_meta: Option>, } -const MCP_TOOL_RESOURCE_URI_META_KEY: &str = "resource_uri"; +const MCP_TOOL_CODEX_APPS_META_KEY: &str = "_codex_apps"; fn build_mcp_tool_call_request_meta( server: &str, metadata: Option<&McpToolApprovalMetadata>, ) -> Option { - let resource_uri = if server == CODEX_APPS_MCP_SERVER_NAME { - metadata - .and_then(|metadata| metadata.resource_uri.as_deref()) - .map(str::trim) - .filter(|resource_uri| !resource_uri.is_empty()) - } else { - None - }?; + 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_RESOURCE_URI_META_KEY: resource_uri, + MCP_TOOL_CODEX_APPS_META_KEY: codex_apps_meta, })) } @@ -769,15 +766,13 @@ 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), - resource_uri: tool_info + codex_apps_meta: tool_info .tool .meta .as_ref() - .and_then(|meta| meta.get(MCP_TOOL_RESOURCE_URI_META_KEY)) - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|resource_uri| !resource_uri.is_empty()) - .map(str::to_string), + .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 0dc6a72804a..21c72a1ebd3 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -40,7 +40,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), - resource_uri: None, + codex_apps_meta: None, } } @@ -410,7 +410,7 @@ fn sanitize_mcp_tool_result_for_model_preserves_image_when_supported() { } #[test] -fn codex_apps_tool_call_request_meta_includes_resource_uri() { +fn codex_apps_tool_call_request_meta_includes_codex_apps_meta() { let metadata = McpToolApprovalMetadata { annotations: None, connector_id: Some("calendar".to_string()), @@ -418,13 +418,26 @@ fn codex_apps_tool_call_request_meta_includes_resource_uri() { connector_description: Some("Manage events".to_string()), tool_title: Some("Create Event".to_string()), tool_description: Some("Create a calendar event.".to_string()), - resource_uri: Some("connector://calendar/tools/calendar_create_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_RESOURCE_URI_META_KEY: "connector://calendar/tools/calendar_create_event", + MCP_TOOL_CODEX_APPS_META_KEY: { + "resource_uri": "connector://calendar/tools/calendar_create_event", + "contains_mcp_source": true, + "connector_id": "calendar", + }, })) ); } @@ -547,7 +560,7 @@ fn guardian_mcp_review_request_includes_annotations_when_present() { connector_description: None, tool_title: None, tool_description: None, - resource_uri: None, + codex_apps_meta: None, }; let request = build_guardian_mcp_tool_review_request(&invocation, Some(&metadata)); @@ -851,7 +864,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, - resource_uri: None, + codex_apps_meta: None, }; let decision = maybe_request_mcp_tool_approval( @@ -915,7 +928,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()), - resource_uri: None, + codex_apps_meta: None, }; let decision = maybe_request_mcp_tool_approval( diff --git a/codex-rs/core/tests/common/apps_test_server.rs b/codex-rs/core/tests/common/apps_test_server.rs index f1703e1817e..8ac60ffb13b 100644 --- a/codex-rs/core/tests/common/apps_test_server.rs +++ b/codex-rs/core/tests/common/apps_test_server.rs @@ -179,7 +179,11 @@ impl Respond for CodexAppsJsonRpcResponder { "connector_id": CONNECTOR_ID, "connector_name": self.connector_name.clone(), "connector_description": self.connector_description.clone(), - "resource_uri": CALENDAR_CREATE_EVENT_RESOURCE_URI + "_codex_apps": { + "resource_uri": CALENDAR_CREATE_EVENT_RESOURCE_URI, + "contains_mcp_source": true, + "connector_id": CONNECTOR_ID + } } }, { @@ -197,7 +201,11 @@ impl Respond for CodexAppsJsonRpcResponder { "connector_id": CONNECTOR_ID, "connector_name": self.connector_name.clone(), "connector_description": self.connector_description.clone(), - "resource_uri": CALENDAR_LIST_EVENTS_RESOURCE_URI + "_codex_apps": { + "resource_uri": CALENDAR_LIST_EVENTS_RESOURCE_URI, + "contains_mcp_source": true, + "connector_id": CONNECTOR_ID + } } } ], @@ -219,9 +227,7 @@ impl Respond for CodexAppsJsonRpcResponder { .pointer("/params/arguments/starts_at") .and_then(Value::as_str) .unwrap_or_default(); - let resource_uri = body - .pointer("/params/_meta/resource_uri") - .and_then(Value::as_str); + let codex_apps_meta = body.pointer("/params/_meta/_codex_apps").cloned(); ResponseTemplate::new(200).set_body_json(json!({ "jsonrpc": "2.0", @@ -232,7 +238,7 @@ impl Respond for CodexAppsJsonRpcResponder { "text": format!("called {tool_name} for {title} at {starts_at}") }], "structuredContent": { - "resource_uri": resource_uri, + "_codex_apps": codex_apps_meta, }, "isError": false } diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index c37d26914a7..158645782b1 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -411,7 +411,11 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - .expect("tool call should succeed") .structured_content, Some(json!({ - "resource_uri": CALENDAR_CREATE_EVENT_RESOURCE_URI, + "_codex_apps": { + "resource_uri": CALENDAR_CREATE_EVENT_RESOURCE_URI, + "contains_mcp_source": true, + "connector_id": "calendar", + }, })) ); From 36afd3111b47e5dab40c39deebf01e12ba64cc08 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Fri, 13 Mar 2026 23:38:03 -0700 Subject: [PATCH 3/6] update --- codex-rs/core/src/mcp_tool_call_tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index c9b66841ad1..55f47f674f7 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1058,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( From 8a6d2eec86e5e4adcc309b52553a3bc304fd7832 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Sat, 14 Mar 2026 16:31:14 -0700 Subject: [PATCH 4/6] update --- codex-rs/core/config.schema.json | 6 - codex-rs/core/src/apps/render.rs | 2 +- codex-rs/core/src/features.rs | 8 -- codex-rs/core/src/mcp/mod.rs | 29 +---- codex-rs/core/src/mcp/mod_tests.rs | 86 ++------------- codex-rs/core/src/tools/spec.rs | 63 ++++++++--- codex-rs/core/src/tools/spec_tests.rs | 103 +++++++++++++++++- .../templates/search_tool/tool_description.md | 5 +- codex-rs/core/tests/suite/client.rs | 14 +-- codex-rs/core/tests/suite/plugins.rs | 4 - codex-rs/core/tests/suite/search_tool.rs | 9 +- 11 files changed, 169 insertions(+), 160 deletions(-) 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/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/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..046f51bdb9a 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 might not have been provided to you upfront, 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 use `tool_search` instead of `list_mcp_resources` or `list_mcp_resource_templates` for tool discovery. 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 7496a3f31f1..485a216b4af 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -31,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"; @@ -90,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()); From 0982a306918e5ad8b23747a0c0face502808b7b1 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Sat, 14 Mar 2026 17:12:05 -0700 Subject: [PATCH 5/6] update --- codex-rs/core/templates/search_tool/tool_description.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/templates/search_tool/tool_description.md b/codex-rs/core/templates/search_tool/tool_description.md index 046f51bdb9a..211a71f2972 100644 --- a/codex-rs/core/templates/search_tool/tool_description.md +++ b/codex-rs/core/templates/search_tool/tool_description.md @@ -4,4 +4,4 @@ Searches over apps/connectors tool metadata with BM25 and exposes matching tools You have access to all the tools of the following apps/connectors: {{app_descriptions}} -Some of the tools might not have been provided to you upfront, 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 use `tool_search` instead of `list_mcp_resources` or `list_mcp_resource_templates` for tool discovery. +Some of the tools may not have been provided to you upfront, and this tool (`tool_search`) helps you search for the required tools and load them from 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. From 34bee849f72f048bcdb2b8ba90f4258c00732145 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Sat, 14 Mar 2026 18:14:53 -0700 Subject: [PATCH 6/6] update --- codex-rs/core/templates/search_tool/tool_description.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/templates/search_tool/tool_description.md b/codex-rs/core/templates/search_tool/tool_description.md index 211a71f2972..6472011c207 100644 --- a/codex-rs/core/templates/search_tool/tool_description.md +++ b/codex-rs/core/templates/search_tool/tool_description.md @@ -4,4 +4,4 @@ Searches over apps/connectors tool metadata with BM25 and exposes matching tools 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 this tool (`tool_search`) helps you search for the required tools and load them from 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. +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.