mirror of
https://github.com/openai/codex.git
synced 2026-11-08 20:40:33 +00:00
Bound MCP namespace descriptions (#35941)
## What changed - Limit model-facing MCP namespace descriptions to 1,000 bytes without splitting UTF-8 characters or mutating the stored server metadata. - Cap the aggregate source-description list in `tool_search` at 4 KiB while retaining source names and discovery instructions. - Cover direct and deferred MCP tool exposure, including multibyte descriptions and tool visibility. GitOrigin-RevId: 92736ec39475a1d6b3b03f5e3063e28225d8a5f6
This commit is contained in:
@@ -23,11 +23,13 @@ use codex_tools::ToolSearchInfo;
|
||||
use codex_tools::ToolSearchSourceInfo;
|
||||
use codex_tools::ToolSpec;
|
||||
use codex_tools::mcp_tool_to_responses_api_tool;
|
||||
use codex_utils_string::take_bytes_at_char_boundary;
|
||||
use serde_json::Map;
|
||||
use serde_json::Value;
|
||||
|
||||
const LEGACY_MCP_TOOL_NAME_PREFIX: &str = "mcp__";
|
||||
const MCP_TOOL_NAME_DELIMITER: &str = "__";
|
||||
const MAX_MCP_NAMESPACE_DESCRIPTION_BYTES: usize = 1_000;
|
||||
|
||||
pub struct McpHandler {
|
||||
tool_info: ToolInfo,
|
||||
@@ -102,6 +104,7 @@ impl ToolExecutor<ToolInvocation> for McpHandler {
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|description| !description.is_empty())
|
||||
.map(bounded_mcp_namespace_description)
|
||||
.map(str::to_string),
|
||||
});
|
||||
|
||||
@@ -254,11 +257,15 @@ fn create_tool_spec(tool_info: &ToolInfo) -> Result<ToolSpec, serde_json::Error>
|
||||
|
||||
Ok(ToolSpec::Namespace(ResponsesApiNamespace {
|
||||
name: tool_info.callable_namespace.clone(),
|
||||
description,
|
||||
description: bounded_mcp_namespace_description(&description).to_string(),
|
||||
tools: vec![ResponsesApiNamespaceTool::Function(tool)],
|
||||
}))
|
||||
}
|
||||
|
||||
fn bounded_mcp_namespace_description(description: &str) -> &str {
|
||||
take_bytes_at_char_boundary(description, MAX_MCP_NAMESPACE_DESCRIPTION_BYTES)
|
||||
}
|
||||
|
||||
fn mcp_hook_tool_input(raw_arguments: &str) -> Value {
|
||||
if raw_arguments.trim().is_empty() {
|
||||
return Value::Object(Map::new());
|
||||
|
||||
@@ -42,6 +42,32 @@ fn search_info_uses_connector_name_for_output_namespace_description() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_namespace_descriptions_are_bounded_without_mutating_metadata() {
|
||||
let expected_description = "é".repeat(499);
|
||||
let full_description = format!("{expected_description}🦀keep the complete app metadata");
|
||||
let mut info = tool_info();
|
||||
info.namespace_description = Some(full_description.clone());
|
||||
let handler = McpHandler::new(info).expect("MCP tool spec should build");
|
||||
let search_info = handler.search_info().expect("MCP search info");
|
||||
|
||||
assert_eq!(
|
||||
search_info.source_info,
|
||||
Some(ToolSearchSourceInfo {
|
||||
name: "Calendar".to_string(),
|
||||
description: Some(expected_description.clone()),
|
||||
})
|
||||
);
|
||||
let LoadableToolSpec::Namespace(namespace) = search_info.entry.output else {
|
||||
panic!("expected namespace search output");
|
||||
};
|
||||
assert_eq!(namespace.description, expected_description);
|
||||
assert_eq!(
|
||||
handler.tool_info.namespace_description,
|
||||
Some(full_description)
|
||||
);
|
||||
}
|
||||
|
||||
fn tool_info() -> ToolInfo {
|
||||
ToolInfo {
|
||||
server_name: "codex-apps".to_string(),
|
||||
|
||||
@@ -2,8 +2,11 @@ use codex_tools::JsonSchema;
|
||||
use codex_tools::TOOL_SEARCH_TOOL_NAME;
|
||||
use codex_tools::ToolSearchSourceInfo;
|
||||
use codex_tools::ToolSpec;
|
||||
use codex_utils_string::take_bytes_at_char_boundary;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const MAX_TOOL_SEARCH_SOURCE_DESCRIPTION_BYTES: usize = 4 * 1024;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ToolSearchSourceListing {
|
||||
Include,
|
||||
@@ -45,14 +48,40 @@ pub(crate) fn create_tool_search_tool(
|
||||
let source_descriptions = if source_descriptions.is_empty() {
|
||||
"None currently enabled.".to_string()
|
||||
} else {
|
||||
source_descriptions
|
||||
.into_iter()
|
||||
.map(|(name, description)| match description {
|
||||
Some(description) => format!("- {name}: {description}"),
|
||||
None => format!("- {name}"),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
let reserved_name_bytes = source_descriptions.keys().fold(
|
||||
source_descriptions.len().saturating_sub(1),
|
||||
|reserved, name| reserved.saturating_add(2).saturating_add(name.len()),
|
||||
);
|
||||
let mut description_budget =
|
||||
MAX_TOOL_SEARCH_SOURCE_DESCRIPTION_BYTES.saturating_sub(reserved_name_bytes);
|
||||
let mut rendered = String::new();
|
||||
for (name, description) in source_descriptions {
|
||||
let separator_bytes = usize::from(!rendered.is_empty());
|
||||
let required = separator_bytes.saturating_add(2).saturating_add(name.len());
|
||||
if required
|
||||
> MAX_TOOL_SEARCH_SOURCE_DESCRIPTION_BYTES.saturating_sub(rendered.len())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if !rendered.is_empty() {
|
||||
rendered.push('\n');
|
||||
}
|
||||
rendered.push_str("- ");
|
||||
rendered.push_str(&name);
|
||||
|
||||
if let Some(description) = description
|
||||
&& description_budget >= 2
|
||||
{
|
||||
rendered.push_str(": ");
|
||||
description_budget -= 2;
|
||||
let bounded_description =
|
||||
take_bytes_at_char_boundary(&description, description_budget);
|
||||
rendered.push_str(bounded_description);
|
||||
description_budget -= bounded_description.len();
|
||||
}
|
||||
}
|
||||
rendered
|
||||
};
|
||||
format!(
|
||||
"\n\nYou have access to tools from the following sources:\n{source_descriptions}\n"
|
||||
@@ -144,4 +173,47 @@ mod tests {
|
||||
assert!(!description.contains("Google Drive"));
|
||||
assert!(description.contains("use this tool (`tool_search`) to search"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_tool_search_tool_bounds_aggregate_source_descriptions() {
|
||||
let sources = (0..8)
|
||||
.map(|index| ToolSearchSourceInfo {
|
||||
name: format!("source-{index:02}"),
|
||||
description: Some("🦀".repeat(300)),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let ToolSpec::ToolSearch { description, .. } = create_tool_search_tool(
|
||||
&sources,
|
||||
/*default_limit*/ 8,
|
||||
ToolSearchSourceListing::Include,
|
||||
) else {
|
||||
panic!("expected tool search spec");
|
||||
};
|
||||
|
||||
let (_, source_section) = description
|
||||
.split_once("You have access to tools from the following sources:\n")
|
||||
.expect("tool search should retain its source introduction");
|
||||
let (source_descriptions, _) = source_section
|
||||
.split_once("\nSome of the tools may not have been provided to you upfront")
|
||||
.expect("tool search should retain its discovery instructions");
|
||||
assert!(source_descriptions.len() <= MAX_TOOL_SEARCH_SOURCE_DESCRIPTION_BYTES);
|
||||
assert!(source_descriptions.starts_with("- source-00: 🦀"));
|
||||
let advertised_names = source_descriptions
|
||||
.lines()
|
||||
.map(|line| {
|
||||
let source = line
|
||||
.strip_prefix("- ")
|
||||
.expect("each source should be a complete list item");
|
||||
source
|
||||
.split_once(": ")
|
||||
.map_or(source, |(name, _)| name)
|
||||
.to_string()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let expected_names = (0..8)
|
||||
.map(|index| format!("source-{index:02}"))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(advertised_names, expected_names);
|
||||
assert!(description.contains("always use `tool_search`"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user