hanzo-mcp 0.5.1__py3-none-any.whl → 0.6.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of hanzo-mcp might be problematic. Click here for more details.

Files changed (118) hide show
  1. hanzo_mcp/__init__.py +1 -1
  2. hanzo_mcp/cli.py +32 -0
  3. hanzo_mcp/dev_server.py +246 -0
  4. hanzo_mcp/prompts/__init__.py +1 -1
  5. hanzo_mcp/prompts/project_system.py +43 -7
  6. hanzo_mcp/server.py +5 -1
  7. hanzo_mcp/tools/__init__.py +168 -6
  8. hanzo_mcp/tools/agent/__init__.py +1 -1
  9. hanzo_mcp/tools/agent/agent.py +401 -0
  10. hanzo_mcp/tools/agent/agent_tool.py +3 -4
  11. hanzo_mcp/tools/common/__init__.py +1 -1
  12. hanzo_mcp/tools/common/base.py +9 -4
  13. hanzo_mcp/tools/common/batch_tool.py +3 -5
  14. hanzo_mcp/tools/common/config_tool.py +1 -1
  15. hanzo_mcp/tools/common/context.py +1 -1
  16. hanzo_mcp/tools/common/palette.py +344 -0
  17. hanzo_mcp/tools/common/palette_loader.py +108 -0
  18. hanzo_mcp/tools/common/stats.py +261 -0
  19. hanzo_mcp/tools/common/thinking_tool.py +3 -5
  20. hanzo_mcp/tools/common/tool_disable.py +144 -0
  21. hanzo_mcp/tools/common/tool_enable.py +182 -0
  22. hanzo_mcp/tools/common/tool_list.py +260 -0
  23. hanzo_mcp/tools/config/__init__.py +10 -0
  24. hanzo_mcp/tools/config/config_tool.py +212 -0
  25. hanzo_mcp/tools/config/index_config.py +176 -0
  26. hanzo_mcp/tools/config/palette_tool.py +166 -0
  27. hanzo_mcp/tools/database/__init__.py +71 -0
  28. hanzo_mcp/tools/database/database_manager.py +246 -0
  29. hanzo_mcp/tools/database/graph.py +482 -0
  30. hanzo_mcp/tools/database/graph_add.py +257 -0
  31. hanzo_mcp/tools/database/graph_query.py +536 -0
  32. hanzo_mcp/tools/database/graph_remove.py +267 -0
  33. hanzo_mcp/tools/database/graph_search.py +348 -0
  34. hanzo_mcp/tools/database/graph_stats.py +345 -0
  35. hanzo_mcp/tools/database/sql.py +411 -0
  36. hanzo_mcp/tools/database/sql_query.py +229 -0
  37. hanzo_mcp/tools/database/sql_search.py +296 -0
  38. hanzo_mcp/tools/database/sql_stats.py +254 -0
  39. hanzo_mcp/tools/editor/__init__.py +11 -0
  40. hanzo_mcp/tools/editor/neovim_command.py +272 -0
  41. hanzo_mcp/tools/editor/neovim_edit.py +290 -0
  42. hanzo_mcp/tools/editor/neovim_session.py +356 -0
  43. hanzo_mcp/tools/filesystem/__init__.py +52 -13
  44. hanzo_mcp/tools/filesystem/base.py +1 -1
  45. hanzo_mcp/tools/filesystem/batch_search.py +812 -0
  46. hanzo_mcp/tools/filesystem/content_replace.py +3 -5
  47. hanzo_mcp/tools/filesystem/diff.py +193 -0
  48. hanzo_mcp/tools/filesystem/directory_tree.py +3 -5
  49. hanzo_mcp/tools/filesystem/edit.py +3 -5
  50. hanzo_mcp/tools/filesystem/find.py +443 -0
  51. hanzo_mcp/tools/filesystem/find_files.py +348 -0
  52. hanzo_mcp/tools/filesystem/git_search.py +505 -0
  53. hanzo_mcp/tools/filesystem/grep.py +2 -2
  54. hanzo_mcp/tools/filesystem/multi_edit.py +3 -5
  55. hanzo_mcp/tools/filesystem/read.py +17 -5
  56. hanzo_mcp/tools/filesystem/{grep_ast_tool.py → symbols.py} +17 -27
  57. hanzo_mcp/tools/filesystem/symbols_unified.py +376 -0
  58. hanzo_mcp/tools/filesystem/tree.py +268 -0
  59. hanzo_mcp/tools/filesystem/unified_search.py +465 -443
  60. hanzo_mcp/tools/filesystem/unix_aliases.py +99 -0
  61. hanzo_mcp/tools/filesystem/watch.py +174 -0
  62. hanzo_mcp/tools/filesystem/write.py +3 -5
  63. hanzo_mcp/tools/jupyter/__init__.py +9 -12
  64. hanzo_mcp/tools/jupyter/base.py +1 -1
  65. hanzo_mcp/tools/jupyter/jupyter.py +326 -0
  66. hanzo_mcp/tools/jupyter/notebook_edit.py +3 -4
  67. hanzo_mcp/tools/jupyter/notebook_read.py +3 -5
  68. hanzo_mcp/tools/llm/__init__.py +31 -0
  69. hanzo_mcp/tools/llm/consensus_tool.py +351 -0
  70. hanzo_mcp/tools/llm/llm_manage.py +413 -0
  71. hanzo_mcp/tools/llm/llm_tool.py +346 -0
  72. hanzo_mcp/tools/llm/llm_unified.py +851 -0
  73. hanzo_mcp/tools/llm/provider_tools.py +412 -0
  74. hanzo_mcp/tools/mcp/__init__.py +15 -0
  75. hanzo_mcp/tools/mcp/mcp_add.py +263 -0
  76. hanzo_mcp/tools/mcp/mcp_remove.py +127 -0
  77. hanzo_mcp/tools/mcp/mcp_stats.py +165 -0
  78. hanzo_mcp/tools/mcp/mcp_unified.py +503 -0
  79. hanzo_mcp/tools/shell/__init__.py +21 -23
  80. hanzo_mcp/tools/shell/base.py +1 -1
  81. hanzo_mcp/tools/shell/base_process.py +303 -0
  82. hanzo_mcp/tools/shell/bash_unified.py +134 -0
  83. hanzo_mcp/tools/shell/logs.py +265 -0
  84. hanzo_mcp/tools/shell/npx.py +194 -0
  85. hanzo_mcp/tools/shell/npx_background.py +254 -0
  86. hanzo_mcp/tools/shell/npx_unified.py +101 -0
  87. hanzo_mcp/tools/shell/open.py +107 -0
  88. hanzo_mcp/tools/shell/pkill.py +262 -0
  89. hanzo_mcp/tools/shell/process_unified.py +131 -0
  90. hanzo_mcp/tools/shell/processes.py +279 -0
  91. hanzo_mcp/tools/shell/run_background.py +326 -0
  92. hanzo_mcp/tools/shell/run_command.py +3 -4
  93. hanzo_mcp/tools/shell/run_command_windows.py +3 -4
  94. hanzo_mcp/tools/shell/uvx.py +187 -0
  95. hanzo_mcp/tools/shell/uvx_background.py +249 -0
  96. hanzo_mcp/tools/shell/uvx_unified.py +101 -0
  97. hanzo_mcp/tools/todo/__init__.py +1 -1
  98. hanzo_mcp/tools/todo/base.py +1 -1
  99. hanzo_mcp/tools/todo/todo.py +265 -0
  100. hanzo_mcp/tools/todo/todo_read.py +3 -5
  101. hanzo_mcp/tools/todo/todo_write.py +3 -5
  102. hanzo_mcp/tools/vector/__init__.py +6 -1
  103. hanzo_mcp/tools/vector/git_ingester.py +3 -0
  104. hanzo_mcp/tools/vector/index_tool.py +358 -0
  105. hanzo_mcp/tools/vector/infinity_store.py +98 -0
  106. hanzo_mcp/tools/vector/project_manager.py +27 -5
  107. hanzo_mcp/tools/vector/vector.py +311 -0
  108. hanzo_mcp/tools/vector/vector_index.py +1 -1
  109. hanzo_mcp/tools/vector/vector_search.py +12 -7
  110. hanzo_mcp-0.6.1.dist-info/METADATA +336 -0
  111. hanzo_mcp-0.6.1.dist-info/RECORD +134 -0
  112. hanzo_mcp-0.6.1.dist-info/entry_points.txt +3 -0
  113. hanzo_mcp-0.5.1.dist-info/METADATA +0 -276
  114. hanzo_mcp-0.5.1.dist-info/RECORD +0 -68
  115. hanzo_mcp-0.5.1.dist-info/entry_points.txt +0 -2
  116. {hanzo_mcp-0.5.1.dist-info → hanzo_mcp-0.6.1.dist-info}/WHEEL +0 -0
  117. {hanzo_mcp-0.5.1.dist-info → hanzo_mcp-0.6.1.dist-info}/licenses/LICENSE +0 -0
  118. {hanzo_mcp-0.5.1.dist-info → hanzo_mcp-0.6.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,812 @@
1
+ """Batch search tool that runs multiple search queries in parallel.
2
+
3
+ This tool allows running multiple searches of different types concurrently:
4
+ - grep: Fast pattern/regex search
5
+ - grep_ast: AST-aware code search
6
+ - vector_search: Semantic similarity search
7
+ - git_search: Search through git history
8
+
9
+ Results are combined and ranked for comprehensive search coverage.
10
+ Perfect for complex research and refactoring tasks where you need
11
+ to find all occurrences across different dimensions.
12
+ """
13
+
14
+ import asyncio
15
+ import json
16
+ import re
17
+ from dataclasses import dataclass, asdict
18
+ from pathlib import Path
19
+ from typing import Dict, List, Optional, Set, Tuple, Any, Union
20
+ from enum import Enum
21
+
22
+ from mcp.server.fastmcp import Context as MCPContext
23
+ from mcp.server import FastMCP
24
+ from pydantic import Field
25
+ from typing_extensions import Annotated, TypedDict, Unpack, final, override
26
+
27
+ from hanzo_mcp.tools.filesystem.base import FilesystemBaseTool
28
+ from hanzo_mcp.tools.filesystem.grep import Grep
29
+ from hanzo_mcp.tools.filesystem.symbols import SymbolsTool
30
+ from hanzo_mcp.tools.filesystem.git_search import GitSearchTool
31
+ from hanzo_mcp.tools.vector.vector_search import VectorSearchTool
32
+ from hanzo_mcp.tools.vector.ast_analyzer import ASTAnalyzer, Symbol
33
+ from hanzo_mcp.tools.common.permissions import PermissionManager
34
+ from hanzo_mcp.tools.vector.project_manager import ProjectVectorManager
35
+
36
+
37
+ class SearchType(Enum):
38
+ """Types of searches that can be performed."""
39
+ GREP = "grep"
40
+ VECTOR = "vector"
41
+ AST = "ast"
42
+ SYMBOL = "symbol"
43
+ GIT = "git"
44
+
45
+
46
+ @dataclass
47
+ class SearchResult:
48
+ """Unified search result combining different search types."""
49
+ file_path: str
50
+ line_number: Optional[int]
51
+ content: str
52
+ search_type: SearchType
53
+ score: float # Relevance score (0-1)
54
+ context: Optional[str] = None # AST/function context
55
+ symbol_info: Optional[Symbol] = None
56
+ project: Optional[str] = None
57
+
58
+ def to_dict(self) -> Dict[str, Any]:
59
+ """Convert to dictionary for JSON serialization."""
60
+ result = asdict(self)
61
+ result['search_type'] = self.search_type.value
62
+ if self.symbol_info:
63
+ result['symbol_info'] = asdict(self.symbol_info)
64
+ return result
65
+
66
+
67
+ @dataclass
68
+ class BatchSearchResults:
69
+ """Container for all batch search results."""
70
+ query: str
71
+ total_results: int
72
+ results_by_type: Dict[SearchType, List[SearchResult]]
73
+ combined_results: List[SearchResult]
74
+ search_time_ms: float
75
+
76
+ def to_dict(self) -> Dict[str, Any]:
77
+ """Convert to dictionary for JSON serialization."""
78
+ return {
79
+ 'query': self.query,
80
+ 'total_results': self.total_results,
81
+ 'results_by_type': {k.value: [r.to_dict() for r in v] for k, v in self.results_by_type.items()},
82
+ 'combined_results': [r.to_dict() for r in self.combined_results],
83
+ 'search_time_ms': self.search_time_ms,
84
+ }
85
+
86
+
87
+ Queries = Annotated[List[Dict[str, Any]], Field(description="List of search queries with types", min_items=1)]
88
+ SearchPath = Annotated[str, Field(description="Path to search in", default=".")]
89
+ Include = Annotated[str, Field(description="File pattern to include", default="*")]
90
+ MaxResults = Annotated[int, Field(description="Maximum results per query", default=20)]
91
+ IncludeContext = Annotated[bool, Field(description="Include function/method context", default=True)]
92
+ CombineResults = Annotated[bool, Field(description="Combine and deduplicate results", default=True)]
93
+
94
+
95
+ class BatchSearchParams(TypedDict):
96
+ """Parameters for batch search.
97
+
98
+ queries format: [
99
+ {"type": "grep", "pattern": "TODO"},
100
+ {"type": "vector_search", "query": "error handling"},
101
+ {"type": "grep_ast", "pattern": "def.*test"},
102
+ {"type": "git_search", "pattern": "bug fix", "search_type": "commits"}
103
+ ]
104
+ """
105
+ queries: Queries
106
+ path: SearchPath
107
+ include: Include
108
+ max_results: MaxResults
109
+ include_context: IncludeContext
110
+ combine_results: CombineResults
111
+
112
+
113
+ @final
114
+ class BatchSearchTool(FilesystemBaseTool):
115
+ """Unified search tool combining multiple search strategies."""
116
+
117
+ def __init__(self, permission_manager: PermissionManager,
118
+ project_manager: Optional[ProjectVectorManager] = None):
119
+ """Initialize the unified search tool."""
120
+ super().__init__(permission_manager)
121
+ self.project_manager = project_manager
122
+
123
+ # Initialize component search tools
124
+ self.grep_tool = Grep(permission_manager)
125
+ self.grep_ast_tool = SymbolsTool(permission_manager)
126
+ self.git_search_tool = GitSearchTool(permission_manager)
127
+ self.ast_analyzer = ASTAnalyzer()
128
+
129
+ # Vector search is optional
130
+ self.vector_tool = None
131
+ if project_manager:
132
+ self.vector_tool = VectorSearchTool(permission_manager, project_manager)
133
+
134
+ # Cache for AST analysis results
135
+ self._ast_cache: Dict[str, Any] = {}
136
+ self._symbol_cache: Dict[str, List[Symbol]] = {}
137
+
138
+ @property
139
+ @override
140
+ def name(self) -> str:
141
+ """Get the tool name."""
142
+ return "batch_search"
143
+
144
+ @property
145
+ @override
146
+ def description(self) -> str:
147
+ """Get the tool description."""
148
+ return """Run multiple search queries in parallel across different search types.
149
+
150
+ Supports running concurrent searches:
151
+ - Multiple grep patterns
152
+ - Multiple vector queries
153
+ - Multiple AST searches
154
+ - Combined with git history search
155
+
156
+ Examples:
157
+ - Search for 'config' in code + 'configuration' in docs + 'CONFIG' in constants
158
+ - Find all references to a function across code, comments, and git history
159
+ - Search for concept across different naming conventions
160
+
161
+ Results are intelligently combined, deduplicated, and ranked by relevance.
162
+ Perfect for comprehensive code analysis and refactoring tasks."""
163
+
164
+ def _detect_search_intent(self, pattern: str) -> Tuple[bool, bool, bool]:
165
+ """Analyze pattern to determine which search types to enable.
166
+
167
+ Returns:
168
+ Tuple of (should_use_vector, should_use_ast, should_use_symbol)
169
+ """
170
+ # Default to all enabled
171
+ use_vector = True
172
+ use_ast = True
173
+ use_symbol = True
174
+
175
+ # If pattern looks like regex, focus on text search
176
+ regex_indicators = ['.*', '\\w', '\\d', '\\s', '[', ']', '(', ')', '|', '^', '$']
177
+ if any(indicator in pattern for indicator in regex_indicators):
178
+ use_vector = False # Regex patterns don't work well with vector search
179
+
180
+ # If pattern looks like a function/class name, prioritize symbol search
181
+ if re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', pattern):
182
+ use_symbol = True
183
+ use_ast = True
184
+
185
+ # If pattern contains natural language, prioritize vector search
186
+ words = pattern.split()
187
+ if len(words) > 2 and not any(c in pattern for c in ['(', ')', '{', '}', '[', ']']):
188
+ use_vector = True
189
+
190
+ return use_vector, use_ast, use_symbol
191
+
192
+ async def _run_grep_search(self, pattern: str, path: str, include: str,
193
+ tool_ctx, max_results: int) -> List[SearchResult]:
194
+ """Run grep search and convert results."""
195
+ await tool_ctx.info(f"Running grep search for: {pattern}")
196
+
197
+ try:
198
+ # Use the existing grep tool
199
+ grep_result = await self.grep_tool.call(
200
+ tool_ctx.mcp_context,
201
+ pattern=pattern,
202
+ path=path,
203
+ include=include
204
+ )
205
+
206
+ results = []
207
+ if "Found" in grep_result and "matches" in grep_result:
208
+ # Parse grep results
209
+ lines = grep_result.split('\n')
210
+ for line in lines[2:]: # Skip header lines
211
+ if ':' in line and len(line.strip()) > 0:
212
+ try:
213
+ parts = line.split(':', 2)
214
+ if len(parts) >= 3:
215
+ file_path = parts[0]
216
+ line_num = int(parts[1])
217
+ content = parts[2].strip()
218
+
219
+ result = SearchResult(
220
+ file_path=file_path,
221
+ line_number=line_num,
222
+ content=content,
223
+ search_type=SearchType.GREP,
224
+ score=1.0, # Grep results are exact matches
225
+ )
226
+ results.append(result)
227
+
228
+ if len(results) >= max_results:
229
+ break
230
+ except (ValueError, IndexError):
231
+ continue
232
+
233
+ await tool_ctx.info(f"Grep search found {len(results)} results")
234
+ return results
235
+
236
+ except Exception as e:
237
+ await tool_ctx.error(f"Grep search failed: {str(e)}")
238
+ return []
239
+
240
+ async def _run_vector_search(self, pattern: str, path: str, tool_ctx,
241
+ max_results: int) -> List[SearchResult]:
242
+ """Run vector search and convert results."""
243
+ if not self.vector_tool:
244
+ return []
245
+
246
+ await tool_ctx.info(f"Running vector search for: {pattern}")
247
+
248
+ try:
249
+ # Determine search scope based on path
250
+ if path == ".":
251
+ search_scope = "current"
252
+ else:
253
+ search_scope = "all" # Could be enhanced to detect project
254
+
255
+ vector_result = await self.vector_tool.call(
256
+ tool_ctx.mcp_context,
257
+ query=pattern,
258
+ limit=max_results,
259
+ score_threshold=0.3,
260
+ search_scope=search_scope,
261
+ include_content=True
262
+ )
263
+
264
+ results = []
265
+ # Parse vector search results - this would need to be enhanced
266
+ # based on the actual format returned by vector_tool
267
+ if "Found" in vector_result:
268
+ # This is a simplified parser - would need to match actual format
269
+ lines = vector_result.split('\n')
270
+ current_file = None
271
+ current_score = 0.0
272
+
273
+ for line in lines:
274
+ if "Result" in line and "Score:" in line:
275
+ # Extract score
276
+ score_match = re.search(r'Score: ([\d.]+)%', line)
277
+ if score_match:
278
+ current_score = float(score_match.group(1)) / 100.0
279
+
280
+ # Extract file path
281
+ if " - " in line:
282
+ parts = line.split(" - ")
283
+ if len(parts) > 1:
284
+ current_file = parts[-1].strip()
285
+
286
+ elif current_file and line.strip() and not line.startswith('-'):
287
+ # This is content
288
+ result = SearchResult(
289
+ file_path=current_file,
290
+ line_number=None,
291
+ content=line.strip(),
292
+ search_type=SearchType.VECTOR,
293
+ score=current_score,
294
+ )
295
+ results.append(result)
296
+
297
+ if len(results) >= max_results:
298
+ break
299
+
300
+ await tool_ctx.info(f"Vector search found {len(results)} results")
301
+ return results
302
+
303
+ except Exception as e:
304
+ await tool_ctx.error(f"Vector search failed: {str(e)}")
305
+ return []
306
+
307
+ async def _run_ast_search(self, pattern: str, path: str, include: str,
308
+ tool_ctx, max_results: int) -> List[SearchResult]:
309
+ """Run AST-aware search and convert results."""
310
+ await tool_ctx.info(f"Running AST search for: {pattern}")
311
+
312
+ try:
313
+ ast_result = await self.grep_ast_tool.call(
314
+ tool_ctx.mcp_context,
315
+ pattern=pattern,
316
+ path=path,
317
+ ignore_case=False,
318
+ line_number=True
319
+ )
320
+
321
+ results = []
322
+ if ast_result and not ast_result.startswith("No matches"):
323
+ # Parse AST results - they include structural context
324
+ current_file = None
325
+ context_lines = []
326
+
327
+ for line in ast_result.split('\n'):
328
+ if line.endswith(':') and '/' in line:
329
+ # This is a file header
330
+ current_file = line[:-1]
331
+ context_lines = []
332
+ elif current_file and line.strip():
333
+ if ':' in line and line.strip()[0].isdigit():
334
+ # This looks like a line with number
335
+ try:
336
+ parts = line.split(':', 1)
337
+ line_num = int(parts[0].strip())
338
+ content = parts[1].strip() if len(parts) > 1 else ""
339
+
340
+ result = SearchResult(
341
+ file_path=current_file,
342
+ line_number=line_num,
343
+ content=content,
344
+ search_type=SearchType.AST,
345
+ score=0.9, # High score for AST matches
346
+ context='\n'.join(context_lines) if context_lines else None
347
+ )
348
+ results.append(result)
349
+
350
+ if len(results) >= max_results:
351
+ break
352
+
353
+ except ValueError:
354
+ context_lines.append(line)
355
+ else:
356
+ context_lines.append(line)
357
+
358
+ await tool_ctx.info(f"AST search found {len(results)} results")
359
+ return results
360
+
361
+ except Exception as e:
362
+ await tool_ctx.error(f"AST search failed: {str(e)}")
363
+ return []
364
+
365
+ async def _run_symbol_search(self, pattern: str, path: str, tool_ctx,
366
+ max_results: int) -> List[SearchResult]:
367
+ """Run symbol search using AST analysis."""
368
+ await tool_ctx.info(f"Running symbol search for: {pattern}")
369
+
370
+ try:
371
+ results = []
372
+ path_obj = Path(path)
373
+
374
+ # Find files to analyze
375
+ files_to_check = []
376
+ if path_obj.is_file():
377
+ files_to_check.append(str(path_obj))
378
+ elif path_obj.is_dir():
379
+ # Look for source files
380
+ for ext in ['.py', '.js', '.ts', '.java', '.cpp', '.c']:
381
+ files_to_check.extend(path_obj.rglob(f'*{ext}'))
382
+ files_to_check = [str(f) for f in files_to_check[:50]] # Limit for performance
383
+
384
+ # Analyze files for symbols
385
+ for file_path in files_to_check:
386
+ if not self.is_path_allowed(file_path):
387
+ continue
388
+
389
+ # Check cache first
390
+ if file_path in self._symbol_cache:
391
+ symbols = self._symbol_cache[file_path]
392
+ else:
393
+ # Analyze file
394
+ file_ast = self.ast_analyzer.analyze_file(file_path)
395
+ symbols = file_ast.symbols if file_ast else []
396
+ self._symbol_cache[file_path] = symbols
397
+
398
+ # Search symbols
399
+ for symbol in symbols:
400
+ if re.search(pattern, symbol.name, re.IGNORECASE):
401
+ result = SearchResult(
402
+ file_path=symbol.file_path,
403
+ line_number=symbol.line_start,
404
+ content=f"{symbol.type} {symbol.name}" + (f" - {symbol.docstring[:100]}..." if symbol.docstring else ""),
405
+ search_type=SearchType.SYMBOL,
406
+ score=0.95, # Very high score for symbol matches
407
+ symbol_info=symbol,
408
+ context=symbol.signature
409
+ )
410
+ results.append(result)
411
+
412
+ if len(results) >= max_results:
413
+ break
414
+
415
+ if len(results) >= max_results:
416
+ break
417
+
418
+ await tool_ctx.info(f"Symbol search found {len(results)} results")
419
+ return results
420
+
421
+ except Exception as e:
422
+ await tool_ctx.error(f"Symbol search failed: {str(e)}")
423
+ return []
424
+
425
+ async def _add_function_context(self, results: List[SearchResult], tool_ctx) -> List[SearchResult]:
426
+ """Add function/method context to results where relevant."""
427
+ enhanced_results = []
428
+
429
+ for result in results:
430
+ enhanced_result = result
431
+
432
+ if result.line_number and not result.context:
433
+ try:
434
+ # Read the file and find surrounding function
435
+ file_path = Path(result.file_path)
436
+ if file_path.exists() and self.is_path_allowed(str(file_path)):
437
+
438
+ # Check if we have AST analysis cached
439
+ if str(file_path) not in self._ast_cache:
440
+ file_ast = self.ast_analyzer.analyze_file(str(file_path))
441
+ self._ast_cache[str(file_path)] = file_ast
442
+ else:
443
+ file_ast = self._ast_cache[str(file_path)]
444
+
445
+ if file_ast:
446
+ # Find symbol containing this line
447
+ for symbol in file_ast.symbols:
448
+ if (symbol.line_start <= result.line_number <= symbol.line_end and
449
+ symbol.type in ['function', 'method']):
450
+ enhanced_result = SearchResult(
451
+ file_path=result.file_path,
452
+ line_number=result.line_number,
453
+ content=result.content,
454
+ search_type=result.search_type,
455
+ score=result.score,
456
+ context=f"In {symbol.type} {symbol.name}(): {symbol.signature or ''}",
457
+ symbol_info=symbol,
458
+ project=result.project
459
+ )
460
+ break
461
+ except Exception as e:
462
+ await tool_ctx.warning(f"Could not add context for {result.file_path}: {str(e)}")
463
+
464
+ enhanced_results.append(enhanced_result)
465
+
466
+ return enhanced_results
467
+
468
+ def _combine_and_rank_results(self, results_by_type: Dict[SearchType, List[SearchResult]]) -> List[SearchResult]:
469
+ """Combine results from different search types and rank by relevance."""
470
+ all_results = []
471
+ seen_combinations = set()
472
+
473
+ # Combine all results, avoiding duplicates
474
+ for search_type, results in results_by_type.items():
475
+ for result in results:
476
+ # Create a key to identify duplicates
477
+ key = (result.file_path, result.line_number)
478
+
479
+ if key not in seen_combinations:
480
+ seen_combinations.add(key)
481
+ all_results.append(result)
482
+ else:
483
+ # Merge with existing result based on score and type priority
484
+ type_priority = {
485
+ SearchType.SYMBOL: 4,
486
+ SearchType.GREP: 3,
487
+ SearchType.AST: 2,
488
+ SearchType.VECTOR: 1
489
+ }
490
+
491
+ for existing in all_results:
492
+ existing_key = (existing.file_path, existing.line_number)
493
+ if existing_key == key:
494
+ # Update if the new result has higher priority or better score
495
+ result_priority = type_priority[result.search_type]
496
+ existing_priority = type_priority[existing.search_type]
497
+
498
+ # Replace existing if: higher priority type, or same priority but higher score
499
+ if (result_priority > existing_priority or
500
+ (result_priority == existing_priority and result.score > existing.score)):
501
+ # Replace the entire result to preserve type
502
+ idx = all_results.index(existing)
503
+ all_results[idx] = result
504
+ else:
505
+ # Still merge useful information
506
+ existing.context = existing.context or result.context
507
+ existing.symbol_info = existing.symbol_info or result.symbol_info
508
+ break
509
+
510
+ # Sort by score (descending) then by search type priority
511
+ type_priority = {
512
+ SearchType.SYMBOL: 4,
513
+ SearchType.GREP: 3,
514
+ SearchType.AST: 2,
515
+ SearchType.VECTOR: 1
516
+ }
517
+
518
+ all_results.sort(key=lambda r: (r.score, type_priority[r.search_type]), reverse=True)
519
+
520
+ return all_results
521
+
522
+ @override
523
+ async def call(self, ctx: MCPContext, **params: Unpack[BatchSearchParams]) -> str:
524
+ """Execute batch search with multiple queries in parallel."""
525
+ import time
526
+ start_time = time.time()
527
+
528
+ tool_ctx = self.create_tool_context(ctx)
529
+
530
+ # Extract parameters
531
+ queries = params["queries"]
532
+ path = params.get("path", ".")
533
+ include = params.get("include", "*")
534
+ max_results = params.get("max_results", 20)
535
+ include_context = params.get("include_context", True)
536
+ combine_results = params.get("combine_results", True)
537
+
538
+ # Validate path
539
+ path_validation = self.validate_path(path)
540
+ if path_validation.is_error:
541
+ await tool_ctx.error(path_validation.error_message)
542
+ return f"Error: {path_validation.error_message}"
543
+
544
+ # Check path permissions and existence
545
+ allowed, error_msg = await self.check_path_allowed(path, tool_ctx)
546
+ if not allowed:
547
+ return error_msg
548
+
549
+ exists, error_msg = await self.check_path_exists(path, tool_ctx)
550
+ if not exists:
551
+ return error_msg
552
+
553
+ await tool_ctx.info(f"Starting batch search with {len(queries)} queries in {path}")
554
+
555
+ # Run all queries in parallel
556
+ search_tasks = []
557
+ query_info = [] # Track query info for results
558
+
559
+ for query in queries:
560
+ query_type = query.get("type", "grep")
561
+ query_info.append(query)
562
+
563
+ if query_type == "grep":
564
+ pattern = query.get("pattern")
565
+ if pattern:
566
+ search_tasks.append(
567
+ self._run_grep_search(pattern, path, include, tool_ctx, max_results)
568
+ )
569
+
570
+ elif query_type == "grep_ast":
571
+ pattern = query.get("pattern")
572
+ if pattern:
573
+ search_tasks.append(
574
+ self._run_ast_search(pattern, path, include, tool_ctx, max_results)
575
+ )
576
+
577
+ elif query_type == "vector_search" and self.vector_tool:
578
+ search_query = query.get("query") or query.get("pattern")
579
+ if search_query:
580
+ search_tasks.append(
581
+ self._run_vector_search(search_query, path, tool_ctx, max_results)
582
+ )
583
+
584
+ elif query_type == "git_search":
585
+ pattern = query.get("pattern")
586
+ search_type = query.get("search_type", "content")
587
+ if pattern:
588
+ search_tasks.append(
589
+ self._run_git_search(pattern, path, search_type, tool_ctx, max_results)
590
+ )
591
+
592
+ else:
593
+ await tool_ctx.warning(f"Unknown or unavailable search type: {query_type}")
594
+
595
+ # Execute all searches in parallel
596
+ search_results = await asyncio.gather(*search_tasks, return_exceptions=True)
597
+
598
+ # Collect all results
599
+ all_results = []
600
+ results_by_query = {}
601
+
602
+ for i, (query, result) in enumerate(zip(query_info, search_results)):
603
+ if isinstance(result, Exception):
604
+ await tool_ctx.error(f"Query {i+1} failed: {str(result)}")
605
+ results_by_query[i] = []
606
+ else:
607
+ results_by_query[i] = result
608
+ all_results.extend(result)
609
+
610
+ # Combine and deduplicate results if requested
611
+ if combine_results:
612
+ combined_results = self._combine_results(all_results)
613
+ else:
614
+ combined_results = all_results
615
+
616
+ # Add context if requested
617
+ if include_context:
618
+ combined_results = await self._add_context_to_results(combined_results, tool_ctx)
619
+
620
+ end_time = time.time()
621
+ search_time_ms = (end_time - start_time) * 1000
622
+
623
+ # Sort by relevance score
624
+ combined_results.sort(key=lambda r: r.score, reverse=True)
625
+
626
+ # Limit total results
627
+ combined_results = combined_results[:max_results * 2] # Allow more when combining
628
+
629
+ # Create batch results object
630
+ batch_results = BatchSearchResults(
631
+ query=f"Batch search with {len(queries)} queries",
632
+ total_results=len(combined_results),
633
+ results_by_type={
634
+ SearchType.GREP: [r for r in combined_results if r.search_type == SearchType.GREP],
635
+ SearchType.VECTOR: [r for r in combined_results if r.search_type == SearchType.VECTOR],
636
+ SearchType.AST: [r for r in combined_results if r.search_type == SearchType.AST],
637
+ SearchType.GIT: [r for r in combined_results if r.search_type == SearchType.GIT],
638
+ },
639
+ combined_results=combined_results,
640
+ search_time_ms=search_time_ms
641
+ )
642
+
643
+ # Format output
644
+ return self._format_batch_results(batch_results, query_info)
645
+
646
+ async def _run_git_search(self, pattern: str, path: str, search_type: str,
647
+ tool_ctx, max_results: int) -> List[SearchResult]:
648
+ """Run git search and convert results."""
649
+ await tool_ctx.info(f"Running git search for: {pattern} (type: {search_type})")
650
+
651
+ try:
652
+ # Use the git search tool
653
+ git_result = await self.git_search_tool.call(
654
+ tool_ctx.mcp_context,
655
+ pattern=pattern,
656
+ path=path,
657
+ search_type=search_type,
658
+ max_count=max_results
659
+ )
660
+
661
+ results = []
662
+ if "Found" in git_result:
663
+ # Parse git search results - simplified parser
664
+ lines = git_result.split('\n')
665
+ current_file = None
666
+
667
+ for line in lines:
668
+ if line.strip():
669
+ # Extract file path and content
670
+ if ':' in line:
671
+ parts = line.split(':', 2)
672
+ if len(parts) >= 2:
673
+ file_path = parts[0].strip()
674
+ content = parts[-1].strip() if len(parts) > 2 else line
675
+
676
+ result = SearchResult(
677
+ file_path=file_path,
678
+ line_number=None,
679
+ content=content,
680
+ search_type=SearchType.GIT,
681
+ score=0.8, # Git results are relevant
682
+ )
683
+ results.append(result)
684
+
685
+ if len(results) >= max_results:
686
+ break
687
+
688
+ await tool_ctx.info(f"Git search found {len(results)} results")
689
+ return results
690
+
691
+ except Exception as e:
692
+ await tool_ctx.error(f"Git search failed: {str(e)}")
693
+ return []
694
+
695
+ def _combine_results(self, results: List[SearchResult]) -> List[SearchResult]:
696
+ """Combine and deduplicate search results."""
697
+ # Use file path and line number as key for deduplication
698
+ seen = {}
699
+ combined = []
700
+
701
+ for result in results:
702
+ key = (result.file_path, result.line_number)
703
+
704
+ if key not in seen:
705
+ seen[key] = result
706
+ combined.append(result)
707
+ else:
708
+ # If we've seen this location, keep the one with higher score
709
+ existing = seen[key]
710
+ if result.score > existing.score:
711
+ # Replace with higher scored result
712
+ idx = combined.index(existing)
713
+ combined[idx] = result
714
+ seen[key] = result
715
+
716
+ return combined
717
+
718
+ async def _add_context_to_results(self, results: List[SearchResult], tool_ctx) -> List[SearchResult]:
719
+ """Add function/method context to results."""
720
+ # This is a simplified version - you could enhance with full AST context
721
+ return await self._add_function_context(results, tool_ctx)
722
+
723
+ def _format_batch_results(self, results: BatchSearchResults, query_info: List[Dict]) -> str:
724
+ """Format batch search results for display."""
725
+ output = []
726
+
727
+ # Header
728
+ output.append(f"=== Batch Search Results ===")
729
+ output.append(f"Queries: {len(query_info)}")
730
+ output.append(f"Total results: {results.total_results}")
731
+ output.append(f"Search time: {results.search_time_ms:.1f}ms\n")
732
+
733
+ # Summary by type
734
+ output.append("Results by type:")
735
+ for search_type, type_results in results.results_by_type.items():
736
+ if type_results:
737
+ output.append(f" {search_type.value}: {len(type_results)} results")
738
+ output.append("")
739
+
740
+ # Query summary
741
+ output.append("Queries executed:")
742
+ for i, query in enumerate(query_info):
743
+ query_type = query.get("type", "grep")
744
+ pattern = query.get("pattern") or query.get("query", "")
745
+ output.append(f" {i+1}. {query_type}: {pattern}")
746
+ output.append("")
747
+
748
+ # Results
749
+ if results.combined_results:
750
+ output.append("=== Top Results ===\n")
751
+
752
+ # Group by file
753
+ results_by_file = {}
754
+ for result in results.combined_results[:50]: # Limit display
755
+ if result.file_path not in results_by_file:
756
+ results_by_file[result.file_path] = []
757
+ results_by_file[result.file_path].append(result)
758
+
759
+ # Display results by file
760
+ for file_path, file_results in results_by_file.items():
761
+ output.append(f"{file_path}")
762
+ output.append("-" * len(file_path))
763
+
764
+ # Sort by line number if available
765
+ file_results.sort(key=lambda r: r.line_number or 0)
766
+
767
+ for result in file_results:
768
+ score_str = f"[{result.search_type.value} {result.score:.2f}]"
769
+
770
+ if result.line_number:
771
+ output.append(f" {result.line_number:>4}: {score_str} {result.content}")
772
+ else:
773
+ output.append(f" {score_str} {result.content}")
774
+
775
+ if result.context:
776
+ output.append(f" Context: {result.context}")
777
+
778
+ output.append("")
779
+ else:
780
+ output.append("No results found.")
781
+
782
+ return "\n".join(output)
783
+
784
+
785
+ @override
786
+ def register(self, mcp_server: FastMCP) -> None:
787
+ """Register the unified search tool with the MCP server."""
788
+ tool_self = self
789
+
790
+ @mcp_server.tool(name=self.name, description=self.description)
791
+ async def unified_search(
792
+ ctx: MCPContext,
793
+ pattern: Pattern,
794
+ path: SearchPath = ".",
795
+ include: Include = "*",
796
+ max_results: MaxResults = 20,
797
+ enable_vector: EnableVector = True,
798
+ enable_ast: EnableAST = True,
799
+ enable_symbol: EnableSymbol = True,
800
+ include_context: IncludeContext = True,
801
+ ) -> str:
802
+ return await tool_self.call(
803
+ ctx,
804
+ pattern=pattern,
805
+ path=path,
806
+ include=include,
807
+ max_results=max_results,
808
+ enable_vector=enable_vector,
809
+ enable_ast=enable_ast,
810
+ enable_symbol=enable_symbol,
811
+ include_context=include_context,
812
+ )