hanzo-mcp 0.7.6__py3-none-any.whl → 0.8.0__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.
- hanzo_mcp/__init__.py +7 -1
- hanzo_mcp/__main__.py +1 -1
- hanzo_mcp/analytics/__init__.py +2 -2
- hanzo_mcp/analytics/posthog_analytics.py +76 -82
- hanzo_mcp/cli.py +31 -36
- hanzo_mcp/cli_enhanced.py +94 -72
- hanzo_mcp/cli_plugin.py +27 -17
- hanzo_mcp/config/__init__.py +2 -2
- hanzo_mcp/config/settings.py +112 -88
- hanzo_mcp/config/tool_config.py +32 -34
- hanzo_mcp/dev_server.py +66 -67
- hanzo_mcp/prompts/__init__.py +94 -12
- hanzo_mcp/prompts/enhanced_prompts.py +809 -0
- hanzo_mcp/prompts/example_custom_prompt.py +6 -5
- hanzo_mcp/prompts/project_todo_reminder.py +0 -1
- hanzo_mcp/prompts/tool_explorer.py +10 -7
- hanzo_mcp/server.py +17 -21
- hanzo_mcp/server_enhanced.py +15 -22
- hanzo_mcp/tools/__init__.py +56 -28
- hanzo_mcp/tools/agent/__init__.py +16 -19
- hanzo_mcp/tools/agent/agent.py +82 -65
- hanzo_mcp/tools/agent/agent_tool.py +152 -122
- hanzo_mcp/tools/agent/agent_tool_v1_deprecated.py +66 -62
- hanzo_mcp/tools/agent/clarification_protocol.py +55 -50
- hanzo_mcp/tools/agent/clarification_tool.py +11 -10
- hanzo_mcp/tools/agent/claude_cli_tool.py +21 -20
- hanzo_mcp/tools/agent/claude_desktop_auth.py +130 -144
- hanzo_mcp/tools/agent/cli_agent_base.py +59 -53
- hanzo_mcp/tools/agent/code_auth.py +102 -107
- hanzo_mcp/tools/agent/code_auth_tool.py +28 -27
- hanzo_mcp/tools/agent/codex_cli_tool.py +20 -19
- hanzo_mcp/tools/agent/critic_tool.py +86 -73
- hanzo_mcp/tools/agent/gemini_cli_tool.py +21 -20
- hanzo_mcp/tools/agent/grok_cli_tool.py +21 -20
- hanzo_mcp/tools/agent/iching_tool.py +404 -139
- hanzo_mcp/tools/agent/network_tool.py +89 -73
- hanzo_mcp/tools/agent/prompt.py +2 -1
- hanzo_mcp/tools/agent/review_tool.py +101 -98
- hanzo_mcp/tools/agent/swarm_alias.py +87 -0
- hanzo_mcp/tools/agent/swarm_tool.py +246 -161
- hanzo_mcp/tools/agent/swarm_tool_v1_deprecated.py +134 -92
- hanzo_mcp/tools/agent/tool_adapter.py +21 -11
- hanzo_mcp/tools/common/__init__.py +1 -1
- hanzo_mcp/tools/common/base.py +3 -5
- hanzo_mcp/tools/common/batch_tool.py +46 -39
- hanzo_mcp/tools/common/config_tool.py +120 -84
- hanzo_mcp/tools/common/context.py +1 -5
- hanzo_mcp/tools/common/context_fix.py +5 -3
- hanzo_mcp/tools/common/critic_tool.py +4 -8
- hanzo_mcp/tools/common/decorators.py +58 -56
- hanzo_mcp/tools/common/enhanced_base.py +29 -32
- hanzo_mcp/tools/common/fastmcp_pagination.py +91 -94
- hanzo_mcp/tools/common/forgiving_edit.py +91 -87
- hanzo_mcp/tools/common/mode.py +15 -17
- hanzo_mcp/tools/common/mode_loader.py +27 -24
- hanzo_mcp/tools/common/paginated_base.py +61 -53
- hanzo_mcp/tools/common/paginated_response.py +72 -79
- hanzo_mcp/tools/common/pagination.py +50 -53
- hanzo_mcp/tools/common/permissions.py +4 -4
- hanzo_mcp/tools/common/personality.py +186 -138
- hanzo_mcp/tools/common/plugin_loader.py +54 -54
- hanzo_mcp/tools/common/stats.py +65 -47
- hanzo_mcp/tools/common/test_helpers.py +31 -0
- hanzo_mcp/tools/common/thinking_tool.py +4 -8
- hanzo_mcp/tools/common/tool_disable.py +17 -12
- hanzo_mcp/tools/common/tool_enable.py +13 -14
- hanzo_mcp/tools/common/tool_list.py +36 -28
- hanzo_mcp/tools/common/truncate.py +23 -23
- hanzo_mcp/tools/config/__init__.py +4 -4
- hanzo_mcp/tools/config/config_tool.py +42 -29
- hanzo_mcp/tools/config/index_config.py +37 -34
- hanzo_mcp/tools/config/mode_tool.py +175 -55
- hanzo_mcp/tools/database/__init__.py +15 -12
- hanzo_mcp/tools/database/database_manager.py +77 -75
- hanzo_mcp/tools/database/graph.py +137 -91
- hanzo_mcp/tools/database/graph_add.py +30 -18
- hanzo_mcp/tools/database/graph_query.py +178 -102
- hanzo_mcp/tools/database/graph_remove.py +33 -28
- hanzo_mcp/tools/database/graph_search.py +97 -75
- hanzo_mcp/tools/database/graph_stats.py +91 -59
- hanzo_mcp/tools/database/sql.py +107 -79
- hanzo_mcp/tools/database/sql_query.py +30 -24
- hanzo_mcp/tools/database/sql_search.py +29 -25
- hanzo_mcp/tools/database/sql_stats.py +47 -35
- hanzo_mcp/tools/editor/neovim_command.py +25 -28
- hanzo_mcp/tools/editor/neovim_edit.py +21 -23
- hanzo_mcp/tools/editor/neovim_session.py +60 -54
- hanzo_mcp/tools/filesystem/__init__.py +31 -30
- hanzo_mcp/tools/filesystem/ast_multi_edit.py +329 -249
- hanzo_mcp/tools/filesystem/ast_tool.py +4 -4
- hanzo_mcp/tools/filesystem/base.py +1 -1
- hanzo_mcp/tools/filesystem/batch_search.py +316 -224
- hanzo_mcp/tools/filesystem/content_replace.py +4 -4
- hanzo_mcp/tools/filesystem/diff.py +71 -59
- hanzo_mcp/tools/filesystem/directory_tree.py +7 -7
- hanzo_mcp/tools/filesystem/directory_tree_paginated.py +49 -37
- hanzo_mcp/tools/filesystem/edit.py +4 -4
- hanzo_mcp/tools/filesystem/find.py +173 -80
- hanzo_mcp/tools/filesystem/find_files.py +73 -52
- hanzo_mcp/tools/filesystem/git_search.py +157 -104
- hanzo_mcp/tools/filesystem/grep.py +8 -8
- hanzo_mcp/tools/filesystem/multi_edit.py +4 -8
- hanzo_mcp/tools/filesystem/read.py +12 -10
- hanzo_mcp/tools/filesystem/rules_tool.py +59 -43
- hanzo_mcp/tools/filesystem/search_tool.py +263 -207
- hanzo_mcp/tools/filesystem/symbols_tool.py +94 -54
- hanzo_mcp/tools/filesystem/tree.py +35 -33
- hanzo_mcp/tools/filesystem/unix_aliases.py +13 -18
- hanzo_mcp/tools/filesystem/watch.py +37 -36
- hanzo_mcp/tools/filesystem/write.py +4 -8
- hanzo_mcp/tools/jupyter/__init__.py +4 -4
- hanzo_mcp/tools/jupyter/base.py +4 -5
- hanzo_mcp/tools/jupyter/jupyter.py +67 -47
- hanzo_mcp/tools/jupyter/notebook_edit.py +4 -4
- hanzo_mcp/tools/jupyter/notebook_read.py +4 -7
- hanzo_mcp/tools/llm/__init__.py +5 -7
- hanzo_mcp/tools/llm/consensus_tool.py +72 -52
- hanzo_mcp/tools/llm/llm_manage.py +101 -60
- hanzo_mcp/tools/llm/llm_tool.py +226 -166
- hanzo_mcp/tools/llm/provider_tools.py +25 -26
- hanzo_mcp/tools/lsp/__init__.py +1 -1
- hanzo_mcp/tools/lsp/lsp_tool.py +228 -143
- hanzo_mcp/tools/mcp/__init__.py +2 -3
- hanzo_mcp/tools/mcp/mcp_add.py +27 -25
- hanzo_mcp/tools/mcp/mcp_remove.py +7 -8
- hanzo_mcp/tools/mcp/mcp_stats.py +23 -22
- hanzo_mcp/tools/mcp/mcp_tool.py +129 -98
- hanzo_mcp/tools/memory/__init__.py +39 -21
- hanzo_mcp/tools/memory/knowledge_tools.py +124 -99
- hanzo_mcp/tools/memory/memory_tools.py +90 -108
- hanzo_mcp/tools/search/__init__.py +7 -2
- hanzo_mcp/tools/search/find_tool.py +297 -212
- hanzo_mcp/tools/search/unified_search.py +366 -314
- hanzo_mcp/tools/shell/__init__.py +8 -7
- hanzo_mcp/tools/shell/auto_background.py +56 -49
- hanzo_mcp/tools/shell/base.py +1 -1
- hanzo_mcp/tools/shell/base_process.py +75 -75
- hanzo_mcp/tools/shell/bash_session.py +2 -2
- hanzo_mcp/tools/shell/bash_session_executor.py +4 -4
- hanzo_mcp/tools/shell/bash_tool.py +24 -31
- hanzo_mcp/tools/shell/command_executor.py +12 -12
- hanzo_mcp/tools/shell/logs.py +43 -33
- hanzo_mcp/tools/shell/npx.py +13 -13
- hanzo_mcp/tools/shell/npx_background.py +24 -21
- hanzo_mcp/tools/shell/npx_tool.py +18 -22
- hanzo_mcp/tools/shell/open.py +19 -21
- hanzo_mcp/tools/shell/pkill.py +31 -26
- hanzo_mcp/tools/shell/process_tool.py +32 -32
- hanzo_mcp/tools/shell/processes.py +57 -58
- hanzo_mcp/tools/shell/run_background.py +24 -25
- hanzo_mcp/tools/shell/run_command.py +5 -5
- hanzo_mcp/tools/shell/run_command_windows.py +5 -5
- hanzo_mcp/tools/shell/session_storage.py +3 -3
- hanzo_mcp/tools/shell/streaming_command.py +141 -126
- hanzo_mcp/tools/shell/uvx.py +24 -25
- hanzo_mcp/tools/shell/uvx_background.py +35 -33
- hanzo_mcp/tools/shell/uvx_tool.py +18 -22
- hanzo_mcp/tools/todo/__init__.py +6 -2
- hanzo_mcp/tools/todo/todo.py +50 -37
- hanzo_mcp/tools/todo/todo_read.py +5 -8
- hanzo_mcp/tools/todo/todo_write.py +5 -7
- hanzo_mcp/tools/vector/__init__.py +40 -28
- hanzo_mcp/tools/vector/ast_analyzer.py +176 -143
- hanzo_mcp/tools/vector/git_ingester.py +170 -179
- hanzo_mcp/tools/vector/index_tool.py +96 -44
- hanzo_mcp/tools/vector/infinity_store.py +283 -228
- hanzo_mcp/tools/vector/mock_infinity.py +39 -40
- hanzo_mcp/tools/vector/project_manager.py +88 -78
- hanzo_mcp/tools/vector/vector.py +59 -42
- hanzo_mcp/tools/vector/vector_index.py +30 -27
- hanzo_mcp/tools/vector/vector_search.py +64 -45
- hanzo_mcp/types.py +6 -4
- {hanzo_mcp-0.7.6.dist-info → hanzo_mcp-0.8.0.dist-info}/METADATA +1 -1
- hanzo_mcp-0.8.0.dist-info/RECORD +185 -0
- hanzo_mcp-0.7.6.dist-info/RECORD +0 -182
- {hanzo_mcp-0.7.6.dist-info → hanzo_mcp-0.8.0.dist-info}/WHEEL +0 -0
- {hanzo_mcp-0.7.6.dist-info → hanzo_mcp-0.8.0.dist-info}/entry_points.txt +0 -0
- {hanzo_mcp-0.7.6.dist-info → hanzo_mcp-0.8.0.dist-info}/top_level.txt +0 -0
|
@@ -3,23 +3,18 @@
|
|
|
3
3
|
import os
|
|
4
4
|
import time
|
|
5
5
|
import fnmatch
|
|
6
|
-
import re
|
|
7
|
-
from typing import List, Optional, Dict, Any, Set
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
from dataclasses import dataclass
|
|
10
6
|
import subprocess
|
|
11
|
-
import
|
|
7
|
+
from typing import Any, Set, Dict, List, Optional
|
|
8
|
+
from pathlib import Path
|
|
12
9
|
from datetime import datetime
|
|
13
|
-
from
|
|
10
|
+
from dataclasses import dataclass
|
|
14
11
|
|
|
15
|
-
from hanzo_mcp.tools.common.base import BaseTool
|
|
16
|
-
from hanzo_mcp.tools.common.paginated_response import AutoPaginatedResponse
|
|
17
|
-
from hanzo_mcp.tools.common.decorators import with_context_normalization
|
|
18
12
|
from hanzo_mcp.types import MCPResourceDocument
|
|
13
|
+
from hanzo_mcp.tools.common.base import BaseTool
|
|
19
14
|
|
|
20
15
|
# Check if ffind command is available
|
|
21
16
|
try:
|
|
22
|
-
subprocess.run([
|
|
17
|
+
subprocess.run(["ffind", "--version"], capture_output=True, check=True)
|
|
23
18
|
FFIND_AVAILABLE = True
|
|
24
19
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
25
20
|
FFIND_AVAILABLE = False
|
|
@@ -28,6 +23,7 @@ except (subprocess.CalledProcessError, FileNotFoundError):
|
|
|
28
23
|
@dataclass
|
|
29
24
|
class FileMatch:
|
|
30
25
|
"""Represents a found file."""
|
|
26
|
+
|
|
31
27
|
path: str
|
|
32
28
|
name: str
|
|
33
29
|
size: int
|
|
@@ -35,7 +31,7 @@ class FileMatch:
|
|
|
35
31
|
is_dir: bool
|
|
36
32
|
extension: str
|
|
37
33
|
depth: int
|
|
38
|
-
|
|
34
|
+
|
|
39
35
|
def to_dict(self) -> Dict[str, Any]:
|
|
40
36
|
return {
|
|
41
37
|
"path": self.path,
|
|
@@ -44,17 +40,17 @@ class FileMatch:
|
|
|
44
40
|
"modified": datetime.fromtimestamp(self.modified).isoformat(),
|
|
45
41
|
"is_dir": self.is_dir,
|
|
46
42
|
"extension": self.extension,
|
|
47
|
-
"depth": self.depth
|
|
43
|
+
"depth": self.depth,
|
|
48
44
|
}
|
|
49
45
|
|
|
50
46
|
|
|
51
47
|
class FindTool(BaseTool):
|
|
52
48
|
"""Fast file and directory finding tool.
|
|
53
|
-
|
|
49
|
+
|
|
54
50
|
This tool is optimized for quickly finding files and directories by name,
|
|
55
51
|
pattern, or attributes. It uses ffind (when available) for blazing fast
|
|
56
52
|
performance and falls back to optimized Python implementation.
|
|
57
|
-
|
|
53
|
+
|
|
58
54
|
Key features:
|
|
59
55
|
- Lightning fast file discovery
|
|
60
56
|
- Smart pattern matching (glob, regex, fuzzy)
|
|
@@ -63,7 +59,7 @@ class FindTool(BaseTool):
|
|
|
63
59
|
- Built-in caching for repeated searches
|
|
64
60
|
- Respects .gitignore by default
|
|
65
61
|
"""
|
|
66
|
-
|
|
62
|
+
|
|
67
63
|
name = "find"
|
|
68
64
|
description = """Find files and directories by name, pattern, or attributes.
|
|
69
65
|
|
|
@@ -78,138 +74,162 @@ class FindTool(BaseTool):
|
|
|
78
74
|
This is the primary tool for discovering files in a project. Use it before
|
|
79
75
|
reading or searching within files.
|
|
80
76
|
"""
|
|
81
|
-
|
|
77
|
+
|
|
82
78
|
def __init__(self):
|
|
83
79
|
super().__init__()
|
|
84
80
|
self._cache = {}
|
|
85
81
|
self._gitignore_cache = {}
|
|
86
|
-
|
|
82
|
+
|
|
87
83
|
def _parse_size(self, size_str: str) -> int:
|
|
88
84
|
"""Parse human-readable size to bytes."""
|
|
89
85
|
# Order matters - check longer units first
|
|
90
86
|
units = [
|
|
91
|
-
(
|
|
92
|
-
(
|
|
87
|
+
("TB", 1024**4),
|
|
88
|
+
("GB", 1024**3),
|
|
89
|
+
("MB", 1024**2),
|
|
90
|
+
("KB", 1024),
|
|
91
|
+
("T", 1024**4),
|
|
92
|
+
("G", 1024**3),
|
|
93
|
+
("M", 1024**2),
|
|
94
|
+
("K", 1024),
|
|
95
|
+
("B", 1),
|
|
93
96
|
]
|
|
94
|
-
|
|
97
|
+
|
|
95
98
|
size_str = size_str.upper().strip()
|
|
96
99
|
for unit, multiplier in units:
|
|
97
100
|
if size_str.endswith(unit):
|
|
98
|
-
num_str = size_str[
|
|
101
|
+
num_str = size_str[: -len(unit)].strip()
|
|
99
102
|
if num_str:
|
|
100
103
|
try:
|
|
101
104
|
return int(float(num_str) * multiplier)
|
|
102
105
|
except ValueError:
|
|
103
106
|
return 0
|
|
104
|
-
|
|
107
|
+
|
|
105
108
|
try:
|
|
106
109
|
return int(size_str)
|
|
107
110
|
except ValueError:
|
|
108
111
|
return 0
|
|
109
|
-
|
|
112
|
+
|
|
110
113
|
def _parse_time(self, time_str: str) -> float:
|
|
111
114
|
"""Parse human-readable time to timestamp."""
|
|
112
115
|
import re
|
|
113
116
|
from datetime import datetime, timedelta
|
|
114
|
-
|
|
117
|
+
|
|
115
118
|
# Handle relative times like "1 day ago", "2 hours ago"
|
|
116
|
-
match = re.match(
|
|
119
|
+
match = re.match(
|
|
120
|
+
r"(\d+)\s*(second|minute|hour|day|week|month|year)s?\s*ago",
|
|
121
|
+
time_str.lower(),
|
|
122
|
+
)
|
|
117
123
|
if match:
|
|
118
124
|
amount = int(match.group(1))
|
|
119
125
|
unit = match.group(2)
|
|
120
|
-
|
|
121
|
-
if unit ==
|
|
126
|
+
|
|
127
|
+
if unit == "second":
|
|
122
128
|
delta = timedelta(seconds=amount)
|
|
123
|
-
elif unit ==
|
|
129
|
+
elif unit == "minute":
|
|
124
130
|
delta = timedelta(minutes=amount)
|
|
125
|
-
elif unit ==
|
|
131
|
+
elif unit == "hour":
|
|
126
132
|
delta = timedelta(hours=amount)
|
|
127
|
-
elif unit ==
|
|
133
|
+
elif unit == "day":
|
|
128
134
|
delta = timedelta(days=amount)
|
|
129
|
-
elif unit ==
|
|
135
|
+
elif unit == "week":
|
|
130
136
|
delta = timedelta(weeks=amount)
|
|
131
|
-
elif unit ==
|
|
137
|
+
elif unit == "month":
|
|
132
138
|
delta = timedelta(days=amount * 30) # Approximate
|
|
133
|
-
elif unit ==
|
|
139
|
+
elif unit == "year":
|
|
134
140
|
delta = timedelta(days=amount * 365) # Approximate
|
|
135
|
-
|
|
141
|
+
|
|
136
142
|
return (datetime.now() - delta).timestamp()
|
|
137
|
-
|
|
143
|
+
|
|
138
144
|
# Try parsing as date
|
|
139
145
|
try:
|
|
140
146
|
return datetime.fromisoformat(time_str).timestamp()
|
|
141
|
-
except:
|
|
147
|
+
except Exception:
|
|
142
148
|
return datetime.now().timestamp()
|
|
143
|
-
|
|
149
|
+
|
|
144
150
|
def _load_gitignore(self, root: str) -> Set[str]:
|
|
145
151
|
"""Load and parse .gitignore patterns."""
|
|
146
152
|
if root in self._gitignore_cache:
|
|
147
153
|
return self._gitignore_cache[root]
|
|
148
|
-
|
|
154
|
+
|
|
149
155
|
patterns = set()
|
|
150
|
-
gitignore_path = Path(root) /
|
|
151
|
-
|
|
156
|
+
gitignore_path = Path(root) / ".gitignore"
|
|
157
|
+
|
|
152
158
|
if gitignore_path.exists():
|
|
153
159
|
try:
|
|
154
|
-
with open(gitignore_path,
|
|
160
|
+
with open(gitignore_path, "r") as f:
|
|
155
161
|
for line in f:
|
|
156
162
|
line = line.strip()
|
|
157
|
-
if line and not line.startswith(
|
|
163
|
+
if line and not line.startswith("#"):
|
|
158
164
|
patterns.add(line)
|
|
159
|
-
except:
|
|
165
|
+
except Exception:
|
|
160
166
|
pass
|
|
161
|
-
|
|
167
|
+
|
|
162
168
|
# Add common ignore patterns
|
|
163
|
-
patterns.update(
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
+
patterns.update(
|
|
170
|
+
[
|
|
171
|
+
"*.pyc",
|
|
172
|
+
"__pycache__",
|
|
173
|
+
".git",
|
|
174
|
+
".svn",
|
|
175
|
+
".hg",
|
|
176
|
+
"node_modules",
|
|
177
|
+
".env",
|
|
178
|
+
".venv",
|
|
179
|
+
"venv",
|
|
180
|
+
"*.swp",
|
|
181
|
+
"*.swo",
|
|
182
|
+
".DS_Store",
|
|
183
|
+
"Thumbs.db",
|
|
184
|
+
]
|
|
185
|
+
)
|
|
186
|
+
|
|
169
187
|
self._gitignore_cache[root] = patterns
|
|
170
188
|
return patterns
|
|
171
|
-
|
|
189
|
+
|
|
172
190
|
def _should_ignore(self, path: str, ignore_patterns: Set[str]) -> bool:
|
|
173
191
|
"""Check if path should be ignored."""
|
|
174
192
|
path_obj = Path(path)
|
|
175
|
-
|
|
193
|
+
|
|
176
194
|
for pattern in ignore_patterns:
|
|
177
195
|
# Check against full path and basename
|
|
178
196
|
if fnmatch.fnmatch(path_obj.name, pattern):
|
|
179
197
|
return True
|
|
180
198
|
if fnmatch.fnmatch(str(path_obj), pattern):
|
|
181
199
|
return True
|
|
182
|
-
|
|
200
|
+
|
|
183
201
|
# Check if any parent directory matches
|
|
184
202
|
for parent in path_obj.parents:
|
|
185
203
|
if fnmatch.fnmatch(parent.name, pattern):
|
|
186
204
|
return True
|
|
187
|
-
|
|
205
|
+
|
|
188
206
|
return False
|
|
189
|
-
|
|
190
|
-
async def run(
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
207
|
+
|
|
208
|
+
async def run(
|
|
209
|
+
self,
|
|
210
|
+
pattern: str = "*",
|
|
211
|
+
path: str = ".",
|
|
212
|
+
type: Optional[str] = None, # "file", "dir", "any"
|
|
213
|
+
min_size: Optional[str] = None,
|
|
214
|
+
max_size: Optional[str] = None,
|
|
215
|
+
modified_after: Optional[str] = None,
|
|
216
|
+
modified_before: Optional[str] = None,
|
|
217
|
+
max_depth: Optional[int] = None,
|
|
218
|
+
case_sensitive: bool = False,
|
|
219
|
+
regex: bool = False,
|
|
220
|
+
fuzzy: bool = False,
|
|
221
|
+
in_content: bool = False,
|
|
222
|
+
follow_symlinks: bool = False,
|
|
223
|
+
respect_gitignore: bool = True,
|
|
224
|
+
max_results: int = 1000,
|
|
225
|
+
sort_by: str = "path", # "path", "name", "size", "modified"
|
|
226
|
+
reverse: bool = False,
|
|
227
|
+
page_size: int = 100,
|
|
228
|
+
page: int = 1,
|
|
229
|
+
**kwargs,
|
|
230
|
+
) -> MCPResourceDocument:
|
|
211
231
|
"""Find files and directories.
|
|
212
|
-
|
|
232
|
+
|
|
213
233
|
Args:
|
|
214
234
|
pattern: Search pattern (glob by default, regex if regex=True)
|
|
215
235
|
path: Root directory to search from
|
|
@@ -231,44 +251,62 @@ class FindTool(BaseTool):
|
|
|
231
251
|
page_size: Results per page
|
|
232
252
|
page: Page number
|
|
233
253
|
"""
|
|
234
|
-
|
|
254
|
+
|
|
235
255
|
start_time = time.time()
|
|
236
|
-
|
|
256
|
+
|
|
237
257
|
# Resolve path
|
|
238
258
|
root_path = Path(path).resolve()
|
|
239
259
|
if not root_path.exists():
|
|
240
|
-
return MCPResourceDocument(
|
|
241
|
-
"error": f"Path does not exist: {path}",
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
260
|
+
return MCPResourceDocument(
|
|
261
|
+
data={"error": f"Path does not exist: {path}", "results": []}
|
|
262
|
+
)
|
|
263
|
+
|
|
245
264
|
# Get ignore patterns
|
|
246
265
|
ignore_patterns = set()
|
|
247
266
|
if respect_gitignore:
|
|
248
267
|
ignore_patterns = self._load_gitignore(str(root_path))
|
|
249
|
-
|
|
268
|
+
|
|
250
269
|
# Parse filters
|
|
251
270
|
min_size_bytes = self._parse_size(min_size) if min_size else None
|
|
252
271
|
max_size_bytes = self._parse_size(max_size) if max_size else None
|
|
253
272
|
modified_after_ts = self._parse_time(modified_after) if modified_after else None
|
|
254
|
-
modified_before_ts =
|
|
255
|
-
|
|
273
|
+
modified_before_ts = (
|
|
274
|
+
self._parse_time(modified_before) if modified_before else None
|
|
275
|
+
)
|
|
276
|
+
|
|
256
277
|
# Collect matches
|
|
257
278
|
matches = []
|
|
258
|
-
|
|
279
|
+
|
|
259
280
|
if FFIND_AVAILABLE and not in_content:
|
|
260
281
|
# Use ffind for fast file discovery
|
|
261
282
|
matches = await self._find_with_ffind(
|
|
262
|
-
pattern,
|
|
263
|
-
|
|
283
|
+
pattern,
|
|
284
|
+
root_path,
|
|
285
|
+
type,
|
|
286
|
+
case_sensitive,
|
|
287
|
+
regex,
|
|
288
|
+
fuzzy,
|
|
289
|
+
max_depth,
|
|
290
|
+
follow_symlinks,
|
|
291
|
+
respect_gitignore,
|
|
292
|
+
ignore_patterns,
|
|
264
293
|
)
|
|
265
294
|
else:
|
|
266
295
|
# Fall back to Python implementation
|
|
267
296
|
matches = await self._find_with_python(
|
|
268
|
-
pattern,
|
|
269
|
-
|
|
297
|
+
pattern,
|
|
298
|
+
root_path,
|
|
299
|
+
type,
|
|
300
|
+
case_sensitive,
|
|
301
|
+
regex,
|
|
302
|
+
fuzzy,
|
|
303
|
+
in_content,
|
|
304
|
+
max_depth,
|
|
305
|
+
follow_symlinks,
|
|
306
|
+
respect_gitignore,
|
|
307
|
+
ignore_patterns,
|
|
270
308
|
)
|
|
271
|
-
|
|
309
|
+
|
|
272
310
|
# Apply filters
|
|
273
311
|
filtered_matches = []
|
|
274
312
|
for match in matches:
|
|
@@ -277,18 +315,18 @@ class FindTool(BaseTool):
|
|
|
277
315
|
continue
|
|
278
316
|
if max_size_bytes and match.size > max_size_bytes:
|
|
279
317
|
continue
|
|
280
|
-
|
|
318
|
+
|
|
281
319
|
# Time filters
|
|
282
320
|
if modified_after_ts and match.modified < modified_after_ts:
|
|
283
321
|
continue
|
|
284
322
|
if modified_before_ts and match.modified > modified_before_ts:
|
|
285
323
|
continue
|
|
286
|
-
|
|
324
|
+
|
|
287
325
|
filtered_matches.append(match)
|
|
288
|
-
|
|
326
|
+
|
|
289
327
|
if len(filtered_matches) >= max_results:
|
|
290
328
|
break
|
|
291
|
-
|
|
329
|
+
|
|
292
330
|
# Sort results
|
|
293
331
|
if sort_by == "name":
|
|
294
332
|
filtered_matches.sort(key=lambda m: m.name, reverse=reverse)
|
|
@@ -298,54 +336,63 @@ class FindTool(BaseTool):
|
|
|
298
336
|
filtered_matches.sort(key=lambda m: m.modified, reverse=reverse)
|
|
299
337
|
else: # path
|
|
300
338
|
filtered_matches.sort(key=lambda m: m.path, reverse=reverse)
|
|
301
|
-
|
|
339
|
+
|
|
302
340
|
# Paginate
|
|
303
341
|
total_results = len(filtered_matches)
|
|
304
342
|
start_idx = (page - 1) * page_size
|
|
305
343
|
end_idx = start_idx + page_size
|
|
306
344
|
page_results = filtered_matches[start_idx:end_idx]
|
|
307
|
-
|
|
345
|
+
|
|
308
346
|
# Format results
|
|
309
347
|
formatted_results = [match.to_dict() for match in page_results]
|
|
310
|
-
|
|
348
|
+
|
|
311
349
|
# Statistics
|
|
312
350
|
stats = {
|
|
313
351
|
"total_found": total_results,
|
|
314
352
|
"search_time_ms": int((time.time() - start_time) * 1000),
|
|
315
|
-
"search_method":
|
|
353
|
+
"search_method": (
|
|
354
|
+
"ffind" if FFIND_AVAILABLE and not in_content else "python"
|
|
355
|
+
),
|
|
316
356
|
"root_path": str(root_path),
|
|
317
357
|
"filters_applied": {
|
|
318
358
|
"pattern": pattern,
|
|
319
359
|
"type": type,
|
|
320
|
-
"size":
|
|
321
|
-
|
|
360
|
+
"size": (
|
|
361
|
+
{"min": min_size, "max": max_size} if min_size or max_size else None
|
|
362
|
+
),
|
|
363
|
+
"modified": (
|
|
364
|
+
{"after": modified_after, "before": modified_before}
|
|
365
|
+
if modified_after or modified_before
|
|
366
|
+
else None
|
|
367
|
+
),
|
|
322
368
|
"max_depth": max_depth,
|
|
323
|
-
"gitignore": respect_gitignore
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
return MCPResourceDocument(data={
|
|
328
|
-
"results": formatted_results,
|
|
329
|
-
"pagination": {
|
|
330
|
-
"page": page,
|
|
331
|
-
"page_size": page_size,
|
|
332
|
-
"total_results": total_results,
|
|
333
|
-
"total_pages": (total_results + page_size - 1) // page_size,
|
|
334
|
-
"has_next": end_idx < total_results,
|
|
335
|
-
"has_prev": page > 1
|
|
369
|
+
"gitignore": respect_gitignore,
|
|
336
370
|
},
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return MCPResourceDocument(
|
|
374
|
+
data={
|
|
375
|
+
"results": formatted_results,
|
|
376
|
+
"pagination": {
|
|
377
|
+
"page": page,
|
|
378
|
+
"page_size": page_size,
|
|
379
|
+
"total_results": total_results,
|
|
380
|
+
"total_pages": (total_results + page_size - 1) // page_size,
|
|
381
|
+
"has_next": end_idx < total_results,
|
|
382
|
+
"has_prev": page > 1,
|
|
383
|
+
},
|
|
384
|
+
"statistics": stats,
|
|
385
|
+
}
|
|
386
|
+
)
|
|
387
|
+
|
|
340
388
|
async def call(self, **kwargs) -> str:
|
|
341
389
|
"""Tool interface for MCP - converts result to JSON string."""
|
|
342
390
|
result = await self.run(**kwargs)
|
|
343
391
|
return result.to_json_string()
|
|
344
|
-
|
|
392
|
+
|
|
345
393
|
def register(self, mcp_server) -> None:
|
|
346
394
|
"""Register tool with MCP server."""
|
|
347
|
-
|
|
348
|
-
|
|
395
|
+
|
|
349
396
|
@mcp_server.tool(name=self.name, description=self.description)
|
|
350
397
|
async def find_handler(
|
|
351
398
|
pattern: str,
|
|
@@ -388,91 +435,102 @@ class FindTool(BaseTool):
|
|
|
388
435
|
page_size=page_size,
|
|
389
436
|
page=page,
|
|
390
437
|
)
|
|
391
|
-
|
|
392
|
-
async def _find_with_ffind(
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
438
|
+
|
|
439
|
+
async def _find_with_ffind(
|
|
440
|
+
self,
|
|
441
|
+
pattern: str,
|
|
442
|
+
root: Path,
|
|
443
|
+
file_type: Optional[str],
|
|
444
|
+
case_sensitive: bool,
|
|
445
|
+
regex: bool,
|
|
446
|
+
fuzzy: bool,
|
|
447
|
+
max_depth: Optional[int],
|
|
448
|
+
follow_symlinks: bool,
|
|
449
|
+
respect_gitignore: bool,
|
|
450
|
+
ignore_patterns: Set[str],
|
|
451
|
+
) -> List[FileMatch]:
|
|
403
452
|
"""Use ffind for fast file discovery."""
|
|
404
453
|
matches = []
|
|
405
|
-
|
|
454
|
+
|
|
406
455
|
# Configure ffind
|
|
407
456
|
ffind_args = {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
457
|
+
"path": str(root),
|
|
458
|
+
"pattern": pattern,
|
|
459
|
+
"regex": regex,
|
|
460
|
+
"case_sensitive": case_sensitive,
|
|
461
|
+
"follow_symlinks": follow_symlinks,
|
|
413
462
|
}
|
|
414
|
-
|
|
463
|
+
|
|
415
464
|
if fuzzy:
|
|
416
|
-
ffind_args[
|
|
417
|
-
|
|
465
|
+
ffind_args["fuzzy"] = True
|
|
466
|
+
|
|
418
467
|
if max_depth:
|
|
419
|
-
ffind_args[
|
|
420
|
-
|
|
468
|
+
ffind_args["max_depth"] = max_depth
|
|
469
|
+
|
|
421
470
|
try:
|
|
422
471
|
# Build ffind command
|
|
423
|
-
cmd = [
|
|
424
|
-
|
|
472
|
+
cmd = ["ffind"]
|
|
473
|
+
|
|
425
474
|
if not case_sensitive:
|
|
426
|
-
cmd.append(
|
|
427
|
-
|
|
475
|
+
cmd.append("-i")
|
|
476
|
+
|
|
428
477
|
if regex:
|
|
429
|
-
cmd.append(
|
|
430
|
-
|
|
478
|
+
cmd.append("-E")
|
|
479
|
+
|
|
431
480
|
if fuzzy:
|
|
432
|
-
cmd.append(
|
|
433
|
-
|
|
481
|
+
cmd.append("-f")
|
|
482
|
+
|
|
434
483
|
if follow_symlinks:
|
|
435
|
-
cmd.append(
|
|
436
|
-
|
|
484
|
+
cmd.append("-L")
|
|
485
|
+
|
|
437
486
|
if max_depth:
|
|
438
|
-
cmd.extend([
|
|
439
|
-
|
|
440
|
-
# Add
|
|
441
|
-
cmd.append(pattern)
|
|
487
|
+
cmd.extend(["-D", str(max_depth)])
|
|
488
|
+
|
|
489
|
+
# Add path and pattern (ffind expects directory first)
|
|
442
490
|
cmd.append(str(root))
|
|
443
|
-
|
|
491
|
+
cmd.append(pattern)
|
|
492
|
+
|
|
444
493
|
# Run ffind command
|
|
445
494
|
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
446
|
-
|
|
495
|
+
|
|
447
496
|
if result.returncode != 0:
|
|
448
497
|
# Fall back to Python implementation on error
|
|
449
498
|
return await self._find_with_python(
|
|
450
|
-
pattern,
|
|
451
|
-
|
|
499
|
+
pattern,
|
|
500
|
+
root,
|
|
501
|
+
file_type,
|
|
502
|
+
case_sensitive,
|
|
503
|
+
regex,
|
|
504
|
+
fuzzy,
|
|
505
|
+
False,
|
|
506
|
+
max_depth,
|
|
507
|
+
follow_symlinks,
|
|
508
|
+
respect_gitignore,
|
|
509
|
+
ignore_patterns,
|
|
452
510
|
)
|
|
453
|
-
|
|
511
|
+
|
|
454
512
|
# Parse results
|
|
455
|
-
results = result.stdout.strip().split(
|
|
456
|
-
|
|
513
|
+
results = result.stdout.strip().split("\n") if result.stdout else []
|
|
514
|
+
|
|
457
515
|
for path in results:
|
|
458
516
|
if not path: # Skip empty lines
|
|
459
517
|
continue
|
|
460
|
-
|
|
518
|
+
|
|
461
519
|
# Check ignore patterns
|
|
462
520
|
if self._should_ignore(path, ignore_patterns):
|
|
463
521
|
continue
|
|
464
|
-
|
|
522
|
+
|
|
465
523
|
# Get file info
|
|
466
524
|
try:
|
|
467
525
|
stat = os.stat(path)
|
|
468
526
|
is_dir = os.path.isdir(path)
|
|
469
|
-
|
|
527
|
+
|
|
470
528
|
# Apply type filter
|
|
471
529
|
if file_type == "file" and is_dir:
|
|
472
530
|
continue
|
|
473
531
|
if file_type == "dir" and not is_dir:
|
|
474
532
|
continue
|
|
475
|
-
|
|
533
|
+
|
|
476
534
|
match = FileMatch(
|
|
477
535
|
path=path,
|
|
478
536
|
name=os.path.basename(path),
|
|
@@ -480,40 +538,51 @@ class FindTool(BaseTool):
|
|
|
480
538
|
modified=stat.st_mtime,
|
|
481
539
|
is_dir=is_dir,
|
|
482
540
|
extension=Path(path).suffix,
|
|
483
|
-
depth=len(Path(path).relative_to(root).parts)
|
|
541
|
+
depth=len(Path(path).relative_to(root).parts),
|
|
484
542
|
)
|
|
485
543
|
matches.append(match)
|
|
486
|
-
|
|
544
|
+
|
|
487
545
|
except OSError:
|
|
488
546
|
continue
|
|
489
|
-
|
|
490
|
-
except Exception
|
|
547
|
+
|
|
548
|
+
except Exception:
|
|
491
549
|
# Fall back to Python implementation
|
|
492
550
|
return await self._find_with_python(
|
|
493
|
-
pattern,
|
|
494
|
-
|
|
551
|
+
pattern,
|
|
552
|
+
root,
|
|
553
|
+
file_type,
|
|
554
|
+
case_sensitive,
|
|
555
|
+
regex,
|
|
556
|
+
False,
|
|
557
|
+
False,
|
|
558
|
+
max_depth,
|
|
559
|
+
follow_symlinks,
|
|
560
|
+
respect_gitignore,
|
|
561
|
+
ignore_patterns,
|
|
495
562
|
)
|
|
496
|
-
|
|
563
|
+
|
|
497
564
|
return matches
|
|
498
|
-
|
|
499
|
-
async def _find_with_python(
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
565
|
+
|
|
566
|
+
async def _find_with_python(
|
|
567
|
+
self,
|
|
568
|
+
pattern: str,
|
|
569
|
+
root: Path,
|
|
570
|
+
file_type: Optional[str],
|
|
571
|
+
case_sensitive: bool,
|
|
572
|
+
regex: bool,
|
|
573
|
+
fuzzy: bool,
|
|
574
|
+
in_content: bool,
|
|
575
|
+
max_depth: Optional[int],
|
|
576
|
+
follow_symlinks: bool,
|
|
577
|
+
respect_gitignore: bool,
|
|
578
|
+
ignore_patterns: Set[str],
|
|
579
|
+
) -> List[FileMatch]:
|
|
511
580
|
"""Python implementation of file finding."""
|
|
512
581
|
matches = []
|
|
513
|
-
|
|
582
|
+
|
|
514
583
|
import re
|
|
515
584
|
from difflib import SequenceMatcher
|
|
516
|
-
|
|
585
|
+
|
|
517
586
|
# Prepare pattern matcher
|
|
518
587
|
if regex:
|
|
519
588
|
flags = 0 if case_sensitive else re.IGNORECASE
|
|
@@ -524,8 +593,12 @@ class FindTool(BaseTool):
|
|
|
524
593
|
matcher = lambda name: pattern in name
|
|
525
594
|
elif fuzzy:
|
|
526
595
|
pattern_lower = pattern.lower() if not case_sensitive else pattern
|
|
527
|
-
matcher =
|
|
528
|
-
|
|
596
|
+
matcher = (
|
|
597
|
+
lambda name: SequenceMatcher(
|
|
598
|
+
None, pattern_lower, name.lower() if not case_sensitive else name
|
|
599
|
+
).ratio()
|
|
600
|
+
> 0.6
|
|
601
|
+
)
|
|
529
602
|
else:
|
|
530
603
|
# Glob pattern
|
|
531
604
|
if not case_sensitive:
|
|
@@ -533,20 +606,28 @@ class FindTool(BaseTool):
|
|
|
533
606
|
matcher = lambda name: fnmatch.fnmatch(name.lower(), pattern)
|
|
534
607
|
else:
|
|
535
608
|
matcher = lambda name: fnmatch.fnmatch(name, pattern)
|
|
536
|
-
|
|
609
|
+
|
|
537
610
|
# Walk directory tree
|
|
538
|
-
for dirpath, dirnames, filenames in os.walk(
|
|
611
|
+
for dirpath, dirnames, filenames in os.walk(
|
|
612
|
+
str(root), followlinks=follow_symlinks
|
|
613
|
+
):
|
|
539
614
|
# Check depth
|
|
540
615
|
if max_depth is not None:
|
|
541
616
|
depth = len(Path(dirpath).relative_to(root).parts)
|
|
542
617
|
if depth > max_depth:
|
|
543
618
|
dirnames.clear() # Don't recurse deeper
|
|
544
619
|
continue
|
|
545
|
-
|
|
620
|
+
|
|
546
621
|
# Filter directories to skip
|
|
547
622
|
if respect_gitignore:
|
|
548
|
-
dirnames[:] = [
|
|
549
|
-
|
|
623
|
+
dirnames[:] = [
|
|
624
|
+
d
|
|
625
|
+
for d in dirnames
|
|
626
|
+
if not self._should_ignore(
|
|
627
|
+
os.path.join(dirpath, d), ignore_patterns
|
|
628
|
+
)
|
|
629
|
+
]
|
|
630
|
+
|
|
550
631
|
# Check directories
|
|
551
632
|
if file_type != "file":
|
|
552
633
|
for dirname in dirnames:
|
|
@@ -562,29 +643,31 @@ class FindTool(BaseTool):
|
|
|
562
643
|
modified=stat.st_mtime,
|
|
563
644
|
is_dir=True,
|
|
564
645
|
extension="",
|
|
565
|
-
depth=len(Path(full_path).relative_to(root).parts)
|
|
646
|
+
depth=len(Path(full_path).relative_to(root).parts),
|
|
566
647
|
)
|
|
567
648
|
matches.append(match)
|
|
568
649
|
except OSError:
|
|
569
650
|
continue
|
|
570
|
-
|
|
651
|
+
|
|
571
652
|
# Check files
|
|
572
653
|
if file_type != "dir":
|
|
573
654
|
for filename in filenames:
|
|
574
655
|
full_path = os.path.join(dirpath, filename)
|
|
575
|
-
|
|
656
|
+
|
|
576
657
|
if self._should_ignore(full_path, ignore_patterns):
|
|
577
658
|
continue
|
|
578
|
-
|
|
659
|
+
|
|
579
660
|
# Match against filename
|
|
580
661
|
if matcher(filename):
|
|
581
662
|
match_found = True
|
|
582
663
|
elif in_content:
|
|
583
664
|
# Search in file content
|
|
584
|
-
match_found = await self._search_in_file(
|
|
665
|
+
match_found = await self._search_in_file(
|
|
666
|
+
full_path, pattern, case_sensitive
|
|
667
|
+
)
|
|
585
668
|
else:
|
|
586
669
|
match_found = False
|
|
587
|
-
|
|
670
|
+
|
|
588
671
|
if match_found:
|
|
589
672
|
try:
|
|
590
673
|
stat = os.stat(full_path)
|
|
@@ -595,28 +678,30 @@ class FindTool(BaseTool):
|
|
|
595
678
|
modified=stat.st_mtime,
|
|
596
679
|
is_dir=False,
|
|
597
680
|
extension=Path(filename).suffix,
|
|
598
|
-
depth=len(Path(full_path).relative_to(root).parts)
|
|
681
|
+
depth=len(Path(full_path).relative_to(root).parts),
|
|
599
682
|
)
|
|
600
683
|
matches.append(match)
|
|
601
684
|
except OSError:
|
|
602
685
|
continue
|
|
603
|
-
|
|
686
|
+
|
|
604
687
|
return matches
|
|
605
|
-
|
|
606
|
-
async def _search_in_file(
|
|
688
|
+
|
|
689
|
+
async def _search_in_file(
|
|
690
|
+
self, file_path: str, pattern: str, case_sensitive: bool
|
|
691
|
+
) -> bool:
|
|
607
692
|
"""Search for pattern in file content."""
|
|
608
693
|
try:
|
|
609
|
-
with open(file_path,
|
|
694
|
+
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
610
695
|
content = f.read()
|
|
611
696
|
if not case_sensitive:
|
|
612
697
|
return pattern.lower() in content.lower()
|
|
613
698
|
else:
|
|
614
699
|
return pattern in content
|
|
615
|
-
except:
|
|
700
|
+
except Exception:
|
|
616
701
|
return False
|
|
617
702
|
|
|
618
703
|
|
|
619
704
|
# Tool registration
|
|
620
705
|
def create_find_tool():
|
|
621
706
|
"""Factory function to create find tool."""
|
|
622
|
-
return FindTool()
|
|
707
|
+
return FindTool()
|