mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
## Why Refreshing the MCP catalog with unchanged tool metadata recreated handlers and forced the tool search index to rebuild. ## What changed Cache handlers by canonical tool name and reuse them when tool metadata, agent plugin status, and the input schema size limit are unchanged. Replace handlers when those inputs change and evict tools removed from the catalog, preserving search index reuse across equivalent bindings. ## Testing Extend the deferred tool world state test to verify that an unchanged catalog refresh captures a new binding without rebuilding the search index, and that removing all deferred tools stops advertising `tool_search`. GitOrigin-RevId: 150866f635e9ee817d7ca44fb76e724a3f7608a5
204 lines
6.8 KiB
Rust
204 lines
6.8 KiB
Rust
use std::collections::HashMap;
|
|
use std::collections::HashSet;
|
|
use std::num::NonZeroUsize;
|
|
use std::sync::Arc;
|
|
use std::sync::Mutex;
|
|
|
|
use codex_connectors::AppToolPolicyEvaluator;
|
|
use codex_connectors::AppToolPolicyInput;
|
|
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
|
use codex_mcp::McpBinding;
|
|
use codex_mcp::ToolInfo as McpToolInfo;
|
|
use codex_mcp::tool_is_model_visible;
|
|
use codex_tools::ToolExposure;
|
|
use codex_tools::ToolName;
|
|
use tracing::instrument;
|
|
use tracing::warn;
|
|
|
|
const MAX_AGENT_PLUGIN_MCP_SPEC_BYTES: usize = 8_000;
|
|
const MAX_AGENT_PLUGIN_MCP_TOTAL_BYTES: usize = 64_000;
|
|
|
|
use crate::config::Config;
|
|
use crate::tools::handlers::McpHandler;
|
|
use crate::tools::registry::ToolRegistry;
|
|
|
|
#[derive(Default)]
|
|
pub(crate) struct McpHandlerCache {
|
|
handlers: Mutex<HashMap<ToolName, CachedMcpHandler>>,
|
|
}
|
|
|
|
struct CachedMcpHandler {
|
|
tool_info: McpToolInfo,
|
|
agent_plugin: bool,
|
|
schema_max_bytes: Option<NonZeroUsize>,
|
|
handler: Arc<McpHandler>,
|
|
}
|
|
|
|
impl McpHandlerCache {
|
|
pub(crate) fn append_mcp_tools(
|
|
&self,
|
|
binding: &Arc<McpBinding>,
|
|
config: &Config,
|
|
apps_enabled: bool,
|
|
mcp_server_catalog: &codex_mcp::ResolvedMcpCatalog,
|
|
search_tool_enabled: bool,
|
|
registry: &mut ToolRegistry,
|
|
) -> HashSet<ToolName> {
|
|
let mut handlers = self
|
|
.handlers
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
append_mcp_tools(
|
|
binding.tools(),
|
|
config,
|
|
apps_enabled,
|
|
mcp_server_catalog,
|
|
search_tool_enabled,
|
|
&mut handlers,
|
|
registry,
|
|
)
|
|
}
|
|
}
|
|
|
|
#[instrument(level = "trace", skip_all)]
|
|
fn append_mcp_tools(
|
|
all_mcp_tools: &[McpToolInfo],
|
|
config: &Config,
|
|
apps_enabled: bool,
|
|
mcp_server_catalog: &codex_mcp::ResolvedMcpCatalog,
|
|
search_tool_enabled: bool,
|
|
handlers: &mut HashMap<ToolName, CachedMcpHandler>,
|
|
registry: &mut ToolRegistry,
|
|
) -> HashSet<ToolName> {
|
|
let current_tools: HashSet<_> = all_mcp_tools
|
|
.iter()
|
|
.map(McpToolInfo::canonical_tool_name)
|
|
.collect();
|
|
handlers.retain(|name, _| current_tools.contains(name));
|
|
|
|
// Keep regular MCP tools first; Apps tools also require connector and policy checks.
|
|
let non_app_tools = filter_non_codex_apps_mcp_tools_only(all_mcp_tools);
|
|
let app_tools = apps_enabled
|
|
.then(|| filter_codex_apps_mcp_tools(all_mcp_tools, config))
|
|
.into_iter()
|
|
.flatten();
|
|
let exposure = if search_tool_enabled {
|
|
ToolExposure::Deferred
|
|
} else {
|
|
ToolExposure::Direct
|
|
};
|
|
let mut registered_tools = HashSet::new();
|
|
let mut agent_plugin_bytes = 0usize;
|
|
for tool in non_app_tools.chain(app_tools) {
|
|
let tool_name = tool.canonical_tool_name();
|
|
let server = mcp_server_catalog.server(&tool.server_name);
|
|
let agent_plugin = server.is_some_and(|server| server.source().is_agent_plugin());
|
|
let tool_input_schema_max_bytes =
|
|
server.and_then(|server| server.config().tool_input_schema_max_bytes);
|
|
// Handlers contain immutable tool metadata, not a connection or authorization snapshot.
|
|
// Preserve their identity across equivalent bindings so the search index can also be reused.
|
|
let handler = if let Some(cached) = handlers.get(&tool_name).filter(|cached| {
|
|
cached.tool_info == *tool
|
|
&& cached.agent_plugin == agent_plugin
|
|
&& cached.schema_max_bytes == tool_input_schema_max_bytes
|
|
}) {
|
|
Arc::clone(&cached.handler)
|
|
} else {
|
|
handlers.remove(&tool_name);
|
|
let handler = if agent_plugin {
|
|
McpHandler::new_agent_plugin(tool.clone())
|
|
} else if let Some(budget) = tool_input_schema_max_bytes {
|
|
McpHandler::new_with_schema_max_bytes(tool.clone(), budget.get())
|
|
} else {
|
|
McpHandler::new(tool.clone())
|
|
};
|
|
|
|
let handler = match handler {
|
|
Ok(handler) => Arc::new(handler),
|
|
Err(err) => {
|
|
warn!("Skipping MCP tool `{tool_name}`: failed to build tool spec: {err}");
|
|
continue;
|
|
}
|
|
};
|
|
handlers.insert(
|
|
tool_name.clone(),
|
|
CachedMcpHandler {
|
|
tool_info: tool.clone(),
|
|
agent_plugin,
|
|
schema_max_bytes: tool_input_schema_max_bytes,
|
|
handler: Arc::clone(&handler),
|
|
},
|
|
);
|
|
handler
|
|
};
|
|
|
|
let fits_agent_budget = if agent_plugin {
|
|
handler.model_spec_bytes().is_ok_and(|bytes| {
|
|
if bytes > MAX_AGENT_PLUGIN_MCP_SPEC_BYTES {
|
|
return false;
|
|
}
|
|
let next = agent_plugin_bytes.saturating_add(bytes);
|
|
if next <= MAX_AGENT_PLUGIN_MCP_TOTAL_BYTES {
|
|
agent_plugin_bytes = next;
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
})
|
|
} else {
|
|
true
|
|
};
|
|
let tool_exposure = if fits_agent_budget {
|
|
exposure
|
|
} else {
|
|
ToolExposure::Hidden
|
|
};
|
|
if registry.register_external_with_exposure(handler, tool_exposure) && fits_agent_budget {
|
|
registered_tools.insert(tool_name);
|
|
}
|
|
}
|
|
registered_tools
|
|
}
|
|
|
|
fn filter_non_codex_apps_mcp_tools_only(
|
|
mcp_tools: &[McpToolInfo],
|
|
) -> impl Iterator<Item = &McpToolInfo> + '_ {
|
|
mcp_tools.iter().filter(|tool| {
|
|
tool.server_name != CODEX_APPS_MCP_SERVER_NAME && tool_is_model_visible(tool)
|
|
})
|
|
}
|
|
|
|
fn filter_codex_apps_mcp_tools<'a>(
|
|
mcp_tools: &'a [McpToolInfo],
|
|
config: &'a Config,
|
|
) -> impl Iterator<Item = &'a McpToolInfo> + 'a {
|
|
let app_tool_policy = AppToolPolicyEvaluator::new(&config.config_layer_stack);
|
|
|
|
mcp_tools.iter().filter(move |tool| {
|
|
if tool.server_name != CODEX_APPS_MCP_SERVER_NAME {
|
|
return false;
|
|
}
|
|
if !tool_is_model_visible(tool) {
|
|
return false;
|
|
}
|
|
let Some(connector_id) = tool.connector_id.as_deref() else {
|
|
return false;
|
|
};
|
|
let annotations = tool.tool.annotations.as_ref();
|
|
app_tool_policy
|
|
.policy(AppToolPolicyInput {
|
|
connector_id: Some(connector_id),
|
|
link_id: None,
|
|
tool_name: &tool.tool.name,
|
|
tool_title: tool.tool.title.as_deref(),
|
|
destructive_hint: annotations.and_then(|annotations| annotations.destructive_hint),
|
|
open_world_hint: annotations.and_then(|annotations| annotations.open_world_hint),
|
|
})
|
|
.enabled
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "mcp_tool_exposure_test.rs"]
|
|
mod tests;
|