1mod astgrep;
4mod builtins;
5mod cache;
6mod declarations;
7mod error;
8mod executors;
9mod legacy;
10mod policy;
11mod pty;
12mod registration;
13mod utils;
14
15pub use declarations::{
16 build_function_declarations, build_function_declarations_for_level,
17 build_function_declarations_with_mode,
18};
19pub use error::{ToolErrorType, ToolExecutionError, classify_error};
20pub use registration::{ToolExecutorFn, ToolHandler, ToolRegistration};
21
22use builtins::register_builtin_tools;
23use utils::normalize_tool_output;
24
25use crate::config::PtyConfig;
26use crate::config::ToolsConfig;
27use crate::config::constants::tools;
28use crate::tool_policy::{ToolPolicy, ToolPolicyManager};
29use crate::tools::ast_grep::AstGrepEngine;
30use crate::tools::grep_search::GrepSearchManager;
31use anyhow::{Result, anyhow};
32use serde_json::Value;
33use std::collections::{HashMap, HashSet};
34use std::path::PathBuf;
35use std::sync::Arc;
36use std::sync::atomic::AtomicUsize;
37use tracing::{debug, warn};
38
39use super::bash_tool::BashTool;
40use super::command::CommandTool;
41use super::curl_tool::CurlTool;
42use super::file_ops::FileOpsTool;
43use super::plan::PlanManager;
44use super::search::SearchTool;
45use super::simple_search::SimpleSearchTool;
46use super::srgn::SrgnTool;
47use crate::mcp_client::{McpClient, McpToolExecutor, McpToolInfo};
48
49#[cfg(test)]
50use super::traits::Tool;
51#[cfg(test)]
52use crate::config::types::CapabilityLevel;
53
54#[derive(Clone)]
55pub struct ToolRegistry {
56 workspace_root: PathBuf,
57 search_tool: SearchTool,
58 simple_search_tool: SimpleSearchTool,
59 bash_tool: BashTool,
60 file_ops_tool: FileOpsTool,
61 command_tool: CommandTool,
62 curl_tool: CurlTool,
63 grep_search: Arc<GrepSearchManager>,
64 ast_grep_engine: Option<Arc<AstGrepEngine>>,
65 tool_policy: Option<ToolPolicyManager>,
66 pty_config: PtyConfig,
67 active_pty_sessions: Arc<AtomicUsize>,
68 srgn_tool: SrgnTool,
69 plan_manager: PlanManager,
70 mcp_client: Option<Arc<McpClient>>,
71 mcp_tool_index: HashMap<String, Vec<String>>,
72 tool_registrations: Vec<ToolRegistration>,
73 tool_lookup: HashMap<&'static str, usize>,
74 preapproved_tools: HashSet<String>,
75 full_auto_allowlist: Option<HashSet<String>>,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum ToolPermissionDecision {
80 Allow,
81 Deny,
82 Prompt,
83}
84
85impl ToolRegistry {
86 pub fn new(workspace_root: PathBuf) -> Self {
87 Self::build(workspace_root, PtyConfig::default(), true)
88 }
89
90 pub fn new_with_config(workspace_root: PathBuf, pty_config: PtyConfig) -> Self {
91 Self::build(workspace_root, pty_config, true)
92 }
93
94 pub fn new_with_features(workspace_root: PathBuf, todo_planning_enabled: bool) -> Self {
95 Self::build(workspace_root, PtyConfig::default(), todo_planning_enabled)
96 }
97
98 pub fn new_with_config_and_features(
99 workspace_root: PathBuf,
100 pty_config: PtyConfig,
101 todo_planning_enabled: bool,
102 ) -> Self {
103 Self::build(workspace_root, pty_config, todo_planning_enabled)
104 }
105
106 fn build(workspace_root: PathBuf, pty_config: PtyConfig, todo_planning_enabled: bool) -> Self {
107 let grep_search = Arc::new(GrepSearchManager::new(workspace_root.clone()));
108
109 let search_tool = SearchTool::new(workspace_root.clone(), grep_search.clone());
110 let simple_search_tool = SimpleSearchTool::new(workspace_root.clone());
111 let bash_tool = BashTool::new(workspace_root.clone());
112 let file_ops_tool = FileOpsTool::new(workspace_root.clone(), grep_search.clone());
113 let command_tool = CommandTool::new(workspace_root.clone());
114 let curl_tool = CurlTool::new();
115 let srgn_tool = SrgnTool::new(workspace_root.clone());
116 let plan_manager = PlanManager::new();
117
118 let ast_grep_engine = match AstGrepEngine::new() {
119 Ok(engine) => Some(Arc::new(engine)),
120 Err(err) => {
121 eprintln!("Warning: Failed to initialize AST-grep engine: {}", err);
122 None
123 }
124 };
125
126 let policy_manager = match ToolPolicyManager::new_with_workspace(&workspace_root) {
127 Ok(manager) => Some(manager),
128 Err(err) => {
129 eprintln!("Warning: Failed to initialize tool policy manager: {}", err);
130 None
131 }
132 };
133
134 let mut registry = Self {
135 workspace_root,
136 search_tool,
137 simple_search_tool,
138 bash_tool,
139 file_ops_tool,
140 command_tool,
141 curl_tool,
142 grep_search,
143 ast_grep_engine,
144 tool_policy: policy_manager,
145 pty_config,
146 active_pty_sessions: Arc::new(AtomicUsize::new(0)),
147 srgn_tool,
148 plan_manager,
149 mcp_client: None,
150 mcp_tool_index: HashMap::new(),
151 tool_registrations: Vec::new(),
152 tool_lookup: HashMap::new(),
153 preapproved_tools: HashSet::new(),
154 full_auto_allowlist: None,
155 };
156
157 register_builtin_tools(&mut registry, todo_planning_enabled);
158 registry
159 }
160
161 pub fn register_tool(&mut self, registration: ToolRegistration) -> Result<()> {
162 if self.tool_lookup.contains_key(registration.name()) {
163 return Err(anyhow!(format!(
164 "Tool '{}' is already registered",
165 registration.name()
166 )));
167 }
168
169 let index = self.tool_registrations.len();
170 self.tool_lookup.insert(registration.name(), index);
171 self.tool_registrations.push(registration);
172 Ok(())
173 }
174
175 pub fn available_tools(&self) -> Vec<String> {
176 self.tool_registrations
177 .iter()
178 .map(|registration| registration.name().to_string())
179 .collect()
180 }
181
182 fn mcp_policy_keys(&self) -> Vec<String> {
183 let mut keys = Vec::new();
184 for (provider, tools) in &self.mcp_tool_index {
185 for tool in tools {
186 keys.push(format!("mcp::{}::{}", provider, tool));
187 }
188 }
189 keys
190 }
191
192 fn find_mcp_provider(&self, tool_name: &str) -> Option<String> {
193 for (provider, tools) in &self.mcp_tool_index {
194 if tools.iter().any(|candidate| candidate == tool_name) {
195 return Some(provider.clone());
196 }
197 }
198 None
199 }
200
201 pub fn enable_full_auto_mode(&mut self, allowed_tools: &[String]) {
202 let mut normalized: HashSet<String> = HashSet::new();
203 if allowed_tools
204 .iter()
205 .any(|tool| tool.trim() == tools::WILDCARD_ALL)
206 {
207 for tool in self.available_tools() {
208 normalized.insert(tool);
209 }
210 } else {
211 for tool in allowed_tools {
212 let trimmed = tool.trim();
213 if !trimmed.is_empty() {
214 normalized.insert(trimmed.to_string());
215 }
216 }
217 }
218
219 self.full_auto_allowlist = Some(normalized);
220 }
221
222 pub fn current_full_auto_allowlist(&self) -> Option<Vec<String>> {
223 self.full_auto_allowlist.as_ref().map(|set| {
224 let mut items: Vec<String> = set.iter().cloned().collect();
225 items.sort();
226 items
227 })
228 }
229
230 pub fn has_tool(&self, name: &str) -> bool {
231 self.tool_lookup.contains_key(name)
232 }
233
234 pub fn with_ast_grep(mut self, engine: Arc<AstGrepEngine>) -> Self {
235 self.ast_grep_engine = Some(engine);
236 self
237 }
238
239 pub fn workspace_root(&self) -> &PathBuf {
240 &self.workspace_root
241 }
242
243 pub fn plan_manager(&self) -> PlanManager {
244 self.plan_manager.clone()
245 }
246
247 pub fn current_plan(&self) -> crate::tools::TaskPlan {
248 self.plan_manager.snapshot()
249 }
250
251 pub async fn initialize_async(&mut self) -> Result<()> {
252 Ok(())
253 }
254
255 pub fn apply_config_policies(&mut self, tools_config: &ToolsConfig) -> Result<()> {
256 if let Ok(policy_manager) = self.policy_manager_mut() {
257 policy_manager.apply_tools_config(tools_config)?;
258 }
259
260 Ok(())
261 }
262
263 pub async fn execute_tool(&mut self, name: &str, args: Value) -> Result<Value> {
264 if let Some(allowlist) = &self.full_auto_allowlist
265 && !allowlist.contains(name)
266 {
267 let error = ToolExecutionError::new(
268 name.to_string(),
269 ToolErrorType::PolicyViolation,
270 format!(
271 "Tool '{}' is not permitted while full-auto mode is active",
272 name
273 ),
274 );
275 return Ok(error.to_json_value());
276 }
277
278 let skip_policy_prompt = self.preapproved_tools.remove(name);
279
280 if !skip_policy_prompt
281 && let Ok(policy_manager) = self.policy_manager_mut()
282 && !policy_manager.should_execute_tool(name)?
283 {
284 let error = ToolExecutionError::new(
285 name.to_string(),
286 ToolErrorType::PolicyViolation,
287 format!("Tool '{}' execution denied by policy", name),
288 );
289 return Ok(error.to_json_value());
290 }
291
292 let args = match self.apply_policy_constraints(name, args) {
293 Ok(args) => args,
294 Err(err) => {
295 let error = ToolExecutionError::with_original_error(
296 name.to_string(),
297 ToolErrorType::InvalidParameters,
298 "Failed to apply policy constraints".to_string(),
299 err.to_string(),
300 );
301 return Ok(error.to_json_value());
302 }
303 };
304
305 let registration = match self
306 .tool_lookup
307 .get(name)
308 .and_then(|index| self.tool_registrations.get(*index))
309 {
310 Some(registration) => registration,
311 None => {
312 if let Some(mcp_client) = &self.mcp_client {
314 if name.starts_with("mcp_") {
316 let actual_tool_name = &name[4..]; match mcp_client.has_mcp_tool(actual_tool_name).await {
318 Ok(true) => {
319 debug!(
320 "MCP tool '{}' found, executing via MCP client",
321 actual_tool_name
322 );
323 return self.execute_mcp_tool(actual_tool_name, args).await;
324 }
325 Ok(false) => {
326 if let Some(resolved_name) =
327 self.resolve_mcp_tool_alias(actual_tool_name).await
328 {
329 if resolved_name != actual_tool_name {
330 debug!(
331 "Resolved MCP tool alias '{}' to '{}'",
332 actual_tool_name, resolved_name
333 );
334 return self.execute_mcp_tool(&resolved_name, args).await;
335 }
336 }
337
338 let error = ToolExecutionError::new(
340 name.to_string(),
341 ToolErrorType::ToolNotFound,
342 format!("Unknown MCP tool: {}", actual_tool_name),
343 );
344 return Ok(error.to_json_value());
345 }
346 Err(e) => {
347 warn!(
348 "Error checking MCP tool availability for '{}': {}",
349 actual_tool_name, e
350 );
351 let error = ToolExecutionError::with_original_error(
352 name.to_string(),
353 ToolErrorType::ExecutionError,
354 format!(
355 "Failed to verify MCP tool '{}' due to provider errors",
356 actual_tool_name
357 ),
358 e.to_string(),
359 );
360 return Ok(error.to_json_value());
361 }
362 }
363 } else {
364 match mcp_client.has_mcp_tool(name).await {
366 Ok(true) => {
367 debug!(
368 "Tool '{}' not found in registry, delegating to MCP client",
369 name
370 );
371 return self.execute_mcp_tool(name, args).await;
372 }
373 Ok(false) => {
374 let error = ToolExecutionError::new(
376 name.to_string(),
377 ToolErrorType::ToolNotFound,
378 format!("Unknown tool: {}", name),
379 );
380 return Ok(error.to_json_value());
381 }
382 Err(e) => {
383 warn!("Error checking MCP tool availability for '{}': {}", name, e);
384 let error = ToolExecutionError::with_original_error(
385 name.to_string(),
386 ToolErrorType::ExecutionError,
387 format!(
388 "Failed to verify MCP tool '{}' due to provider errors",
389 name
390 ),
391 e.to_string(),
392 );
393 return Ok(error.to_json_value());
394 }
395 }
396 }
397 } else {
398 let error = ToolExecutionError::new(
400 name.to_string(),
401 ToolErrorType::ToolNotFound,
402 format!("Unknown tool: {}", name),
403 );
404 return Ok(error.to_json_value());
405 }
406 }
407 };
408
409 let uses_pty = registration.uses_pty();
410 if uses_pty && let Err(err) = self.start_pty_session() {
411 let error = ToolExecutionError::with_original_error(
412 name.to_string(),
413 ToolErrorType::ExecutionError,
414 "Failed to start PTY session".to_string(),
415 err.to_string(),
416 );
417 return Ok(error.to_json_value());
418 }
419
420 let handler = registration.handler();
421 let result = match handler {
422 ToolHandler::RegistryFn(executor) => executor(self, args).await,
423 ToolHandler::TraitObject(tool) => tool.execute(args).await,
424 };
425
426 if uses_pty {
427 self.end_pty_session();
428 }
429
430 match result {
431 Ok(value) => Ok(normalize_tool_output(value)),
432 Err(err) => {
433 let error_type = classify_error(&err);
434 let error = ToolExecutionError::with_original_error(
435 name.to_string(),
436 error_type,
437 format!("Tool execution failed: {}", err),
438 err.to_string(),
439 );
440 Ok(error.to_json_value())
441 }
442 }
443 }
444
445 pub fn with_mcp_client(mut self, mcp_client: Arc<McpClient>) -> Self {
447 self.mcp_client = Some(mcp_client);
448 self
449 }
450
451 pub fn mcp_client(&self) -> Option<&Arc<McpClient>> {
453 self.mcp_client.as_ref()
454 }
455
456 pub async fn list_mcp_tools(&self) -> Result<Vec<McpToolInfo>> {
458 if let Some(mcp_client) = &self.mcp_client {
459 mcp_client.list_mcp_tools().await
460 } else {
461 Ok(Vec::new())
462 }
463 }
464
465 pub async fn has_mcp_tool(&self, tool_name: &str) -> bool {
467 if let Some(mcp_client) = &self.mcp_client {
468 match mcp_client.has_mcp_tool(tool_name).await {
469 Ok(true) => true,
470 Ok(false) => false,
471 Err(_) => {
472 false
474 }
475 }
476 } else {
477 false
478 }
479 }
480
481 pub async fn execute_mcp_tool(&self, tool_name: &str, args: Value) -> Result<Value> {
483 if let Some(mcp_client) = &self.mcp_client {
484 mcp_client.execute_mcp_tool(tool_name, args).await
485 } else {
486 Err(anyhow::anyhow!("MCP client not available"))
487 }
488 }
489
490 async fn resolve_mcp_tool_alias(&self, tool_name: &str) -> Option<String> {
491 let Some(mcp_client) = &self.mcp_client else {
492 return None;
493 };
494
495 let normalized = normalize_mcp_tool_identifier(tool_name);
496 if normalized.is_empty() {
497 return None;
498 }
499
500 let tools = match mcp_client.list_mcp_tools().await {
501 Ok(list) => list,
502 Err(err) => {
503 warn!(
504 "Failed to list MCP tools while resolving alias '{}': {}",
505 tool_name, err
506 );
507 return None;
508 }
509 };
510
511 for tool in tools {
512 if normalize_mcp_tool_identifier(&tool.name) == normalized {
513 return Some(tool.name);
514 }
515 }
516
517 None
518 }
519
520 pub async fn refresh_mcp_tools(&mut self) -> Result<()> {
522 if let Some(mcp_client) = &self.mcp_client {
523 debug!(
524 "Refreshing MCP tools for {} providers",
525 mcp_client.get_status().provider_count
526 );
527
528 let tools = mcp_client.list_mcp_tools().await?;
529 let mut provider_map: HashMap<String, Vec<String>> = HashMap::new();
530
531 for tool in tools {
532 provider_map
533 .entry(tool.provider.clone())
534 .or_default()
535 .push(tool.name.clone());
536 }
537
538 for tools in provider_map.values_mut() {
539 tools.sort();
540 tools.dedup();
541 }
542
543 self.mcp_tool_index = provider_map;
544
545 if let Some(policy_manager) = self.tool_policy.as_mut() {
546 policy_manager.update_mcp_tools(&self.mcp_tool_index)?;
547 let allowlist = policy_manager.mcp_allowlist().clone();
548 mcp_client.update_allowlist(allowlist);
549 }
550
551 self.sync_policy_available_tools();
552 Ok(())
553 } else {
554 debug!("No MCP client configured, nothing to refresh");
555 Ok(())
556 }
557 }
558}
559
560impl ToolRegistry {
561 pub fn preflight_tool_permission(&mut self, name: &str) -> Result<bool> {
563 match self.evaluate_tool_policy(name)? {
564 ToolPermissionDecision::Allow => Ok(true),
565 ToolPermissionDecision::Deny => Ok(false),
566 ToolPermissionDecision::Prompt => Ok(true),
567 }
568 }
569
570 pub fn evaluate_tool_policy(&mut self, name: &str) -> Result<ToolPermissionDecision> {
571 if let Some(tool_name) = name.strip_prefix("mcp_") {
572 return self.evaluate_mcp_tool_policy(name, tool_name);
573 }
574
575 if let Some(allowlist) = self.full_auto_allowlist.as_ref() {
576 if !allowlist.contains(name) {
577 return Ok(ToolPermissionDecision::Deny);
578 }
579
580 if let Some(policy_manager) = self.tool_policy.as_mut() {
581 match policy_manager.get_policy(name) {
582 ToolPolicy::Deny => return Ok(ToolPermissionDecision::Deny),
583 ToolPolicy::Allow | ToolPolicy::Prompt => {
584 self.preapproved_tools.insert(name.to_string());
585 return Ok(ToolPermissionDecision::Allow);
586 }
587 }
588 }
589
590 self.preapproved_tools.insert(name.to_string());
591 return Ok(ToolPermissionDecision::Allow);
592 }
593
594 if let Some(policy_manager) = self.tool_policy.as_mut() {
595 match policy_manager.get_policy(name) {
596 ToolPolicy::Allow => {
597 self.preapproved_tools.insert(name.to_string());
598 Ok(ToolPermissionDecision::Allow)
599 }
600 ToolPolicy::Deny => Ok(ToolPermissionDecision::Deny),
601 ToolPolicy::Prompt => {
602 if ToolPolicyManager::is_auto_allow_tool(name) {
603 policy_manager.set_policy(name, ToolPolicy::Allow)?;
604 self.preapproved_tools.insert(name.to_string());
605 Ok(ToolPermissionDecision::Allow)
606 } else {
607 Ok(ToolPermissionDecision::Prompt)
608 }
609 }
610 }
611 } else {
612 self.preapproved_tools.insert(name.to_string());
613 Ok(ToolPermissionDecision::Allow)
614 }
615 }
616
617 fn evaluate_mcp_tool_policy(
618 &mut self,
619 full_name: &str,
620 tool_name: &str,
621 ) -> Result<ToolPermissionDecision> {
622 let provider = match self.find_mcp_provider(tool_name) {
623 Some(provider) => provider,
624 None => {
625 return Ok(ToolPermissionDecision::Prompt);
627 }
628 };
629
630 if let Some(allowlist) = self.full_auto_allowlist.as_ref() {
631 if !allowlist.contains(full_name) {
632 return Ok(ToolPermissionDecision::Deny);
633 }
634
635 if let Some(policy_manager) = self.tool_policy.as_mut() {
636 match policy_manager.get_mcp_tool_policy(&provider, tool_name) {
637 ToolPolicy::Deny => return Ok(ToolPermissionDecision::Deny),
638 ToolPolicy::Allow | ToolPolicy::Prompt => {
639 self.preapproved_tools.insert(full_name.to_string());
640 return Ok(ToolPermissionDecision::Allow);
641 }
642 }
643 }
644
645 self.preapproved_tools.insert(full_name.to_string());
646 return Ok(ToolPermissionDecision::Allow);
647 }
648
649 if let Some(policy_manager) = self.tool_policy.as_mut() {
650 match policy_manager.get_mcp_tool_policy(&provider, tool_name) {
651 ToolPolicy::Allow => {
652 self.preapproved_tools.insert(full_name.to_string());
653 Ok(ToolPermissionDecision::Allow)
654 }
655 ToolPolicy::Deny => Ok(ToolPermissionDecision::Deny),
656 ToolPolicy::Prompt => Ok(ToolPermissionDecision::Prompt),
657 }
658 } else {
659 self.preapproved_tools.insert(full_name.to_string());
660 Ok(ToolPermissionDecision::Allow)
661 }
662 }
663
664 pub fn mark_tool_preapproved(&mut self, name: &str) {
665 self.preapproved_tools.insert(name.to_string());
666 }
667
668 pub fn persist_mcp_tool_policy(&mut self, name: &str, policy: ToolPolicy) -> Result<()> {
669 if !name.starts_with("mcp_") {
670 return Ok(());
671 }
672
673 let Some(tool_name) = name.strip_prefix("mcp_") else {
674 return Ok(());
675 };
676
677 let Some(provider) = self.find_mcp_provider(tool_name) else {
678 return Ok(());
679 };
680
681 if let Some(manager) = self.tool_policy.as_mut() {
682 manager.set_mcp_tool_policy(&provider, tool_name, policy)?;
683 }
684
685 Ok(())
686 }
687}
688
689fn normalize_mcp_tool_identifier(value: &str) -> String {
690 let mut normalized = String::new();
691 for ch in value.chars() {
692 if ch.is_ascii_alphanumeric() {
693 normalized.push(ch.to_ascii_lowercase());
694 }
695 }
696 normalized
697}
698
699#[cfg(test)]
700mod tests {
701 use super::*;
702 use async_trait::async_trait;
703 use serde_json::json;
704 use tempfile::TempDir;
705
706 const CUSTOM_TOOL_NAME: &str = "custom_test_tool";
707
708 struct CustomEchoTool;
709
710 #[async_trait]
711 impl Tool for CustomEchoTool {
712 async fn execute(&self, args: Value) -> Result<Value> {
713 Ok(json!({
714 "success": true,
715 "args": args,
716 }))
717 }
718
719 fn name(&self) -> &'static str {
720 CUSTOM_TOOL_NAME
721 }
722
723 fn description(&self) -> &'static str {
724 "Custom echo tool for testing"
725 }
726 }
727
728 #[tokio::test]
729 async fn registers_builtin_tools() -> Result<()> {
730 let temp_dir = TempDir::new()?;
731 let registry = ToolRegistry::new(temp_dir.path().to_path_buf());
732 let available = registry.available_tools();
733
734 assert!(available.contains(&tools::READ_FILE.to_string()));
735 assert!(available.contains(&tools::RUN_TERMINAL_CMD.to_string()));
736 assert!(available.contains(&tools::CURL.to_string()));
737 Ok(())
738 }
739
740 #[tokio::test]
741 async fn allows_registering_custom_tools() -> Result<()> {
742 let temp_dir = TempDir::new()?;
743 let mut registry = ToolRegistry::new(temp_dir.path().to_path_buf());
744
745 registry.register_tool(ToolRegistration::from_tool_instance(
746 CUSTOM_TOOL_NAME,
747 CapabilityLevel::CodeSearch,
748 CustomEchoTool,
749 ))?;
750
751 registry.sync_policy_available_tools();
752
753 registry.allow_all_tools().ok();
754
755 let available = registry.available_tools();
756 assert!(available.contains(&CUSTOM_TOOL_NAME.to_string()));
757
758 let response = registry
759 .execute_tool(CUSTOM_TOOL_NAME, json!({"input": "value"}))
760 .await?;
761 assert!(response["success"].as_bool().unwrap_or(false));
762 Ok(())
763 }
764
765 #[tokio::test]
766 async fn full_auto_allowlist_enforced() -> Result<()> {
767 let temp_dir = TempDir::new()?;
768 let mut registry = ToolRegistry::new(temp_dir.path().to_path_buf());
769
770 registry.enable_full_auto_mode(&vec![tools::READ_FILE.to_string()]);
771
772 assert!(registry.preflight_tool_permission(tools::READ_FILE)?);
773 assert!(!registry.preflight_tool_permission(tools::RUN_TERMINAL_CMD)?);
774
775 Ok(())
776 }
777
778 #[test]
779 fn normalizes_mcp_tool_identifiers() {
780 assert_eq!(
781 normalize_mcp_tool_identifier("sequential-thinking"),
782 "sequentialthinking"
783 );
784 assert_eq!(
785 normalize_mcp_tool_identifier("Context7.Lookup"),
786 "context7lookup"
787 );
788 assert_eq!(normalize_mcp_tool_identifier("alpha_beta"), "alphabeta");
789 }
790}