diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 9944c2994..148901a70 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -362,6 +362,13 @@ "CodeModeConfigToml": { "additionalProperties": false, "properties": { + "direct_only_tool_namespaces": { + "description": "Exact tool namespaces to expose only as direct model tools. These tools bypass deferral, remain top-level in code-mode-only sessions, and are omitted from the nested code-mode tool surface.", + "items": { + "type": "string" + }, + "type": "array" + }, "enabled": { "type": "boolean" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 47aa8910d..dd6169fbb 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -438,6 +438,7 @@ async fn load_config_resolves_code_mode_config() -> std::io::Result<()> { [features.code_mode] enabled = true excluded_tool_namespaces = ["mcp__codex_apps", "multi_agent_v1"] +direct_only_tool_namespaces = ["mcp__history", "mcp__notes"] "#, ) .expect("TOML deserialization should succeed"); @@ -452,6 +453,10 @@ excluded_tool_namespaces = ["mcp__codex_apps", "multi_agent_v1"] config.code_mode.excluded_tool_namespaces, vec!["mcp__codex_apps".to_string(), "multi_agent_v1".to_string()] ); + assert_eq!( + config.code_mode.direct_only_tool_namespaces, + vec!["mcp__history".to_string(), "mcp__notes".to_string()] + ); assert!(config.features.enabled(Feature::CodeMode)); Ok(()) } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 72d3d45c9..a54137b58 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1061,6 +1061,7 @@ pub struct Config { #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] pub struct CodeModeConfig { pub excluded_tool_namespaces: Vec, + pub direct_only_tool_namespaces: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -2404,6 +2405,10 @@ fn resolve_code_mode_config(config_toml: &ConfigToml) -> CodeModeConfig { .and_then(|config| config.excluded_tool_namespaces.as_ref()) .cloned() .unwrap_or_default(), + direct_only_tool_namespaces: base + .and_then(|config| config.direct_only_tool_namespaces.as_ref()) + .cloned() + .unwrap_or_default(), } } diff --git a/codex-rs/core/src/tools/handlers/dynamic.rs b/codex-rs/core/src/tools/handlers/dynamic.rs index 401278576..28af3434f 100644 --- a/codex-rs/core/src/tools/handlers/dynamic.rs +++ b/codex-rs/core/src/tools/handlers/dynamic.rs @@ -56,7 +56,9 @@ impl DynamicToolHandler { namespace.map(|namespace| namespace.name.clone()), tool.name.clone(), ); - let output_tool = dynamic_tool_to_responses_api_tool(tool).ok()?; + let mut output_tool = dynamic_tool_to_responses_api_tool(tool).ok()?; + // Exposure controls deferral; tool search restores this marker for deferred results. + output_tool.defer_loading = None; let spec = match namespace { Some(namespace) => ToolSpec::Namespace(ResponsesApiNamespace { name: namespace.name.clone(), diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index c697f287a..660f3f418 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -192,11 +192,41 @@ fn build_tool_specs_and_registry( }; let mut planned_tools = PlannedTools::default(); add_tool_sources(&context, &mut planned_tools); + apply_direct_model_only_namespace_overrides(turn_context, &mut planned_tools); append_tool_search_executor(&context, &mut planned_tools); prepend_code_mode_executors(&context, &mut planned_tools); build_model_visible_specs_and_registry(turn_context, planned_tools) } +fn apply_direct_model_only_namespace_overrides( + turn_context: &TurnContext, + planned_tools: &mut PlannedTools, +) { + for runtime in &mut planned_tools.runtimes { + let configured = runtime + .tool_name() + .namespace + .as_ref() + .is_some_and(|namespace| { + turn_context + .config + .code_mode + .direct_only_tool_namespaces + .contains(namespace) + }); + match runtime.exposure() { + ToolExposure::Direct | ToolExposure::Deferred if configured => { + *runtime = + override_tool_exposure(Arc::clone(runtime), ToolExposure::DirectModelOnly); + } + ToolExposure::Direct + | ToolExposure::Deferred + | ToolExposure::DirectModelOnly + | ToolExposure::Hidden => {} + } + } +} + #[instrument(level = "trace", skip_all)] fn build_model_visible_specs_and_registry( turn_context: &TurnContext, diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 43bcf03a6..78eba0bc7 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -1011,6 +1011,48 @@ async fn code_mode_only_exposes_code_executor_and_hides_nested_tools() { ); } +#[tokio::test] +async fn code_mode_only_exposes_configured_dynamic_namespace_directly() { + let plan = probe_with( + |turn| { + set_features(turn, &[Feature::CodeMode, Feature::CodeModeOnly]); + turn.model_info.supports_search_tool = true; + update_config(turn, |config| { + config.code_mode.direct_only_tool_namespaces = vec!["direct_only".to_string()]; + }); + }, + ToolPlanInputs { + dynamic_tools: vec![dynamic_tool( + Some("direct_only"), + "lookup", + /*defer_loading*/ true, + )], + ..ToolPlanInputs::default() + }, + ) + .await; + + plan.assert_visible_contains(&[ + codex_code_mode::PUBLIC_TOOL_NAME, + codex_code_mode::WAIT_TOOL_NAME, + "direct_only", + ]); + plan.assert_visible_lacks(&["tool_search"]); + assert_eq!( + plan.exposure(&ToolName::namespaced("direct_only", "lookup").to_string()), + ToolExposure::DirectModelOnly + ); + let ToolSpec::Namespace(namespace) = plan.visible_spec("direct_only") else { + panic!("expected direct-only namespace spec"); + }; + let ResponsesApiNamespaceTool::Function(tool) = &namespace.tools[0]; + assert_eq!(tool.defer_loading, None); + let ToolSpec::Freeform(exec) = plan.visible_spec(codex_code_mode::PUBLIC_TOOL_NAME) else { + panic!("expected code mode exec tool"); + }; + assert!(!exec.description.contains("direct_only_lookup(args:")); +} + #[tokio::test] async fn excluded_deferred_namespaces_do_not_enable_nested_tool_guidance() { let plan = probe_with( diff --git a/codex-rs/core/tests/suite/mcp_tool_exposure.rs b/codex-rs/core/tests/suite/mcp_tool_exposure.rs new file mode 100644 index 000000000..44f2f87c3 --- /dev/null +++ b/codex-rs/core/tests/suite/mcp_tool_exposure.rs @@ -0,0 +1,85 @@ +use anyhow::Result; +use codex_features::Feature; +use core_test_support::apps_test_server::AppsTestServer; +use core_test_support::apps_test_server::SEARCH_CALENDAR_CREATE_TOOL; +use core_test_support::apps_test_server::SEARCH_CALENDAR_NAMESPACE; +use core_test_support::apps_test_server::search_capable_apps_builder; +use core_test_support::responses; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::namespace_child_tool; +use core_test_support::responses::sse; +use core_test_support::skip_if_no_network; +use serde_json::Value; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_only_exposes_direct_model_only_mcp_namespaces() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let apps_server = AppsTestServer::mount_searchable(&server).await?; + let response = responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]), + ) + .await; + + let mut builder = search_capable_apps_builder(apps_server.chatgpt_base_url.clone()) + .with_config(move |config| { + config + .features + .enable(Feature::CodeModeOnly) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::ToolSearchAlwaysDeferMcpTools) + .expect("test config should allow feature update"); + config.code_mode.direct_only_tool_namespaces = + vec![SEARCH_CALENDAR_NAMESPACE.to_string()]; + }); + let test = builder.build(&server).await?; + test.submit_turn("inspect directly exposed MCP tools") + .await?; + let body = response.single_request().body_json(); + let tools = body + .get("tools") + .and_then(Value::as_array) + .expect("request should contain tools"); + + assert!( + namespace_child_tool( + &body, + SEARCH_CALENDAR_NAMESPACE, + SEARCH_CALENDAR_CREATE_TOOL, + ) + .is_some(), + "configured MCP namespace should remain top-level: {body}" + ); + assert!( + !tools.iter().any(|tool| { + tool.get("name") + .or_else(|| tool.get("type")) + .and_then(Value::as_str) + == Some("tool_search") + }), + "configured MCP namespace should not be deferred: {body}" + ); + let exec_description = tools.iter().find_map(|tool| { + (tool.get("name").and_then(Value::as_str) == Some("exec")) + .then(|| tool.get("description").and_then(Value::as_str)) + .flatten() + }); + assert!( + exec_description.is_some_and(|description| { + !description.contains("mcp__codex_apps__calendar_create_event(args:") + }), + "direct-model-only MCP namespace should not be available through exec: {body}" + ); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 87dc0ffce..275458ffd 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -65,6 +65,7 @@ mod image_rollout; mod items; mod json_result; mod live_cli; +mod mcp_tool_exposure; mod mcp_turn_metadata; mod model_overrides; mod model_runtime_selectors; diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index f59b89e9a..0acb9b7d3 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -12,6 +12,11 @@ pub struct CodeModeConfigToml { /// Exact tool namespaces to omit from the code-mode nested tool surface. #[serde(skip_serializing_if = "Option::is_none")] pub excluded_tool_namespaces: Option>, + /// Exact tool namespaces to expose only as direct model tools. + /// These tools bypass deferral, remain top-level in code-mode-only sessions, and are omitted + /// from the nested code-mode tool surface. + #[serde(skip_serializing_if = "Option::is_none")] + pub direct_only_tool_namespaces: Option>, } impl FeatureConfig for CodeModeConfigToml {