Skip to main content

vtcode_core/tools/
mcp.rs

1use std::borrow::Cow;
2use std::sync::Arc;
3
4use anyhow::Result;
5use async_trait::async_trait;
6use serde_json::Value;
7use vtcode_commons::utils::calculate_sha256;
8
9use crate::config::types::CapabilityLevel;
10use crate::mcp::{McpClient, McpToolExecutor, McpToolInfo};
11use crate::tool_policy::ToolPolicy;
12use crate::tools::native_cgp_tool_factory;
13use crate::tools::registry::{ToolCatalogSource, ToolRegistration};
14use crate::tools::traits::Tool;
15
16pub const MCP_QUALIFIED_TOOL_PREFIX: &str = "mcp__";
17const MCP_TOOL_NAME_MAX_LEN: usize = 64;
18const MCP_HASH_SUFFIX_LEN: usize = 8;
19
20pub fn is_legacy_mcp_tool_name(name: &str) -> bool {
21    name.starts_with("mcp_") && !name.starts_with(MCP_QUALIFIED_TOOL_PREFIX)
22}
23
24pub fn legacy_mcp_tool_name(name: &str) -> Option<&str> {
25    if is_legacy_mcp_tool_name(name) {
26        name.strip_prefix("mcp_")
27    } else {
28        None
29    }
30}
31
32pub fn parse_canonical_mcp_tool_name(name: &str) -> Option<(&str, &str)> {
33    let mut parts = name.splitn(3, "::");
34    match (parts.next()?, parts.next(), parts.next()) {
35        ("mcp", Some(provider), Some(tool)) if !provider.is_empty() && !tool.is_empty() => {
36            Some((provider, tool))
37        }
38        _ => None,
39    }
40}
41
42pub fn model_visible_mcp_tool_name(provider: &str, tool_name: &str) -> String {
43    let provider = sanitize_tool_segment(provider);
44    let tool = sanitize_tool_segment(tool_name);
45    let qualified = format!("{MCP_QUALIFIED_TOOL_PREFIX}{provider}__{tool}");
46
47    if qualified.len() <= MCP_TOOL_NAME_MAX_LEN {
48        return qualified;
49    }
50
51    let hash = calculate_sha256(qualified.as_bytes());
52    let hash = &hash[..MCP_HASH_SUFFIX_LEN];
53    let keep = MCP_TOOL_NAME_MAX_LEN.saturating_sub(1 + MCP_HASH_SUFFIX_LEN);
54    let prefix = &qualified[..keep];
55    format!("{prefix}_{hash}")
56}
57
58fn sanitize_tool_segment(input: &str) -> Cow<'_, str> {
59    if input.is_empty() {
60        return Cow::Borrowed("tool");
61    }
62
63    let first_bad = input
64        .bytes()
65        .position(|b| !b.is_ascii_alphanumeric() && b != b'_' && b != b'-');
66
67    match first_bad {
68        None => Cow::Borrowed(input),
69        Some(pos) => {
70            let mut out = String::with_capacity(input.len());
71            out.push_str(&input[..pos]);
72            for ch in input[pos..].chars() {
73                if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
74                    out.push(ch);
75                } else {
76                    out.push('_');
77                }
78            }
79            Cow::Owned(out)
80        }
81    }
82}
83
84/// Build a ToolRegistration for a remote MCP tool.
85///
86/// Naming strategy:
87/// - Primary: `mcp::<provider>::<tool>`
88/// - Alias: `mcp__<provider>__<tool>` (sanitized and length-capped for model compatibility).
89pub fn build_mcp_registration(
90    client: Arc<McpClient>,
91    provider: &str,
92    tool: &McpToolInfo,
93    server_hint: Option<String>,
94) -> ToolRegistration {
95    let primary_name = format!("mcp::{}::{}", provider, tool.name);
96
97    let description = tool.description.as_str();
98    let desc_with_hint = match server_hint.as_deref() {
99        Some(hint) => format!("{description}\nHint: {hint}"),
100        None => description.to_string(),
101    };
102
103    let aliases = vec![model_visible_mcp_tool_name(provider, &tool.name)];
104    let remote_name = tool.name.clone();
105    let input_schema = tool.input_schema.clone();
106
107    let proxy = McpProxyTool {
108        client: Arc::clone(&client),
109        remote_name: remote_name.clone(),
110        input_schema: input_schema.clone(),
111    };
112
113    let mut metadata = crate::tools::registry::ToolMetadata::default()
114        .with_description(desc_with_hint)
115        .with_parameter_schema(tool.input_schema.clone())
116        .with_permission(ToolPolicy::Prompt)
117        .with_aliases(aliases);
118    if let Some(hint) = server_hint {
119        metadata = metadata.with_server_hint(hint);
120    }
121
122    ToolRegistration::from_tool_with_metadata(
123        primary_name,
124        CapabilityLevel::Basic,
125        Arc::new(proxy),
126        metadata,
127    )
128    .with_catalog_source(ToolCatalogSource::Mcp)
129    .with_llm_visibility(false)
130    .with_native_cgp_factory(native_cgp_tool_factory(move || McpProxyTool {
131        client: Arc::clone(&client),
132        remote_name: remote_name.clone(),
133        input_schema: input_schema.clone(),
134    }))
135}
136
137struct McpProxyTool {
138    client: Arc<McpClient>,
139    remote_name: String,
140    input_schema: Value,
141}
142
143#[async_trait]
144impl Tool for McpProxyTool {
145    async fn execute(&self, args: Value) -> Result<Value> {
146        self.client.execute_mcp_tool(&self.remote_name, &args).await
147    }
148
149    fn name(&self) -> &str {
150        "mcp_proxy"
151    }
152
153    fn description(&self) -> &str {
154        "MCP tool proxy"
155    }
156
157    fn parameter_schema(&self) -> Option<Value> {
158        Some(self.input_schema.clone())
159    }
160
161    fn prompt_path(&self) -> Option<Cow<'static, str>> {
162        None
163    }
164
165    fn default_permission(&self) -> ToolPolicy {
166        ToolPolicy::Prompt
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::{
173        build_mcp_registration, is_legacy_mcp_tool_name, legacy_mcp_tool_name,
174        model_visible_mcp_tool_name, parse_canonical_mcp_tool_name,
175    };
176    use crate::mcp::{McpClient, McpToolInfo};
177    use crate::tool_policy::ToolPolicy;
178    use crate::tools::CgpRuntimeMode;
179    use serde_json::json;
180    use std::path::PathBuf;
181    use std::sync::Arc;
182
183    #[test]
184    fn model_visible_name_uses_qualified_prefix() {
185        let name = model_visible_mcp_tool_name("context7", "search-docs");
186        assert_eq!(name, "mcp__context7__search-docs");
187    }
188
189    #[test]
190    fn model_visible_name_is_capped() {
191        let name = model_visible_mcp_tool_name("provider_with_a_very_long_name", &"x".repeat(80));
192        assert!(name.len() <= 64);
193    }
194
195    #[test]
196    fn legacy_detection_ignores_qualified_prefix() {
197        assert!(is_legacy_mcp_tool_name("mcp_fetch"));
198        assert!(!is_legacy_mcp_tool_name("mcp__context7__search"));
199        assert_eq!(legacy_mcp_tool_name("mcp_fetch"), Some("fetch"));
200        assert_eq!(legacy_mcp_tool_name("mcp__context7__search"), None);
201    }
202
203    #[test]
204    fn parse_canonical_name_extracts_provider_and_tool() {
205        assert_eq!(
206            parse_canonical_mcp_tool_name("mcp::context7::search-docs"),
207            Some(("context7", "search-docs"))
208        );
209        assert_eq!(parse_canonical_mcp_tool_name("mcp__context7__search"), None);
210    }
211
212    #[test]
213    fn build_mcp_registration_exposes_native_cgp_factory() {
214        let client = Arc::new(McpClient::new(
215            vtcode_config::mcp::McpClientConfig::default(),
216        ));
217        let tool = McpToolInfo {
218            name: "search-docs".to_string(),
219            description: "Search docs".to_string(),
220            provider: "context7".to_string(),
221            input_schema: json!({
222                "type": "object",
223                "properties": {
224                    "query": { "type": "string" }
225                }
226            }),
227        };
228
229        let registration =
230            build_mcp_registration(client, "context7", &tool, Some("provider hint".to_string()));
231        let native_factory = registration
232            .native_cgp_factory()
233            .expect("MCP registration should expose native CGP factory");
234        let wrapped = native_factory(
235            &registration,
236            PathBuf::from("/tmp/test"),
237            CgpRuntimeMode::Interactive,
238        );
239
240        assert_eq!(wrapped.name(), "mcp::context7::search-docs");
241        assert_eq!(wrapped.description(), "Search docs\nHint: provider hint");
242        assert_eq!(wrapped.parameter_schema(), Some(tool.input_schema.clone()));
243        assert_eq!(wrapped.default_permission(), ToolPolicy::Prompt);
244    }
245}