[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`).
This commit is contained in:
Alex Daley
2026-06-18 17:19:24 -04:00
committed by GitHub
Unverified
parent ec848dde0e
commit 4af7762f01
2 changed files with 88 additions and 3 deletions
+24 -3
View File
@@ -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<AppInfo> {
collect_accessible_connectors_from_mcp_tools(mcp_tools.iter())
}
fn collect_accessible_connectors_from_mcp_tools<'a>(
mcp_tools: impl Iterator<Item = &'a ToolInfo>,
) -> Vec<AppInfo> {
// 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<AppInfo> {
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<AppInfo>, config: &Config) -> Vec<AppInfo> {
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();
+64
View File
@@ -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");