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.
@@ -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()