hanzo-mcp 0.5.2__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 (114) 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 +66 -35
  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 +2 -2
  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 +1 -1
  19. hanzo_mcp/tools/common/thinking_tool.py +3 -5
  20. hanzo_mcp/tools/common/tool_disable.py +1 -1
  21. hanzo_mcp/tools/common/tool_enable.py +1 -1
  22. hanzo_mcp/tools/common/tool_list.py +49 -52
  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 +1 -1
  28. hanzo_mcp/tools/database/graph.py +482 -0
  29. hanzo_mcp/tools/database/graph_add.py +1 -1
  30. hanzo_mcp/tools/database/graph_query.py +1 -1
  31. hanzo_mcp/tools/database/graph_remove.py +1 -1
  32. hanzo_mcp/tools/database/graph_search.py +1 -1
  33. hanzo_mcp/tools/database/graph_stats.py +1 -1
  34. hanzo_mcp/tools/database/sql.py +411 -0
  35. hanzo_mcp/tools/database/sql_query.py +1 -1
  36. hanzo_mcp/tools/database/sql_search.py +1 -1
  37. hanzo_mcp/tools/database/sql_stats.py +1 -1
  38. hanzo_mcp/tools/editor/neovim_command.py +1 -1
  39. hanzo_mcp/tools/editor/neovim_edit.py +1 -1
  40. hanzo_mcp/tools/editor/neovim_session.py +1 -1
  41. hanzo_mcp/tools/filesystem/__init__.py +42 -13
  42. hanzo_mcp/tools/filesystem/base.py +1 -1
  43. hanzo_mcp/tools/filesystem/batch_search.py +4 -4
  44. hanzo_mcp/tools/filesystem/content_replace.py +3 -5
  45. hanzo_mcp/tools/filesystem/diff.py +193 -0
  46. hanzo_mcp/tools/filesystem/directory_tree.py +3 -5
  47. hanzo_mcp/tools/filesystem/edit.py +3 -5
  48. hanzo_mcp/tools/filesystem/find.py +443 -0
  49. hanzo_mcp/tools/filesystem/find_files.py +1 -1
  50. hanzo_mcp/tools/filesystem/git_search.py +1 -1
  51. hanzo_mcp/tools/filesystem/grep.py +2 -2
  52. hanzo_mcp/tools/filesystem/multi_edit.py +3 -5
  53. hanzo_mcp/tools/filesystem/read.py +17 -5
  54. hanzo_mcp/tools/filesystem/{grep_ast_tool.py → symbols.py} +17 -27
  55. hanzo_mcp/tools/filesystem/symbols_unified.py +376 -0
  56. hanzo_mcp/tools/filesystem/tree.py +268 -0
  57. hanzo_mcp/tools/filesystem/unified_search.py +711 -0
  58. hanzo_mcp/tools/filesystem/unix_aliases.py +99 -0
  59. hanzo_mcp/tools/filesystem/watch.py +174 -0
  60. hanzo_mcp/tools/filesystem/write.py +3 -5
  61. hanzo_mcp/tools/jupyter/__init__.py +9 -12
  62. hanzo_mcp/tools/jupyter/base.py +1 -1
  63. hanzo_mcp/tools/jupyter/jupyter.py +326 -0
  64. hanzo_mcp/tools/jupyter/notebook_edit.py +3 -4
  65. hanzo_mcp/tools/jupyter/notebook_read.py +3 -5
  66. hanzo_mcp/tools/llm/__init__.py +4 -0
  67. hanzo_mcp/tools/llm/consensus_tool.py +1 -1
  68. hanzo_mcp/tools/llm/llm_manage.py +1 -1
  69. hanzo_mcp/tools/llm/llm_tool.py +1 -1
  70. hanzo_mcp/tools/llm/llm_unified.py +851 -0
  71. hanzo_mcp/tools/llm/provider_tools.py +1 -1
  72. hanzo_mcp/tools/mcp/__init__.py +4 -0
  73. hanzo_mcp/tools/mcp/mcp_add.py +1 -1
  74. hanzo_mcp/tools/mcp/mcp_remove.py +1 -1
  75. hanzo_mcp/tools/mcp/mcp_stats.py +1 -1
  76. hanzo_mcp/tools/mcp/mcp_unified.py +503 -0
  77. hanzo_mcp/tools/shell/__init__.py +20 -42
  78. hanzo_mcp/tools/shell/base.py +1 -1
  79. hanzo_mcp/tools/shell/base_process.py +303 -0
  80. hanzo_mcp/tools/shell/bash_unified.py +134 -0
  81. hanzo_mcp/tools/shell/logs.py +1 -1
  82. hanzo_mcp/tools/shell/npx.py +1 -1
  83. hanzo_mcp/tools/shell/npx_background.py +1 -1
  84. hanzo_mcp/tools/shell/npx_unified.py +101 -0
  85. hanzo_mcp/tools/shell/open.py +107 -0
  86. hanzo_mcp/tools/shell/pkill.py +1 -1
  87. hanzo_mcp/tools/shell/process_unified.py +131 -0
  88. hanzo_mcp/tools/shell/processes.py +1 -1
  89. hanzo_mcp/tools/shell/run_background.py +1 -1
  90. hanzo_mcp/tools/shell/run_command.py +3 -4
  91. hanzo_mcp/tools/shell/run_command_windows.py +3 -4
  92. hanzo_mcp/tools/shell/uvx.py +1 -1
  93. hanzo_mcp/tools/shell/uvx_background.py +1 -1
  94. hanzo_mcp/tools/shell/uvx_unified.py +101 -0
  95. hanzo_mcp/tools/todo/__init__.py +1 -1
  96. hanzo_mcp/tools/todo/base.py +1 -1
  97. hanzo_mcp/tools/todo/todo.py +265 -0
  98. hanzo_mcp/tools/todo/todo_read.py +3 -5
  99. hanzo_mcp/tools/todo/todo_write.py +3 -5
  100. hanzo_mcp/tools/vector/__init__.py +1 -1
  101. hanzo_mcp/tools/vector/index_tool.py +1 -1
  102. hanzo_mcp/tools/vector/project_manager.py +27 -5
  103. hanzo_mcp/tools/vector/vector.py +311 -0
  104. hanzo_mcp/tools/vector/vector_index.py +1 -1
  105. hanzo_mcp/tools/vector/vector_search.py +1 -1
  106. hanzo_mcp-0.6.1.dist-info/METADATA +336 -0
  107. hanzo_mcp-0.6.1.dist-info/RECORD +134 -0
  108. hanzo_mcp-0.6.1.dist-info/entry_points.txt +3 -0
  109. hanzo_mcp-0.5.2.dist-info/METADATA +0 -276
  110. hanzo_mcp-0.5.2.dist-info/RECORD +0 -106
  111. hanzo_mcp-0.5.2.dist-info/entry_points.txt +0 -2
  112. {hanzo_mcp-0.5.2.dist-info → hanzo_mcp-0.6.1.dist-info}/WHEEL +0 -0
  113. {hanzo_mcp-0.5.2.dist-info → hanzo_mcp-0.6.1.dist-info}/licenses/LICENSE +0 -0
  114. {hanzo_mcp-0.5.2.dist-info → hanzo_mcp-0.6.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,711 @@
1
+ """Unified search tool that runs multiple search types in parallel.
2
+
3
+ This tool consolidates all search capabilities and runs them concurrently:
4
+ - grep: Fast pattern/regex search using ripgrep
5
+ - grep_ast: AST-aware code search with structural context
6
+ - vector_search: Semantic similarity search
7
+ - git_search: Search through git history
8
+ - symbol_search: Find symbols (functions, classes) in code
9
+
10
+ Results are combined, deduplicated, and ranked for comprehensive search coverage.
11
+ """
12
+
13
+ import asyncio
14
+ import re
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Annotated, Dict, List, Optional, Set, Tuple, TypedDict, Unpack, final, override
18
+ from enum import Enum
19
+
20
+ from mcp.server.fastmcp import Context as MCPContext
21
+ from mcp.server import FastMCP
22
+ from pydantic import Field
23
+
24
+ from hanzo_mcp.tools.filesystem.base import FilesystemBaseTool
25
+ from hanzo_mcp.tools.filesystem.grep import Grep
26
+ from hanzo_mcp.tools.filesystem.symbols import SymbolsTool
27
+ from hanzo_mcp.tools.filesystem.git_search import GitSearchTool
28
+ from hanzo_mcp.tools.vector.vector_search import VectorSearchTool
29
+ from hanzo_mcp.tools.common.permissions import PermissionManager
30
+ from hanzo_mcp.tools.vector.project_manager import ProjectVectorManager
31
+
32
+
33
+ class SearchType(Enum):
34
+ """Types of searches that can be performed."""
35
+ GREP = "grep"
36
+ GREP_AST = "grep_ast"
37
+ VECTOR = "vector"
38
+ GIT = "git"
39
+ SYMBOL = "symbol" # Searches for function/class definitions
40
+
41
+
42
+ @dataclass
43
+ class SearchResult:
44
+ """Unified search result from any search type."""
45
+ file_path: str
46
+ line_number: Optional[int]
47
+ content: str
48
+ search_type: SearchType
49
+ score: float # Relevance score (0-1)
50
+ context: Optional[str] = None # Function/class context
51
+ match_count: int = 1 # Number of matches in this location
52
+
53
+
54
+ Pattern = Annotated[
55
+ str,
56
+ Field(
57
+ description="The search pattern (supports regex for grep, natural language for vector search)",
58
+ min_length=1,
59
+ ),
60
+ ]
61
+
62
+ SearchPath = Annotated[
63
+ str,
64
+ Field(
65
+ description="The directory to search in. Defaults to current directory.",
66
+ default=".",
67
+ ),
68
+ ]
69
+
70
+ Include = Annotated[
71
+ str,
72
+ Field(
73
+ description='File pattern to include (e.g. "*.js", "*.{ts,tsx}")',
74
+ default="*",
75
+ ),
76
+ ]
77
+
78
+ MaxResults = Annotated[
79
+ int,
80
+ Field(
81
+ description="Maximum number of results to return",
82
+ default=50,
83
+ ),
84
+ ]
85
+
86
+ EnableGrep = Annotated[
87
+ bool,
88
+ Field(
89
+ description="Enable fast pattern/regex search",
90
+ default=True,
91
+ ),
92
+ ]
93
+
94
+ EnableGrepAst = Annotated[
95
+ bool,
96
+ Field(
97
+ description="Enable AST-aware search with code structure context",
98
+ default=True,
99
+ ),
100
+ ]
101
+
102
+ EnableVector = Annotated[
103
+ bool,
104
+ Field(
105
+ description="Enable semantic similarity search",
106
+ default=True,
107
+ ),
108
+ ]
109
+
110
+ EnableGit = Annotated[
111
+ bool,
112
+ Field(
113
+ description="Enable git history search",
114
+ default=True,
115
+ ),
116
+ ]
117
+
118
+ EnableSymbol = Annotated[
119
+ bool,
120
+ Field(
121
+ description="Enable symbol search (functions, classes)",
122
+ default=True,
123
+ ),
124
+ ]
125
+
126
+ IncludeContext = Annotated[
127
+ bool,
128
+ Field(
129
+ description="Include function/class context for matches",
130
+ default=True,
131
+ ),
132
+ ]
133
+
134
+
135
+ class UnifiedSearchParams(TypedDict):
136
+ """Parameters for unified search."""
137
+ pattern: Pattern
138
+ path: SearchPath
139
+ include: Include
140
+ max_results: MaxResults
141
+ enable_grep: EnableGrep
142
+ enable_grep_ast: EnableGrepAst
143
+ enable_vector: EnableVector
144
+ enable_git: EnableGit
145
+ enable_symbol: EnableSymbol
146
+ include_context: IncludeContext
147
+
148
+
149
+ @final
150
+ class UnifiedSearchTool(FilesystemBaseTool):
151
+ """Unified search tool that runs multiple search types in parallel."""
152
+
153
+ def __init__(self, permission_manager: PermissionManager,
154
+ project_manager: Optional[ProjectVectorManager] = None):
155
+ """Initialize the unified search tool.
156
+
157
+ Args:
158
+ permission_manager: Permission manager for access control
159
+ project_manager: Optional project manager for vector search
160
+ """
161
+ super().__init__(permission_manager)
162
+ self.project_manager = project_manager
163
+
164
+ # Initialize component tools
165
+ self.grep_tool = Grep(permission_manager)
166
+ self.grep_ast_tool = SymbolsTool(permission_manager)
167
+ self.git_search_tool = GitSearchTool(permission_manager)
168
+
169
+ # Vector search is optional
170
+ self.vector_tool = None
171
+ if project_manager:
172
+ self.vector_tool = VectorSearchTool(permission_manager, project_manager)
173
+
174
+ @property
175
+ @override
176
+ def name(self) -> str:
177
+ """Get the tool name."""
178
+ return "unified_search"
179
+
180
+ @property
181
+ @override
182
+ def description(self) -> str:
183
+ """Get the tool description."""
184
+ return """Unified search that runs multiple search strategies in parallel.
185
+
186
+ Automatically runs the most appropriate search types based on your pattern:
187
+ - Pattern matching (grep) for exact text/regex
188
+ - AST search for code structure understanding
189
+ - Semantic search for concepts and meaning
190
+ - Git history for tracking changes
191
+ - Symbol search for finding definitions
192
+
193
+ All searches run concurrently for maximum speed. Results are combined,
194
+ deduplicated, and ranked by relevance.
195
+
196
+ Examples:
197
+ - Search for TODO comments: pattern="TODO"
198
+ - Find error handling: pattern="error handling implementation"
199
+ - Locate function: pattern="processPayment"
200
+ - Track changes: pattern="bug fix" (searches git history too)
201
+
202
+ This is the recommended search tool for comprehensive results."""
203
+
204
+ def _analyze_pattern(self, pattern: str) -> Dict[str, bool]:
205
+ """Analyze the pattern to determine optimal search strategies.
206
+
207
+ Args:
208
+ pattern: The search pattern
209
+
210
+ Returns:
211
+ Dictionary of search type recommendations
212
+ """
213
+ # Check if pattern looks like regex
214
+ regex_chars = r'[.*+?^${}()|[\]\\]'
215
+ has_regex = bool(re.search(regex_chars, pattern))
216
+
217
+ # Check if pattern looks like a symbol name
218
+ is_symbol = bool(re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', pattern))
219
+
220
+ # Check if pattern is natural language
221
+ words = pattern.split()
222
+ is_natural_language = len(words) > 2 and not has_regex
223
+
224
+ return {
225
+ 'use_grep': True, # Always useful
226
+ 'use_grep_ast': not has_regex, # AST doesn't handle regex well
227
+ 'use_vector': is_natural_language or len(pattern) > 10,
228
+ 'use_git': True, # Always check history
229
+ 'use_symbol': is_symbol or 'def' in pattern or 'class' in pattern
230
+ }
231
+
232
+ async def _run_grep_search(self, pattern: str, path: str, include: str,
233
+ tool_ctx, max_results: int) -> List[SearchResult]:
234
+ """Run grep search and parse results."""
235
+ try:
236
+ result = await self.grep_tool.call(
237
+ tool_ctx.mcp_context,
238
+ pattern=pattern,
239
+ path=path,
240
+ include=include
241
+ )
242
+
243
+ results = []
244
+ if "Found" in result and "matches" in result:
245
+ lines = result.split('\n')
246
+ for line in lines[2:]: # Skip header
247
+ if ':' in line and line.strip():
248
+ try:
249
+ parts = line.split(':', 2)
250
+ if len(parts) >= 3:
251
+ results.append(SearchResult(
252
+ file_path=parts[0],
253
+ line_number=int(parts[1]),
254
+ content=parts[2].strip(),
255
+ search_type=SearchType.GREP,
256
+ score=1.0 # Exact matches get perfect score
257
+ ))
258
+ if len(results) >= max_results:
259
+ break
260
+ except ValueError:
261
+ continue
262
+
263
+ await tool_ctx.info(f"Grep found {len(results)} results")
264
+ return results
265
+
266
+ except Exception as e:
267
+ await tool_ctx.error(f"Grep search failed: {e}")
268
+ return []
269
+
270
+ async def _run_grep_ast_search(self, pattern: str, path: str,
271
+ tool_ctx, max_results: int) -> List[SearchResult]:
272
+ """Run AST-aware search and parse results."""
273
+ try:
274
+ result = await self.grep_ast_tool.call(
275
+ tool_ctx.mcp_context,
276
+ pattern=pattern,
277
+ path=path,
278
+ ignore_case=True,
279
+ line_number=True
280
+ )
281
+
282
+ results = []
283
+ if result and not result.startswith("No matches"):
284
+ current_file = None
285
+ current_context = []
286
+
287
+ for line in result.split('\n'):
288
+ if line.endswith(':') and '/' in line:
289
+ current_file = line[:-1]
290
+ current_context = []
291
+ elif current_file and ':' in line:
292
+ try:
293
+ # Try to parse line with number
294
+ parts = line.split(':', 1)
295
+ line_num = int(parts[0].strip())
296
+ content = parts[1].strip() if len(parts) > 1 else ""
297
+
298
+ results.append(SearchResult(
299
+ file_path=current_file,
300
+ line_number=line_num,
301
+ content=content,
302
+ search_type=SearchType.GREP_AST,
303
+ score=0.95, # High score for AST matches
304
+ context=" > ".join(current_context) if current_context else None
305
+ ))
306
+
307
+ if len(results) >= max_results:
308
+ break
309
+ except ValueError:
310
+ # This might be context info
311
+ if line.strip():
312
+ current_context.append(line.strip())
313
+
314
+ await tool_ctx.info(f"AST search found {len(results)} results")
315
+ return results
316
+
317
+ except Exception as e:
318
+ await tool_ctx.error(f"AST search failed: {e}")
319
+ return []
320
+
321
+ async def _run_vector_search(self, pattern: str, path: str,
322
+ tool_ctx, max_results: int) -> List[SearchResult]:
323
+ """Run semantic vector search."""
324
+ if not self.vector_tool:
325
+ return []
326
+
327
+ try:
328
+ # Determine search scope
329
+ search_scope = "current" if path == "." else "all"
330
+
331
+ result = await self.vector_tool.call(
332
+ tool_ctx.mcp_context,
333
+ query=pattern,
334
+ limit=max_results,
335
+ score_threshold=0.3,
336
+ search_scope=search_scope,
337
+ include_content=True
338
+ )
339
+
340
+ results = []
341
+ if "Found" in result:
342
+ # Parse vector search results
343
+ lines = result.split('\n')
344
+ current_file = None
345
+ current_score = 0.0
346
+
347
+ for line in lines:
348
+ if "Result" in line and "Score:" in line:
349
+ # Extract score and file
350
+ score_match = re.search(r'Score: ([\d.]+)%', line)
351
+ if score_match:
352
+ current_score = float(score_match.group(1)) / 100.0
353
+
354
+ file_match = re.search(r' - ([^\s]+)$', line)
355
+ if file_match:
356
+ current_file = file_match.group(1)
357
+
358
+ elif current_file and line.strip() and not line.startswith('-'):
359
+ # Content line
360
+ results.append(SearchResult(
361
+ file_path=current_file,
362
+ line_number=None,
363
+ content=line.strip()[:200], # Limit content length
364
+ search_type=SearchType.VECTOR,
365
+ score=current_score
366
+ ))
367
+
368
+ if len(results) >= max_results:
369
+ break
370
+
371
+ await tool_ctx.info(f"Vector search found {len(results)} results")
372
+ return results
373
+
374
+ except Exception as e:
375
+ await tool_ctx.error(f"Vector search failed: {e}")
376
+ return []
377
+
378
+ async def _run_git_search(self, pattern: str, path: str,
379
+ tool_ctx, max_results: int) -> List[SearchResult]:
380
+ """Run git history search."""
381
+ try:
382
+ # Search in both content and commits
383
+ tasks = [
384
+ self.git_search_tool.call(
385
+ tool_ctx.mcp_context,
386
+ pattern=pattern,
387
+ path=path,
388
+ search_type="content",
389
+ max_count=max_results // 2
390
+ ),
391
+ self.git_search_tool.call(
392
+ tool_ctx.mcp_context,
393
+ pattern=pattern,
394
+ path=path,
395
+ search_type="commits",
396
+ max_count=max_results // 2
397
+ )
398
+ ]
399
+
400
+ git_results = await asyncio.gather(*tasks, return_exceptions=True)
401
+
402
+ results = []
403
+ for i, result in enumerate(git_results):
404
+ if isinstance(result, Exception):
405
+ continue
406
+
407
+ if "Found" in result:
408
+ # Parse git results
409
+ lines = result.split('\n')
410
+ for line in lines:
411
+ if ':' in line and line.strip():
412
+ parts = line.split(':', 2)
413
+ if len(parts) >= 2:
414
+ results.append(SearchResult(
415
+ file_path=parts[0].strip(),
416
+ line_number=None,
417
+ content=parts[-1].strip() if len(parts) > 2 else line,
418
+ search_type=SearchType.GIT,
419
+ score=0.8 # Good score for git matches
420
+ ))
421
+
422
+ if len(results) >= max_results:
423
+ break
424
+
425
+ await tool_ctx.info(f"Git search found {len(results)} results")
426
+ return results
427
+
428
+ except Exception as e:
429
+ await tool_ctx.error(f"Git search failed: {e}")
430
+ return []
431
+
432
+ async def _run_symbol_search(self, pattern: str, path: str,
433
+ tool_ctx, max_results: int) -> List[SearchResult]:
434
+ """Search for symbol definitions using grep with specific patterns."""
435
+ try:
436
+ # Create patterns for common symbol definitions
437
+ symbol_patterns = [
438
+ f"(def|class|function|func|fn)\\s+{pattern}", # Python, JS, various
439
+ f"(public|private|protected)?\\s*(static)?\\s*\\w+\\s+{pattern}\\s*\\(", # Java/C++
440
+ f"const\\s+{pattern}\\s*=", # JS/TS const
441
+ f"let\\s+{pattern}\\s*=", # JS/TS let
442
+ f"var\\s+{pattern}\\s*=", # JS/TS var
443
+ ]
444
+
445
+ # Run grep searches in parallel for each pattern
446
+ tasks = []
447
+ for sp in symbol_patterns:
448
+ tasks.append(
449
+ self.grep_tool.call(
450
+ tool_ctx.mcp_context,
451
+ pattern=sp,
452
+ path=path,
453
+ include="*"
454
+ )
455
+ )
456
+
457
+ grep_results = await asyncio.gather(*tasks, return_exceptions=True)
458
+
459
+ results = []
460
+ for result in grep_results:
461
+ if isinstance(result, Exception):
462
+ continue
463
+
464
+ if "Found" in result and "matches" in result:
465
+ lines = result.split('\n')
466
+ for line in lines[2:]: # Skip header
467
+ if ':' in line and line.strip():
468
+ try:
469
+ parts = line.split(':', 2)
470
+ if len(parts) >= 3:
471
+ results.append(SearchResult(
472
+ file_path=parts[0],
473
+ line_number=int(parts[1]),
474
+ content=parts[2].strip(),
475
+ search_type=SearchType.SYMBOL,
476
+ score=0.98 # Very high score for symbol definitions
477
+ ))
478
+ if len(results) >= max_results:
479
+ break
480
+ except ValueError:
481
+ continue
482
+
483
+ await tool_ctx.info(f"Symbol search found {len(results)} results")
484
+ return results
485
+
486
+ except Exception as e:
487
+ await tool_ctx.error(f"Symbol search failed: {e}")
488
+ return []
489
+
490
+ def _deduplicate_results(self, all_results: List[SearchResult]) -> List[SearchResult]:
491
+ """Deduplicate results, keeping the highest scoring version."""
492
+ seen = {}
493
+
494
+ for result in all_results:
495
+ key = (result.file_path, result.line_number)
496
+
497
+ if key not in seen or result.score > seen[key].score:
498
+ seen[key] = result
499
+ elif key in seen and result.context and not seen[key].context:
500
+ # Add context if missing
501
+ seen[key].context = result.context
502
+
503
+ return list(seen.values())
504
+
505
+ def _rank_results(self, results: List[SearchResult]) -> List[SearchResult]:
506
+ """Rank results by relevance score and search type priority."""
507
+ # Define search type priorities
508
+ type_priority = {
509
+ SearchType.SYMBOL: 5,
510
+ SearchType.GREP: 4,
511
+ SearchType.GREP_AST: 3,
512
+ SearchType.GIT: 2,
513
+ SearchType.VECTOR: 1
514
+ }
515
+
516
+ # Sort by score (descending) and then by type priority
517
+ results.sort(
518
+ key=lambda r: (r.score, type_priority.get(r.search_type, 0)),
519
+ reverse=True
520
+ )
521
+
522
+ return results
523
+
524
+ @override
525
+ async def call(
526
+ self,
527
+ ctx: MCPContext,
528
+ **params: Unpack[UnifiedSearchParams],
529
+ ) -> str:
530
+ """Execute unified search across all enabled search types."""
531
+ import time
532
+ start_time = time.time()
533
+
534
+ tool_ctx = self.create_tool_context(ctx)
535
+
536
+ # Extract parameters
537
+ pattern = params["pattern"]
538
+ path = params.get("path", ".")
539
+ include = params.get("include", "*")
540
+ max_results = params.get("max_results", 50)
541
+ include_context = params.get("include_context", True)
542
+
543
+ # Validate path
544
+ path_validation = self.validate_path(path)
545
+ if path_validation.is_error:
546
+ await tool_ctx.error(path_validation.error_message)
547
+ return f"Error: {path_validation.error_message}"
548
+
549
+ # Check permissions
550
+ allowed, error_msg = await self.check_path_allowed(path, tool_ctx)
551
+ if not allowed:
552
+ return error_msg
553
+
554
+ # Check existence
555
+ exists, error_msg = await self.check_path_exists(path, tool_ctx)
556
+ if not exists:
557
+ return error_msg
558
+
559
+ # Analyze pattern to determine best search strategies
560
+ pattern_analysis = self._analyze_pattern(pattern)
561
+
562
+ await tool_ctx.info(f"Starting unified search for '{pattern}' in {path}")
563
+
564
+ # Build list of search tasks based on enabled types and pattern analysis
565
+ search_tasks = []
566
+ search_names = []
567
+
568
+ if params.get("enable_grep", True) and pattern_analysis['use_grep']:
569
+ search_tasks.append(self._run_grep_search(pattern, path, include, tool_ctx, max_results))
570
+ search_names.append("grep")
571
+
572
+ if params.get("enable_grep_ast", True) and pattern_analysis['use_grep_ast']:
573
+ search_tasks.append(self._run_grep_ast_search(pattern, path, tool_ctx, max_results))
574
+ search_names.append("grep_ast")
575
+
576
+ if params.get("enable_vector", True) and self.vector_tool and pattern_analysis['use_vector']:
577
+ search_tasks.append(self._run_vector_search(pattern, path, tool_ctx, max_results))
578
+ search_names.append("vector")
579
+
580
+ if params.get("enable_git", True) and pattern_analysis['use_git']:
581
+ search_tasks.append(self._run_git_search(pattern, path, tool_ctx, max_results))
582
+ search_names.append("git")
583
+
584
+ if params.get("enable_symbol", True) and pattern_analysis['use_symbol']:
585
+ search_tasks.append(self._run_symbol_search(pattern, path, tool_ctx, max_results))
586
+ search_names.append("symbol")
587
+
588
+ await tool_ctx.info(f"Running {len(search_tasks)} search types in parallel: {', '.join(search_names)}")
589
+
590
+ # Run all searches in parallel
591
+ search_results = await asyncio.gather(*search_tasks, return_exceptions=True)
592
+
593
+ # Collect all results
594
+ all_results = []
595
+ results_by_type = {}
596
+
597
+ for search_type, results in zip(search_names, search_results):
598
+ if isinstance(results, Exception):
599
+ await tool_ctx.error(f"{search_type} search failed: {results}")
600
+ results_by_type[search_type] = []
601
+ else:
602
+ results_by_type[search_type] = results
603
+ all_results.extend(results)
604
+
605
+ # Deduplicate and rank results
606
+ unique_results = self._deduplicate_results(all_results)
607
+ ranked_results = self._rank_results(unique_results)
608
+
609
+ # Limit total results
610
+ final_results = ranked_results[:max_results]
611
+
612
+ # Calculate search time
613
+ search_time = (time.time() - start_time) * 1000
614
+
615
+ # Format output
616
+ return self._format_results(
617
+ pattern=pattern,
618
+ results=final_results,
619
+ results_by_type=results_by_type,
620
+ search_time_ms=search_time,
621
+ include_context=include_context
622
+ )
623
+
624
+ def _format_results(self, pattern: str, results: List[SearchResult],
625
+ results_by_type: Dict[str, List[SearchResult]],
626
+ search_time_ms: float, include_context: bool) -> str:
627
+ """Format search results for display."""
628
+ output = []
629
+
630
+ # Header
631
+ output.append(f"=== Unified Search Results ===")
632
+ output.append(f"Pattern: '{pattern}'")
633
+ output.append(f"Total results: {len(results)}")
634
+ output.append(f"Search time: {search_time_ms:.1f}ms")
635
+
636
+ # Summary by type
637
+ output.append("\nResults by type:")
638
+ for search_type, type_results in results_by_type.items():
639
+ if type_results:
640
+ output.append(f" {search_type}: {len(type_results)} matches")
641
+
642
+ if not results:
643
+ output.append("\nNo results found.")
644
+ return "\n".join(output)
645
+
646
+ # Group results by file
647
+ results_by_file = {}
648
+ for result in results:
649
+ if result.file_path not in results_by_file:
650
+ results_by_file[result.file_path] = []
651
+ results_by_file[result.file_path].append(result)
652
+
653
+ # Display results
654
+ output.append(f"\n=== Results ({len(results)} total) ===\n")
655
+
656
+ for file_path, file_results in results_by_file.items():
657
+ output.append(f"{file_path}")
658
+ output.append("-" * len(file_path))
659
+
660
+ # Sort by line number
661
+ file_results.sort(key=lambda r: r.line_number or 0)
662
+
663
+ for result in file_results:
664
+ # Format result line
665
+ score_str = f"[{result.search_type.value} {result.score:.2f}]"
666
+
667
+ if result.line_number:
668
+ output.append(f" {result.line_number:>4}: {score_str} {result.content}")
669
+ else:
670
+ output.append(f" {score_str} {result.content}")
671
+
672
+ # Add context if available and requested
673
+ if include_context and result.context:
674
+ output.append(f" Context: {result.context}")
675
+
676
+ output.append("") # Empty line between files
677
+
678
+ return "\n".join(output)
679
+
680
+ @override
681
+ def register(self, mcp_server: FastMCP) -> None:
682
+ """Register the unified search tool with the MCP server."""
683
+ tool_self = self
684
+
685
+ @mcp_server.tool(name=self.name, description=self.description)
686
+ async def unified_search(
687
+ ctx: MCPContext,
688
+ pattern: Pattern,
689
+ path: SearchPath = ".",
690
+ include: Include = "*",
691
+ max_results: MaxResults = 50,
692
+ enable_grep: EnableGrep = True,
693
+ enable_grep_ast: EnableGrepAst = True,
694
+ enable_vector: EnableVector = True,
695
+ enable_git: EnableGit = True,
696
+ enable_symbol: EnableSymbol = True,
697
+ include_context: IncludeContext = True,
698
+ ) -> str:
699
+ return await tool_self.call(
700
+ ctx,
701
+ pattern=pattern,
702
+ path=path,
703
+ include=include,
704
+ max_results=max_results,
705
+ enable_grep=enable_grep,
706
+ enable_grep_ast=enable_grep_ast,
707
+ enable_vector=enable_vector,
708
+ enable_git=enable_git,
709
+ enable_symbol=enable_symbol,
710
+ include_context=include_context,
711
+ )