mirror of
https://github.com/openai/codex.git
synced 2026-10-27 18:48:37 +00:00
## 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
272 lines
10 KiB
Rust
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
|
|
}
|
|
}
|