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