Skip to content

Commit 6193bd4

Browse files
author
feryc@hotmail.com
committed
feat: add search_directory tool for multi-file pattern search
1 parent 3fa4253 commit 6193bd4

9 files changed

Lines changed: 812 additions & 3 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ Navigate, search, and edit large codebases, logs, and data files that exceed AI
3737
| `edit_content` | Safe search/replace with automatic backups |
3838
| `revert_edit` | Recovering from bad edits |
3939
| `list_directory` | Browse directory trees with recursive depth control |
40+
| `search_directory` | Search patterns across all files in a directory |
4041

4142
## When to Use Largefile
4243

docs/API.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Detailed documentation for the Largefile MCP Server tools.
44

55
## Overview
66

7-
The Largefile MCP Server provides 6 tools for working with large text files:
7+
The Largefile MCP Server provides 7 tools for working with large text files:
88

99
| Tool | Purpose |
1010
|------|---------||
@@ -14,6 +14,7 @@ The Largefile MCP Server provides 6 tools for working with large text files:
1414
| **edit_content** | Batch search/replace editing with automatic backups |
1515
| **revert_edit** | Recover from bad edits via backup restoration |
1616
| **list_directory** | List directory contents with recursive depth and filtering |
17+
| **search_directory** | Multi-file pattern search across directory trees |
1718

1819
All tools require absolute file paths and support auto-detected text encoding.
1920

docs/configuration.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,4 +315,21 @@ LARGEFILE_IGNORED_DIR_PATTERNS=__pycache__,node_modules,.git
315315
- `LARGEFILE_MAX_DIR_ENTRIES` is a hard cap across all recursion depths combined.
316316
- `LARGEFILE_IGNORED_DIR_PATTERNS` matches exact directory *names* (not paths or globs).
317317
Hidden directories (e.g. `.git`) are filtered independently by `include_hidden`.
318-
- Entries are always listed directories-first, then files, each group sorted alphabetically.
318+
- Entries are always listed directories-first, then files, each group sorted alphabetically.
319+
320+
## Directory Search Configuration
321+
322+
Control the `search_directory` tool behaviour:
323+
324+
```bash
325+
# Maximum total matches returned by search_directory across all files (default: 100)
326+
LARGEFILE_MAX_DIR_SEARCH_RESULTS=100
327+
```
328+
329+
**Notes:**
330+
- `LARGEFILE_MAX_DIR_SEARCH_RESULTS` is a hard cap on *total matches* (not files).
331+
When reached, `truncated=True` and `truncated_at` indicates where scanning stopped.
332+
- `LARGEFILE_IGNORED_DIR_PATTERNS` is shared with `list_directory` — one config
333+
controls both tools.
334+
- `fuzzy=False` is the default for `search_directory`; enabling it on large trees
335+
can be slow. Use `include_pattern` to narrow the scope first.

src/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ class Config:
3434
tree_sitter_timeout: int = int(os.getenv("LARGEFILE_TREE_SITTER_TIMEOUT", "5"))
3535

3636
max_dir_entries: int = int(os.getenv("LARGEFILE_MAX_DIR_ENTRIES", "200"))
37+
max_dir_search_results: int = int(
38+
os.getenv("LARGEFILE_MAX_DIR_SEARCH_RESULTS", "100")
39+
)
3740
ignored_dir_patterns: list[str] = field(
3841
default_factory=lambda: [
3942
p.strip()

src/data_models.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,17 @@ class DirectoryListing:
137137
total_dirs: int
138138
truncated: bool
139139
truncated_at: str | None = None
140+
141+
142+
@dataclass
143+
class DirectorySearchResult:
144+
"""Summary metadata for a multi-file search operation."""
145+
146+
path: str
147+
pattern: str
148+
include_pattern: str
149+
total_matches: int
150+
files_searched: int
151+
files_with_matches: int
152+
truncated: bool
153+
truncated_at: str | None = None

src/mcp_schemas.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ def read_content(self, **kwargs: Any) -> Any: ...
1919
def edit_content(self, **kwargs: Any) -> Any: ...
2020
def revert_edit(self, **kwargs: Any) -> Any: ...
2121
def list_directory(self, **kwargs: Any) -> Any: ...
22+
def search_directory(self, **kwargs: Any) -> Any: ...
2223

2324

2425
def get_tool_schemas() -> list[types.Tool]:
@@ -271,6 +272,73 @@ def get_tool_schemas() -> list[types.Tool]:
271272
},
272273
annotations=types.ToolAnnotations(readOnlyHint=True),
273274
),
275+
types.Tool(
276+
name="search_directory",
277+
description=(
278+
"Search for a text pattern across all files in a directory. "
279+
"Returns results grouped by file with line numbers and context. "
280+
"Use include_pattern to filter by file extension (e.g. '*.py'). "
281+
"Automatically ignores __pycache__, node_modules, and .git. "
282+
"Prefer fuzzy=False (default) for multi-file search performance."
283+
),
284+
inputSchema={
285+
"type": "object",
286+
"properties": {
287+
"absolute_dir_path": {
288+
"type": "string",
289+
"description": "The absolute path to the directory to search.",
290+
},
291+
"pattern": {
292+
"type": "string",
293+
"description": "Text pattern to search for.",
294+
},
295+
"include_pattern": {
296+
"type": "string",
297+
"description": (
298+
"fnmatch glob matched against file names (default: '*'). "
299+
"Examples: '*.py', '*.md', '*.ts'."
300+
),
301+
"default": "*",
302+
},
303+
"max_results": {
304+
"type": "integer",
305+
"description": "Total match cap across all files. Defaults to server config (100).",
306+
},
307+
"context_lines": {
308+
"type": "integer",
309+
"description": "Lines of context before/after each match (default: 2).",
310+
"default": 2,
311+
},
312+
"fuzzy": {
313+
"type": "boolean",
314+
"description": "Enable fuzzy matching (default: false \u2014 expensive for many files).",
315+
"default": False,
316+
},
317+
"regex": {
318+
"type": "boolean",
319+
"description": "Enable Python regex matching (default: false).",
320+
"default": False,
321+
},
322+
"case_sensitive": {
323+
"type": "boolean",
324+
"description": "Case-sensitive search (default: false).",
325+
"default": False,
326+
},
327+
"invert": {
328+
"type": "boolean",
329+
"description": "Return non-matching lines, like grep -v (default: false).",
330+
"default": False,
331+
},
332+
"include_hidden": {
333+
"type": "boolean",
334+
"description": "Include dot-files and dot-dirs (default: false).",
335+
"default": False,
336+
},
337+
},
338+
"required": ["absolute_dir_path", "pattern"],
339+
},
340+
annotations=types.ToolAnnotations(readOnlyHint=True),
341+
),
274342
]
275343

276344

@@ -297,6 +365,8 @@ async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
297365
result = tools_module.revert_edit(**arguments)
298366
elif name == "list_directory":
299367
result = tools_module.list_directory(**arguments)
368+
elif name == "search_directory":
369+
result = tools_module.search_directory(**arguments)
300370
else:
301371
raise ValueError(f"Unknown tool: {name}")
302372

src/tools.py

Lines changed: 188 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import fnmatch
12
import os
23
import shutil
3-
from collections.abc import Callable
4+
from collections.abc import Callable, Generator
45
from dataclasses import dataclass
56
from pathlib import Path
67
from typing import Any
@@ -12,6 +13,7 @@
1213
ChangeResult,
1314
DirectoryEntry,
1415
DirectoryListing,
16+
DirectorySearchResult,
1517
FileOverview,
1618
LongLineStats,
1719
SearchResult,
@@ -834,3 +836,188 @@ def list_directory(
834836
"truncated": listing.truncated,
835837
"truncated_at": listing.truncated_at,
836838
}
839+
840+
841+
# ---------------------------------------------------------------------------
842+
# Directory search helpers
843+
# ---------------------------------------------------------------------------
844+
845+
846+
def _iter_searchable_files(
847+
dir_path: str,
848+
include_pattern: str,
849+
include_hidden: bool,
850+
ignored_patterns: list[str],
851+
) -> Generator[str, None, None]:
852+
"""Yield absolute paths of files matching include_pattern under dir_path.
853+
854+
Args:
855+
dir_path: Root directory to walk.
856+
include_pattern: fnmatch pattern matched against file names.
857+
include_hidden: Whether to yield files/dirs starting with '.'.
858+
ignored_patterns: Directory names to skip entirely.
859+
860+
Yields:
861+
Absolute file paths in sorted, deterministic order.
862+
"""
863+
for root, dirs, files in os.walk(dir_path):
864+
dirs[:] = sorted(
865+
d
866+
for d in dirs
867+
if (include_hidden or not d.startswith(".")) and d not in ignored_patterns
868+
)
869+
for fname in sorted(files):
870+
if not include_hidden and fname.startswith("."):
871+
continue
872+
if not fnmatch.fnmatch(fname, include_pattern):
873+
continue
874+
yield os.path.join(root, fname)
875+
876+
877+
@handle_tool_errors
878+
def search_directory(
879+
absolute_dir_path: str,
880+
pattern: str,
881+
include_pattern: str = "*",
882+
max_results: int | None = None,
883+
context_lines: int = 2,
884+
fuzzy: bool = False,
885+
regex: bool = False,
886+
case_sensitive: bool = False,
887+
invert: bool = False,
888+
include_hidden: bool = False,
889+
) -> dict:
890+
"""Search for a pattern across all files in a directory.
891+
892+
Walks the directory recursively, applies include_pattern filtering on
893+
filenames, and runs search_file() on each candidate. Results are grouped
894+
by file with relative paths from the search root. Binary and unreadable
895+
files are skipped silently.
896+
897+
CRITICAL: You must use an absolute directory path.
898+
899+
Args:
900+
absolute_dir_path: Absolute path to the directory to search.
901+
pattern: Search pattern (exact text, fuzzy, or regex).
902+
include_pattern: fnmatch glob matched against file names (default "*").
903+
Examples: "*.py", "*.md", "*.ts".
904+
max_results: Total match cap across all files. Defaults to server
905+
config (LARGEFILE_MAX_DIR_SEARCH_RESULTS = 100).
906+
context_lines: Lines of context before/after each match (default 2).
907+
fuzzy: Enable fuzzy matching (default False — expensive for many files).
908+
regex: Enable Python regex matching (default False).
909+
case_sensitive: Case-sensitive search (default False for multi-file).
910+
invert: Return non-matching lines, like grep -v (default False).
911+
include_hidden: Include dot-files and dot-dirs (default False).
912+
913+
Returns:
914+
Dict with results list grouped by file, match totals, and truncation status.
915+
"""
916+
dir_path = normalize_path(absolute_dir_path)
917+
918+
if not os.path.isdir(dir_path):
919+
raise FileAccessError(f"Not a directory or does not exist: {dir_path}")
920+
921+
effective_max = (
922+
max_results if max_results is not None else config.max_dir_search_results
923+
)
924+
if effective_max < 1:
925+
raise FileAccessError(f"Invalid max_results {effective_max}: must be >= 1")
926+
927+
total_matches = 0
928+
files_searched = 0
929+
files_with_matches = 0
930+
truncated = False
931+
truncated_at: str | None = None
932+
results: list[dict] = []
933+
934+
for abs_path in _iter_searchable_files(
935+
dir_path, include_pattern, include_hidden, config.ignored_dir_patterns
936+
):
937+
try:
938+
matches = search_file(
939+
abs_path, pattern, fuzzy, regex, case_sensitive, invert
940+
)
941+
except Exception:
942+
continue # skip unreadable / binary files silently
943+
944+
files_searched += 1
945+
946+
if not matches:
947+
continue
948+
949+
remaining = effective_max - total_matches
950+
if remaining <= 0:
951+
truncated = True
952+
truncated_at = os.path.relpath(abs_path, dir_path).replace("\\", "/")
953+
break
954+
955+
clipped = matches[:remaining]
956+
957+
try:
958+
lines = read_file_lines(abs_path)
959+
except Exception:
960+
lines = []
961+
962+
match_dicts: list[dict] = []
963+
for m in clipped:
964+
line_num = m.line_number
965+
966+
context_before = [
967+
lines[i - 1].rstrip("\n\r")
968+
for i in range(max(1, line_num - context_lines), line_num)
969+
if i <= len(lines)
970+
]
971+
context_after = [
972+
lines[i - 1].rstrip("\n\r")
973+
for i in range(
974+
line_num + 1,
975+
min(len(lines) + 1, line_num + context_lines + 1),
976+
)
977+
if i <= len(lines)
978+
]
979+
match_content, is_truncated = truncate_line(m.content)
980+
match_dicts.append(
981+
{
982+
"line_number": line_num,
983+
"match": match_content,
984+
"context_before": context_before,
985+
"context_after": context_after,
986+
"similarity_score": m.similarity_score,
987+
"match_type": m.match_type,
988+
"truncated": is_truncated,
989+
}
990+
)
991+
992+
total_matches += len(clipped)
993+
files_with_matches += 1
994+
rel_path = os.path.relpath(abs_path, dir_path).replace("\\", "/")
995+
results.append({"file": rel_path, "matches": match_dicts})
996+
997+
if len(clipped) < len(matches):
998+
truncated = True
999+
truncated_at = rel_path
1000+
break
1001+
1002+
summary = DirectorySearchResult(
1003+
path=dir_path,
1004+
pattern=pattern,
1005+
include_pattern=include_pattern,
1006+
total_matches=total_matches,
1007+
files_searched=files_searched,
1008+
files_with_matches=files_with_matches,
1009+
truncated=truncated,
1010+
truncated_at=truncated_at,
1011+
)
1012+
1013+
return {
1014+
"path": summary.path,
1015+
"pattern": summary.pattern,
1016+
"include_pattern": summary.include_pattern,
1017+
"total_matches": summary.total_matches,
1018+
"files_searched": summary.files_searched,
1019+
"files_with_matches": summary.files_with_matches,
1020+
"truncated": summary.truncated,
1021+
"truncated_at": summary.truncated_at,
1022+
"results": results,
1023+
}

0 commit comments

Comments
 (0)