Skip to main content

vtcode_core/utils/
tool_name_parsing.rs

1//! Pure string-parsing utilities for tool names.
2//!
3//! These functions parse and normalize tool name strings without depending
4//! on any tool system types. They live in `utils` to break the circular
5//! dependency between `tool_policy` and `tools::mcp`.
6//!
7//! # MCP Tool Name Format
8//!
9//! MCP tools use the canonical format `mcp::<provider>::<tool_name>`.
10//! Legacy format is `mcp_<provider>_<tool_name>`.
11
12use std::borrow::Cow;
13
14use vtcode_commons::utils::calculate_sha256;
15
16/// The MCP tool name prefix used in model-visible form.
17pub const MCP_QUALIFIED_TOOL_PREFIX: &str = "mcp__";
18
19/// Maximum length for MCP tool names before hashing.
20const MCP_TOOL_NAME_MAX_LEN: usize = 64;
21
22/// Length of the hash suffix used for truncated MCP tool names.
23const MCP_HASH_SUFFIX_LEN: usize = 8;
24
25/// Check if a tool name uses the legacy MCP format (`mcp_<provider>_<tool>`).
26///
27/// Legacy names start with `mcp_` but not `mcp__` (the qualified prefix).
28pub fn is_legacy_mcp_tool_name(name: &str) -> bool {
29    name.starts_with("mcp_") && !name.starts_with(MCP_QUALIFIED_TOOL_PREFIX)
30}
31
32/// Extract the tool name from a legacy MCP tool name.
33///
34/// For `mcp_<provider>_<tool>`, returns the part after `mcp_`.
35pub fn legacy_mcp_tool_name(name: &str) -> Option<&str> {
36    if is_legacy_mcp_tool_name(name) {
37        name.strip_prefix("mcp_")
38    } else {
39        None
40    }
41}
42
43/// Parse a canonical MCP tool name of the form `mcp::<provider>::<tool_name>`.
44///
45/// Returns `Some((provider, tool_name))` if the name matches the canonical format.
46pub fn parse_canonical_mcp_tool_name(name: &str) -> Option<(&str, &str)> {
47    let mut parts = name.splitn(3, "::");
48    match (parts.next()?, parts.next(), parts.next()) {
49        ("mcp", Some(provider), Some(tool)) if !provider.is_empty() && !tool.is_empty() => Some((provider, tool)),
50        _ => None,
51    }
52}
53
54/// Build a model-visible MCP tool name from provider and tool name.
55///
56/// Uses the format `mcp__<provider>__<tool>`, truncated with a hash suffix
57/// if the name exceeds the maximum length.
58pub fn model_visible_mcp_tool_name(provider: &str, tool_name: &str) -> String {
59    let provider = sanitize_tool_segment(provider);
60    let tool = sanitize_tool_segment(tool_name);
61    let qualified = format!("{MCP_QUALIFIED_TOOL_PREFIX}{provider}__{tool}");
62
63    if qualified.len() <= MCP_TOOL_NAME_MAX_LEN {
64        return qualified;
65    }
66
67    let hash = calculate_sha256(qualified.as_bytes());
68    let hash = &hash[..MCP_HASH_SUFFIX_LEN];
69    let keep = MCP_TOOL_NAME_MAX_LEN.saturating_sub(1 + MCP_HASH_SUFFIX_LEN);
70    let prefix = &qualified[..keep];
71    format!("{prefix}_{hash}")
72}
73
74fn sanitize_tool_segment(input: &str) -> Cow<'_, str> {
75    if input.is_empty() {
76        return Cow::Borrowed("tool");
77    }
78
79    let first_bad = input.bytes().position(|b| !b.is_ascii_alphanumeric() && b != b'_' && b != b'-');
80
81    match first_bad {
82        None => Cow::Borrowed(input),
83        Some(pos) => {
84            let mut out = String::with_capacity(input.len());
85            out.push_str(&input[..pos]);
86            for ch in input[pos..].chars() {
87                if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
88                    out.push(ch);
89                } else {
90                    out.push('_');
91                }
92            }
93            Cow::Owned(out)
94        }
95    }
96}
97
98/// Use direct tool name without alias resolution.
99/// Alias resolution is now handled by the tool registry inventory
100/// which maintains a mapping of aliases to canonical tool names.
101pub const fn canonical_tool_name(name: &str) -> &str {
102    name
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn parse_canonical_mcp_tool_name_valid() {
111        assert_eq!(parse_canonical_mcp_tool_name("mcp::github::list_issues"), Some(("github", "list_issues")));
112    }
113
114    #[test]
115    fn parse_canonical_mcp_tool_name_invalid() {
116        assert_eq!(parse_canonical_mcp_tool_name("read_file"), None);
117        assert_eq!(parse_canonical_mcp_tool_name("mcp::"), None);
118        assert_eq!(parse_canonical_mcp_tool_name("mcp::provider"), None);
119    }
120
121    #[test]
122    fn legacy_mcp_tool_name_basic() {
123        assert_eq!(legacy_mcp_tool_name("mcp_github_list_issues"), Some("github_list_issues"));
124        assert_eq!(legacy_mcp_tool_name("read_file"), None);
125    }
126
127    #[test]
128    fn is_legacy_mcp_tool_name_detects_prefix() {
129        assert!(is_legacy_mcp_tool_name("mcp_github_list"));
130        assert!(!is_legacy_mcp_tool_name("mcp__github__list"));
131        assert!(!is_legacy_mcp_tool_name("read_file"));
132    }
133
134    #[test]
135    fn canonical_tool_name_is_identity() {
136        assert_eq!(canonical_tool_name("list_files"), "list_files");
137        assert_eq!(canonical_tool_name("unknown_tool"), "unknown_tool");
138    }
139
140    #[test]
141    fn model_visible_mcp_tool_name_short() {
142        let name = model_visible_mcp_tool_name("github", "list");
143        assert_eq!(name, "mcp__github__list");
144    }
145
146    #[test]
147    fn model_visible_mcp_tool_name_long_truncates() {
148        let name = model_visible_mcp_tool_name("very_long_provider_name", "very_long_tool_name_that_exceeds_limit");
149        assert!(name.len() <= MCP_TOOL_NAME_MAX_LEN);
150        assert!(name.starts_with("mcp__"));
151    }
152}