diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 76c1e4389c..e17ede115c 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -85,6 +85,7 @@ use codex_tools::request_user_input_available_modes; use codex_tools::shell_command_backend_for_features; use codex_tools::shell_type_for_model_and_features; use std::collections::BTreeMap; +use std::collections::HashMap; use std::collections::HashSet; use std::sync::Arc; use tracing::instrument; @@ -426,6 +427,7 @@ fn is_excluded_from_code_mode(turn_context: &TurnContext, tool_name: &ToolName) fn build_code_mode_executors( turn_context: &TurnContext, executors: &[Arc], + deferred_mcp_tools: Option<&[ToolInfo]>, ) -> Vec> { if !matches!( turn_context.tool_mode, @@ -438,6 +440,17 @@ fn build_code_mode_executors( let mut exec_prompt_tool_specs = Vec::new(); let mut deferred_tools_available = false; let deferred_tools_guidance_enabled = search_tool_enabled(turn_context); + let deferred_mcp_instructions = deferred_mcp_tools + .unwrap_or_default() + .iter() + .filter_map(|tool| { + tool.namespace_description + .as_deref() + .map(str::trim) + .filter(|instructions| !instructions.is_empty()) + .map(|instructions| (tool.canonical_tool_name(), instructions)) + }) + .collect::>(); for executor in executors { let exposure = executor.exposure(); if exposure == ToolExposure::DirectModelOnly { @@ -452,9 +465,22 @@ fn build_code_mode_executors( continue; } - let spec = executor.spec(); + let mut spec = executor.spec(); if exposure == ToolExposure::Deferred { + if let Some(instructions) = deferred_mcp_instructions.get(&executor.tool_name()) + && let ToolSpec::Namespace(namespace) = &mut spec + { + for tool in &mut namespace.tools { + let ResponsesApiNamespaceTool::Function(tool) = tool; + if tool.description.trim().is_empty() { + tool.description = (*instructions).to_string(); + } else { + tool.description = + format!("{}\n\n{instructions}", tool.description.trim_end()); + } + } + } // Only show deferred-tool guidance when supported and an included spec is usable by code mode. deferred_tools_available |= deferred_tools_guidance_enabled && !collect_code_mode_exec_prompt_tool_definitions(std::iter::once(&spec)) @@ -864,7 +890,11 @@ fn prepend_code_mode_executors( planned_tools: &mut PlannedTools, ) { let turn_context = context.turn_context; - let code_mode_executors = build_code_mode_executors(turn_context, planned_tools.runtimes()); + let code_mode_executors = build_code_mode_executors( + turn_context, + planned_tools.runtimes(), + context.deferred_mcp_tools, + ); planned_tools.runtimes.splice(0..0, code_mode_executors); } diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 0a697d6df0..31686beed9 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -330,8 +330,13 @@ async fn run_code_mode_turn_with_rmcp_model( model: &'static str, ) -> Result<(TestCodex, ResponseMock)> { run_code_mode_turn_with_rmcp_config( - server, prompt, code, model, /*code_mode_only*/ false, + server, + prompt, + code, + model, + /*code_mode_only*/ false, /*non_prefixed_mcp_tool_names*/ false, + McpToolLoading::Direct, ) .await } @@ -349,10 +354,17 @@ async fn run_code_mode_turn_with_rmcp_mode( "test-gpt-5.1-codex", code_mode_only, /*non_prefixed_mcp_tool_names*/ false, + McpToolLoading::Direct, ) .await } +#[derive(Clone, Copy, Eq, PartialEq)] +enum McpToolLoading { + Direct, + Deferred, +} + async fn run_code_mode_turn_with_rmcp_config( server: &MockServer, prompt: &str, @@ -360,6 +372,7 @@ async fn run_code_mode_turn_with_rmcp_config( model: &'static str, code_mode_only: bool, non_prefixed_mcp_tool_names: bool, + tool_loading: McpToolLoading, ) -> Result<(TestCodex, ResponseMock)> { let rmcp_test_server_bin = stdio_server_bin()?; let mut builder = test_codex().with_model(model).with_config(move |config| { @@ -371,6 +384,21 @@ async fn run_code_mode_turn_with_rmcp_config( if non_prefixed_mcp_tool_names { let _ = config.features.enable(Feature::NonPrefixedMcpToolNames); } + if tool_loading == McpToolLoading::Deferred { + config + .features + .enable(Feature::ToolSearchAlwaysDeferMcpTools) + .expect("test config should allow feature update"); + let mut model_catalog = bundled_models_response() + .unwrap_or_else(|err| panic!("bundled models.json should parse: {err}")); + let model = model_catalog + .models + .iter_mut() + .find(|model_info| model_info.slug == model) + .unwrap_or_else(|| panic!("{model} exists in bundled models.json")); + model.supports_search_tool = true; + config.model_catalog = Some(model_catalog); + } let mut servers = config.mcp_servers.get().clone(); servers.insert( @@ -3019,6 +3047,7 @@ text(JSON.stringify({ "test-gpt-5.1-codex", /*code_mode_only*/ false, /*non_prefixed_mcp_tool_names*/ true, + McpToolLoading::Direct, ) .await?; @@ -3299,6 +3328,60 @@ text(JSON.stringify(tool)); Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_appends_server_instructions_to_deferred_mcp_tool_descriptions() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let code = r#" +const tool = ALL_TOOLS.find( + ({ name }) => name === "mcp__rmcp__echo" +); +text(JSON.stringify(tool)); +"#; + + let (_test, second_mock) = run_code_mode_turn_with_rmcp_config( + &server, + "use exec to inspect ALL_TOOLS", + code, + "gpt-5.4", + /*code_mode_only*/ true, + /*non_prefixed_mcp_tool_names*/ false, + McpToolLoading::Deferred, + ) + .await?; + + let req = second_mock.single_request(); + let (output, success) = custom_tool_output_body_and_success(&req, "call-1"); + assert_ne!( + success, + Some(false), + "exec ALL_TOOLS MCP lookup failed unexpectedly: {output}" + ); + + let parsed: Value = serde_json::from_str( + &custom_tool_output_last_non_empty_text(&req, "call-1") + .expect("exec ALL_TOOLS lookup should emit JSON"), + )?; + assert_eq!( + parsed, + serde_json::json!({ + "name": "mcp__rmcp__echo", + "description": concat!( + "Echo back the provided message and include environment data.\n\n", + "Use these tools to exercise the rmcp test server.\n\n", + "exec tool declaration:\n", + "```ts\n", + "declare const tools: { mcp__rmcp__echo(args: { env_var?: string; message: string; }): ", + "Promise>; };\n", + "```", + ), + }) + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn code_mode_can_call_hidden_dynamic_tools() -> Result<()> { skip_if_no_network!(Ok(()));