vtcode_commons/
mcp_tool_names.rs1use std::borrow::Cow;
14
15use crate::utils::calculate_sha256;
16
17pub const MCP_QUALIFIED_TOOL_PREFIX: &str = "mcp__";
19
20const MCP_TOOL_NAME_MAX_LEN: usize = 64;
22
23const MCP_HASH_SUFFIX_LEN: usize = 8;
25
26#[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#[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#[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#[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}