Skip to content

Commit 8a31e3d

Browse files
committed
feat: make the indexing file cap configurable
Add JCODEMUNCH_MAX_INDEX_FILES so large repos can be indexed with a higher file cap when needed. Apply the limit to both local folder and GitHub repo indexing, and only show truncation notices when files were actually dropped. Cover the env override and truncation behavior in tests.
1 parent 4d67c30 commit 8a31e3d

7 files changed

Lines changed: 158 additions & 16 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,7 @@ For **LM Studio**, ensure the Local Server is running (usually on port 1234):
374374
| `OPENAI_MODEL` | Model name for local LLMs (default: `qwen3-coder`) | No |
375375
| `OPENAI_TIMEOUT` | Timeout in seconds for local requests (default: `60.0`) | No |
376376
| `CODE_INDEX_PATH` | Custom cache path | No |
377+
| `JCODEMUNCH_MAX_INDEX_FILES`| Maximum files to index per repo/folder (default: `500`) | No |
377378
| `JCODEMUNCH_SHARE_SAVINGS` | Set to `0` to disable anonymous community token savings reporting | No |
378379
| `JCODEMUNCH_LOG_LEVEL` | Log level: `DEBUG`, `INFO`, `WARNING`, `ERROR` (default: `WARNING`) | No |
379380
| `JCODEMUNCH_LOG_FILE` | Path to log file. If unset, logs go to stderr. Use a file to avoid polluting MCP stdio. | No |

SECURITY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ When a secret file is detected, a warning is included in the indexing response.
5656

5757
* **Default maximum:** 500 KB per file (configurable via `max_file_size`).
5858
* Files exceeding the limit are skipped during discovery.
59-
* A configurable **file count limit** (default: 500 files) prevents runaway indexing of extremely large repositories.
59+
* A configurable **file count limit** (default: 500 files) prevents runaway indexing of extremely large repositories. Can be overridden using the `JCODEMUNCH_MAX_INDEX_FILES` environment variable.
6060

6161
---
6262

src/jcodemunch_mcp/security.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,38 @@ def safe_decode(data: bytes, encoding: str = "utf-8") -> str:
206206
# --- Composite Filters ---
207207

208208
DEFAULT_MAX_FILE_SIZE = 500 * 1024 # 500KB
209+
DEFAULT_MAX_INDEX_FILES = 500
210+
MAX_INDEX_FILES_ENV_VAR = "JCODEMUNCH_MAX_INDEX_FILES"
211+
212+
213+
def get_max_index_files(max_files: Optional[int] = None) -> int:
214+
"""Resolve the maximum indexed file count from arg or environment.
215+
216+
Args:
217+
max_files: Explicit override. Must be a positive integer when provided.
218+
219+
Returns:
220+
Positive file-count limit. Falls back to the default if the environment
221+
variable is unset or invalid.
222+
"""
223+
if max_files is not None:
224+
if max_files <= 0:
225+
raise ValueError("max_files must be a positive integer")
226+
return max_files
227+
228+
value = os.environ.get(MAX_INDEX_FILES_ENV_VAR)
229+
if value is None:
230+
return DEFAULT_MAX_INDEX_FILES
231+
232+
try:
233+
parsed = int(value)
234+
except ValueError:
235+
return DEFAULT_MAX_INDEX_FILES
236+
237+
if parsed <= 0:
238+
return DEFAULT_MAX_INDEX_FILES
239+
240+
return parsed
209241

210242

211243
def should_exclude_file(

src/jcodemunch_mcp/tools/index_folder.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
is_binary_file,
1818
should_exclude_file,
1919
DEFAULT_MAX_FILE_SIZE,
20+
get_max_index_files,
2021
)
2122
from ..storage import IndexStore
2223
from ..summarizer import summarize_symbols
@@ -60,7 +61,7 @@ def _load_gitignore(folder_path: Path) -> Optional[pathspec.PathSpec]:
6061

6162
def discover_local_files(
6263
folder_path: Path,
63-
max_files: int = 500,
64+
max_files: Optional[int] = None,
6465
max_size: int = DEFAULT_MAX_FILE_SIZE,
6566
extra_ignore_patterns: Optional[list[str]] = None,
6667
follow_symlinks: bool = False,
@@ -77,6 +78,7 @@ def discover_local_files(
7778
Returns:
7879
Tuple of (list of Path objects for source files, list of warning strings).
7980
"""
81+
max_files = get_max_index_files(max_files)
8082
files = []
8183
warnings = []
8284
root = folder_path.resolve()
@@ -93,6 +95,7 @@ def discover_local_files(
9395
"too_large": 0,
9496
"unreadable": 0,
9597
"binary": 0,
98+
"file_limit": 0,
9699
}
97100

98101
# Load .gitignore
@@ -194,6 +197,7 @@ def discover_local_files(
194197

195198
# File count limit with prioritization
196199
if len(files) > max_files:
200+
skip_counts["file_limit"] = len(files) - max_files
197201
# Prioritize: src/, lib/, pkg/, cmd/, internal/ first
198202
priority_dirs = ["src/", "lib/", "pkg/", "cmd/", "internal/"]
199203

@@ -247,11 +251,13 @@ def index_folder(
247251
return {"success": False, "error": f"Path is not a directory: {path}"}
248252

249253
warnings = []
254+
max_files = get_max_index_files()
250255

251256
try:
252257
# Discover source files (with security filtering)
253258
source_files, discover_warnings, skip_counts = discover_local_files(
254259
folder_path,
260+
max_files=max_files,
255261
extra_ignore_patterns=extra_ignore_patterns,
256262
follow_symlinks=follow_symlinks,
257263
)
@@ -430,8 +436,8 @@ def index_folder(
430436
if warnings:
431437
result["warnings"] = warnings
432438

433-
if len(source_files) >= 500:
434-
result["note"] = "Folder has many files; indexed first 500"
439+
if skip_counts.get("file_limit", 0) > 0:
440+
result["note"] = f"Folder has many files; indexed first {max_files}"
435441

436442
return result
437443

src/jcodemunch_mcp/tools/index_repo.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import httpx
99

1010
from ..parser import parse_file, LANGUAGE_EXTENSIONS
11-
from ..security import is_secret_file, is_binary_extension
11+
from ..security import is_secret_file, is_binary_extension, get_max_index_files
1212
from ..storage import IndexStore
1313
from ..summarizer import summarize_symbols
1414

@@ -86,9 +86,9 @@ def should_skip_file(path: str) -> bool:
8686
def discover_source_files(
8787
tree_entries: list[dict],
8888
gitignore_content: Optional[str] = None,
89-
max_files: int = 500,
89+
max_files: Optional[int] = None,
9090
max_size: int = 500 * 1024 # 500KB
91-
) -> list[str]:
91+
) -> tuple[list[str], bool]:
9292
"""Discover source files from tree entries.
9393
9494
Applies filtering pipeline:
@@ -100,6 +100,8 @@ def discover_source_files(
100100
6. File count limit
101101
"""
102102
import pathspec
103+
104+
max_files = get_max_index_files(max_files)
103105

104106
# Parse gitignore if provided
105107
gitignore_spec = None
@@ -149,8 +151,10 @@ def discover_source_files(
149151

150152
files.append(path)
151153

154+
truncated = len(files) > max_files
155+
152156
# File count limit with prioritization
153-
if len(files) > max_files:
157+
if truncated:
154158
# Prioritize: src/, lib/, pkg/, cmd/, internal/ first
155159
priority_dirs = ["src/", "lib/", "pkg/", "cmd/", "internal/"]
156160

@@ -165,7 +169,7 @@ def priority_key(path):
165169
files.sort(key=priority_key)
166170
files = files[:max_files]
167171

168-
return files
172+
return files, truncated
169173

170174

171175
async def fetch_file_content(
@@ -228,6 +232,7 @@ async def index_repo(
228232
github_token = os.environ.get("GITHUB_TOKEN")
229233

230234
warnings = []
235+
max_files = get_max_index_files()
231236

232237
try:
233238
# Fetch tree
@@ -244,7 +249,11 @@ async def index_repo(
244249
gitignore_content = await fetch_gitignore(owner, repo, github_token)
245250

246251
# Discover source files
247-
source_files = discover_source_files(tree_entries, gitignore_content)
252+
source_files, truncated = discover_source_files(
253+
tree_entries,
254+
gitignore_content,
255+
max_files=max_files,
256+
)
248257

249258
if not source_files:
250259
return {"success": False, "error": "No source files found"}
@@ -381,8 +390,8 @@ async def fetch_with_limit(path: str) -> tuple[str, str]:
381390
if warnings:
382391
result["warnings"] = warnings
383392

384-
if len(source_files) >= 500:
385-
result["warnings"] = warnings + ["Repository has many files; indexed first 500"]
393+
if truncated:
394+
result["warnings"] = warnings + [f"Repository has many files; indexed first {max_files}"]
386395

387396
return result
388397

tests/test_security.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import sys
55
import pytest
66
from pathlib import Path
7+
from unittest.mock import patch
78

89
from jcodemunch_mcp.security import (
910
validate_path,
@@ -16,6 +17,9 @@
1617
should_exclude_file,
1718
SECRET_PATTERNS,
1819
BINARY_EXTENSIONS,
20+
DEFAULT_MAX_INDEX_FILES,
21+
MAX_INDEX_FILES_ENV_VAR,
22+
get_max_index_files,
1923
)
2024

2125

@@ -221,6 +225,24 @@ def test_checks_can_be_disabled(self, tmp_path):
221225
assert should_exclude_file(f, tmp_path, check_secrets=False) is None
222226

223227

228+
class TestMaxIndexFilesConfig:
229+
def test_defaults_when_env_is_unset(self):
230+
with patch.dict(os.environ, {}, clear=True):
231+
assert get_max_index_files() == DEFAULT_MAX_INDEX_FILES
232+
233+
def test_reads_env_override(self):
234+
with patch.dict(os.environ, {MAX_INDEX_FILES_ENV_VAR: "1234"}, clear=True):
235+
assert get_max_index_files() == 1234
236+
237+
def test_invalid_env_falls_back_to_default(self):
238+
with patch.dict(os.environ, {MAX_INDEX_FILES_ENV_VAR: "invalid"}, clear=True):
239+
assert get_max_index_files() == DEFAULT_MAX_INDEX_FILES
240+
241+
def test_non_positive_explicit_value_is_rejected(self):
242+
with pytest.raises(ValueError, match="positive integer"):
243+
get_max_index_files(0)
244+
245+
224246
# --- Integration: discover_local_files with security ---
225247

226248
class TestDiscoverLocalFilesSecure:
@@ -277,6 +299,31 @@ def test_extra_ignore_patterns(self, tmp_path):
277299
assert "main.py" in names
278300
assert "temp.py" not in names
279301

302+
def test_respects_env_file_limit(self, tmp_path):
303+
"""Environment override controls local folder file discovery limit."""
304+
from jcodemunch_mcp.tools.index_folder import discover_local_files
305+
306+
for i in range(10):
307+
(tmp_path / f"file{i}.py").write_text(f"x = {i}\n")
308+
309+
with patch.dict(os.environ, {MAX_INDEX_FILES_ENV_VAR: "3"}, clear=False):
310+
files, *_ = discover_local_files(tmp_path)
311+
312+
assert len(files) == 3
313+
314+
def test_exact_env_file_limit_does_not_report_truncation(self, tmp_path):
315+
"""Exact file-count matches should not be treated as truncation."""
316+
from jcodemunch_mcp.tools.index_folder import discover_local_files
317+
318+
for i in range(3):
319+
(tmp_path / f"file{i}.py").write_text(f"x = {i}\n")
320+
321+
with patch.dict(os.environ, {MAX_INDEX_FILES_ENV_VAR: "3"}, clear=False):
322+
files, _, skip_counts = discover_local_files(tmp_path)
323+
324+
assert len(files) == 3
325+
assert skip_counts["file_limit"] == 0
326+
280327
@pytest.mark.skipif(sys.platform == "win32", reason="Symlinks unreliable on Windows")
281328
def test_symlinks_skipped_by_default(self, tmp_path):
282329
"""Symlinks are skipped when follow_symlinks=False."""
@@ -308,11 +355,12 @@ def test_secret_files_filtered_in_discovery(self):
308355
{"path": "src/utils.py", "type": "blob", "size": 500},
309356
]
310357

311-
files = discover_source_files(tree_entries)
358+
files, truncated = discover_source_files(tree_entries)
312359
assert "src/main.py" in files
313360
assert "src/utils.py" in files
314361
assert ".env" not in files
315362
assert "certs/server.pem" not in files
363+
assert truncated is False
316364

317365

318366
# --- Encoding safety in index_store ---

tests/test_tools.py

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
"""Tests for tools module."""
22

33
import pytest
4+
from unittest.mock import patch
5+
46
from jcodemunch_mcp.tools.index_repo import (
57
parse_github_url,
68
discover_source_files,
79
should_skip_file,
810
)
11+
from jcodemunch_mcp.security import MAX_INDEX_FILES_ENV_VAR
912

1013

1114
def test_parse_github_url_full():
@@ -39,12 +42,13 @@ def test_discover_source_files():
3942
{"path": "src/utils.py", "type": "blob", "size": 500},
4043
]
4144

42-
files = discover_source_files(tree_entries, gitignore_content=None)
45+
files, truncated = discover_source_files(tree_entries, gitignore_content=None)
4346

4447
assert "src/main.py" in files
4548
assert "src/utils.py" in files
4649
assert "node_modules/foo.js" not in files
4750
assert "README.md" not in files # Not a source file
51+
assert truncated is False
4852

4953

5054
def test_discover_source_files_respects_max():
@@ -54,8 +58,9 @@ def test_discover_source_files_respects_max():
5458
for i in range(1000)
5559
]
5660

57-
files = discover_source_files(tree_entries, max_files=100)
61+
files, truncated = discover_source_files(tree_entries, max_files=100)
5862
assert len(files) == 100
63+
assert truncated is True
5964

6065

6166
def test_discover_source_files_prioritizes_src():
@@ -68,8 +73,49 @@ def test_discover_source_files_prioritizes_src():
6873
for i in range(300)
6974
]
7075

71-
files = discover_source_files(tree_entries, max_files=100)
76+
files, truncated = discover_source_files(tree_entries, max_files=100)
7277
# Most files should be from src/
7378
src_count = sum(1 for f in files if f.startswith("src/"))
7479
assert src_count > 50 # Majority should be src/
80+
assert truncated is True
81+
82+
83+
def test_discover_source_files_uses_env_override():
84+
"""Test that environment override is used when max_files is omitted."""
85+
tree_entries = [
86+
{"path": f"file{i}.py", "type": "blob", "size": 100}
87+
for i in range(20)
88+
]
89+
90+
with patch.dict("os.environ", {MAX_INDEX_FILES_ENV_VAR: "7"}, clear=False):
91+
files, truncated = discover_source_files(tree_entries)
92+
93+
assert len(files) == 7
94+
assert truncated is True
95+
96+
97+
def test_discover_source_files_explicit_max_overrides_env():
98+
"""Explicit max_files should win over environment configuration."""
99+
tree_entries = [
100+
{"path": f"file{i}.py", "type": "blob", "size": 100}
101+
for i in range(20)
102+
]
103+
104+
with patch.dict("os.environ", {MAX_INDEX_FILES_ENV_VAR: "7"}, clear=False):
105+
files, truncated = discover_source_files(tree_entries, max_files=5)
106+
107+
assert len(files) == 5
108+
assert truncated is True
109+
110+
111+
def test_discover_source_files_exact_limit_is_not_truncated():
112+
"""An exact match to the limit should not be reported as truncation."""
113+
tree_entries = [
114+
{"path": f"file{i}.py", "type": "blob", "size": 100}
115+
for i in range(5)
116+
]
117+
118+
files, truncated = discover_source_files(tree_entries, max_files=5)
75119

120+
assert len(files) == 5
121+
assert truncated is False

0 commit comments

Comments
 (0)