Skip to content

Commit 2ebd7a5

Browse files
authored
fix: minor cleanup fixes for new tools (#8)
* fix ignored-pattern guard, root permission error, and broad except Only skip entries matching ignored_dir_patterns when they are actually directories, surface PermissionError at root depth instead of returning silent empty results, and narrow an overly broad except clause in search_directory to catch only FileAccessError. - Added minimum: 1 to max_depth and max_entries in the list_directory JSON schema * fix misleading comment, noisy search hints, and unused test helper Corrected the "Java-specific nodes" comment to include TypeScript, replaced low-signal Java search hints (void, public, private) with structural tokens (enum, import, @interface), and removed a dead _make_tree helper from list_directory tests. * add list_directory API docs and fix search_directory doc errors Added the missing list_directory detailed section to API.md. Fixed several inaccuracies in search_directory docs: `directory` key renamed to `path`, added `include_pattern` to return keys, removed non-existent `match_count` from per-file results. - Fixed double-pipe typo in API.md overview table - Added trailing newline to configuration.md * bump version to 0.1.6
1 parent 8f30b39 commit 2ebd7a5

8 files changed

Lines changed: 128 additions & 43 deletions

File tree

docs/API.md

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Detailed documentation for the Largefile MCP Server tools.
77
The Largefile MCP Server provides 7 tools for working with large text files:
88

99
| Tool | Purpose |
10-
|------|---------||
10+
|------|---------|
1111
| **get_overview** | File structure analysis with Tree-sitter semantic outline |
1212
| **search_content** | Pattern search with fuzzy, regex, and invert matching |
1313
| **read_content** | Targeted reading by offset, pattern, tail, or head mode |
@@ -363,6 +363,57 @@ for backup in result["available_backups"]:
363363
}
364364
```
365365

366+
### list_directory
367+
368+
List directory contents with optional recursive depth control.
369+
370+
**Signature:**
371+
```python
372+
def list_directory(
373+
absolute_dir_path: str,
374+
max_depth: int = 1,
375+
max_entries: int | None = None,
376+
include_hidden: bool = False,
377+
) -> dict
378+
```
379+
380+
**Parameters:**
381+
- `absolute_dir_path`: Absolute path to the directory to list (required)
382+
- `max_depth`: How many levels deep to recurse (default: 1 = direct children only)
383+
- `max_entries`: Maximum total entries to return (default: server config, 200)
384+
- `include_hidden`: Include entries starting with `.` (default: False)
385+
386+
**Returns:** Dictionary with:
387+
- `path`: Absolute path of the listed directory
388+
- `entries`: List of entry objects (see below)
389+
- `total_files`: Number of files found
390+
- `total_dirs`: Number of directories found
391+
- `truncated`: True if `max_entries` cap was reached before listing all entries
392+
- `truncated_at`: Path of the entry where truncation occurred (if `truncated`)
393+
394+
**Entry Object:**
395+
- `name`: Relative name from the listing root (e.g. `"src/utils.py"` at depth 2)
396+
- `type`: `"file"` or `"dir"`
397+
- `size_bytes`: File size in bytes (0 for directories)
398+
- `child_count`: Number of visible children for directories (null for files)
399+
400+
**Example:**
401+
```python
402+
# List direct children
403+
result = list_directory("/path/to/project")
404+
for entry in result["entries"]:
405+
if entry["type"] == "dir":
406+
print(f"{entry['name']}/ ({entry['child_count']} children)")
407+
else:
408+
print(f"{entry['name']} ({entry['size_bytes']} bytes)")
409+
410+
# Recurse two levels deep
411+
result = list_directory("/path/to/project", max_depth=2, include_hidden=True)
412+
print(f"Found {result['total_files']} files, {result['total_dirs']} dirs")
413+
```
414+
415+
Directories are listed before files, sorted alphabetically within each group. `__pycache__`, `node_modules`, and `.git` are ignored by default (configurable via `LARGEFILE_IGNORED_DIR_PATTERNS`).
416+
366417
### search_directory
367418

368419
Search for a pattern across all files in a directory tree.
@@ -403,19 +454,19 @@ def search_directory(
403454
- `truncated`: True if cap was reached before finishing
404455
- `truncated_at`: Relative path of file where truncation occurred (if `truncated`)
405456
- `pattern`: The search pattern used
406-
- `directory`: The absolute directory searched
457+
- `include_pattern`: The file glob pattern used to filter files
458+
- `path`: The absolute directory searched
407459

408460
**Per-file Result Object:**
409461
- `file`: Relative path from the search root (e.g. `"src/tools.py"`)
410462
- `matches`: List of match objects (same structure as `search_content` results)
411-
- `match_count`: Number of matches in this file
412463

413464
**Examples:**
414465
```python
415466
# Find all TODO comments in Python files
416467
result = search_directory("/path/to/project", "TODO", include_pattern="*.py", fuzzy=False)
417468
for file_result in result["results"]:
418-
print(f"{file_result['file']}: {file_result['match_count']} TODOs")
469+
print(f"{file_result['file']}: {len(file_result['matches'])} TODOs")
419470

420471
# Case-insensitive search (explicit)
421472
result = search_directory("/path/to/project", "error", case_sensitive=False, fuzzy=False)

docs/configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,4 +338,4 @@ LARGEFILE_MAX_DIR_SEARCH_FILES=10000
338338
- `LARGEFILE_IGNORED_DIR_PATTERNS` is shared with `list_directory` — one config
339339
controls both tools.
340340
- `fuzzy=False` is the default for `search_directory`; enabling it on large trees
341-
can be slow. Use `include_pattern` to narrow the scope first.
341+
can be slow. Use `include_pattern` to narrow the scope first.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "largefile"
3-
version = "0.1.5"
3+
version = "0.1.6"
44
description = "MCP server for AI assistants to navigate, search, and edit large codebases, logs, and data files with semantic code analysis"
55
readme = "README.md"
66
requires-python = ">=3.10"

src/mcp_schemas.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,10 +257,12 @@ def get_tool_schemas() -> list[types.Tool]:
257257
"type": "integer",
258258
"description": "How many levels deep to recurse (default: 1 = direct children only).",
259259
"default": 1,
260+
"minimum": 1,
260261
},
261262
"max_entries": {
262263
"type": "integer",
263264
"description": "Maximum total entries to return. Defaults to server config (200).",
265+
"minimum": 1,
264266
},
265267
"include_hidden": {
266268
"type": "boolean",

src/tools.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ def get_overview(absolute_file_path: str) -> dict:
168168
elif file_ext == ".rs":
169169
search_hints = ["fn ", "struct ", "impl ", "use "]
170170
elif file_ext == ".java":
171-
search_hints = ["class ", "interface ", "void ", "public ", "private "]
171+
search_hints = ["class ", "interface ", "enum ", "import ", "@interface "]
172172
else:
173173
search_hints = ["TODO", "FIXME", "NOTE", "HACK"]
174174

@@ -699,7 +699,9 @@ def _collect_entries(
699699
os.scandir(dir_path),
700700
key=lambda e: (not e.is_dir(follow_symlinks=False), e.name.lower()),
701701
)
702-
except PermissionError:
702+
except PermissionError as exc:
703+
if current_depth == 0:
704+
raise FileAccessError(f"Permission denied: {dir_path}") from exc
703705
return entries
704706

705707
for entry in raw:
@@ -711,7 +713,7 @@ def _collect_entries(
711713
break
712714
if not include_hidden and entry.name.startswith("."):
713715
continue
714-
if entry.name in ignored_patterns:
716+
if entry.is_dir(follow_symlinks=False) and entry.name in ignored_patterns:
715717
continue
716718

717719
counter.total += 1
@@ -723,7 +725,9 @@ def _collect_entries(
723725
1
724726
for c in os.scandir(entry.path)
725727
if (include_hidden or not c.name.startswith("."))
726-
and c.name not in ignored_patterns
728+
and not (
729+
c.is_dir(follow_symlinks=False) and c.name in ignored_patterns
730+
)
727731
)
728732
except PermissionError:
729733
child_count = 0
@@ -964,7 +968,7 @@ def search_directory(
964968

965969
try:
966970
lines = read_file_lines(abs_path)
967-
except Exception:
971+
except FileAccessError:
968972
lines = []
969973

970974
match_dicts: list[dict] = []

src/tree_parser.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -399,7 +399,7 @@ def create_outline_item_from_node(node: Any, depth: int) -> OutlineItem | None:
399399
line_count=node.end_point[0] - node.start_point[0] + 1,
400400
)
401401

402-
# Java-specific nodes
402+
# Java and TypeScript declaration nodes
403403
elif node_type == "class_declaration":
404404
name = extract_node_name(node, "identifier")
405405
if name:

tests/unit/test_tools_list_directory.py

Lines changed: 55 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,30 +6,6 @@
66

77
from src.tools import list_directory
88

9-
# ---------------------------------------------------------------------------
10-
# Helpers
11-
# ---------------------------------------------------------------------------
12-
13-
14-
def _make_tree(base: Path, structure: dict) -> None:
15-
"""Recursively create files/dirs described by *structure*.
16-
17-
Keys ending in '/' are directories; other keys are files.
18-
Values for files are the byte-string content; values for dirs are
19-
nested structure dicts.
20-
"""
21-
for name, content in structure.items():
22-
if name.endswith("/"):
23-
child = base / name.rstrip("/")
24-
child.mkdir(parents=True, exist_ok=True)
25-
if isinstance(content, dict):
26-
_make_tree(child, content)
27-
else:
28-
(base / name).write_bytes(
29-
content if isinstance(content, bytes) else content.encode()
30-
)
31-
32-
339
# ---------------------------------------------------------------------------
3410
# TestListDirectoryErrors
3511
# ---------------------------------------------------------------------------
@@ -181,6 +157,36 @@ def test_default_ignores_dotgit(self, tmp_path: Path) -> None:
181157
names = [e["name"] for e in result["entries"]]
182158
assert ".git" not in names
183159

160+
def test_file_named_like_ignored_pattern_is_not_skipped(
161+
self, tmp_path: Path
162+
) -> None:
163+
"""A file named '__pycache__' must still appear in listings."""
164+
(tmp_path / "__pycache__").write_text("I am a file")
165+
result = list_directory(str(tmp_path))
166+
names = [e["name"] for e in result["entries"]]
167+
assert "__pycache__" in names
168+
169+
def test_dir_named_like_ignored_pattern_is_still_skipped(
170+
self, tmp_path: Path
171+
) -> None:
172+
"""A directory named '__pycache__' must be skipped."""
173+
(tmp_path / "__pycache__").mkdir()
174+
result = list_directory(str(tmp_path))
175+
names = [e["name"] for e in result["entries"]]
176+
assert "__pycache__" not in names
177+
178+
def test_child_count_includes_file_named_like_ignored_pattern(
179+
self, tmp_path: Path
180+
) -> None:
181+
"""child_count must count files whose name matches an ignored pattern."""
182+
parent = tmp_path / "parent"
183+
parent.mkdir()
184+
(parent / "__pycache__").write_text("I am a file")
185+
(parent / "regular.txt").write_text("ok")
186+
result = list_directory(str(tmp_path))
187+
entry = next(e for e in result["entries"] if e["name"] == "parent")
188+
assert entry["child_count"] == 2
189+
184190
def test_empty_env_var_disables_all_ignore_patterns(self, tmp_path: Path) -> None:
185191
"""LARGEFILE_IGNORED_DIR_PATTERNS='' must not produce a spurious [''] pattern."""
186192
with patch.dict(os.environ, {"LARGEFILE_IGNORED_DIR_PATTERNS": ""}):
@@ -283,14 +289,33 @@ def test_no_truncation_when_under_limit(self, tmp_path: Path) -> None:
283289

284290

285291
class TestListDirectoryErrorPaths:
286-
def test_permission_error_on_root_scan_returns_empty(self, tmp_path: Path) -> None:
287-
"""PermissionError on os.scandir(dir_path) returns empty entries (lines 698-699)."""
292+
def test_permission_error_on_root_scan_returns_error(self, tmp_path: Path) -> None:
293+
"""PermissionError on os.scandir(dir_path) at root depth returns error dict."""
288294
with patch("src.tools.os.scandir", side_effect=PermissionError):
289295
result = list_directory(str(tmp_path))
290-
assert result["entries"] == []
291-
assert result["total_files"] == 0
292-
assert result["total_dirs"] == 0
293-
assert result["truncated"] is False
296+
assert "error" in result
297+
assert "Permission denied" in result["error"]
298+
299+
def test_permission_error_on_nested_scan_returns_empty(
300+
self, tmp_path: Path
301+
) -> None:
302+
"""PermissionError on a nested directory returns empty children gracefully."""
303+
sub = tmp_path / "restricted"
304+
sub.mkdir()
305+
306+
real_scandir = os.scandir
307+
call_count = {"n": 0}
308+
309+
def patched_scandir(path: str) -> object:
310+
call_count["n"] += 1
311+
if call_count["n"] == 3: # third call: recursing into restricted/
312+
raise PermissionError("access denied")
313+
return real_scandir(path)
314+
315+
with patch("src.tools.os.scandir", side_effect=patched_scandir):
316+
result = list_directory(str(tmp_path), max_depth=2)
317+
assert "error" not in result
318+
assert result["total_dirs"] == 1
294319

295320
def test_recursive_truncation_stops_parent_iteration(self, tmp_path: Path) -> None:
296321
"""counter.truncated=True from recursive call breaks parent loop (line 703)."""

tests/unit/test_tools_search_directory.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,10 @@ def test_read_file_lines_failure_yields_empty_context(self, tmp_path: Path) -> N
382382
"""If read_file_lines fails after a match is found, context is empty."""
383383
(tmp_path / "f.txt").write_text("needle\n")
384384

385-
with patch("src.tools.read_file_lines", side_effect=OSError("read failed")):
385+
with patch(
386+
"src.tools.read_file_lines",
387+
side_effect=FileAccessError("read failed"),
388+
):
386389
result = search_directory(str(tmp_path), "needle", fuzzy=False)
387390

388391
assert result["total_matches"] == 1

0 commit comments

Comments
 (0)