From 4af7762f0129e903461a351cc1e6daa79cb0cea0 Mon Sep 17 00:00:00 2001 From: Alex Daley Date: Thu, 18 Jun 2026 17:19:24 -0400 Subject: [PATCH] [connectors] Ignore synthetic links for app accessibility (#28770) Summary - Stop treating Codex Apps MCP tools with `_meta._codex_apps.synthetic_link: true` as evidence that a connector is accessible in `app/list`. - Preserve synthetic tools in the agent-facing MCP connector set so they remain available for install/auth flows. - Keep the app-list accessibility cache limited to connectors backed by at least one non-synthetic tool. - Add focused regression coverage for both sides of the boundary. Validation - `just fmt` - `just test -p codex-core synthetic_links_are_exposed_to_the_agent_but_not_accessible_in_app_list` - `git diff --check` - A crate-wide `just test -p codex-core` run completed with 2,699 passing and 51 unrelated local sandbox/state failures, primarily state DB migration races (`UNIQUE constraint failed: _sqlx_migrations.version`). --- codex-rs/core/src/connectors.rs | 27 +++++++++-- codex-rs/core/src/connectors_tests.rs | 64 +++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 8cc83be3d..86e5f07f9 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -32,6 +32,7 @@ use codex_features::Feature; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; +use codex_mcp::MCP_TOOL_CODEX_APPS_META_KEY; use codex_mcp::McpConnectionManager; use codex_mcp::McpRuntimeContext; use codex_mcp::ToolInfo; @@ -154,7 +155,7 @@ pub(crate) fn refresh_accessible_connectors_cache_from_mcp_tools( } let cache_key = accessible_connectors_cache_key(config, auth); - let accessible_connectors = accessible_connectors_from_mcp_tools(mcp_tools); + let accessible_connectors = accessible_connectors_for_app_list_from_mcp_tools(mcp_tools); write_cached_accessible_connectors(cache_key, &accessible_connectors); } @@ -339,7 +340,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager( cancel_token.cancel(); } - let accessible_connectors = accessible_connectors_from_mcp_tools(&tools); + let accessible_connectors = accessible_connectors_for_app_list_from_mcp_tools(&tools); if codex_apps_ready || !accessible_connectors.is_empty() { write_cached_accessible_connectors(cache_key, &accessible_connectors); } @@ -469,9 +470,15 @@ async fn cached_directory_connectors_for_tool_suggest_with_auth( } pub(crate) fn accessible_connectors_from_mcp_tools(mcp_tools: &[ToolInfo]) -> Vec { + collect_accessible_connectors_from_mcp_tools(mcp_tools.iter()) +} + +fn collect_accessible_connectors_from_mcp_tools<'a>( + mcp_tools: impl Iterator, +) -> Vec { // ToolInfo already carries plugin provenance, so app-level plugin sources // can be derived here instead of requiring a separate enrichment pass. - let tools = mcp_tools.iter().filter_map(|tool| { + let tools = mcp_tools.filter_map(|tool| { if tool.server_name != CODEX_APPS_MCP_SERVER_NAME { return None; } @@ -486,6 +493,20 @@ pub(crate) fn accessible_connectors_from_mcp_tools(mcp_tools: &[ToolInfo]) -> Ve codex_connectors::accessible::collect_accessible_connectors(tools) } +fn accessible_connectors_for_app_list_from_mcp_tools(mcp_tools: &[ToolInfo]) -> Vec { + let non_synthetic_tools = mcp_tools.iter().filter(|tool| { + tool.tool + .meta + .as_deref() + .and_then(|meta| meta.get(MCP_TOOL_CODEX_APPS_META_KEY)) + .and_then(serde_json::Value::as_object) + .and_then(|meta| meta.get("synthetic_link")) + .and_then(serde_json::Value::as_bool) + != Some(true) + }); + collect_accessible_connectors_from_mcp_tools(non_synthetic_tools) +} + pub fn with_app_enabled_state(mut connectors: Vec, config: &Config) -> Vec { let user_apps_config = apps_config_from_layer_stack(&config.config_layer_stack); let requirements_apps_config = config.config_layer_stack.requirements_toml().apps.as_ref(); diff --git a/codex-rs/core/src/connectors_tests.rs b/codex-rs/core/src/connectors_tests.rs index 4e6cb55aa..5bbd6a1f8 100644 --- a/codex-rs/core/src/connectors_tests.rs +++ b/codex-rs/core/src/connectors_tests.rs @@ -17,6 +17,7 @@ use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_mcp::ToolInfo; use pretty_assertions::assert_eq; use rmcp::model::JsonObject; +use rmcp::model::Meta; use rmcp::model::Tool; use std::collections::BTreeMap; use std::collections::HashSet; @@ -140,6 +141,69 @@ fn accessible_connectors_from_mcp_tools_carries_plugin_display_names() { ); } +#[test] +fn synthetic_links_are_exposed_to_the_agent_but_not_accessible_in_app_list() { + let mut synthetic_tool = codex_app_tool("gmail_batch_read_email", "gmail", Some("Gmail"), &[]); + synthetic_tool.tool.meta = Some(Meta( + serde_json::json!({ + "resource_name": "gmail.batch_read_email", + "_codex_apps": { + "resource_uri": "/connector/gmail/batch_read_email", + "contains_mcp_source": false, + "synthetic_link": true + } + }) + .as_object() + .expect("meta should be an object") + .clone(), + )); + let tools = vec![ + synthetic_tool, + codex_app_tool("calendar_list_events", "calendar", Some("Calendar"), &[]), + ]; + + let calendar = AppInfo { + id: "calendar".to_string(), + name: "Calendar".to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: Some(connector_install_url("Calendar", "calendar")), + branding: None, + app_metadata: None, + labels: None, + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + }; + assert_eq!( + accessible_connectors_for_app_list_from_mcp_tools(&tools), + vec![calendar.clone()] + ); + assert_eq!( + accessible_connectors_from_mcp_tools(&tools), + vec![ + calendar, + AppInfo { + id: "gmail".to_string(), + name: "Gmail".to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: Some(connector_install_url("Gmail", "gmail")), + branding: None, + app_metadata: None, + labels: None, + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + } + ] + ); +} + #[tokio::test] async fn refresh_accessible_connectors_cache_from_mcp_tools_writes_latest_installed_apps() { let codex_home = tempdir().expect("tempdir should succeed");