Skip to main content

vtcode_commons/
mcp_tool_names.rs

1//! MCP tool-name parsing and building.
2//!
3//! Pure string functions over tool name spellings, with no dependency on the
4//! tool system. This is the single home for MCP name shapes so the agent
5//! runtime, policy gates, and transcript display never fork the parsing.
6//!
7//! # MCP Tool Name Format
8//!
9//! Canonical form is `mcp::<provider>::<tool_name>`.
10//! Model-visible form is `mcp__<provider>__<tool>`.
11//! Legacy form is `mcp_<provider>_<tool_name>`.
12
13use std::borrow::Cow;
14
15use crate::utils::calculate_sha256;
16
17/// The MCP tool name prefix used in model-visible form.
18pub const MCP_QUALIFIED_TOOL_PREFIX: &str = "mcp__";
19
20/// Maximum length for MCP tool names before hashing.
21const MCP_TOOL_NAME_MAX_LEN: usize = 64;
22
23/// Length of the hash suffix used for truncated MCP tool names.
24const MCP_HASH_SUFFIX_LEN: usize = 8;
25
26/// Check if a tool name uses the legacy MCP format (`mcp_<provider>_<tool>`).
27///
28/// Legacy names start with `mcp_` but not `mcp__` (the qualified prefix).
29#[must_use]
30pub fn is_legacy_mcp_tool_name(name: &str) -> bool {
31    name.starts_with("mcp_") && !name.starts_with(MCP_QUALIFIED_TOOL_PREFIX)
32}
33
34/// Extract the tool name from a legacy MCP tool name.
35///
36/// For `mcp_<provider>_<tool>`, returns the part after `mcp_`.
37#[must_use]
38pub fn legacy_mcp_tool_name(name: &str) -> Option<&str> {
39    if is_legacy_mcp_tool_name(name) {
40        name.strip_prefix("mcp_")
41    } else {
42        None
43    }
44}
45
46/// Parse a canonical MCP tool name of the form `mcp::<provider>::<tool_name>`.
47///
48/// Returns `Some((provider, tool_name))` if the name matches the canonical format.
49/// A tool segment containing further `::` separators is kept whole.
50#[must_use]
51pub fn parse_canonical_mcp_tool_name(name: &str) -> Option<(&str, &str)> {
52    let mut parts = name.splitn(3, "::");
53    match (parts.next()?, parts.next(), parts.next()) {
54        ("mcp", Some(provider), Some(tool)) if !provider.is_empty() && !tool.is_empty() => Some((provider, tool)),
55        _ => None,
56    }
57}
58
59/// Build a model-visible MCP tool name from provider and tool name.
60///
61/// Uses the format `mcp__<provider>__<tool>`, truncated with a hash suffix
62/// if the name exceeds the maximum length.
63#[expect(
64    clippy::string_slice,
65    reason = "Slices land on proven ASCII: the hash is lowercase hex and the qualified name is built from sanitized ASCII-only segments."
66)]
67#[must_use]
68pub fn model_visible_mcp_tool_name(provider: &str, tool_name: &str) -> String {
69    let provider = sanitize_tool_segment(provider);
70    let tool = sanitize_tool_segment(tool_name);
71    let qualified = format!("{MCP_QUALIFIED_TOOL_PREFIX}{provider}__{tool}");
72
73    if qualified.len() <= MCP_TOOL_NAME_MAX_LEN {
74        return qualified;
75    }
76
77    let hash = calculate_sha256(qualified.as_bytes());
78    let hash = &hash[..MCP_HASH_SUFFIX_LEN];
79    let keep = MCP_TOOL_NAME_MAX_LEN.saturating_sub(1 + MCP_HASH_SUFFIX_LEN);
80    let prefix = &qualified[..keep];
81    format!("{prefix}_{hash}")
82}
83
84#[expect(
85    clippy::string_slice,
86    reason = "`pos` is the first byte failing an ASCII-class test, so every preceding byte is a complete ASCII char and `pos` is a char boundary."
87)]
88fn sanitize_tool_segment(input: &str) -> Cow<'_, str> {
89    if input.is_empty() {
90        return Cow::Borrowed("tool");
91    }
92
93    let first_bad = input.bytes().position(|b| !b.is_ascii_alphanumeric() && b != b'_' && b != b'-');
94
95    match first_bad {
96        None => Cow::Borrowed(input),
97        Some(pos) => {
98            let mut out = String::with_capacity(input.len());
99            out.push_str(&input[..pos]);
100            for ch in input[pos..].chars() {
101                if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
102                    out.push(ch);
103                } else {
104                    out.push('_');
105                }
106            }
107            Cow::Owned(out)
108        }
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn parse_canonical_mcp_tool_name_valid() {
118        assert_eq!(parse_canonical_mcp_tool_name("mcp::github::list_issues"), Some(("github", "list_issues")));
119    }
120
121    #[test]
122    fn parse_canonical_mcp_tool_name_invalid() {
123        assert_eq!(parse_canonical_mcp_tool_name("read_file"), None);
124        assert_eq!(parse_canonical_mcp_tool_name("mcp::"), None);
125        assert_eq!(parse_canonical_mcp_tool_name("mcp::provider"), None);
126    }
127
128    #[test]
129    fn parse_canonical_mcp_tool_name_keeps_remainder_in_tool() {
130        assert_eq!(parse_canonical_mcp_tool_name("mcp::a::b::c"), Some(("a", "b::c")));
131    }
132
133    #[test]
134    fn legacy_mcp_tool_name_basic() {
135        assert_eq!(legacy_mcp_tool_name("mcp_github_list_issues"), Some("github_list_issues"));
136        assert_eq!(legacy_mcp_tool_name("read_file"), None);
137    }
138
139    #[test]
140    fn is_legacy_mcp_tool_name_detects_prefix() {
141        assert!(is_legacy_mcp_tool_name("mcp_github_list"));
142        assert!(!is_legacy_mcp_tool_name("mcp__github__list"));
143        assert!(!is_legacy_mcp_tool_name("read_file"));
144    }
145
146    #[test]
147    fn legacy_prefix_edges() {
148        assert!(is_legacy_mcp_tool_name("mcp_"));
149        assert_eq!(legacy_mcp_tool_name("mcp_"), Some(""));
150        assert!(!is_legacy_mcp_tool_name("mcp"));
151        assert!(!is_legacy_mcp_tool_name("mcp__"));
152    }
153
154    #[test]
155    fn model_visible_mcp_tool_name_short() {
156        let name = model_visible_mcp_tool_name("github", "list");
157        assert_eq!(name, "mcp__github__list");
158    }
159
160    #[test]
161    fn model_visible_mcp_tool_name_long_truncates() {
162        let name = model_visible_mcp_tool_name("very_long_provider_name", "very_long_tool_name_that_exceeds_limit");
163        assert!(name.len() <= MCP_TOOL_NAME_MAX_LEN);
164        assert!(name.starts_with("mcp__"));
165    }
166
167    #[test]
168    fn model_visible_mcp_tool_name_at_limit_is_unchanged() {
169        let provider = "p".repeat(28);
170        let tool = "t".repeat(29);
171        let expected = format!("mcp__{provider}__{tool}");
172        assert_eq!(expected.len(), MCP_TOOL_NAME_MAX_LEN);
173        assert_eq!(model_visible_mcp_tool_name(&provider, &tool), expected);
174    }
175
176    #[test]
177    fn model_visible_mcp_tool_name_empty_segments_default() {
178        assert_eq!(model_visible_mcp_tool_name("", ""), "mcp__tool__tool");
179    }
180
181    #[test]
182    fn model_visible_mcp_tool_name_sanitizes_unicode() {
183        assert_eq!(model_visible_mcp_tool_name("githüb", "lïst"), "mcp__gith_b__l_st");
184    }
185
186    #[test]
187    fn model_visible_mcp_tool_name_is_deterministic_and_order_sensitive() {
188        let ab_c = model_visible_mcp_tool_name("ab", "c");
189        let a_bc = model_visible_mcp_tool_name("a", "bc");
190        assert_ne!(ab_c, a_bc);
191        assert_eq!(ab_c, model_visible_mcp_tool_name("ab", "c"));
192    }
193}