Skip to main content

codex_mcp/
tools.rs

1//! MCP tool metadata, filtering, and name normalization.
2//!
3//! Raw MCP tool identities must be preserved for protocol calls, while
4//! model-visible tool names must be sanitized, deduplicated, and kept within API
5//! limits. This module owns that translation as well as the shared [`ToolInfo`]
6//! type.
7
8use std::collections::HashMap;
9use std::collections::HashSet;
10
11use codex_config::McpServerConfig;
12use codex_protocol::ToolName;
13use rmcp::model::Tool;
14use serde::Deserialize;
15use serde::Serialize;
16use sha1::Digest;
17use sha1::Sha1;
18use tracing::warn;
19
20use crate::mcp::sanitize_responses_api_tool_name;
21
22const LEGACY_MCP_TOOL_NAME_PREFIX: &str = "mcp__";
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ToolInfo {
26    /// Raw MCP server name used for routing the tool call.
27    pub server_name: String,
28    /// Whether calls routed to this server may run in parallel.
29    #[serde(default)]
30    pub supports_parallel_tool_calls: bool,
31    /// MCP server origin used for telemetry and diagnostics, when known.
32    #[serde(default)]
33    pub server_origin: Option<String>,
34    /// Model-visible tool name used in Responses API tool declarations.
35    #[serde(rename = "tool_name", alias = "callable_name")]
36    pub callable_name: String,
37    /// Model-visible namespace used for deferred tool loading.
38    #[serde(rename = "tool_namespace", alias = "callable_namespace")]
39    pub callable_namespace: String,
40    /// Model-visible namespace description.
41    // Keep the old serialized field name readable for cached ToolInfo values.
42    #[serde(default, alias = "connector_description")]
43    pub namespace_description: Option<String>,
44    /// Raw MCP tool definition; `tool.name` is sent back to the MCP server.
45    pub tool: Tool,
46    /// Optional provided-file fields accepted by each declared `openai/fileParams`
47    /// argument. This is derived from the raw MCP schema before file arguments are
48    /// masked as local paths for the model.
49    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
50    pub openai_file_input_optional_fields: HashMap<String, Vec<String>>,
51    pub connector_id: Option<String>,
52    pub connector_name: Option<String>,
53    #[serde(default)]
54    pub plugin_display_names: Vec<String>,
55}
56
57impl ToolInfo {
58    pub fn canonical_tool_name(&self) -> ToolName {
59        ToolName::namespaced(self.callable_namespace.clone(), self.callable_name.clone())
60    }
61}
62
63/// A tool is allowed to be used if both are true:
64/// 1. enabled is None (no allowlist is set) or the tool is explicitly enabled.
65/// 2. The tool is not explicitly disabled.
66#[derive(Default, Clone)]
67pub(crate) struct ToolFilter {
68    pub(crate) enabled: Option<HashSet<String>>,
69    pub(crate) disabled: HashSet<String>,
70}
71
72impl ToolFilter {
73    pub(crate) fn from_config(cfg: &McpServerConfig) -> Self {
74        let enabled = cfg
75            .enabled_tools
76            .as_ref()
77            .map(|tools| tools.iter().cloned().collect::<HashSet<_>>());
78        let disabled = cfg
79            .disabled_tools
80            .as_ref()
81            .map(|tools| tools.iter().cloned().collect::<HashSet<_>>())
82            .unwrap_or_default();
83
84        Self { enabled, disabled }
85    }
86
87    pub(crate) fn allows(&self, tool_name: &str) -> bool {
88        if let Some(enabled) = &self.enabled
89            && !enabled.contains(tool_name)
90        {
91            return false;
92        }
93
94        !self.disabled.contains(tool_name)
95    }
96}
97
98pub(crate) fn filter_tools(tools: Vec<ToolInfo>, filter: &ToolFilter) -> Vec<ToolInfo> {
99    tools
100        .into_iter()
101        .filter(|tool| filter.allows(&tool.tool.name))
102        .collect()
103}
104
105/// Returns MCP tools with model-visible names normalized.
106///
107/// Raw MCP server/tool names are kept on each [`ToolInfo`] for protocol calls, while
108/// `callable_namespace` / `callable_name` are sanitized and, when necessary, hashed so
109/// every model-visible name is unique and <= 64 bytes.
110///
111/// When `prefix_mcp_tool_names` is true, the historical `mcp__` namespace
112/// prefix is added without restoring the old trailing `__` namespace suffix.
113pub(crate) fn normalize_tools_for_model_with_prefix<I>(
114    tools: I,
115    prefix_mcp_tool_names: bool,
116) -> Vec<ToolInfo>
117where
118    I: IntoIterator<Item = ToolInfo>,
119{
120    let mut seen_raw_names = HashSet::new();
121    let mut candidates = Vec::new();
122    for tool in tools {
123        let raw_namespace_identity = format!(
124            "{}\0{}\0{}",
125            tool.server_name,
126            tool.callable_namespace,
127            tool.connector_id.as_deref().unwrap_or_default()
128        );
129        let raw_tool_identity = format!(
130            "{}\0{}\0{}",
131            raw_namespace_identity, tool.callable_name, tool.tool.name
132        );
133        if !seen_raw_names.insert(raw_tool_identity.clone()) {
134            warn!("skipping duplicated tool {}", tool.tool.name);
135            continue;
136        }
137
138        let callable_namespace = callable_namespace_with_prefix(
139            &sanitize_responses_api_tool_name(&tool.callable_namespace),
140            prefix_mcp_tool_names,
141        );
142
143        candidates.push(CallableToolCandidate {
144            callable_namespace,
145            callable_name: sanitize_responses_api_tool_name(&tool.callable_name),
146            raw_namespace_identity,
147            raw_tool_identity,
148            tool,
149        });
150    }
151
152    let mut namespace_identities_by_base = HashMap::<String, HashSet<String>>::new();
153    for candidate in &candidates {
154        namespace_identities_by_base
155            .entry(candidate.callable_namespace.clone())
156            .or_default()
157            .insert(candidate.raw_namespace_identity.clone());
158    }
159    let colliding_namespaces = namespace_identities_by_base
160        .into_iter()
161        .filter_map(|(namespace, identities)| (identities.len() > 1).then_some(namespace))
162        .collect::<HashSet<_>>();
163    for candidate in &mut candidates {
164        if colliding_namespaces.contains(&candidate.callable_namespace) {
165            candidate.callable_namespace = append_namespace_hash_suffix(
166                &candidate.callable_namespace,
167                &candidate.raw_namespace_identity,
168            );
169        }
170    }
171
172    let mut tool_identities_by_base = HashMap::<(String, String), HashSet<String>>::new();
173    for candidate in &candidates {
174        tool_identities_by_base
175            .entry((
176                candidate.callable_namespace.clone(),
177                candidate.callable_name.clone(),
178            ))
179            .or_default()
180            .insert(candidate.raw_tool_identity.clone());
181    }
182    let colliding_tools = tool_identities_by_base
183        .into_iter()
184        .filter_map(|(key, identities)| (identities.len() > 1).then_some(key))
185        .collect::<HashSet<_>>();
186    for candidate in &mut candidates {
187        if colliding_tools.contains(&(
188            candidate.callable_namespace.clone(),
189            candidate.callable_name.clone(),
190        )) {
191            candidate.callable_name =
192                append_hash_suffix(&candidate.callable_name, &candidate.raw_tool_identity);
193        }
194    }
195
196    candidates.sort_by(|left, right| left.raw_tool_identity.cmp(&right.raw_tool_identity));
197
198    let mut used_names = HashSet::new();
199    let mut model_tools = Vec::new();
200    for mut candidate in candidates {
201        let (callable_namespace, callable_name) = unique_callable_parts(
202            &candidate.callable_namespace,
203            &candidate.callable_name,
204            &candidate.raw_tool_identity,
205            &mut used_names,
206            MCP_TOOL_NAME_DELIMITER.len(),
207        );
208        candidate.tool.callable_namespace = callable_namespace;
209        candidate.tool.callable_name = callable_name;
210        model_tools.push(candidate.tool);
211    }
212    model_tools
213}
214
215#[derive(Debug)]
216struct CallableToolCandidate {
217    tool: ToolInfo,
218    raw_namespace_identity: String,
219    raw_tool_identity: String,
220    callable_namespace: String,
221    callable_name: String,
222}
223
224const MCP_TOOL_NAME_DELIMITER: &str = "__";
225const MAX_TOOL_NAME_LENGTH: usize = 64;
226const CALLABLE_NAME_HASH_LEN: usize = 12;
227fn callable_namespace_with_prefix(namespace: &str, prefix_mcp_tool_names: bool) -> String {
228    if !prefix_mcp_tool_names || namespace.starts_with(LEGACY_MCP_TOOL_NAME_PREFIX) {
229        namespace.to_string()
230    } else {
231        format!("{LEGACY_MCP_TOOL_NAME_PREFIX}{namespace}")
232    }
233}
234
235fn sha1_hex(s: &str) -> String {
236    let mut hasher = Sha1::new();
237    hasher.update(s.as_bytes());
238    let sha1 = hasher.finalize();
239    format!("{sha1:x}")
240}
241
242fn callable_name_hash_suffix(raw_identity: &str) -> String {
243    let hash = sha1_hex(raw_identity);
244    format!("_{}", &hash[..CALLABLE_NAME_HASH_LEN])
245}
246
247fn append_hash_suffix(value: &str, raw_identity: &str) -> String {
248    format!("{value}{}", callable_name_hash_suffix(raw_identity))
249}
250
251fn append_namespace_hash_suffix(namespace: &str, raw_identity: &str) -> String {
252    if let Some(namespace) = namespace.strip_suffix(MCP_TOOL_NAME_DELIMITER) {
253        format!(
254            "{}{}{}",
255            namespace,
256            callable_name_hash_suffix(raw_identity),
257            MCP_TOOL_NAME_DELIMITER
258        )
259    } else {
260        append_hash_suffix(namespace, raw_identity)
261    }
262}
263
264fn truncate_name(value: &str, max_len: usize) -> String {
265    value.chars().take(max_len).collect()
266}
267
268fn fit_callable_parts_with_hash(
269    namespace: &str,
270    tool_name: &str,
271    raw_identity: &str,
272    reserved_len: usize,
273) -> (String, String) {
274    let suffix = callable_name_hash_suffix(raw_identity);
275    let max_tool_len = MAX_TOOL_NAME_LENGTH.saturating_sub(namespace.len() + reserved_len);
276    if max_tool_len >= suffix.len() {
277        let prefix_len = max_tool_len - suffix.len();
278        return (
279            namespace.to_string(),
280            format!("{}{}", truncate_name(tool_name, prefix_len), suffix),
281        );
282    }
283
284    let max_namespace_len = MAX_TOOL_NAME_LENGTH.saturating_sub(suffix.len() + reserved_len);
285    (truncate_name(namespace, max_namespace_len), suffix)
286}
287
288fn unique_callable_parts(
289    namespace: &str,
290    tool_name: &str,
291    raw_identity: &str,
292    used_names: &mut HashSet<String>,
293    reserved_len: usize,
294) -> (String, String) {
295    let model_name = format!("{namespace}{tool_name}");
296    if model_name.len() + reserved_len <= MAX_TOOL_NAME_LENGTH && used_names.insert(model_name) {
297        return (namespace.to_string(), tool_name.to_string());
298    }
299
300    let mut attempt = 0_u32;
301    loop {
302        let hash_input = if attempt == 0 {
303            raw_identity.to_string()
304        } else {
305            format!("{raw_identity}\0{attempt}")
306        };
307        let (namespace, tool_name) =
308            fit_callable_parts_with_hash(namespace, tool_name, &hash_input, reserved_len);
309        let model_name = format!("{namespace}{tool_name}");
310        if used_names.insert(model_name) {
311            return (namespace, tool_name);
312        }
313        attempt = attempt.saturating_add(1);
314    }
315}