mcp-win-stdio-explorer 0.2.3__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.
- mcp_win_stdio/explorer/__init__.py +5 -0
- mcp_win_stdio/explorer/__main__.py +8 -0
- mcp_win_stdio/explorer/cli.py +176 -0
- mcp_win_stdio/explorer/guide.py +68 -0
- mcp_win_stdio/explorer/server.py +1799 -0
- mcp_win_stdio_explorer-0.2.3.dist-info/METADATA +105 -0
- mcp_win_stdio_explorer-0.2.3.dist-info/RECORD +9 -0
- mcp_win_stdio_explorer-0.2.3.dist-info/WHEEL +4 -0
- mcp_win_stdio_explorer-0.2.3.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,1799 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Workspace Explorer MCP Server
|
|
3
|
+
High-performance, token-safe directory exploration, smart .gitignore resolution,
|
|
4
|
+
collapsible heavy-folder summaries, RapidFuzz fuzzy search, code outline extraction,
|
|
5
|
+
in-file grep, windowed reading, and disk export.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
from datetime import datetime, timedelta, timezone
|
|
10
|
+
import fnmatch
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import mimetypes
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
import re
|
|
17
|
+
import stat
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
from typing import Any, Dict, List, Literal, Optional, Set, Tuple, Union
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
from mcp.server.mcpserver import MCPServer as FastMCP
|
|
24
|
+
except (ImportError, ModuleNotFoundError):
|
|
25
|
+
from mcp.server.fastmcp import FastMCP
|
|
26
|
+
import pathspec
|
|
27
|
+
from rapidfuzz import fuzz, process
|
|
28
|
+
|
|
29
|
+
# Initialize FastMCP Server
|
|
30
|
+
mcp = FastMCP("workspace-explorer")
|
|
31
|
+
|
|
32
|
+
# Directories known to be heavy or contain build caches
|
|
33
|
+
HEAVY_BUILD_DIRS = {
|
|
34
|
+
"node_modules",
|
|
35
|
+
".next",
|
|
36
|
+
".nuxt",
|
|
37
|
+
"dist",
|
|
38
|
+
"build",
|
|
39
|
+
"out",
|
|
40
|
+
".output",
|
|
41
|
+
"target",
|
|
42
|
+
"bin",
|
|
43
|
+
"obj",
|
|
44
|
+
"__pycache__",
|
|
45
|
+
".venv",
|
|
46
|
+
"venv",
|
|
47
|
+
"env",
|
|
48
|
+
".pytest_cache",
|
|
49
|
+
".mypy_cache",
|
|
50
|
+
".ruff_cache",
|
|
51
|
+
".cache",
|
|
52
|
+
"coverage",
|
|
53
|
+
".turbo",
|
|
54
|
+
".parcel-cache",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
GIT_DIRS = {
|
|
58
|
+
".git",
|
|
59
|
+
".hg",
|
|
60
|
+
".svn",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
BINARY_EXTENSIONS = {
|
|
64
|
+
".exe", ".dll", ".so", ".dylib", ".bin", ".iso", ".img",
|
|
65
|
+
".zip", ".tar", ".gz", ".7z", ".rar", ".bz2", ".xz",
|
|
66
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".bmp", ".tiff",
|
|
67
|
+
".mp3", ".wav", ".flac", ".ogg", ".aac",
|
|
68
|
+
".mp4", ".mkv", ".avi", ".mov", ".wmv", ".webm",
|
|
69
|
+
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
|
|
70
|
+
".pyc", ".pyo", ".pyd", ".class", ".jar", ".war",
|
|
71
|
+
".ttf", ".otf", ".woff", ".woff2", ".eot"
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ==========================================
|
|
76
|
+
# HELPER UTILITIES
|
|
77
|
+
# ==========================================
|
|
78
|
+
|
|
79
|
+
def _format_size(size_bytes: int) -> str:
|
|
80
|
+
if size_bytes < 1024:
|
|
81
|
+
return f"{size_bytes} B"
|
|
82
|
+
elif size_bytes < 1024 * 1024:
|
|
83
|
+
return f"{size_bytes / 1024:.1f} KB"
|
|
84
|
+
elif size_bytes < 1024 * 1024 * 1024:
|
|
85
|
+
return f"{size_bytes / (1024 * 1024):.2f} MB"
|
|
86
|
+
else:
|
|
87
|
+
return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _is_hidden(path: Path) -> bool:
|
|
91
|
+
try:
|
|
92
|
+
if path.name.startswith("."):
|
|
93
|
+
return True
|
|
94
|
+
if os.name == "nt":
|
|
95
|
+
attrs = path.stat().st_file_attributes
|
|
96
|
+
return bool(attrs & stat.FILE_ATTRIBUTE_HIDDEN)
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _is_binary_file(path: Path) -> bool:
|
|
103
|
+
if path.suffix.lower() in BINARY_EXTENSIONS:
|
|
104
|
+
return True
|
|
105
|
+
try:
|
|
106
|
+
with open(path, "rb") as f:
|
|
107
|
+
chunk = f.read(1024)
|
|
108
|
+
if b"\x00" in chunk:
|
|
109
|
+
return True
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _read_file_text_with_fallback(path: Path) -> Tuple[str, str]:
|
|
116
|
+
encodings = ["utf-8", "utf-8-sig", "latin-1", "cp1252", "utf-16"]
|
|
117
|
+
for enc in encodings:
|
|
118
|
+
try:
|
|
119
|
+
with open(path, "r", encoding=enc) as f:
|
|
120
|
+
return f.read(), enc
|
|
121
|
+
except (UnicodeDecodeError, UnicodeError):
|
|
122
|
+
continue
|
|
123
|
+
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
124
|
+
return f.read(), "utf-8 (lossy)"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _parse_relative_time(time_str: str) -> Optional[datetime]:
|
|
128
|
+
time_str = time_str.strip().lower()
|
|
129
|
+
now = datetime.now(timezone.utc)
|
|
130
|
+
match = re.match(r"^(\d+)\s*([smhdw])$", time_str)
|
|
131
|
+
if match:
|
|
132
|
+
val = int(match.group(1))
|
|
133
|
+
unit = match.group(2)
|
|
134
|
+
if unit == "s":
|
|
135
|
+
return now - timedelta(seconds=val)
|
|
136
|
+
elif unit == "m":
|
|
137
|
+
return now - timedelta(minutes=val)
|
|
138
|
+
elif unit == "h":
|
|
139
|
+
return now - timedelta(hours=val)
|
|
140
|
+
elif unit == "d":
|
|
141
|
+
return now - timedelta(days=val)
|
|
142
|
+
elif unit == "w":
|
|
143
|
+
return now - timedelta(weeks=val)
|
|
144
|
+
try:
|
|
145
|
+
dt = datetime.fromisoformat(time_str)
|
|
146
|
+
if dt.tzinfo is None:
|
|
147
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
148
|
+
return dt
|
|
149
|
+
except Exception:
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _get_collapsed_summary(dir_path: Path, max_depth: int = 2) -> Tuple[int, int]:
|
|
154
|
+
"""Quickly estimate item count and total size for a collapsed directory without deep traversal."""
|
|
155
|
+
total_count = 0
|
|
156
|
+
total_bytes = 0
|
|
157
|
+
root_depth = len(dir_path.parts)
|
|
158
|
+
try:
|
|
159
|
+
for root, dirs, files in os.walk(dir_path):
|
|
160
|
+
curr_depth = len(Path(root).parts) - root_depth
|
|
161
|
+
if curr_depth >= max_depth:
|
|
162
|
+
dirs[:] = []
|
|
163
|
+
total_count += len(files) + len(dirs)
|
|
164
|
+
for f in files:
|
|
165
|
+
try:
|
|
166
|
+
total_bytes += (Path(root) / f).stat().st_size
|
|
167
|
+
except Exception:
|
|
168
|
+
pass
|
|
169
|
+
except Exception:
|
|
170
|
+
pass
|
|
171
|
+
return total_count, total_bytes
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _load_gitignore_spec(root: Path) -> Optional[pathspec.PathSpec]:
|
|
175
|
+
"""Find and load .gitignore / .mcpignore rules from root or parent directory."""
|
|
176
|
+
patterns = []
|
|
177
|
+
# Check .gitignore
|
|
178
|
+
gi = root / ".gitignore"
|
|
179
|
+
if gi.is_file():
|
|
180
|
+
try:
|
|
181
|
+
with open(gi, "r", encoding="utf-8", errors="replace") as f:
|
|
182
|
+
patterns.extend(f.readlines())
|
|
183
|
+
except Exception:
|
|
184
|
+
pass
|
|
185
|
+
# Check .mcpignore if user created one
|
|
186
|
+
mi = root / ".mcpignore"
|
|
187
|
+
if mi.is_file():
|
|
188
|
+
try:
|
|
189
|
+
with open(mi, "r", encoding="utf-8", errors="replace") as f:
|
|
190
|
+
patterns.extend(f.readlines())
|
|
191
|
+
except Exception:
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
if not patterns:
|
|
195
|
+
return None
|
|
196
|
+
try:
|
|
197
|
+
return pathspec.PathSpec.from_lines("gitwildmatch", patterns)
|
|
198
|
+
except Exception:
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _matches_gitignore(gi_spec: Optional[pathspec.PathSpec], rel_posix: str, is_dir: bool = False) -> bool:
|
|
203
|
+
"""Accurately match relative path against gitignore spec, supporting trailing slashes on directory patterns."""
|
|
204
|
+
if not gi_spec:
|
|
205
|
+
return False
|
|
206
|
+
try:
|
|
207
|
+
if gi_spec.match_file(rel_posix):
|
|
208
|
+
return True
|
|
209
|
+
if is_dir and gi_spec.match_file(rel_posix.rstrip("/") + "/"):
|
|
210
|
+
return True
|
|
211
|
+
except Exception:
|
|
212
|
+
pass
|
|
213
|
+
return False
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ==========================================
|
|
218
|
+
# 1. DIRECTORY LISTING & NAVIGATION
|
|
219
|
+
# ==========================================
|
|
220
|
+
|
|
221
|
+
@mcp.tool()
|
|
222
|
+
def list_dir(
|
|
223
|
+
path: str,
|
|
224
|
+
include_hidden: bool = False,
|
|
225
|
+
filter_pattern: Optional[str] = None,
|
|
226
|
+
sort_by: Literal["name", "size", "modified", "extension", "type"] = "name",
|
|
227
|
+
sort_desc: bool = False,
|
|
228
|
+
offset: int = 0,
|
|
229
|
+
limit: int = 100,
|
|
230
|
+
) -> Dict[str, Any]:
|
|
231
|
+
"""
|
|
232
|
+
List contents of a directory with rich file and folder metadata (PowerShell Get-ChildItem style).
|
|
233
|
+
If a folder is directly requested, it is ALWAYS explored even if it is in an ignore list.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
path: Path to the directory.
|
|
237
|
+
include_hidden: Include hidden files and folders (default False).
|
|
238
|
+
filter_pattern: Optional glob pattern to filter entries (e.g. "*.py", "data_*").
|
|
239
|
+
sort_by: Field to sort by: 'name', 'size', 'modified', 'extension', 'type'.
|
|
240
|
+
sort_desc: Sort in descending order.
|
|
241
|
+
offset: Skip first N items (pagination).
|
|
242
|
+
limit: Maximum number of items to return (default 100, max 500).
|
|
243
|
+
"""
|
|
244
|
+
p = Path(path).resolve()
|
|
245
|
+
if not p.exists():
|
|
246
|
+
return {"error": f"Path not found: {p}"}
|
|
247
|
+
if not p.is_dir():
|
|
248
|
+
return {"error": f"Path is not a directory: {p}"}
|
|
249
|
+
|
|
250
|
+
limit = min(max(1, limit), 500)
|
|
251
|
+
items = []
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
with os.scandir(p) as entries:
|
|
255
|
+
for entry in entries:
|
|
256
|
+
entry_path = Path(entry.path)
|
|
257
|
+
if not include_hidden and _is_hidden(entry_path):
|
|
258
|
+
continue
|
|
259
|
+
if filter_pattern and not fnmatch.fnmatch(entry.name, filter_pattern):
|
|
260
|
+
continue
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
st = entry.stat()
|
|
264
|
+
is_dir = entry.is_dir(follow_symlinks=False)
|
|
265
|
+
size = 0 if is_dir else st.st_size
|
|
266
|
+
mtime = datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat()
|
|
267
|
+
|
|
268
|
+
child_count = None
|
|
269
|
+
is_heavy = False
|
|
270
|
+
if is_dir:
|
|
271
|
+
is_heavy = entry.name in HEAVY_BUILD_DIRS or entry.name in GIT_DIRS
|
|
272
|
+
try:
|
|
273
|
+
child_count = len(os.listdir(entry.path))
|
|
274
|
+
except Exception:
|
|
275
|
+
child_count = None
|
|
276
|
+
|
|
277
|
+
items.append({
|
|
278
|
+
"name": entry.name,
|
|
279
|
+
"path": str(entry_path),
|
|
280
|
+
"is_dir": is_dir,
|
|
281
|
+
"type": "directory" if is_dir else "file",
|
|
282
|
+
"extension": entry_path.suffix.lower() if not is_dir else "",
|
|
283
|
+
"size_bytes": size,
|
|
284
|
+
"size_formatted": _format_size(size) if not is_dir else (
|
|
285
|
+
f"{child_count} items (heavy build/cache)" if is_heavy else f"{child_count} items"
|
|
286
|
+
) if child_count is not None else "dir",
|
|
287
|
+
"child_count": child_count,
|
|
288
|
+
"is_heavy_dir": is_heavy,
|
|
289
|
+
"modified_at": mtime,
|
|
290
|
+
"is_symlink": entry.is_symlink(),
|
|
291
|
+
})
|
|
292
|
+
except Exception:
|
|
293
|
+
continue
|
|
294
|
+
|
|
295
|
+
total_count = len(items)
|
|
296
|
+
|
|
297
|
+
# Sorting
|
|
298
|
+
if sort_by == "name":
|
|
299
|
+
items.sort(key=lambda x: x["name"].lower(), reverse=sort_desc)
|
|
300
|
+
elif sort_by == "size":
|
|
301
|
+
items.sort(key=lambda x: x["size_bytes"], reverse=sort_desc)
|
|
302
|
+
elif sort_by == "modified":
|
|
303
|
+
items.sort(key=lambda x: x["modified_at"], reverse=sort_desc)
|
|
304
|
+
elif sort_by == "extension":
|
|
305
|
+
items.sort(key=lambda x: (not x["is_dir"], x["extension"], x["name"].lower()), reverse=sort_desc)
|
|
306
|
+
elif sort_by == "type":
|
|
307
|
+
items.sort(key=lambda x: (x["is_dir"], x["name"].lower()), reverse=sort_desc)
|
|
308
|
+
|
|
309
|
+
paginated_items = items[offset : offset + limit]
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
"directory": str(p),
|
|
313
|
+
"total_items": total_count,
|
|
314
|
+
"offset": offset,
|
|
315
|
+
"limit": limit,
|
|
316
|
+
"has_more": (offset + limit) < total_count,
|
|
317
|
+
"items": paginated_items,
|
|
318
|
+
}
|
|
319
|
+
except Exception as e:
|
|
320
|
+
return {"error": f"Failed to list directory: {str(e)}"}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# ==========================================
|
|
324
|
+
# 2. DIRECTORY TREE (SMART COLLAPSING & NOISE SAFE)
|
|
325
|
+
# ==========================================
|
|
326
|
+
|
|
327
|
+
@mcp.tool()
|
|
328
|
+
def get_directory_tree(
|
|
329
|
+
path: str,
|
|
330
|
+
max_depth: int = 2,
|
|
331
|
+
include_files: bool = True,
|
|
332
|
+
only_dirs: bool = False,
|
|
333
|
+
ignore_mode: Literal["smart", "git_only", "none"] = "smart",
|
|
334
|
+
respect_gitignore: bool = True,
|
|
335
|
+
collapse_heavy_dirs: bool = True,
|
|
336
|
+
max_nodes: int = 250,
|
|
337
|
+
tree_style: Literal["ascii", "unicode"] = "ascii",
|
|
338
|
+
format: Literal["tree", "json"] = "tree",
|
|
339
|
+
) -> Union[str, Dict[str, Any]]:
|
|
340
|
+
"""
|
|
341
|
+
Render a clean, token-safe directory hierarchy with smart collapsing and .gitignore awareness.
|
|
342
|
+
Instead of blowing context on node_modules or .git, it collapses them into informative summary nodes.
|
|
343
|
+
|
|
344
|
+
Args:
|
|
345
|
+
path: Root directory path.
|
|
346
|
+
max_depth: Maximum recursion depth (1 to 5, default 2).
|
|
347
|
+
include_files: Whether to include files in the output (default True).
|
|
348
|
+
only_dirs: If True, only lists subdirectories.
|
|
349
|
+
ignore_mode: 'smart' (collapses build/cache dirs and honors gitignore), 'git_only' (only collapses .git), or 'none' (raw).
|
|
350
|
+
respect_gitignore: Honor .gitignore/.mcpignore rules found in the root (default True).
|
|
351
|
+
collapse_heavy_dirs: Collapse heavy directories into summary nodes instead of expanding their children (default True).
|
|
352
|
+
max_nodes: Hard safety circuit-breaker cap on total displayed items (default 250).
|
|
353
|
+
tree_style: 'ascii' (|--, \\--) or 'unicode' (├──, └──).
|
|
354
|
+
format: Output format: 'tree' (formatted indented text) or 'json'.
|
|
355
|
+
"""
|
|
356
|
+
root = Path(path).resolve()
|
|
357
|
+
if not root.exists():
|
|
358
|
+
return f"Error: Path not found: {root}"
|
|
359
|
+
if not root.is_dir():
|
|
360
|
+
return f"Error: Path is not a directory: {root}"
|
|
361
|
+
|
|
362
|
+
max_depth = min(max(1, max_depth), 5)
|
|
363
|
+
gi_spec = _load_gitignore_spec(root) if respect_gitignore and ignore_mode == "smart" else None
|
|
364
|
+
|
|
365
|
+
# Connectors
|
|
366
|
+
if tree_style == "unicode":
|
|
367
|
+
c_branch, c_last, c_pipe = "├── ", "└── ", "│ "
|
|
368
|
+
else:
|
|
369
|
+
c_branch, c_last, c_pipe = "|-- ", "\\-- ", "| "
|
|
370
|
+
|
|
371
|
+
nodes_count = [0]
|
|
372
|
+
circuit_broken = [False]
|
|
373
|
+
|
|
374
|
+
if format == "tree":
|
|
375
|
+
lines = [f"{root.name}/ ({str(root)})"]
|
|
376
|
+
stats = {"dirs": 0, "files": 0, "collapsed": 0}
|
|
377
|
+
|
|
378
|
+
def _walk_tree(current: Path, prefix: str, depth: int):
|
|
379
|
+
if depth > max_depth or circuit_broken[0]:
|
|
380
|
+
return
|
|
381
|
+
try:
|
|
382
|
+
entries = sorted(os.scandir(current), key=lambda e: (not e.is_dir(), e.name.lower()))
|
|
383
|
+
except Exception:
|
|
384
|
+
return
|
|
385
|
+
|
|
386
|
+
filtered_entries = []
|
|
387
|
+
for e in entries:
|
|
388
|
+
e_path = Path(e.path)
|
|
389
|
+
rel_posix = str(e_path.relative_to(root)).replace("\\", "/")
|
|
390
|
+
|
|
391
|
+
# Hidden check
|
|
392
|
+
if _is_hidden(e_path) and e.name not in GIT_DIRS:
|
|
393
|
+
continue
|
|
394
|
+
|
|
395
|
+
# Gitignore check
|
|
396
|
+
if _matches_gitignore(gi_spec, rel_posix, is_dir=e.is_dir()):
|
|
397
|
+
if not (e.is_dir() and collapse_heavy_dirs):
|
|
398
|
+
continue
|
|
399
|
+
|
|
400
|
+
if only_dirs and not e.is_dir():
|
|
401
|
+
continue
|
|
402
|
+
if not include_files and not e.is_dir():
|
|
403
|
+
continue
|
|
404
|
+
filtered_entries.append(e)
|
|
405
|
+
|
|
406
|
+
count = len(filtered_entries)
|
|
407
|
+
for i, entry in enumerate(filtered_entries):
|
|
408
|
+
if nodes_count[0] >= max_nodes:
|
|
409
|
+
circuit_broken[0] = True
|
|
410
|
+
lines.append(f"{prefix}... [Circuit breaker: Reached {max_nodes} items safety cap. Narrow down with subfolder or lower max_depth]")
|
|
411
|
+
return
|
|
412
|
+
|
|
413
|
+
is_last = (i == count - 1)
|
|
414
|
+
connector = c_last if is_last else c_branch
|
|
415
|
+
sub_prefix = " " if is_last else c_pipe
|
|
416
|
+
e_path = Path(entry.path)
|
|
417
|
+
|
|
418
|
+
if entry.is_dir():
|
|
419
|
+
stats["dirs"] += 1
|
|
420
|
+
nodes_count[0] += 1
|
|
421
|
+
name = entry.name
|
|
422
|
+
|
|
423
|
+
# Should we collapse this directory?
|
|
424
|
+
should_collapse = False
|
|
425
|
+
if collapse_heavy_dirs:
|
|
426
|
+
if ignore_mode in ("smart", "git_only") and name in GIT_DIRS:
|
|
427
|
+
should_collapse = True
|
|
428
|
+
elif ignore_mode == "smart" and name in HEAVY_BUILD_DIRS:
|
|
429
|
+
should_collapse = True
|
|
430
|
+
elif _matches_gitignore(gi_spec, rel_posix, is_dir=True):
|
|
431
|
+
should_collapse = True
|
|
432
|
+
|
|
433
|
+
if should_collapse:
|
|
434
|
+
stats["collapsed"] += 1
|
|
435
|
+
c_items, c_bytes = _get_collapsed_summary(e_path)
|
|
436
|
+
lines.append(
|
|
437
|
+
f"{prefix}{connector}{name}/ [COLLAPSED: ~{c_items} items, {_format_size(c_bytes)} -> target directly to explore]"
|
|
438
|
+
)
|
|
439
|
+
else:
|
|
440
|
+
lines.append(f"{prefix}{connector}{name}/")
|
|
441
|
+
_walk_tree(e_path, prefix + sub_prefix, depth + 1)
|
|
442
|
+
else:
|
|
443
|
+
stats["files"] += 1
|
|
444
|
+
nodes_count[0] += 1
|
|
445
|
+
sz = ""
|
|
446
|
+
try:
|
|
447
|
+
sz = f" ({_format_size(entry.stat().st_size)})"
|
|
448
|
+
except Exception:
|
|
449
|
+
pass
|
|
450
|
+
lines.append(f"{prefix}{connector}{entry.name}{sz}")
|
|
451
|
+
|
|
452
|
+
_walk_tree(root, "", 1)
|
|
453
|
+
summary = (
|
|
454
|
+
f"\n\nTotal: {stats['dirs']} directories, {stats['files']} files "
|
|
455
|
+
f"({stats['collapsed']} collapsed heavy folders, Max depth: {max_depth})"
|
|
456
|
+
)
|
|
457
|
+
return "\n".join(lines) + summary
|
|
458
|
+
|
|
459
|
+
else:
|
|
460
|
+
# JSON format
|
|
461
|
+
def _walk_json(current: Path, depth: int) -> Dict[str, Any]:
|
|
462
|
+
node = {"name": current.name, "path": str(current), "type": "directory", "children": []}
|
|
463
|
+
if depth > max_depth or circuit_broken[0]:
|
|
464
|
+
return node
|
|
465
|
+
|
|
466
|
+
try:
|
|
467
|
+
entries = sorted(os.scandir(current), key=lambda e: (not e.is_dir(), e.name.lower()))
|
|
468
|
+
for entry in entries:
|
|
469
|
+
if nodes_count[0] >= max_nodes:
|
|
470
|
+
circuit_broken[0] = True
|
|
471
|
+
node["children"].append({"name": "[TRUNCATED]", "message": f"Safety limit of {max_nodes} reached."})
|
|
472
|
+
return node
|
|
473
|
+
|
|
474
|
+
e_path = Path(entry.path)
|
|
475
|
+
rel_posix = str(e_path.relative_to(root)).replace("\\", "/")
|
|
476
|
+
|
|
477
|
+
if _is_hidden(e_path) and entry.name not in GIT_DIRS:
|
|
478
|
+
continue
|
|
479
|
+
if _matches_gitignore(gi_spec, rel_posix, is_dir=entry.is_dir()):
|
|
480
|
+
if not (entry.is_dir() and collapse_heavy_dirs):
|
|
481
|
+
continue
|
|
482
|
+
|
|
483
|
+
if entry.is_dir():
|
|
484
|
+
nodes_count[0] += 1
|
|
485
|
+
is_heavy = (ignore_mode == "smart" and entry.name in HEAVY_BUILD_DIRS) or (entry.name in GIT_DIRS)
|
|
486
|
+
is_gi_ignored = _matches_gitignore(gi_spec, rel_posix, is_dir=True)
|
|
487
|
+
if collapse_heavy_dirs and (is_heavy or is_gi_ignored):
|
|
488
|
+
c_items, c_bytes = _get_collapsed_summary(e_path)
|
|
489
|
+
node["children"].append({
|
|
490
|
+
"name": entry.name,
|
|
491
|
+
"path": str(e_path),
|
|
492
|
+
"type": "directory",
|
|
493
|
+
"collapsed": True,
|
|
494
|
+
"estimated_items": c_items,
|
|
495
|
+
"estimated_size": _format_size(c_bytes),
|
|
496
|
+
})
|
|
497
|
+
else:
|
|
498
|
+
node["children"].append(_walk_json(e_path, depth + 1))
|
|
499
|
+
elif include_files and not only_dirs:
|
|
500
|
+
nodes_count[0] += 1
|
|
501
|
+
st = entry.stat()
|
|
502
|
+
node["children"].append({
|
|
503
|
+
"name": entry.name,
|
|
504
|
+
"path": str(e_path),
|
|
505
|
+
"type": "file",
|
|
506
|
+
"size_bytes": st.st_size,
|
|
507
|
+
"size_formatted": _format_size(st.st_size),
|
|
508
|
+
})
|
|
509
|
+
except Exception:
|
|
510
|
+
pass
|
|
511
|
+
return node
|
|
512
|
+
|
|
513
|
+
return _walk_json(root, 1)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
# ==========================================
|
|
517
|
+
# 3. ADVANCED FILE SEARCH (FIND)
|
|
518
|
+
# ==========================================
|
|
519
|
+
|
|
520
|
+
@mcp.tool()
|
|
521
|
+
def find_files(
|
|
522
|
+
search_path: str,
|
|
523
|
+
pattern: Optional[str] = None,
|
|
524
|
+
extensions: Optional[List[str]] = None,
|
|
525
|
+
item_type: Literal["file", "directory", "any"] = "file",
|
|
526
|
+
min_size_bytes: Optional[int] = None,
|
|
527
|
+
max_size_bytes: Optional[int] = None,
|
|
528
|
+
modified_after: Optional[str] = None,
|
|
529
|
+
modified_before: Optional[str] = None,
|
|
530
|
+
ignore_mode: Literal["smart", "git_only", "none"] = "smart",
|
|
531
|
+
respect_gitignore: bool = True,
|
|
532
|
+
max_depth: Optional[int] = None,
|
|
533
|
+
max_results: int = 50,
|
|
534
|
+
) -> Dict[str, Any]:
|
|
535
|
+
"""
|
|
536
|
+
Advanced file search across directories with filters for name patterns, extensions, sizes, and timestamps.
|
|
537
|
+
Honors .gitignore and build caches dynamically without rigid lockouts.
|
|
538
|
+
|
|
539
|
+
Args:
|
|
540
|
+
search_path: Root folder to search within.
|
|
541
|
+
pattern: Glob pattern for matching names (e.g. "*config*", "data_*.csv", "test*").
|
|
542
|
+
extensions: List of file extensions to include (without leading dot, e.g. ["py", "json"]).
|
|
543
|
+
item_type: 'file', 'directory', or 'any' (default 'file').
|
|
544
|
+
min_size_bytes: Minimum file size in bytes.
|
|
545
|
+
max_size_bytes: Maximum file size in bytes.
|
|
546
|
+
modified_after: ISO date or relative duration like '24h', '7d', '30m'.
|
|
547
|
+
modified_before: ISO date or relative duration.
|
|
548
|
+
ignore_mode: 'smart' (skips build/cache dirs), 'git_only' (skips .git), or 'none'.
|
|
549
|
+
respect_gitignore: Honor .gitignore rules if present.
|
|
550
|
+
max_depth: Optional maximum search depth.
|
|
551
|
+
max_results: Limit results count (default 50, max 300).
|
|
552
|
+
"""
|
|
553
|
+
root = Path(search_path).resolve()
|
|
554
|
+
if not root.exists():
|
|
555
|
+
return {"error": f"Path not found: {root}"}
|
|
556
|
+
if not root.is_dir():
|
|
557
|
+
return {"error": f"Path is not a directory: {root}"}
|
|
558
|
+
|
|
559
|
+
max_results = min(max(1, max_results), 300)
|
|
560
|
+
gi_spec = _load_gitignore_spec(root) if respect_gitignore and ignore_mode == "smart" else None
|
|
561
|
+
|
|
562
|
+
ext_set = {f".{ext.lower().lstrip('.')}" for ext in extensions} if extensions else None
|
|
563
|
+
dt_after = _parse_relative_time(modified_after) if modified_after else None
|
|
564
|
+
dt_before = _parse_relative_time(modified_before) if modified_before else None
|
|
565
|
+
|
|
566
|
+
matches = []
|
|
567
|
+
total_scanned = 0
|
|
568
|
+
root_depth = len(root.parts)
|
|
569
|
+
|
|
570
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
571
|
+
current_p = Path(dirpath)
|
|
572
|
+
current_depth = len(current_p.parts) - root_depth
|
|
573
|
+
|
|
574
|
+
if max_depth is not None and current_depth >= max_depth:
|
|
575
|
+
dirnames[:] = []
|
|
576
|
+
|
|
577
|
+
# Ignore pruning
|
|
578
|
+
if ignore_mode == "smart":
|
|
579
|
+
dirnames[:] = [
|
|
580
|
+
d for d in dirnames
|
|
581
|
+
if d not in HEAVY_BUILD_DIRS
|
|
582
|
+
and d not in GIT_DIRS
|
|
583
|
+
and not _is_hidden(current_p / d)
|
|
584
|
+
and not _matches_gitignore(gi_spec, str((current_p / d).relative_to(root)).replace("\\", "/"), is_dir=True)
|
|
585
|
+
]
|
|
586
|
+
elif ignore_mode == "git_only":
|
|
587
|
+
dirnames[:] = [d for d in dirnames if d not in GIT_DIRS]
|
|
588
|
+
|
|
589
|
+
# Directory matches
|
|
590
|
+
if item_type in ("directory", "any"):
|
|
591
|
+
for d in dirnames:
|
|
592
|
+
total_scanned += 1
|
|
593
|
+
d_path = current_p / d
|
|
594
|
+
rel_posix = str(d_path.relative_to(root)).replace("\\", "/")
|
|
595
|
+
if _matches_gitignore(gi_spec, rel_posix, is_dir=True):
|
|
596
|
+
continue
|
|
597
|
+
if pattern and not fnmatch.fnmatch(d, pattern):
|
|
598
|
+
continue
|
|
599
|
+
try:
|
|
600
|
+
st = d_path.stat()
|
|
601
|
+
mtime = datetime.fromtimestamp(st.st_mtime, tz=timezone.utc)
|
|
602
|
+
if dt_after and mtime < dt_after:
|
|
603
|
+
continue
|
|
604
|
+
if dt_before and mtime > dt_before:
|
|
605
|
+
continue
|
|
606
|
+
matches.append({
|
|
607
|
+
"name": d,
|
|
608
|
+
"path": str(d_path),
|
|
609
|
+
"relative_path": str(d_path.relative_to(root)),
|
|
610
|
+
"type": "directory",
|
|
611
|
+
"size_bytes": 0,
|
|
612
|
+
"size_formatted": "dir",
|
|
613
|
+
"modified_at": mtime.isoformat(),
|
|
614
|
+
})
|
|
615
|
+
if len(matches) >= max_results:
|
|
616
|
+
break
|
|
617
|
+
except Exception:
|
|
618
|
+
pass
|
|
619
|
+
if len(matches) >= max_results:
|
|
620
|
+
break
|
|
621
|
+
|
|
622
|
+
# File matches
|
|
623
|
+
if item_type in ("file", "any"):
|
|
624
|
+
for f in filenames:
|
|
625
|
+
total_scanned += 1
|
|
626
|
+
f_path = current_p / f
|
|
627
|
+
rel_posix = str(f_path.relative_to(root)).replace("\\", "/")
|
|
628
|
+
|
|
629
|
+
if _is_hidden(f_path):
|
|
630
|
+
continue
|
|
631
|
+
if _matches_gitignore(gi_spec, rel_posix, is_dir=False):
|
|
632
|
+
continue
|
|
633
|
+
if pattern and not fnmatch.fnmatch(f, pattern):
|
|
634
|
+
continue
|
|
635
|
+
if ext_set and f_path.suffix.lower() not in ext_set:
|
|
636
|
+
continue
|
|
637
|
+
|
|
638
|
+
try:
|
|
639
|
+
st = f_path.stat()
|
|
640
|
+
size = st.st_size
|
|
641
|
+
if min_size_bytes is not None and size < min_size_bytes:
|
|
642
|
+
continue
|
|
643
|
+
if max_size_bytes is not None and size > max_size_bytes:
|
|
644
|
+
continue
|
|
645
|
+
|
|
646
|
+
mtime = datetime.fromtimestamp(st.st_mtime, tz=timezone.utc)
|
|
647
|
+
if dt_after and mtime < dt_after:
|
|
648
|
+
continue
|
|
649
|
+
if dt_before and mtime > dt_before:
|
|
650
|
+
continue
|
|
651
|
+
|
|
652
|
+
matches.append({
|
|
653
|
+
"name": f,
|
|
654
|
+
"path": str(f_path),
|
|
655
|
+
"relative_path": str(f_path.relative_to(root)),
|
|
656
|
+
"type": "file",
|
|
657
|
+
"extension": f_path.suffix.lower(),
|
|
658
|
+
"size_bytes": size,
|
|
659
|
+
"size_formatted": _format_size(size),
|
|
660
|
+
"modified_at": mtime.isoformat(),
|
|
661
|
+
})
|
|
662
|
+
if len(matches) >= max_results:
|
|
663
|
+
break
|
|
664
|
+
except Exception:
|
|
665
|
+
continue
|
|
666
|
+
|
|
667
|
+
if len(matches) >= max_results:
|
|
668
|
+
break
|
|
669
|
+
|
|
670
|
+
return {
|
|
671
|
+
"search_path": str(root),
|
|
672
|
+
"total_scanned": total_scanned,
|
|
673
|
+
"match_count": len(matches),
|
|
674
|
+
"limit_reached": len(matches) >= max_results,
|
|
675
|
+
"matches": matches,
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
# ==========================================
|
|
680
|
+
# 4. LIGHTNING-FAST FUZZY FILE SEARCH
|
|
681
|
+
# ==========================================
|
|
682
|
+
|
|
683
|
+
@mcp.tool()
|
|
684
|
+
def fuzzy_find(
|
|
685
|
+
query: str,
|
|
686
|
+
search_path: str,
|
|
687
|
+
extensions: Optional[List[str]] = None,
|
|
688
|
+
score_cutoff: float = 50.0,
|
|
689
|
+
limit: int = 25,
|
|
690
|
+
ignore_mode: Literal["smart", "git_only", "none"] = "smart",
|
|
691
|
+
respect_gitignore: bool = True,
|
|
692
|
+
) -> Dict[str, Any]:
|
|
693
|
+
"""
|
|
694
|
+
Rapid, typo-tolerant fuzzy file search using RapidFuzz.
|
|
695
|
+
Find files by approximate name or abbreviation (e.g., 'usrctrl' -> 'user_controller.py', 'authsrv' -> 'auth_service.ts').
|
|
696
|
+
|
|
697
|
+
Args:
|
|
698
|
+
query: Approximate or partial file name to find.
|
|
699
|
+
search_path: Root folder to search.
|
|
700
|
+
extensions: Optional extension filter (e.g. ['py', 'ts']).
|
|
701
|
+
score_cutoff: Minimum similarity score from 0 to 100 (default 50.0).
|
|
702
|
+
limit: Max number of top matches to return (default 25).
|
|
703
|
+
ignore_mode: 'smart', 'git_only', or 'none'.
|
|
704
|
+
respect_gitignore: Honor .gitignore if present.
|
|
705
|
+
"""
|
|
706
|
+
root = Path(search_path).resolve()
|
|
707
|
+
if not root.exists() or not root.is_dir():
|
|
708
|
+
return {"error": f"Invalid directory: {root}"}
|
|
709
|
+
|
|
710
|
+
gi_spec = _load_gitignore_spec(root) if respect_gitignore and ignore_mode == "smart" else None
|
|
711
|
+
ext_set = {f".{ext.lower().lstrip('.')}" for ext in extensions} if extensions else None
|
|
712
|
+
|
|
713
|
+
# Collect candidate file paths
|
|
714
|
+
file_map = {}
|
|
715
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
716
|
+
current_p = Path(dirpath)
|
|
717
|
+
if ignore_mode == "smart":
|
|
718
|
+
dirnames[:] = [
|
|
719
|
+
d for d in dirnames
|
|
720
|
+
if d not in HEAVY_BUILD_DIRS
|
|
721
|
+
and d not in GIT_DIRS
|
|
722
|
+
and not _is_hidden(current_p / d)
|
|
723
|
+
and not _matches_gitignore(gi_spec, str((current_p / d).relative_to(root)).replace("\\", "/"), is_dir=True)
|
|
724
|
+
]
|
|
725
|
+
elif ignore_mode == "git_only":
|
|
726
|
+
dirnames[:] = [d for d in dirnames if d not in GIT_DIRS]
|
|
727
|
+
|
|
728
|
+
for f in filenames:
|
|
729
|
+
f_path = current_p / f
|
|
730
|
+
if _is_hidden(f_path):
|
|
731
|
+
continue
|
|
732
|
+
rel_posix = str(f_path.relative_to(root)).replace("\\", "/")
|
|
733
|
+
if _matches_gitignore(gi_spec, rel_posix, is_dir=False):
|
|
734
|
+
continue
|
|
735
|
+
if ext_set and f_path.suffix.lower() not in ext_set:
|
|
736
|
+
continue
|
|
737
|
+
|
|
738
|
+
# Key for fuzzy matching: relative path gives context (e.g. src/auth/service.py)
|
|
739
|
+
file_map[rel_posix] = f_path
|
|
740
|
+
|
|
741
|
+
if not file_map:
|
|
742
|
+
return {"query": query, "matches_found": 0, "matches": []}
|
|
743
|
+
|
|
744
|
+
# Perform RapidFuzz extraction
|
|
745
|
+
candidates = list(file_map.keys())
|
|
746
|
+
results = process.extract(
|
|
747
|
+
query,
|
|
748
|
+
candidates,
|
|
749
|
+
scorer=fuzz.WRatio,
|
|
750
|
+
score_cutoff=score_cutoff,
|
|
751
|
+
limit=limit,
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
formatted_matches = []
|
|
755
|
+
for match_rel_path, score, _ in results:
|
|
756
|
+
full_p = file_map[match_rel_path]
|
|
757
|
+
try:
|
|
758
|
+
st = full_p.stat()
|
|
759
|
+
formatted_matches.append({
|
|
760
|
+
"name": full_p.name,
|
|
761
|
+
"relative_path": match_rel_path,
|
|
762
|
+
"path": str(full_p),
|
|
763
|
+
"similarity_score": round(score, 1),
|
|
764
|
+
"size_formatted": _format_size(st.st_size),
|
|
765
|
+
"modified_at": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat(),
|
|
766
|
+
})
|
|
767
|
+
except Exception:
|
|
768
|
+
continue
|
|
769
|
+
|
|
770
|
+
return {
|
|
771
|
+
"query": query,
|
|
772
|
+
"search_path": str(root),
|
|
773
|
+
"total_files_indexed": len(file_map),
|
|
774
|
+
"matches_found": len(formatted_matches),
|
|
775
|
+
"matches": formatted_matches,
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
# ==========================================
|
|
780
|
+
# 5. FAST GREP / CONTENT SEARCH
|
|
781
|
+
# ==========================================
|
|
782
|
+
|
|
783
|
+
@mcp.tool()
|
|
784
|
+
def grep_search(
|
|
785
|
+
query: str,
|
|
786
|
+
search_path: str,
|
|
787
|
+
is_regex: bool = False,
|
|
788
|
+
case_sensitive: bool = False,
|
|
789
|
+
file_patterns: Optional[List[str]] = None,
|
|
790
|
+
ignore_mode: Literal["smart", "git_only", "none"] = "smart",
|
|
791
|
+
respect_gitignore: bool = True,
|
|
792
|
+
context_lines: int = 1,
|
|
793
|
+
max_file_size_mb: float = 5.0,
|
|
794
|
+
max_matches: int = 50,
|
|
795
|
+
) -> Dict[str, Any]:
|
|
796
|
+
"""
|
|
797
|
+
Search for text or regex patterns INSIDE files across a directory or within a single file.
|
|
798
|
+
|
|
799
|
+
Args:
|
|
800
|
+
query: Search term or regular expression pattern.
|
|
801
|
+
search_path: Directory or file to search.
|
|
802
|
+
is_regex: Treat query as a regex pattern (default False).
|
|
803
|
+
case_sensitive: Perform case-sensitive search (default False).
|
|
804
|
+
file_patterns: File globs to include, e.g. ["*.py", "*.ts", "*.json"].
|
|
805
|
+
ignore_mode: 'smart', 'git_only', or 'none'.
|
|
806
|
+
respect_gitignore: Honor .gitignore rules if present.
|
|
807
|
+
context_lines: Number of surrounding lines to return for context (0 to 3, default 1).
|
|
808
|
+
max_file_size_mb: Maximum file size to scan (default 5MB).
|
|
809
|
+
max_matches: Maximum number of matching lines to return (default 50, max 200).
|
|
810
|
+
"""
|
|
811
|
+
target = Path(search_path).resolve()
|
|
812
|
+
if not target.exists():
|
|
813
|
+
return {"error": f"Path not found: {target}"}
|
|
814
|
+
|
|
815
|
+
context_lines = min(max(0, context_lines), 3)
|
|
816
|
+
max_matches = min(max(1, max_matches), 200)
|
|
817
|
+
max_bytes = int(max_file_size_mb * 1024 * 1024)
|
|
818
|
+
|
|
819
|
+
flags = 0 if case_sensitive else re.IGNORECASE
|
|
820
|
+
try:
|
|
821
|
+
regex = re.compile(query if is_regex else re.escape(query), flags)
|
|
822
|
+
except re.error as e:
|
|
823
|
+
return {"error": f"Invalid regular expression: {str(e)}"}
|
|
824
|
+
|
|
825
|
+
files_to_scan = []
|
|
826
|
+
gi_spec = _load_gitignore_spec(target) if target.is_dir() and respect_gitignore and ignore_mode == "smart" else None
|
|
827
|
+
|
|
828
|
+
if target.is_file():
|
|
829
|
+
files_to_scan.append(target)
|
|
830
|
+
else:
|
|
831
|
+
for dirpath, dirnames, filenames in os.walk(target):
|
|
832
|
+
current_p = Path(dirpath)
|
|
833
|
+
if ignore_mode == "smart":
|
|
834
|
+
dirnames[:] = [
|
|
835
|
+
d for d in dirnames
|
|
836
|
+
if d not in HEAVY_BUILD_DIRS
|
|
837
|
+
and d not in GIT_DIRS
|
|
838
|
+
and not _is_hidden(current_p / d)
|
|
839
|
+
and not _matches_gitignore(gi_spec, str((current_p / d).relative_to(target)).replace("\\", "/"), is_dir=True)
|
|
840
|
+
]
|
|
841
|
+
elif ignore_mode == "git_only":
|
|
842
|
+
dirnames[:] = [d for d in dirnames if d not in GIT_DIRS]
|
|
843
|
+
|
|
844
|
+
for f in filenames:
|
|
845
|
+
f_path = current_p / f
|
|
846
|
+
if _is_hidden(f_path):
|
|
847
|
+
continue
|
|
848
|
+
rel_posix = str(f_path.relative_to(target)).replace("\\", "/")
|
|
849
|
+
if _matches_gitignore(gi_spec, rel_posix, is_dir=False):
|
|
850
|
+
continue
|
|
851
|
+
if file_patterns and not any(fnmatch.fnmatch(f, pat) for pat in file_patterns):
|
|
852
|
+
continue
|
|
853
|
+
if _is_binary_file(f_path):
|
|
854
|
+
continue
|
|
855
|
+
try:
|
|
856
|
+
if f_path.stat().st_size <= max_bytes:
|
|
857
|
+
files_to_scan.append(f_path)
|
|
858
|
+
except Exception:
|
|
859
|
+
pass
|
|
860
|
+
|
|
861
|
+
matches = []
|
|
862
|
+
files_with_matches = set()
|
|
863
|
+
total_scanned_files = 0
|
|
864
|
+
|
|
865
|
+
for f_path in files_to_scan:
|
|
866
|
+
total_scanned_files += 1
|
|
867
|
+
try:
|
|
868
|
+
content, _ = _read_file_text_with_fallback(f_path)
|
|
869
|
+
lines = content.splitlines()
|
|
870
|
+
|
|
871
|
+
for idx, line in enumerate(lines):
|
|
872
|
+
if regex.search(line):
|
|
873
|
+
files_with_matches.add(str(f_path))
|
|
874
|
+
line_no = idx + 1
|
|
875
|
+
|
|
876
|
+
start_ctx = max(0, idx - context_lines)
|
|
877
|
+
end_ctx = min(len(lines), idx + context_lines + 1)
|
|
878
|
+
ctx_snippet = []
|
|
879
|
+
for c_idx in range(start_ctx, end_ctx):
|
|
880
|
+
prefix = " > " if c_idx == idx else " "
|
|
881
|
+
raw_c = lines[c_idx]
|
|
882
|
+
if len(raw_c) > 200:
|
|
883
|
+
raw_c = raw_c[:200] + "... [line truncated]"
|
|
884
|
+
ctx_snippet.append(f"{c_idx + 1:4d}{prefix}{raw_c}")
|
|
885
|
+
|
|
886
|
+
line_c = line.strip()
|
|
887
|
+
if len(line_c) > 200:
|
|
888
|
+
line_c = line_c[:200] + "... [line truncated]"
|
|
889
|
+
|
|
890
|
+
matches.append({
|
|
891
|
+
"file": str(f_path),
|
|
892
|
+
"line_number": line_no,
|
|
893
|
+
"line_content": line_c,
|
|
894
|
+
"context": "\n".join(ctx_snippet),
|
|
895
|
+
})
|
|
896
|
+
|
|
897
|
+
if len(matches) >= max_matches:
|
|
898
|
+
break
|
|
899
|
+
if len(matches) >= max_matches:
|
|
900
|
+
break
|
|
901
|
+
except Exception:
|
|
902
|
+
continue
|
|
903
|
+
|
|
904
|
+
res_grep: Dict[str, Any] = {
|
|
905
|
+
"query": query,
|
|
906
|
+
"is_regex": is_regex,
|
|
907
|
+
"total_files_scanned": total_scanned_files,
|
|
908
|
+
"matching_files_count": len(files_with_matches),
|
|
909
|
+
"total_matches": len(matches),
|
|
910
|
+
"limit_reached": len(matches) >= max_matches,
|
|
911
|
+
"matches": matches,
|
|
912
|
+
}
|
|
913
|
+
if len(matches) >= max_matches:
|
|
914
|
+
res_grep["notice"] = f"... [TRUNCATED: Reached limit of {max_matches} matches. Narrow your query or file_patterns to see more] ..."
|
|
915
|
+
return res_grep
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
# ==========================================
|
|
919
|
+
# 6. CODE OUTLINE & STRUCTURE EXTRACTOR
|
|
920
|
+
# ==========================================
|
|
921
|
+
|
|
922
|
+
@mcp.tool()
|
|
923
|
+
def get_code_outline(
|
|
924
|
+
file_path: str,
|
|
925
|
+
) -> Dict[str, Any]:
|
|
926
|
+
"""
|
|
927
|
+
Extract high-level classes, functions, interfaces, methods, and exports from a code file
|
|
928
|
+
without loading thousands of lines of implementation. Saves 90%+ context window tokens.
|
|
929
|
+
Supports Python, JavaScript, TypeScript, and JSON files.
|
|
930
|
+
|
|
931
|
+
Args:
|
|
932
|
+
file_path: Path to the source file (.py, .js, .ts, .jsx, .tsx, .json).
|
|
933
|
+
"""
|
|
934
|
+
p = Path(file_path).resolve()
|
|
935
|
+
if not p.exists() or not p.is_file():
|
|
936
|
+
return {"error": f"File not found: {p}"}
|
|
937
|
+
|
|
938
|
+
ext = p.suffix.lower()
|
|
939
|
+
symbols = []
|
|
940
|
+
|
|
941
|
+
try:
|
|
942
|
+
content, enc = _read_file_text_with_fallback(p)
|
|
943
|
+
lines = content.splitlines()
|
|
944
|
+
total_lines = len(lines)
|
|
945
|
+
|
|
946
|
+
# 1. PYTHON AST PARSER
|
|
947
|
+
if ext in (".py", ".pyw"):
|
|
948
|
+
try:
|
|
949
|
+
tree = ast.parse(content, filename=str(p))
|
|
950
|
+
for node in tree.body:
|
|
951
|
+
if isinstance(node, ast.ClassDef):
|
|
952
|
+
bases = [getattr(b, "id", getattr(b, "attr", "Unknown")) for b in node.bases]
|
|
953
|
+
doc = ast.get_docstring(node)
|
|
954
|
+
first_doc = doc.splitlines()[0] if doc else None
|
|
955
|
+
methods = []
|
|
956
|
+
for sub in node.body:
|
|
957
|
+
if isinstance(sub, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
958
|
+
m_doc = ast.get_docstring(sub)
|
|
959
|
+
args = [a.arg for a in sub.args.args]
|
|
960
|
+
methods.append({
|
|
961
|
+
"name": sub.name,
|
|
962
|
+
"type": "async_method" if isinstance(sub, ast.AsyncFunctionDef) else "method",
|
|
963
|
+
"line": sub.lineno,
|
|
964
|
+
"args": args,
|
|
965
|
+
"docstring": m_doc.splitlines()[0] if m_doc else None,
|
|
966
|
+
})
|
|
967
|
+
symbols.append({
|
|
968
|
+
"name": node.name,
|
|
969
|
+
"type": "class",
|
|
970
|
+
"line": node.lineno,
|
|
971
|
+
"bases": bases,
|
|
972
|
+
"docstring": first_doc,
|
|
973
|
+
"methods": methods,
|
|
974
|
+
})
|
|
975
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
976
|
+
doc = ast.get_docstring(node)
|
|
977
|
+
first_doc = doc.splitlines()[0] if doc else None
|
|
978
|
+
args = [a.arg for a in node.args.args]
|
|
979
|
+
symbols.append({
|
|
980
|
+
"name": node.name,
|
|
981
|
+
"type": "async_function" if isinstance(node, ast.AsyncFunctionDef) else "function",
|
|
982
|
+
"line": node.lineno,
|
|
983
|
+
"args": args,
|
|
984
|
+
"docstring": first_doc,
|
|
985
|
+
})
|
|
986
|
+
except SyntaxError as se:
|
|
987
|
+
return {"error": f"Python syntax error in file: {str(se)}"}
|
|
988
|
+
|
|
989
|
+
# 2. JS / TS REGEX PARSER
|
|
990
|
+
elif ext in (".js", ".ts", ".jsx", ".tsx", ".mjs"):
|
|
991
|
+
pat = re.compile(
|
|
992
|
+
r"^\s*(?:export\s+(?:default\s+)?)?(?:async\s+)?(class|function|interface|type|enum|const|let|var)\s+([A-Za-z0-9_$]+)",
|
|
993
|
+
re.MULTILINE,
|
|
994
|
+
)
|
|
995
|
+
for idx, line in enumerate(lines):
|
|
996
|
+
m = pat.match(line)
|
|
997
|
+
if m:
|
|
998
|
+
kind, name = m.group(1), m.group(2)
|
|
999
|
+
symbols.append({
|
|
1000
|
+
"name": name,
|
|
1001
|
+
"type": kind,
|
|
1002
|
+
"line": idx + 1,
|
|
1003
|
+
"signature": line.strip()[:100],
|
|
1004
|
+
})
|
|
1005
|
+
|
|
1006
|
+
# 3. JSON SHAPE
|
|
1007
|
+
elif ext == ".json":
|
|
1008
|
+
try:
|
|
1009
|
+
data = json.loads(content)
|
|
1010
|
+
if isinstance(data, dict):
|
|
1011
|
+
symbols = [
|
|
1012
|
+
{"key": k, "type": type(v).__name__, "length": len(v) if isinstance(v, (list, dict)) else None}
|
|
1013
|
+
for k, v in data.items()
|
|
1014
|
+
]
|
|
1015
|
+
elif isinstance(data, list):
|
|
1016
|
+
symbols = [{"total_array_items": len(data), "sample_element_type": type(data[0]).__name__ if data else None}]
|
|
1017
|
+
except Exception as je:
|
|
1018
|
+
return {"error": f"Invalid JSON file: {str(je)}"}
|
|
1019
|
+
|
|
1020
|
+
return {
|
|
1021
|
+
"file": str(p),
|
|
1022
|
+
"extension": ext,
|
|
1023
|
+
"total_lines": total_lines,
|
|
1024
|
+
"symbols_count": len(symbols),
|
|
1025
|
+
"symbols": symbols,
|
|
1026
|
+
}
|
|
1027
|
+
except Exception as e:
|
|
1028
|
+
return {"error": f"Failed to extract outline: {str(e)}"}
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
# ==========================================
|
|
1032
|
+
# 7. TOKEN-SAFE FILE READING
|
|
1033
|
+
# ==========================================
|
|
1034
|
+
|
|
1035
|
+
@mcp.tool()
|
|
1036
|
+
def read_file(
|
|
1037
|
+
file_path: str,
|
|
1038
|
+
start_line: Optional[int] = None,
|
|
1039
|
+
end_line: Optional[int] = None,
|
|
1040
|
+
line_numbers: bool = True,
|
|
1041
|
+
max_lines: int = 250,
|
|
1042
|
+
encoding: Optional[str] = None,
|
|
1043
|
+
) -> Dict[str, Any]:
|
|
1044
|
+
"""
|
|
1045
|
+
Read text from a file with precise line range windowing and token protection.
|
|
1046
|
+
Detects and protects against binary file dumps.
|
|
1047
|
+
|
|
1048
|
+
Args:
|
|
1049
|
+
file_path: Path to the file.
|
|
1050
|
+
start_line: First line to read (1-indexed, inclusive). Default is 1.
|
|
1051
|
+
end_line: Last line to read (1-indexed, inclusive). Default is min(start + max_lines, total).
|
|
1052
|
+
line_numbers: Prepend line numbers to the output lines (default True).
|
|
1053
|
+
max_lines: Safety cap on number of lines returned (default 250, max 500).
|
|
1054
|
+
encoding: Optional explicit character encoding. Auto-detects if None.
|
|
1055
|
+
"""
|
|
1056
|
+
p = Path(file_path).resolve()
|
|
1057
|
+
if not p.exists():
|
|
1058
|
+
return {"error": f"File not found: {p}"}
|
|
1059
|
+
if not p.is_file():
|
|
1060
|
+
return {"error": f"Path is a directory, not a file: {p}"}
|
|
1061
|
+
|
|
1062
|
+
if _is_binary_file(p):
|
|
1063
|
+
st = p.stat()
|
|
1064
|
+
mime, _ = mimetypes.guess_type(str(p))
|
|
1065
|
+
return {
|
|
1066
|
+
"is_binary": True,
|
|
1067
|
+
"path": str(p),
|
|
1068
|
+
"size_bytes": st.st_size,
|
|
1069
|
+
"size_formatted": _format_size(st.st_size),
|
|
1070
|
+
"mime_type": mime or "application/octet-stream",
|
|
1071
|
+
"message": "File appears to be binary. Content reading was skipped to protect context window.",
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
max_lines = min(max(1, max_lines), 500)
|
|
1075
|
+
|
|
1076
|
+
try:
|
|
1077
|
+
if encoding:
|
|
1078
|
+
with open(p, "r", encoding=encoding, errors="replace") as f:
|
|
1079
|
+
content = f.read()
|
|
1080
|
+
detected_enc = encoding
|
|
1081
|
+
else:
|
|
1082
|
+
content, detected_enc = _read_file_text_with_fallback(p)
|
|
1083
|
+
|
|
1084
|
+
all_lines = content.splitlines()
|
|
1085
|
+
total_lines = len(all_lines)
|
|
1086
|
+
|
|
1087
|
+
s_line = 1 if start_line is None else max(1, start_line)
|
|
1088
|
+
e_line = total_lines if end_line is None else min(total_lines, end_line)
|
|
1089
|
+
|
|
1090
|
+
if (e_line - s_line + 1) > max_lines:
|
|
1091
|
+
e_line = s_line + max_lines - 1
|
|
1092
|
+
|
|
1093
|
+
selected = all_lines[s_line - 1 : e_line]
|
|
1094
|
+
|
|
1095
|
+
if line_numbers:
|
|
1096
|
+
width = len(str(e_line))
|
|
1097
|
+
output_lines = [f"{s_line + idx:{width}d} | {line}" for idx, line in enumerate(selected)]
|
|
1098
|
+
else:
|
|
1099
|
+
output_lines = selected
|
|
1100
|
+
|
|
1101
|
+
res: Dict[str, Any] = {
|
|
1102
|
+
"path": str(p),
|
|
1103
|
+
"total_lines": total_lines,
|
|
1104
|
+
"start_line": s_line,
|
|
1105
|
+
"end_line": e_line,
|
|
1106
|
+
"lines_returned": len(output_lines),
|
|
1107
|
+
"has_more": e_line < total_lines,
|
|
1108
|
+
"encoding": detected_enc,
|
|
1109
|
+
"content": "\n".join(output_lines),
|
|
1110
|
+
}
|
|
1111
|
+
if e_line < total_lines:
|
|
1112
|
+
res["notice"] = f"... [TRUNCATED: Showing lines {s_line}-{e_line} of {total_lines}. Use start_line={e_line+1} to view next window] ..."
|
|
1113
|
+
return res
|
|
1114
|
+
except Exception as e:
|
|
1115
|
+
return {"error": f"Failed to read file: {str(e)}"}
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
@mcp.tool()
|
|
1119
|
+
def read_head_tail(
|
|
1120
|
+
file_path: str,
|
|
1121
|
+
mode: Literal["head", "tail"] = "head",
|
|
1122
|
+
lines: int = 50,
|
|
1123
|
+
line_numbers: bool = True,
|
|
1124
|
+
) -> Dict[str, Any]:
|
|
1125
|
+
"""
|
|
1126
|
+
Quickly read the first N lines (head) or last N lines (tail) of a file.
|
|
1127
|
+
Ideal for inspection of large logs, data files, or configs.
|
|
1128
|
+
|
|
1129
|
+
Args:
|
|
1130
|
+
file_path: Path to the file.
|
|
1131
|
+
mode: 'head' for top lines or 'tail' for bottom lines.
|
|
1132
|
+
lines: Number of lines to retrieve (default 50, max 500).
|
|
1133
|
+
line_numbers: Prepend line numbers (default True).
|
|
1134
|
+
"""
|
|
1135
|
+
lines = min(max(1, lines), 500)
|
|
1136
|
+
p = Path(file_path).resolve()
|
|
1137
|
+
if not p.exists() or not p.is_file():
|
|
1138
|
+
return {"error": f"File not found or not a regular file: {p}"}
|
|
1139
|
+
|
|
1140
|
+
try:
|
|
1141
|
+
content, _ = _read_file_text_with_fallback(p)
|
|
1142
|
+
all_lines = content.splitlines()
|
|
1143
|
+
total_lines = len(all_lines)
|
|
1144
|
+
|
|
1145
|
+
if mode == "head":
|
|
1146
|
+
start_idx = 0
|
|
1147
|
+
end_idx = min(total_lines, lines)
|
|
1148
|
+
else:
|
|
1149
|
+
start_idx = max(0, total_lines - lines)
|
|
1150
|
+
end_idx = total_lines
|
|
1151
|
+
|
|
1152
|
+
selected = all_lines[start_idx:end_idx]
|
|
1153
|
+
|
|
1154
|
+
if line_numbers:
|
|
1155
|
+
width = len(str(end_idx))
|
|
1156
|
+
output_lines = [f"{start_idx + idx + 1:{width}d} | {line}" for idx, line in enumerate(selected)]
|
|
1157
|
+
else:
|
|
1158
|
+
output_lines = selected
|
|
1159
|
+
|
|
1160
|
+
return {
|
|
1161
|
+
"path": str(p),
|
|
1162
|
+
"mode": mode,
|
|
1163
|
+
"total_lines": total_lines,
|
|
1164
|
+
"lines_returned": len(output_lines),
|
|
1165
|
+
"content": "\n".join(output_lines),
|
|
1166
|
+
}
|
|
1167
|
+
except Exception as e:
|
|
1168
|
+
return {"error": f"Failed to read file: {str(e)}"}
|
|
1169
|
+
|
|
1170
|
+
|
|
1171
|
+
# ==========================================
|
|
1172
|
+
# 8. FILE METADATA & DIAGNOSTICS
|
|
1173
|
+
# ==========================================
|
|
1174
|
+
|
|
1175
|
+
@mcp.tool()
|
|
1176
|
+
def get_file_info(
|
|
1177
|
+
path: str,
|
|
1178
|
+
include_hash: bool = False,
|
|
1179
|
+
) -> Dict[str, Any]:
|
|
1180
|
+
"""
|
|
1181
|
+
Get detailed diagnostic metadata about any file or directory.
|
|
1182
|
+
|
|
1183
|
+
Args:
|
|
1184
|
+
path: File or directory path.
|
|
1185
|
+
include_hash: Compute SHA256 and MD5 hashes (files only, default False).
|
|
1186
|
+
"""
|
|
1187
|
+
p = Path(path).resolve()
|
|
1188
|
+
if not p.exists():
|
|
1189
|
+
return {"error": f"Path not found: {p}"}
|
|
1190
|
+
|
|
1191
|
+
try:
|
|
1192
|
+
st = p.stat()
|
|
1193
|
+
is_dir = p.is_dir()
|
|
1194
|
+
is_sym = p.is_symlink()
|
|
1195
|
+
is_bin = False if is_dir else _is_binary_file(p)
|
|
1196
|
+
|
|
1197
|
+
line_count = None
|
|
1198
|
+
line_ending = None
|
|
1199
|
+
if not is_dir and not is_bin:
|
|
1200
|
+
try:
|
|
1201
|
+
content, _ = _read_file_text_with_fallback(p)
|
|
1202
|
+
line_count = len(content.splitlines())
|
|
1203
|
+
if "\r\n" in content:
|
|
1204
|
+
line_ending = "CRLF (Windows)"
|
|
1205
|
+
elif "\n" in content:
|
|
1206
|
+
line_ending = "LF (Unix)"
|
|
1207
|
+
except Exception:
|
|
1208
|
+
pass
|
|
1209
|
+
|
|
1210
|
+
hashes = {}
|
|
1211
|
+
if include_hash and not is_dir and st.st_size <= 100 * 1024 * 1024:
|
|
1212
|
+
try:
|
|
1213
|
+
with open(p, "rb") as f:
|
|
1214
|
+
data = f.read()
|
|
1215
|
+
hashes["md5"] = hashlib.md5(data).hexdigest()
|
|
1216
|
+
hashes["sha256"] = hashlib.sha256(data).hexdigest()
|
|
1217
|
+
except Exception:
|
|
1218
|
+
pass
|
|
1219
|
+
|
|
1220
|
+
return {
|
|
1221
|
+
"path": str(p),
|
|
1222
|
+
"name": p.name,
|
|
1223
|
+
"type": "directory" if is_dir else "file",
|
|
1224
|
+
"is_dir": is_dir,
|
|
1225
|
+
"is_symlink": is_sym,
|
|
1226
|
+
"is_hidden": _is_hidden(p),
|
|
1227
|
+
"is_binary": is_bin,
|
|
1228
|
+
"extension": p.suffix.lower() if not is_dir else "",
|
|
1229
|
+
"size_bytes": st.st_size if not is_dir else None,
|
|
1230
|
+
"size_formatted": _format_size(st.st_size) if not is_dir else None,
|
|
1231
|
+
"created_at": datetime.fromtimestamp(st.st_ctime, tz=timezone.utc).isoformat(),
|
|
1232
|
+
"modified_at": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat(),
|
|
1233
|
+
"line_count": line_count,
|
|
1234
|
+
"line_ending": line_ending,
|
|
1235
|
+
"hashes": hashes if include_hash else None,
|
|
1236
|
+
}
|
|
1237
|
+
except Exception as e:
|
|
1238
|
+
return {"error": f"Failed to retrieve file info: {str(e)}"}
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
# ==========================================
|
|
1242
|
+
# 9. WORKSPACE DIAGNOSTIC SUMMARY
|
|
1243
|
+
# ==========================================
|
|
1244
|
+
|
|
1245
|
+
@mcp.tool()
|
|
1246
|
+
def workspace_summary(
|
|
1247
|
+
path: str,
|
|
1248
|
+
top_n: int = 5,
|
|
1249
|
+
ignore_mode: Literal["smart", "git_only", "none"] = "smart",
|
|
1250
|
+
respect_gitignore: bool = True,
|
|
1251
|
+
) -> Dict[str, Any]:
|
|
1252
|
+
"""
|
|
1253
|
+
Instant comprehensive overview of a project/workspace folder:
|
|
1254
|
+
file count breakdown by extension, total size, recent files, largest files, and Git status.
|
|
1255
|
+
|
|
1256
|
+
Args:
|
|
1257
|
+
path: Path to the workspace root directory.
|
|
1258
|
+
top_n: Number of largest / recent files to highlight (default 5).
|
|
1259
|
+
ignore_mode: 'smart', 'git_only', or 'none'.
|
|
1260
|
+
respect_gitignore: Honor .gitignore if present.
|
|
1261
|
+
"""
|
|
1262
|
+
root = Path(path).resolve()
|
|
1263
|
+
if not root.exists() or not root.is_dir():
|
|
1264
|
+
return {"error": f"Invalid workspace directory: {root}"}
|
|
1265
|
+
|
|
1266
|
+
gi_spec = _load_gitignore_spec(root) if respect_gitignore and ignore_mode == "smart" else None
|
|
1267
|
+
|
|
1268
|
+
ext_counts: Dict[str, int] = {}
|
|
1269
|
+
ext_sizes: Dict[str, int] = {}
|
|
1270
|
+
total_files = 0
|
|
1271
|
+
total_dirs = 0
|
|
1272
|
+
total_bytes = 0
|
|
1273
|
+
|
|
1274
|
+
all_files_info = []
|
|
1275
|
+
|
|
1276
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
1277
|
+
current_p = Path(dirpath)
|
|
1278
|
+
if ignore_mode == "smart":
|
|
1279
|
+
dirnames[:] = [
|
|
1280
|
+
d for d in dirnames
|
|
1281
|
+
if d not in HEAVY_BUILD_DIRS
|
|
1282
|
+
and d not in GIT_DIRS
|
|
1283
|
+
and not _is_hidden(current_p / d)
|
|
1284
|
+
and not _matches_gitignore(gi_spec, str((current_p / d).relative_to(root)).replace("\\", "/"), is_dir=True)
|
|
1285
|
+
]
|
|
1286
|
+
elif ignore_mode == "git_only":
|
|
1287
|
+
dirnames[:] = [d for d in dirnames if d not in GIT_DIRS]
|
|
1288
|
+
|
|
1289
|
+
total_dirs += len(dirnames)
|
|
1290
|
+
|
|
1291
|
+
for f in filenames:
|
|
1292
|
+
f_path = current_p / f
|
|
1293
|
+
if _is_hidden(f_path):
|
|
1294
|
+
continue
|
|
1295
|
+
rel_posix = str(f_path.relative_to(root)).replace("\\", "/")
|
|
1296
|
+
if _matches_gitignore(gi_spec, rel_posix, is_dir=False):
|
|
1297
|
+
continue
|
|
1298
|
+
|
|
1299
|
+
try:
|
|
1300
|
+
st = f_path.stat()
|
|
1301
|
+
sz = st.st_size
|
|
1302
|
+
mtime = st.st_mtime
|
|
1303
|
+
ext = f_path.suffix.lower() or "[no extension]"
|
|
1304
|
+
|
|
1305
|
+
total_files += 1
|
|
1306
|
+
total_bytes += sz
|
|
1307
|
+
ext_counts[ext] = ext_counts.get(ext, 0) + 1
|
|
1308
|
+
ext_sizes[ext] = ext_sizes.get(ext, 0) + sz
|
|
1309
|
+
|
|
1310
|
+
all_files_info.append({
|
|
1311
|
+
"name": f,
|
|
1312
|
+
"relative_path": rel_posix,
|
|
1313
|
+
"size_bytes": sz,
|
|
1314
|
+
"size_formatted": _format_size(sz),
|
|
1315
|
+
"modified_at": datetime.fromtimestamp(mtime, tz=timezone.utc).isoformat(),
|
|
1316
|
+
"mtime_ts": mtime,
|
|
1317
|
+
})
|
|
1318
|
+
except Exception:
|
|
1319
|
+
continue
|
|
1320
|
+
|
|
1321
|
+
largest_raw = sorted(all_files_info, key=lambda x: x["size_bytes"], reverse=True)[:top_n]
|
|
1322
|
+
largest_files = [{k: v for k, v in item.items() if k != "mtime_ts"} for item in largest_raw]
|
|
1323
|
+
|
|
1324
|
+
recent_raw = sorted(all_files_info, key=lambda x: x["mtime_ts"], reverse=True)[:top_n]
|
|
1325
|
+
recent_files = [{k: v for k, v in item.items() if k != "mtime_ts"} for item in recent_raw]
|
|
1326
|
+
|
|
1327
|
+
top_extensions = [
|
|
1328
|
+
{"extension": k, "count": ext_counts[k], "total_size": _format_size(ext_sizes[k])}
|
|
1329
|
+
for k in sorted(ext_counts.keys(), key=lambda k: ext_counts[k], reverse=True)[:10]
|
|
1330
|
+
]
|
|
1331
|
+
|
|
1332
|
+
git_branch = None
|
|
1333
|
+
git_status = None
|
|
1334
|
+
git_dir = root / ".git"
|
|
1335
|
+
if git_dir.exists():
|
|
1336
|
+
try:
|
|
1337
|
+
res_b = subprocess.run(["git", "branch", "--show-current"], cwd=str(root), capture_output=True, text=True, timeout=3)
|
|
1338
|
+
if res_b.returncode == 0:
|
|
1339
|
+
git_branch = res_b.stdout.strip()
|
|
1340
|
+
res_s = subprocess.run(["git", "status", "--porcelain"], cwd=str(root), capture_output=True, text=True, timeout=3)
|
|
1341
|
+
if res_s.returncode == 0:
|
|
1342
|
+
chg = len([l for l in res_s.stdout.splitlines() if l.strip()])
|
|
1343
|
+
git_status = f"{chg} modified/untracked files" if chg else "clean working tree"
|
|
1344
|
+
except Exception:
|
|
1345
|
+
pass
|
|
1346
|
+
|
|
1347
|
+
return {
|
|
1348
|
+
"workspace_root": str(root),
|
|
1349
|
+
"total_files": total_files,
|
|
1350
|
+
"total_directories": total_dirs,
|
|
1351
|
+
"total_size_bytes": total_bytes,
|
|
1352
|
+
"total_size_formatted": _format_size(total_bytes),
|
|
1353
|
+
"git_branch": git_branch,
|
|
1354
|
+
"git_status": git_status,
|
|
1355
|
+
"top_file_types": top_extensions,
|
|
1356
|
+
"largest_files": largest_files,
|
|
1357
|
+
"recently_modified_files": recent_files,
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
# ==========================================
|
|
1362
|
+
# 10. EXPORT COMPLETE INVENTORY TO FILE
|
|
1363
|
+
# ==========================================
|
|
1364
|
+
|
|
1365
|
+
@mcp.tool()
|
|
1366
|
+
def export_tree_to_file(
|
|
1367
|
+
path: str,
|
|
1368
|
+
output_file_path: Optional[str] = None,
|
|
1369
|
+
max_depth: int = 8,
|
|
1370
|
+
include_files: bool = True,
|
|
1371
|
+
format: Literal["markdown", "json"] = "markdown",
|
|
1372
|
+
) -> Dict[str, Any]:
|
|
1373
|
+
"""
|
|
1374
|
+
Export a complete un-truncated directory map or file inventory directly to a file on disk.
|
|
1375
|
+
This avoids consuming chat context window tokens while creating persistent workspace documentation.
|
|
1376
|
+
|
|
1377
|
+
Args:
|
|
1378
|
+
path: Root directory to map.
|
|
1379
|
+
output_file_path: Target path to save the map (default is <path>/workspace_map.md or .json).
|
|
1380
|
+
max_depth: Maximum depth to export (default 8).
|
|
1381
|
+
include_files: Whether to include files (default True).
|
|
1382
|
+
format: 'markdown' or 'json'.
|
|
1383
|
+
"""
|
|
1384
|
+
root = Path(path).resolve()
|
|
1385
|
+
if not root.exists() or not root.is_dir():
|
|
1386
|
+
return {"error": f"Invalid directory: {root}"}
|
|
1387
|
+
|
|
1388
|
+
if output_file_path:
|
|
1389
|
+
out_p = Path(output_file_path).resolve()
|
|
1390
|
+
else:
|
|
1391
|
+
ext = "json" if format == "json" else "md"
|
|
1392
|
+
out_p = root / f"workspace_map.{ext}"
|
|
1393
|
+
|
|
1394
|
+
try:
|
|
1395
|
+
out_p.parent.mkdir(parents=True, exist_ok=True)
|
|
1396
|
+
if format == "json":
|
|
1397
|
+
tree_data = get_directory_tree(
|
|
1398
|
+
path=str(root),
|
|
1399
|
+
max_depth=max_depth,
|
|
1400
|
+
include_files=include_files,
|
|
1401
|
+
max_nodes=100000,
|
|
1402
|
+
format="json",
|
|
1403
|
+
)
|
|
1404
|
+
with open(out_p, "w", encoding="utf-8") as f:
|
|
1405
|
+
json.dump(tree_data, f, indent=2)
|
|
1406
|
+
else:
|
|
1407
|
+
tree_text = get_directory_tree(
|
|
1408
|
+
path=str(root),
|
|
1409
|
+
max_depth=max_depth,
|
|
1410
|
+
include_files=include_files,
|
|
1411
|
+
max_nodes=100000,
|
|
1412
|
+
format="tree",
|
|
1413
|
+
tree_style="ascii",
|
|
1414
|
+
)
|
|
1415
|
+
header = f"# Workspace Map for {root.name}\nGenerated: {datetime.now(timezone.utc).isoformat()}\n\n```text\n"
|
|
1416
|
+
footer = "\n```\n"
|
|
1417
|
+
with open(out_p, "w", encoding="utf-8") as f:
|
|
1418
|
+
f.write(header + str(tree_text) + footer)
|
|
1419
|
+
|
|
1420
|
+
st = out_p.stat()
|
|
1421
|
+
return {
|
|
1422
|
+
"success": True,
|
|
1423
|
+
"export_path": str(out_p),
|
|
1424
|
+
"size_formatted": _format_size(st.st_size),
|
|
1425
|
+
"format": format,
|
|
1426
|
+
"message": f"Full workspace map saved successfully to {out_p.name}",
|
|
1427
|
+
}
|
|
1428
|
+
except Exception as e:
|
|
1429
|
+
return {"error": f"Failed to export tree to file: {str(e)}"}
|
|
1430
|
+
|
|
1431
|
+
|
|
1432
|
+
# ==========================================
|
|
1433
|
+
# 12. ADVANCED EXPLORER TOOLS
|
|
1434
|
+
# ==========================================
|
|
1435
|
+
|
|
1436
|
+
@mcp.tool()
|
|
1437
|
+
def find_symbol(
|
|
1438
|
+
path: str,
|
|
1439
|
+
symbol_name: str,
|
|
1440
|
+
exact_match: bool = False,
|
|
1441
|
+
language: Optional[str] = None,
|
|
1442
|
+
max_results: int = 50,
|
|
1443
|
+
) -> Dict[str, Any]:
|
|
1444
|
+
"""
|
|
1445
|
+
Search for symbol definitions (functions, classes, methods, interfaces, types) across codebase files.
|
|
1446
|
+
Honors .gitignore and smart ignore rules.
|
|
1447
|
+
|
|
1448
|
+
Args:
|
|
1449
|
+
path: Workspace or directory root to search.
|
|
1450
|
+
symbol_name: Name of symbol (e.g. 'UserService', 'calculate_tax', 'handle_request').
|
|
1451
|
+
exact_match: If True, requires exact case-sensitive match; if False, case-insensitive substring match.
|
|
1452
|
+
language: Optional language filter ('python', 'typescript', 'javascript', 'go', 'rust').
|
|
1453
|
+
max_results: Max results to return (default 50, max 150).
|
|
1454
|
+
"""
|
|
1455
|
+
root = Path(path).resolve()
|
|
1456
|
+
if not root.exists():
|
|
1457
|
+
return {"error": f"Path not found: {root}"}
|
|
1458
|
+
|
|
1459
|
+
safe_max = min(max(1, max_results), 150)
|
|
1460
|
+
gi_spec = _load_gitignore_spec(root) if root.is_dir() else None
|
|
1461
|
+
|
|
1462
|
+
# Extension mapping
|
|
1463
|
+
lang_exts = {
|
|
1464
|
+
"python": {".py"},
|
|
1465
|
+
"typescript": {".ts", ".tsx"},
|
|
1466
|
+
"javascript": {".js", ".jsx", ".mjs"},
|
|
1467
|
+
"go": {".go"},
|
|
1468
|
+
"rust": {".rs"},
|
|
1469
|
+
}
|
|
1470
|
+
allowed_exts: Optional[Set[str]] = None
|
|
1471
|
+
if language:
|
|
1472
|
+
allowed_exts = lang_exts.get(language.lower(), {f".{language.lower().lstrip('.')}"})
|
|
1473
|
+
|
|
1474
|
+
files_to_scan = []
|
|
1475
|
+
if root.is_file():
|
|
1476
|
+
files_to_scan.append(root)
|
|
1477
|
+
else:
|
|
1478
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
1479
|
+
curr = Path(dirpath)
|
|
1480
|
+
dirnames[:] = [
|
|
1481
|
+
d for d in dirnames
|
|
1482
|
+
if d not in HEAVY_BUILD_DIRS
|
|
1483
|
+
and d not in GIT_DIRS
|
|
1484
|
+
and not _is_hidden(curr / d)
|
|
1485
|
+
and not _matches_gitignore(gi_spec, str((curr / d).relative_to(root)).replace("\\", "/"), is_dir=True)
|
|
1486
|
+
]
|
|
1487
|
+
for f in filenames:
|
|
1488
|
+
fp = curr / f
|
|
1489
|
+
if _is_hidden(fp) or _is_binary_file(fp):
|
|
1490
|
+
continue
|
|
1491
|
+
rel_posix = str(fp.relative_to(root)).replace("\\", "/")
|
|
1492
|
+
if _matches_gitignore(gi_spec, rel_posix, is_dir=False):
|
|
1493
|
+
continue
|
|
1494
|
+
ext = fp.suffix.lower()
|
|
1495
|
+
if allowed_exts and ext not in allowed_exts:
|
|
1496
|
+
continue
|
|
1497
|
+
if ext in {".py", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".go", ".rs", ".c", ".cpp", ".h"}:
|
|
1498
|
+
try:
|
|
1499
|
+
if fp.stat().st_size <= 2 * 1024 * 1024:
|
|
1500
|
+
files_to_scan.append(fp)
|
|
1501
|
+
except Exception:
|
|
1502
|
+
pass
|
|
1503
|
+
|
|
1504
|
+
symbols_found = []
|
|
1505
|
+
sym_query = symbol_name if exact_match else symbol_name.lower()
|
|
1506
|
+
|
|
1507
|
+
for fp in files_to_scan:
|
|
1508
|
+
ext = fp.suffix.lower()
|
|
1509
|
+
try:
|
|
1510
|
+
content, _ = _read_file_text_with_fallback(fp)
|
|
1511
|
+
lines = content.splitlines()
|
|
1512
|
+
|
|
1513
|
+
# Python AST parsing
|
|
1514
|
+
if ext == ".py":
|
|
1515
|
+
try:
|
|
1516
|
+
tree = ast.parse(content, filename=str(fp))
|
|
1517
|
+
for node in ast.walk(tree):
|
|
1518
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
1519
|
+
n_name = node.name
|
|
1520
|
+
matches = (n_name == sym_query) if exact_match else (sym_query in n_name.lower())
|
|
1521
|
+
if matches:
|
|
1522
|
+
kind = "class" if isinstance(node, ast.ClassDef) else ("async_function" if isinstance(node, ast.AsyncFunctionDef) else "function")
|
|
1523
|
+
symbols_found.append({
|
|
1524
|
+
"file": str(fp),
|
|
1525
|
+
"name": n_name,
|
|
1526
|
+
"kind": kind,
|
|
1527
|
+
"line": node.lineno,
|
|
1528
|
+
"language": "python"
|
|
1529
|
+
})
|
|
1530
|
+
if len(symbols_found) >= safe_max:
|
|
1531
|
+
break
|
|
1532
|
+
except Exception:
|
|
1533
|
+
pass
|
|
1534
|
+
|
|
1535
|
+
# JS/TS regex parsing
|
|
1536
|
+
elif ext in {".ts", ".tsx", ".js", ".jsx", ".mjs"}:
|
|
1537
|
+
pat = re.compile(r"^\s*(?:export\s+(?:default\s+)?)?(?:async\s+)?(class|function|interface|type|enum|const)\s+([A-Za-z0-9_$]+)", re.MULTILINE)
|
|
1538
|
+
for idx, line in enumerate(lines):
|
|
1539
|
+
m = pat.match(line)
|
|
1540
|
+
if m:
|
|
1541
|
+
kind, n_name = m.group(1), m.group(2)
|
|
1542
|
+
matches = (n_name == sym_query) if exact_match else (sym_query in n_name.lower())
|
|
1543
|
+
if matches:
|
|
1544
|
+
symbols_found.append({
|
|
1545
|
+
"file": str(fp),
|
|
1546
|
+
"name": n_name,
|
|
1547
|
+
"kind": kind,
|
|
1548
|
+
"line": idx + 1,
|
|
1549
|
+
"signature": line.strip()[:120],
|
|
1550
|
+
"language": "typescript" if "ts" in ext else "javascript"
|
|
1551
|
+
})
|
|
1552
|
+
if len(symbols_found) >= safe_max:
|
|
1553
|
+
break
|
|
1554
|
+
|
|
1555
|
+
# Go / Rust / C regex
|
|
1556
|
+
elif ext in {".go", ".rs", ".c", ".cpp", ".h"}:
|
|
1557
|
+
pat = re.compile(r"^\s*(?:pub\s+)?(?:fn|func|struct|enum|trait|type|class)\s+([A-Za-z0-9_]+)", re.MULTILINE)
|
|
1558
|
+
for idx, line in enumerate(lines):
|
|
1559
|
+
m = pat.match(line)
|
|
1560
|
+
if m:
|
|
1561
|
+
n_name = m.group(1)
|
|
1562
|
+
matches = (n_name == sym_query) if exact_match else (sym_query in n_name.lower())
|
|
1563
|
+
if matches:
|
|
1564
|
+
symbols_found.append({
|
|
1565
|
+
"file": str(fp),
|
|
1566
|
+
"name": n_name,
|
|
1567
|
+
"kind": "declaration",
|
|
1568
|
+
"line": idx + 1,
|
|
1569
|
+
"signature": line.strip()[:120],
|
|
1570
|
+
"language": ext.lstrip(".")
|
|
1571
|
+
})
|
|
1572
|
+
if len(symbols_found) >= safe_max:
|
|
1573
|
+
break
|
|
1574
|
+
|
|
1575
|
+
if len(symbols_found) >= safe_max:
|
|
1576
|
+
break
|
|
1577
|
+
except Exception:
|
|
1578
|
+
continue
|
|
1579
|
+
|
|
1580
|
+
has_more = len(symbols_found) >= safe_max
|
|
1581
|
+
res: Dict[str, Any] = {
|
|
1582
|
+
"query": symbol_name,
|
|
1583
|
+
"exact_match": exact_match,
|
|
1584
|
+
"total_matches": len(symbols_found),
|
|
1585
|
+
"limit_reached": has_more,
|
|
1586
|
+
"symbols": symbols_found,
|
|
1587
|
+
}
|
|
1588
|
+
if has_more:
|
|
1589
|
+
res["notice"] = f"... [TRUNCATED: Showing first {safe_max} symbol matches. Specify language or exact_match to narrow results] ..."
|
|
1590
|
+
return res
|
|
1591
|
+
|
|
1592
|
+
|
|
1593
|
+
@mcp.tool()
|
|
1594
|
+
def get_code_stats(
|
|
1595
|
+
path: str,
|
|
1596
|
+
respect_gitignore: bool = True,
|
|
1597
|
+
max_file_size_mb: float = 2.0,
|
|
1598
|
+
) -> Dict[str, Any]:
|
|
1599
|
+
"""
|
|
1600
|
+
Compute lines of code (LOC), comments, blank lines, and file counts aggregated by programming language.
|
|
1601
|
+
Honors .gitignore and smart ignore rules.
|
|
1602
|
+
|
|
1603
|
+
Args:
|
|
1604
|
+
path: Workspace or directory root to analyze.
|
|
1605
|
+
respect_gitignore: Whether to ignore files in .gitignore (default True).
|
|
1606
|
+
max_file_size_mb: Skip files larger than this threshold (default 2MB).
|
|
1607
|
+
"""
|
|
1608
|
+
root = Path(path).resolve()
|
|
1609
|
+
if not root.exists() or not root.is_dir():
|
|
1610
|
+
return {"error": f"Invalid directory: {root}"}
|
|
1611
|
+
|
|
1612
|
+
gi_spec = _load_gitignore_spec(root) if respect_gitignore else None
|
|
1613
|
+
max_bytes = int(max_file_size_mb * 1024 * 1024)
|
|
1614
|
+
|
|
1615
|
+
ext_to_lang = {
|
|
1616
|
+
".py": "Python",
|
|
1617
|
+
".ts": "TypeScript",
|
|
1618
|
+
".tsx": "TypeScript (React)",
|
|
1619
|
+
".js": "JavaScript",
|
|
1620
|
+
".jsx": "JavaScript (React)",
|
|
1621
|
+
".mjs": "JavaScript",
|
|
1622
|
+
".json": "JSON",
|
|
1623
|
+
".html": "HTML",
|
|
1624
|
+
".css": "CSS",
|
|
1625
|
+
".scss": "SCSS",
|
|
1626
|
+
".md": "Markdown",
|
|
1627
|
+
".yaml": "YAML",
|
|
1628
|
+
".yml": "YAML",
|
|
1629
|
+
".sql": "SQL",
|
|
1630
|
+
".sh": "Shell",
|
|
1631
|
+
".bash": "Shell",
|
|
1632
|
+
".ps1": "PowerShell",
|
|
1633
|
+
".go": "Go",
|
|
1634
|
+
".rs": "Rust",
|
|
1635
|
+
".c": "C",
|
|
1636
|
+
".cpp": "C++",
|
|
1637
|
+
".h": "C/C++ Header",
|
|
1638
|
+
".toml": "TOML",
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
stats: Dict[str, Dict[str, int]] = {}
|
|
1642
|
+
total_files = 0
|
|
1643
|
+
total_lines = 0
|
|
1644
|
+
|
|
1645
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
1646
|
+
curr = Path(dirpath)
|
|
1647
|
+
dirnames[:] = [
|
|
1648
|
+
d for d in dirnames
|
|
1649
|
+
if d not in HEAVY_BUILD_DIRS
|
|
1650
|
+
and d not in GIT_DIRS
|
|
1651
|
+
and not _is_hidden(curr / d)
|
|
1652
|
+
and not _matches_gitignore(gi_spec, str((curr / d).relative_to(root)).replace("\\", "/"), is_dir=True)
|
|
1653
|
+
]
|
|
1654
|
+
for f in filenames:
|
|
1655
|
+
fp = curr / f
|
|
1656
|
+
if _is_hidden(fp) or _is_binary_file(fp):
|
|
1657
|
+
continue
|
|
1658
|
+
rel_posix = str(fp.relative_to(root)).replace("\\", "/")
|
|
1659
|
+
if _matches_gitignore(gi_spec, rel_posix, is_dir=False):
|
|
1660
|
+
continue
|
|
1661
|
+
|
|
1662
|
+
ext = fp.suffix.lower()
|
|
1663
|
+
lang = ext_to_lang.get(ext)
|
|
1664
|
+
if not lang:
|
|
1665
|
+
continue
|
|
1666
|
+
|
|
1667
|
+
try:
|
|
1668
|
+
if fp.stat().st_size > max_bytes:
|
|
1669
|
+
continue
|
|
1670
|
+
content, _ = _read_file_text_with_fallback(fp)
|
|
1671
|
+
lines = content.splitlines()
|
|
1672
|
+
except Exception:
|
|
1673
|
+
continue
|
|
1674
|
+
|
|
1675
|
+
if lang not in stats:
|
|
1676
|
+
stats[lang] = {"files": 0, "total_lines": 0, "code_lines": 0, "comment_lines": 0, "blank_lines": 0}
|
|
1677
|
+
|
|
1678
|
+
lang_stat = stats[lang]
|
|
1679
|
+
lang_stat["files"] += 1
|
|
1680
|
+
total_files += 1
|
|
1681
|
+
|
|
1682
|
+
for line in lines:
|
|
1683
|
+
total_lines += 1
|
|
1684
|
+
lang_stat["total_lines"] += 1
|
|
1685
|
+
stripped = line.strip()
|
|
1686
|
+
if not stripped:
|
|
1687
|
+
lang_stat["blank_lines"] += 1
|
|
1688
|
+
elif stripped.startswith(("#", "//", "--", ";", "/*", "*")):
|
|
1689
|
+
lang_stat["comment_lines"] += 1
|
|
1690
|
+
else:
|
|
1691
|
+
lang_stat["code_lines"] += 1
|
|
1692
|
+
|
|
1693
|
+
sorted_langs = sorted(stats.items(), key=lambda item: item[1]["total_lines"], reverse=True)
|
|
1694
|
+
|
|
1695
|
+
return {
|
|
1696
|
+
"workspace": str(root),
|
|
1697
|
+
"total_files_analyzed": total_files,
|
|
1698
|
+
"total_lines_of_code": total_lines,
|
|
1699
|
+
"languages": {k: v for k, v in sorted_langs},
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
|
|
1703
|
+
@mcp.tool()
|
|
1704
|
+
def find_duplicate_files(
|
|
1705
|
+
path: str,
|
|
1706
|
+
min_size_bytes: int = 512,
|
|
1707
|
+
max_results: int = 30,
|
|
1708
|
+
) -> Dict[str, Any]:
|
|
1709
|
+
"""
|
|
1710
|
+
Find duplicate files in a workspace by analyzing file sizes and SHA256 checksums.
|
|
1711
|
+
Honors .gitignore and smart ignore rules.
|
|
1712
|
+
|
|
1713
|
+
Args:
|
|
1714
|
+
path: Workspace or directory root to search.
|
|
1715
|
+
min_size_bytes: Minimum file size in bytes to consider (default 512).
|
|
1716
|
+
max_results: Maximum duplicate groups to return (default 30).
|
|
1717
|
+
"""
|
|
1718
|
+
root = Path(path).resolve()
|
|
1719
|
+
if not root.exists() or not root.is_dir():
|
|
1720
|
+
return {"error": f"Invalid directory: {root}"}
|
|
1721
|
+
|
|
1722
|
+
safe_max = min(max(1, max_results), 100)
|
|
1723
|
+
gi_spec = _load_gitignore_spec(root)
|
|
1724
|
+
|
|
1725
|
+
# Step 1: Bucket by size
|
|
1726
|
+
size_buckets: Dict[int, List[Path]] = {}
|
|
1727
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
1728
|
+
curr = Path(dirpath)
|
|
1729
|
+
dirnames[:] = [
|
|
1730
|
+
d for d in dirnames
|
|
1731
|
+
if d not in HEAVY_BUILD_DIRS
|
|
1732
|
+
and d not in GIT_DIRS
|
|
1733
|
+
and not _is_hidden(curr / d)
|
|
1734
|
+
and not _matches_gitignore(gi_spec, str((curr / d).relative_to(root)).replace("\\", "/"), is_dir=True)
|
|
1735
|
+
]
|
|
1736
|
+
for f in filenames:
|
|
1737
|
+
fp = curr / f
|
|
1738
|
+
if _is_hidden(fp):
|
|
1739
|
+
continue
|
|
1740
|
+
rel_posix = str(fp.relative_to(root)).replace("\\", "/")
|
|
1741
|
+
if _matches_gitignore(gi_spec, rel_posix, is_dir=False):
|
|
1742
|
+
continue
|
|
1743
|
+
try:
|
|
1744
|
+
sz = fp.stat().st_size
|
|
1745
|
+
if sz >= min_size_bytes:
|
|
1746
|
+
size_buckets.setdefault(sz, []).append(fp)
|
|
1747
|
+
except Exception:
|
|
1748
|
+
continue
|
|
1749
|
+
|
|
1750
|
+
# Step 2: Hash only sizes with >= 2 files
|
|
1751
|
+
duplicate_groups = []
|
|
1752
|
+
total_wasted_bytes = 0
|
|
1753
|
+
|
|
1754
|
+
for sz, paths in size_buckets.items():
|
|
1755
|
+
if len(paths) < 2:
|
|
1756
|
+
continue
|
|
1757
|
+
|
|
1758
|
+
hash_buckets: Dict[str, List[str]] = {}
|
|
1759
|
+
for p in paths:
|
|
1760
|
+
try:
|
|
1761
|
+
hasher = hashlib.sha256()
|
|
1762
|
+
with open(p, "rb") as fh:
|
|
1763
|
+
while chunk := fh.read(65536):
|
|
1764
|
+
hasher.update(chunk)
|
|
1765
|
+
h = hasher.hexdigest()
|
|
1766
|
+
hash_buckets.setdefault(h, []).append(str(p))
|
|
1767
|
+
except Exception:
|
|
1768
|
+
continue
|
|
1769
|
+
|
|
1770
|
+
for h, dup_paths in hash_buckets.items():
|
|
1771
|
+
if len(dup_paths) > 1:
|
|
1772
|
+
wasted = sz * (len(dup_paths) - 1)
|
|
1773
|
+
total_wasted_bytes += wasted
|
|
1774
|
+
duplicate_groups.append({
|
|
1775
|
+
"sha256": h[:16] + "...",
|
|
1776
|
+
"file_size_bytes": sz,
|
|
1777
|
+
"file_size_formatted": _format_size(sz),
|
|
1778
|
+
"wasted_space_formatted": _format_size(wasted),
|
|
1779
|
+
"duplicate_count": len(dup_paths),
|
|
1780
|
+
"files": dup_paths,
|
|
1781
|
+
})
|
|
1782
|
+
if len(duplicate_groups) >= safe_max:
|
|
1783
|
+
break
|
|
1784
|
+
if len(duplicate_groups) >= safe_max:
|
|
1785
|
+
break
|
|
1786
|
+
|
|
1787
|
+
duplicate_groups.sort(key=lambda g: g["file_size_bytes"] * (g["duplicate_count"] - 1), reverse=True)
|
|
1788
|
+
|
|
1789
|
+
return {
|
|
1790
|
+
"workspace": str(root),
|
|
1791
|
+
"total_duplicate_groups": len(duplicate_groups),
|
|
1792
|
+
"total_wasted_space_formatted": _format_size(total_wasted_bytes),
|
|
1793
|
+
"limit_reached": len(duplicate_groups) >= safe_max,
|
|
1794
|
+
"duplicates": duplicate_groups[:safe_max],
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
|
|
1798
|
+
if __name__ == "__main__":
|
|
1799
|
+
mcp.run()
|