Files
codex/codex-rs/codex-mcp/src/connection_manager/tool_catalog.rs
T
jif 65f8bf6853 Bind MCP calls to captured catalog revisions (#34588)
## Why

An MCP connection or tool catalog can change after a model step captures its
available tools. Calls from that step must not be rerouted to a replacement
client or run against a catalog revision the model did not see.

## What changed

- Add `McpBinding` to capture the ready clients, visible tools, resources, and
  server metadata for one sampling request.
- Prepare calls against the captured client and reject them if the tool catalog
  changes before call preparation and execution finish.
- Keep resource access and cache identity tied to the binding's exact connection
  set, and omit servers that do not have a ready client.
- Preserve each client's unfiltered tool catalog so bindings can apply the
  current filter consistently.

## Testing

Add coverage for connection replacement, stale catalog rejection, serialized
call preparation and refresh, ready-client capture, and binding-scoped resource
identity.

GitOrigin-RevId: a25da4d2c7f5ed06c41caa0c6d15c464c1698314
2026-07-21 18:13:41 +00:00

272 lines
10 KiB
Rust

use std::sync::Arc;
use std::time::Instant;
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use codex_connectors::ConnectorRuntimeFetchSource;
use tracing::Instrument;
use tracing::instrument;
use tracing::trace;
use tracing::trace_span;
use super::McpConnectionManager;
use crate::binding::McpBinding;
use crate::binding::PreparedMcpCall;
use crate::binding_clients::McpBindingClients;
use crate::codex_apps::prepare_openai_file_params_for_model;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::rmcp_client::CODEX_APPS_REFRESH_DURATION_METRIC;
use crate::rmcp_client::MCP_TOOLS_LIST_DURATION_METRIC;
use crate::rmcp_client::ManagedClient;
use crate::rmcp_client::list_tools_for_client_uncached;
use crate::runtime::emit_duration;
use crate::tools::ToolInfo;
use crate::tools::filter_tools;
use crate::tools::normalize_tools_for_model_with_prefix;
impl McpConnectionManager {
/// Returns all tools with model-visible names normalized.
#[instrument(level = "trace", skip_all, fields(mcp_server_count = self.clients.len()))]
pub async fn list_all_tools(&self) -> Vec<ToolInfo> {
let mut tools = Vec::new();
let mut available_server_count = 0;
let mut unavailable_server_count = 0;
for (server_name, managed_client) in &self.clients {
managed_client.reconnect_failed_startup().await;
let has_cached_tools = managed_client.has_cached_tools();
let startup_complete = managed_client
.startup_complete
.load(std::sync::atomic::Ordering::Acquire);
let Some(server_tools) = managed_client
.listed_tools()
.instrument(trace_span!(
"list_tools_for_server",
server_name = %server_name,
has_cached_tools,
startup_complete
))
.await
else {
unavailable_server_count += 1;
trace!(
server_name = %server_name,
has_cached_tools,
startup_complete,
"MCP server tools unavailable while building tool list"
);
continue;
};
available_server_count += 1;
tools.extend(
server_tools
.into_iter()
.map(|tool| self.with_server_metadata(tool)),
);
}
let tools = normalize_tools_for_model_with_prefix(tools, self.prefix_mcp_tool_names);
trace!(
available_server_count,
unavailable_server_count,
tool_count = tools.len(),
"built MCP tool list"
);
tools
}
/// Returns one tool from the current live connection.
pub async fn tool_info(&self, server: &str, tool: &str) -> Option<ToolInfo> {
let client = self.clients.get(server)?;
let managed_client = client.client().await.ok()?;
let tool = client
.prepare_tools(managed_client.listed_tools())
.into_iter()
.find(|tool_info| tool_info.tool.name == tool)?;
Some(self.with_server_metadata(tool))
}
#[expect(
clippy::await_holding_invalid_type,
reason = "catalog capture must remain serialized with catalog replacement"
)]
/// Captures the ready clients, their exact tools, and the supplied runtime metadata.
pub async fn capture_binding_with_metadata(
self: &Arc<Self>,
config: Arc<crate::McpConfig>,
plugins_available: bool,
) -> McpBinding {
let revision = self.tool_catalog_revision.read().await;
let mut listed_tools = Vec::new();
let mut clients = std::collections::HashMap::new();
for (server_name, managed_client) in &self.clients {
let catalog_override = if server_name == CODEX_APPS_MCP_SERVER_NAME {
self.codex_apps_tools_override.read().await.clone()
} else {
None
};
let Some((client, server_tools)) = managed_client
.capture_ready_client_and_tools(catalog_override)
.await
else {
trace!(
server_name = %server_name,
"omitting MCP server without an exact ready client"
);
continue;
};
clients.insert(server_name.clone(), client);
listed_tools.extend(
server_tools
.into_iter()
.map(|tool| self.with_server_metadata(tool)),
);
}
let clients = Arc::new(McpBindingClients::new(clients));
let listed_tools =
normalize_tools_for_model_with_prefix(listed_tools, self.prefix_mcp_tool_names);
let mut tools = Vec::with_capacity(listed_tools.len());
let mut calls = std::collections::HashMap::with_capacity(listed_tools.len());
for tool_info in listed_tools {
let Some(client) = clients.client(&tool_info.server_name) else {
continue;
};
let Some(call) = self.prepare_call(&tool_info, client, *revision) else {
trace!(
server_name = %tool_info.server_name,
tool_name = %tool_info.tool.name,
"omitting MCP tool without an exact ready client"
);
continue;
};
calls.insert(
(
tool_info.server_name.clone(),
tool_info.tool.name.to_string(),
),
call,
);
tools.push(tool_info);
}
McpBinding::new(
Arc::clone(self),
clients,
config,
plugins_available,
tools,
calls,
)
}
fn prepare_call(
self: &Arc<Self>,
tool_info: &ToolInfo,
client: Arc<ManagedClient>,
tool_catalog_revision: u64,
) -> Option<PreparedMcpCall> {
let server_name = &tool_info.server_name;
Some(PreparedMcpCall::new(
Arc::clone(self),
client,
tool_catalog_revision,
Arc::clone(&self.tool_catalog_revision),
tool_info.clone(),
self.server_metadata.get(server_name)?.clone(),
self.plugin_id_for_mcp_server_name(server_name)
.map(str::to_string),
self.is_selected_plugin_mcp_server(server_name),
))
}
/// Force-refresh codex apps tools by bypassing the in-process cache.
///
/// On success, the refreshed tools replace shared cache contents when the
/// cache is enabled and the latest filtered tools are returned directly to
/// the caller. On failure, existing shared cache contents remain unchanged.
#[expect(
clippy::await_holding_invalid_type,
reason = "catalog publication must remain serialized with captured tool calls"
)]
pub async fn hard_refresh_codex_apps_tools_cache(&self) -> Result<Vec<ToolInfo>> {
let _refresh = self.codex_apps_refresh_lock.lock().await;
let refresh_start = Instant::now();
let managed_client = self
.clients
.get(CODEX_APPS_MCP_SERVER_NAME)
.ok_or_else(|| anyhow!("unknown MCP server '{CODEX_APPS_MCP_SERVER_NAME}'"))?
.client()
.await
.context("failed to get client")?;
let list_start = Instant::now();
let fetch_ticket =
managed_client
.codex_apps_tools_cache_context
.as_ref()
.map(|cache_context| {
cache_context.begin_fetch(ConnectorRuntimeFetchSource::HardRefresh)
});
let client_tools = list_tools_for_client_uncached(
CODEX_APPS_MCP_SERVER_NAME,
/*is_codex_apps_mcp_server*/ true,
/*codex_apps_refresh_trigger*/ "explicit",
&managed_client.client,
managed_client.tool_timeout,
managed_client.server_instructions.as_deref(),
)
.await
.with_context(|| {
format!("failed to refresh tools for MCP server '{CODEX_APPS_MCP_SERVER_NAME}'")
})?;
let mut tool_catalog_revision = self.tool_catalog_revision.write().await;
let tools = match (
managed_client.codex_apps_tools_cache_context.as_ref(),
fetch_ticket,
) {
(Some(cache_context), Some(fetch_ticket)) => cache_context.publish_if_newest_accepted(
fetch_ticket,
&managed_client.server_info,
client_tools.clone(),
),
(None, None) => client_tools.clone(),
_ => unreachable!("Codex Apps fetch ticket requires cache context"),
};
*self.codex_apps_tools_override.write().await = Some(client_tools);
*tool_catalog_revision += 1;
drop(tool_catalog_revision);
emit_duration(
MCP_TOOLS_LIST_DURATION_METRIC,
list_start.elapsed(),
&[("cache", "miss")],
);
let tools = filter_tools(tools, &managed_client.tool_filter)
.into_iter()
.map(|mut tool| {
prepare_openai_file_params_for_model(&mut tool);
self.with_server_metadata(tool)
});
let tools = normalize_tools_for_model_with_prefix(tools, self.prefix_mcp_tool_names);
emit_duration(
CODEX_APPS_REFRESH_DURATION_METRIC,
refresh_start.elapsed(),
&[("path", "legacy"), ("trigger", "explicit")],
);
Ok(tools)
}
fn with_server_metadata(&self, mut tool: ToolInfo) -> ToolInfo {
let Some(metadata) = self.server_metadata.get(&tool.server_name) else {
tool.supports_parallel_tool_calls = false;
tool.server_origin = None;
return tool;
};
tool.supports_parallel_tool_calls = metadata.supports_parallel_tool_calls;
tool.server_origin = metadata
.origin
.as_ref()
.map(|origin| origin.as_str().to_string());
tool
}
}