Compare commits

...

1 Commits

Author SHA1 Message Date
Dylan Hurd
7c6bf75071 fix MCP tool budget to use emitted spec 2026-05-18 00:21:25 -07:00
4 changed files with 109 additions and 52 deletions

View File

@@ -4,11 +4,13 @@ use codex_features::Feature;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::ToolInfo as McpToolInfo;
use codex_tools::ToolsConfig;
use codex_utils_output_truncation::approx_token_count;
use crate::config::Config;
use crate::connectors;
use crate::tools::handlers::mcp_tool_spec;
pub(crate) const DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD: usize = 100;
pub(crate) const DIRECT_MCP_TOOL_EXPOSURE_RENDERED_TOKEN_LIMIT: usize = 2_500;
pub(crate) struct McpToolExposure {
pub(crate) direct_tools: Vec<McpToolInfo>,
@@ -35,7 +37,8 @@ pub(crate) fn build_mcp_tool_exposure(
&& (config
.features
.enabled(Feature::ToolSearchAlwaysDeferMcpTools)
|| deferred_tools.len() >= DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD);
|| rendered_tool_list_token_count(&deferred_tools)
>= DIRECT_MCP_TOOL_EXPOSURE_RENDERED_TOKEN_LIMIT);
if !should_defer {
return McpToolExposure {
@@ -58,6 +61,22 @@ pub(crate) fn build_mcp_tool_exposure(
}
}
fn rendered_tool_list_token_count(mcp_tools: &[McpToolInfo]) -> usize {
mcp_tools
.iter()
.map(rendered_tool_token_count)
.sum::<usize>()
}
fn rendered_tool_token_count(tool: &McpToolInfo) -> usize {
let Some(spec) = mcp_tool_spec(tool) else {
return 0;
};
serde_json::to_string(&spec)
.map(|rendered| approx_token_count(&rendered))
.unwrap_or_default()
}
fn filter_non_codex_apps_mcp_tools_only(mcp_tools: &[McpToolInfo]) -> Vec<McpToolInfo> {
mcp_tools
.iter()

View File

@@ -71,22 +71,6 @@ fn make_mcp_tool(
}
}
fn numbered_mcp_tools(count: usize) -> Vec<ToolInfo> {
(0..count)
.map(|index| {
let tool_name = format!("tool_{index}");
make_mcp_tool(
"rmcp",
&tool_name,
"mcp__rmcp__",
&tool_name,
/*connector_id*/ None,
/*connector_name*/ None,
)
})
.collect()
}
fn tool_names(tools: &[ToolInfo]) -> HashSet<ToolName> {
tools
.iter()
@@ -118,7 +102,14 @@ async fn tools_config_for_mcp_tool_exposure(search_tool: bool) -> ToolsConfig {
async fn directly_exposes_small_effective_tool_sets() {
let config = test_config().await;
let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true).await;
let mcp_tools = numbered_mcp_tools(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD - 1);
let mcp_tools = vec![make_mcp_tool(
"rmcp",
"tool",
"mcp__rmcp__",
"tool",
/*connector_id*/ None,
/*connector_name*/ None,
)];
let exposure = build_mcp_tool_exposure(
&mcp_tools,
@@ -133,10 +124,18 @@ async fn directly_exposes_small_effective_tool_sets() {
}
#[tokio::test]
async fn searches_large_effective_tool_sets() {
async fn searches_effective_tool_sets_that_exceed_the_render_budget() {
let config = test_config().await;
let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true).await;
let mcp_tools = numbered_mcp_tools(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD);
let mut mcp_tools = vec![make_mcp_tool(
"rmcp",
"tool",
"mcp__rmcp__",
"tool",
/*connector_id*/ None,
/*connector_name*/ None,
)];
mcp_tools[0].tool.description = Some("Tool description. ".repeat(1_000).into());
let exposure = build_mcp_tool_exposure(
&mcp_tools,
@@ -154,11 +153,49 @@ async fn searches_large_effective_tool_sets() {
assert_eq!(tool_names(deferred_tools), tool_names(&mcp_tools));
}
#[tokio::test]
async fn searches_tool_sets_when_emitted_namespace_description_exceeds_budget() {
let config = test_config().await;
let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true).await;
let mut mcp_tools = vec![make_mcp_tool(
"rmcp",
"tool",
"mcp__rmcp__",
"tool",
/*connector_id*/ None,
/*connector_name*/ None,
)];
mcp_tools[0].namespace_description = Some("Namespace guidance. ".repeat(2_000));
let exposure = build_mcp_tool_exposure(
&mcp_tools,
/*connectors*/ None,
&[],
&config,
&tools_config,
);
assert!(exposure.direct_tools.is_empty());
let deferred_tools = exposure
.deferred_tools
.as_ref()
.expect("large emitted namespace specs should be discoverable through tool_search");
assert_eq!(tool_names(deferred_tools), tool_names(&mcp_tools));
}
#[tokio::test]
async fn directly_exposes_explicit_apps_without_deferred_overlap() {
let config = test_config().await;
let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true).await;
let mut mcp_tools = numbered_mcp_tools(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD - 1);
let mut mcp_tools = vec![make_mcp_tool(
"rmcp",
"tool",
"mcp__rmcp__",
"tool",
/*connector_id*/ None,
/*connector_name*/ None,
)];
mcp_tools[0].tool.description = Some("Tool description. ".repeat(1_000).into());
mcp_tools.push(make_mcp_tool(
CODEX_APPS_MCP_SERVER_NAME,
"calendar_create_event",
@@ -185,10 +222,7 @@ async fn directly_exposes_explicit_apps_without_deferred_overlap() {
"_create_event"
)])
);
assert_eq!(
exposure.deferred_tools.as_ref().map(Vec::len),
Some(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD - 1)
);
assert_eq!(exposure.deferred_tools.as_ref().map(Vec::len), Some(1));
let deferred_tools = exposure
.deferred_tools
.as_ref()
@@ -202,7 +236,7 @@ async fn directly_exposes_explicit_apps_without_deferred_overlap() {
"mcp__codex_apps__calendar",
"_create_event"
)));
assert!(deferred_tool_names.contains(&ToolName::namespaced("mcp__rmcp__", "tool_0")));
assert!(deferred_tool_names.contains(&ToolName::namespaced("mcp__rmcp__", "tool")));
}
#[tokio::test]

View File

@@ -52,31 +52,7 @@ impl ToolExecutor<ToolInvocation> for McpHandler {
}
fn spec(&self) -> Option<ToolSpec> {
let tool_name = self.tool_name();
let namespace_name = tool_name.namespace.as_ref()?;
let tool = mcp_tool_to_responses_api_tool(&tool_name, &self.tool_info.tool).ok()?;
let description = self
.tool_info
.namespace_description
.as_deref()
.map(str::trim)
.filter(|description| !description.is_empty())
.map(str::to_string)
.or_else(|| {
self.tool_info
.connector_name
.as_deref()
.map(str::trim)
.filter(|connector_name| !connector_name.is_empty())
.map(|connector_name| format!("Tools for working with {connector_name}."))
})
.unwrap_or_default();
Some(ToolSpec::Namespace(ResponsesApiNamespace {
name: namespace_name.clone(),
description,
tools: vec![ResponsesApiNamespaceTool::Function(tool)],
}))
mcp_tool_spec(&self.tool_info)
}
fn exposure(&self) -> ToolExposure {
@@ -130,6 +106,33 @@ impl ToolExecutor<ToolInvocation> for McpHandler {
}
}
pub(crate) fn mcp_tool_spec(tool_info: &ToolInfo) -> Option<ToolSpec> {
let tool_name = tool_info.canonical_tool_name();
let namespace_name = tool_name.namespace.as_ref()?;
let tool = mcp_tool_to_responses_api_tool(&tool_name, &tool_info.tool).ok()?;
let description = tool_info
.namespace_description
.as_deref()
.map(str::trim)
.filter(|description| !description.is_empty())
.map(str::to_string)
.or_else(|| {
tool_info
.connector_name
.as_deref()
.map(str::trim)
.filter(|connector_name| !connector_name.is_empty())
.map(|connector_name| format!("Tools for working with {connector_name}."))
})
.unwrap_or_default();
Some(ToolSpec::Namespace(ResponsesApiNamespace {
name: namespace_name.clone(),
description,
tools: vec![ResponsesApiNamespaceTool::Function(tool)],
}))
}
impl CoreToolRuntime for McpHandler {
fn search_info(&self) -> Option<ToolSearchInfo> {
let source_name = self

View File

@@ -55,6 +55,7 @@ pub use goal::CreateGoalHandler;
pub use goal::GetGoalHandler;
pub use goal::UpdateGoalHandler;
pub use mcp::McpHandler;
pub(crate) use mcp::mcp_tool_spec;
pub use mcp_resource::ListMcpResourceTemplatesHandler;
pub use mcp_resource::ListMcpResourcesHandler;
pub use mcp_resource::ReadMcpResourceHandler;