From 4948a9d2737fa7d2f5ad5a2f64ddf65fb0acf849 Mon Sep 17 00:00:00 2001 From: arun Date: Tue, 26 May 2026 19:01:52 +0000 Subject: [PATCH] Hide app-only MCP tools from model --- codex-rs/codex-mcp/src/tools.rs | 19 +++++++ codex-rs/core/src/mcp_tool_exposure.rs | 9 ++- codex-rs/core/src/mcp_tool_exposure_test.rs | 61 +++++++++++++++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/codex-rs/codex-mcp/src/tools.rs b/codex-rs/codex-mcp/src/tools.rs index 83cbfd5e74..7767fac2c0 100644 --- a/codex-rs/codex-mcp/src/tools.rs +++ b/codex-rs/codex-mcp/src/tools.rs @@ -59,6 +59,22 @@ impl ToolInfo { pub fn canonical_tool_name(&self) -> ToolName { ToolName::namespaced(self.callable_namespace.clone(), self.callable_name.clone()) } + + /// Returns whether MCP Apps metadata allows this tool to be presented to the model. + pub fn is_model_visible(&self) -> bool { + self.tool + .meta + .as_deref() + .and_then(|meta| meta.get(META_UI)) + .and_then(JsonValue::as_object) + .and_then(|ui| ui.get(META_UI_VISIBILITY)) + .and_then(JsonValue::as_array) + .is_none_or(|visibility| { + visibility + .iter() + .any(|value| value.as_str() == Some(META_UI_VISIBILITY_MODEL)) + }) + } } pub fn declared_openai_file_input_param_names( @@ -261,6 +277,9 @@ const MCP_TOOL_NAME_DELIMITER: &str = "__"; const MAX_TOOL_NAME_LENGTH: usize = 64; const CALLABLE_NAME_HASH_LEN: usize = 12; const META_OPENAI_FILE_PARAMS: &str = "openai/fileParams"; +const META_UI: &str = "ui"; +const META_UI_VISIBILITY: &str = "visibility"; +const META_UI_VISIBILITY_MODEL: &str = "model"; fn callable_namespace_with_prefix(namespace: &str, prefix_mcp_tool_names: bool) -> String { if !prefix_mcp_tool_names || namespace.starts_with(LEGACY_MCP_TOOL_NAME_PREFIX) { diff --git a/codex-rs/core/src/mcp_tool_exposure.rs b/codex-rs/core/src/mcp_tool_exposure.rs index e58be65f9c..e544133d61 100644 --- a/codex-rs/core/src/mcp_tool_exposure.rs +++ b/codex-rs/core/src/mcp_tool_exposure.rs @@ -20,10 +20,15 @@ pub(crate) fn build_mcp_tool_exposure( config: &Config, search_tool_enabled: bool, ) -> McpToolExposure { - let mut deferred_tools = filter_non_codex_apps_mcp_tools_only(all_mcp_tools); + let model_visible_tools = all_mcp_tools + .iter() + .filter(|tool| tool.is_model_visible()) + .cloned() + .collect::>(); + let mut deferred_tools = filter_non_codex_apps_mcp_tools_only(&model_visible_tools); if let Some(connectors) = connectors { deferred_tools.extend(filter_codex_apps_mcp_tools( - all_mcp_tools, + &model_visible_tools, connectors, config, )); diff --git a/codex-rs/core/src/mcp_tool_exposure_test.rs b/codex-rs/core/src/mcp_tool_exposure_test.rs index 367baaf4a0..95be76aeeb 100644 --- a/codex-rs/core/src/mcp_tool_exposure_test.rs +++ b/codex-rs/core/src/mcp_tool_exposure_test.rs @@ -7,6 +7,7 @@ use codex_mcp::ToolInfo; use codex_tools::ToolName; use pretty_assertions::assert_eq; use rmcp::model::JsonObject; +use rmcp::model::Meta; use rmcp::model::Tool; use super::*; @@ -79,6 +80,20 @@ fn numbered_mcp_tools(count: usize) -> Vec { .collect() } +fn with_ui_visibility(mut tool: ToolInfo, visibility: &[&str]) -> ToolInfo { + tool.tool.meta = Some(Meta( + serde_json::json!({ + "ui": { + "visibility": visibility, + }, + }) + .as_object() + .expect("object") + .clone(), + )); + tool +} + fn tool_names(tools: &[ToolInfo]) -> HashSet { tools .iter() @@ -86,6 +101,52 @@ fn tool_names(tools: &[ToolInfo]) -> HashSet { .collect() } +#[tokio::test] +async fn hides_app_only_tools_from_model_exposure() { + let config = test_config().await; + let public_tool = make_mcp_tool( + "rmcp", + "public_tool", + "mcp__rmcp", + "public_tool", + /*connector_id*/ None, + /*connector_name*/ None, + ); + let app_only_tool = with_ui_visibility( + make_mcp_tool( + "rmcp", + "app_only_tool", + "mcp__rmcp", + "app_only_tool", + /*connector_id*/ None, + /*connector_name*/ None, + ), + &["app"], + ); + let model_only_tool = with_ui_visibility( + make_mcp_tool( + "rmcp", + "model_only_tool", + "mcp__rmcp", + "model_only_tool", + /*connector_id*/ None, + /*connector_name*/ None, + ), + &["model"], + ); + let mcp_tools = vec![public_tool.clone(), app_only_tool, model_only_tool.clone()]; + + let exposure = build_mcp_tool_exposure( + &mcp_tools, /*connectors*/ None, &config, /*search_tool_enabled*/ true, + ); + + assert_eq!( + tool_names(&exposure.direct_tools), + tool_names(&[public_tool, model_only_tool]) + ); + assert!(exposure.deferred_tools.is_none()); +} + #[tokio::test] async fn directly_exposes_small_effective_tool_sets() { let config = test_config().await;