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
84pub 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 ®istration,
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}