Skip to content

Commit d182cb5

Browse files
authored
Merge pull request #28 from allenguarnes/main
Make the indexing file cap configurable
2 parents 84762a3 + 8a31e3d commit d182cb5

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
@@ -376,6 +376,7 @@ For **LM Studio**, ensure the Local Server is running (usually on port 1234):
376376
| `OPENAI_MODEL` | Model name for local LLMs (default: `qwen3-coder`) | No |
377377
| `OPENAI_TIMEOUT` | Timeout in seconds for local requests (default: `60.0`) | No |
378378
| `CODE_INDEX_PATH` | Custom cache path | No |
379+
| `JCODEMUNCH_MAX_INDEX_FILES`| Maximum files to index per repo/folder (default: `500`) | No |
379380
| `JCODEMUNCH_SHARE_SAVINGS` | Set to `0` to disable anonymous community token savings reporting | No |
380381
| `JCODEMUNCH_LOG_LEVEL` | Log level: `DEBUG`, `INFO`, `WARNING`, `ERROR` (default: `WARNING`) | No |
381382
| `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
@@ -18,6 +18,7 @@
1818
is_binary_file,
1919
should_exclude_file,
2020
DEFAULT_MAX_FILE_SIZE,
21+
get_max_index_files,
2122
)
2223
from ..storage import IndexStore
2324
from ..summarizer import summarize_symbols
@@ -61,7 +62,7 @@ def _load_gitignore(folder_path: Path) -> Optional[pathspec.PathSpec]:
6162

6263
def discover_local_files(
6364
folder_path: Path,
64-
max_files: int = 500,
65+
max_files: Optional[int] = None,
6566
max_size: int = DEFAULT_MAX_FILE_SIZE,
6667
extra_ignore_patterns: Optional[list[str]] = None,
6768
follow_symlinks: bool = False,
@@ -78,6 +79,7 @@ def discover_local_files(
7879
Returns:
7980
Tuple of (list of Path objects for source files, list of warning strings).
8081
"""
82+
max_files = get_max_index_files(max_files)
8183
files = []
8284
warnings = []
8385
root = folder_path.resolve()
@@ -94,6 +96,7 @@ def discover_local_files(
9496
"too_large": 0,
9597
"unreadable": 0,
9698
"binary": 0,
99+
"file_limit": 0,
97100
}
98101

99102
# Load .gitignore
@@ -195,6 +198,7 @@ def discover_local_files(
195198

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

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

250254
warnings = []
255+
max_files = get_max_index_files()
251256

252257
try:
253258
# Discover source files (with security filtering)
254259
source_files, discover_warnings, skip_counts = discover_local_files(
255260
folder_path,
261+
max_files=max_files,
256262
extra_ignore_patterns=extra_ignore_patterns,
257263
follow_symlinks=follow_symlinks,
258264
)
@@ -432,8 +438,8 @@ def index_folder(
432438
if warnings:
433439
result["warnings"] = warnings
434440

435-
if len(source_files) >= 500:
436-
result["note"] = "Folder has many files; indexed first 500"
441+
if skip_counts.get("file_limit", 0) > 0:
442+
result["note"] = f"Folder has many files; indexed first {max_files}"
437443

438444
return result
439445

src/jcodemunch_mcp/tools/index_repo.py

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

1111
from ..parser import parse_file, LANGUAGE_EXTENSIONS
12-
from ..security import is_secret_file, is_binary_extension
12+
from ..security import is_secret_file, is_binary_extension, get_max_index_files
1313
from ..storage import IndexStore
1414
from ..summarizer import summarize_symbols
1515

@@ -87,9 +87,9 @@ def should_skip_file(path: str) -> bool:
8787
def discover_source_files(
8888
tree_entries: list[dict],
8989
gitignore_content: Optional[str] = None,
90-
max_files: int = 500,
90+
max_files: Optional[int] = None,
9191
max_size: int = 500 * 1024 # 500KB
92-
) -> list[str]:
92+
) -> tuple[list[str], bool]:
9393
"""Discover source files from tree entries.
9494
9595
Applies filtering pipeline:
@@ -101,6 +101,8 @@ def discover_source_files(
101101
6. File count limit
102102
"""
103103
import pathspec
104+
105+
max_files = get_max_index_files(max_files)
104106

105107
# Parse gitignore if provided
106108
gitignore_spec = None
@@ -150,8 +152,10 @@ def discover_source_files(
150152

151153
files.append(path)
152154

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

@@ -166,7 +170,7 @@ def priority_key(path):
166170
files.sort(key=priority_key)
167171
files = files[:max_files]
168172

169-
return files
173+
return files, truncated
170174

171175

172176
async def fetch_file_content(
@@ -229,6 +233,7 @@ async def index_repo(
229233
github_token = os.environ.get("GITHUB_TOKEN")
230234

231235
warnings = []
236+
max_files = get_max_index_files()
232237

233238
try:
234239
# Fetch tree
@@ -245,7 +250,11 @@ async def index_repo(
245250
gitignore_content = await fetch_gitignore(owner, repo, github_token)
246251

247252
# Discover source files
248-
source_files = discover_source_files(tree_entries, gitignore_content)
253+
source_files, truncated = discover_source_files(
254+
tree_entries,
255+
gitignore_content,
256+
max_files=max_files,
257+
)
249258

250259
if not source_files:
251260
return {"success": False, "error": "No source files found"}
@@ -383,8 +392,8 @@ async def fetch_with_limit(path: str) -> tuple[str, str]:
383392
if warnings:
384393
result["warnings"] = warnings
385394

386-
if len(source_files) >= 500:
387-
result["warnings"] = warnings + ["Repository has many files; indexed first 500"]
395+
if truncated:
396+
result["warnings"] = warnings + [f"Repository has many files; indexed first {max_files}"]
388397

389398
return result
390399

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():
@@ -41,14 +44,15 @@ def test_discover_source_files():
4144
{"path": "include/engine.hpp", "type": "blob", "size": 350},
4245
]
4346

44-
files = discover_source_files(tree_entries, gitignore_content=None)
47+
files, truncated = discover_source_files(tree_entries, gitignore_content=None)
4548

4649
assert "src/main.py" in files
4750
assert "src/utils.py" in files
4851
assert "src/engine.cpp" in files
4952
assert "include/engine.hpp" in files
5053
assert "node_modules/foo.js" not in files
5154
assert "README.md" not in files # Not a source file
55+
assert truncated is False
5256

5357

5458
def test_discover_source_files_respects_max():
@@ -58,8 +62,9 @@ def test_discover_source_files_respects_max():
5862
for i in range(1000)
5963
]
6064

61-
files = discover_source_files(tree_entries, max_files=100)
65+
files, truncated = discover_source_files(tree_entries, max_files=100)
6266
assert len(files) == 100
67+
assert truncated is True
6368

6469

6570
def test_discover_source_files_prioritizes_src():
@@ -72,8 +77,49 @@ def test_discover_source_files_prioritizes_src():
7277
for i in range(300)
7378
]
7479

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

124+
assert len(files) == 5
125+
assert truncated is False

0 commit comments

Comments
 (0)