Skip to content

Commit 0f7452a

Browse files
gebeerclaude
andauthored
feat: diagnostic logging for index_folder — skip counts and no-symbol tracking
* feat: add per-reason discovery logging and diagnostic response fields to index_folder Adds structured skip_counts tracking to discover_local_files() covering all filter paths (symlink, path_traversal, skip_pattern, gitignore, extra_ignore, secret, wrong_extension, too_large, unreadable, binary). Files that pass discovery but yield no AST symbols are now tracked separately with DEBUG logging. Both datasets are returned in the index_folder tool response as discovery_skip_counts, no_symbols_count, and no_symbols_files, making it straightforward to diagnose why files are absent from the index. Adds --log-level / --log-file CLI args to server main() (also via JCODEMUNCH_LOG_LEVEL / JCODEMUNCH_LOG_FILE env vars). Defaults to WARNING so normal operation is silent. Updates README and MCP tool description to document the new flags and response fields. Fixes test_security.py call sites to unpack the new 3-tuple return value. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: trim index_folder tool description to reduce context bloat Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: surface diagnostic fields in incremental index result Add discovery_skip_counts, no_symbols_count, and no_symbols_files to the incremental return path in index_folder, which previously omitted them despite being computed. Also fix test unpacking __, __ -> *_. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f9e3c9c commit 0f7452a

4 files changed

Lines changed: 133 additions & 10 deletions

File tree

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,25 @@ Config file location:
173173
}
174174
```
175175

176+
**With debug logging (useful when diagnosing why files are not indexed):**
177+
178+
```json
179+
{
180+
"mcpServers": {
181+
"jcodemunch": {
182+
"command": "uvx",
183+
"args": [
184+
"jcodemunch-mcp",
185+
"--log-level", "DEBUG",
186+
"--log-file", "/tmp/jcodemunch.log"
187+
]
188+
}
189+
}
190+
}
191+
```
192+
193+
> Logging flags can also be set via env vars `JCODEMUNCH_LOG_LEVEL` and `JCODEMUNCH_LOG_FILE`. Always use `--log-file` (or the env var) when debugging — writing logs to stderr can corrupt the MCP stdio stream in some clients.
194+
176195
After saving the config, **restart Claude Desktop / Claude Code** for the server to appear.
177196

178197
### Google Antigravity
@@ -350,6 +369,8 @@ For **LM Studio**, ensure the Local Server is running (usually on port 1234):
350369
| `OPENAI_TIMEOUT` | Timeout in seconds for local requests (default: `60.0`) | No |
351370
| `CODE_INDEX_PATH` | Custom cache path | No |
352371
| `JCODEMUNCH_SHARE_SAVINGS` | Set to `0` to disable anonymous community token savings reporting | No |
372+
| `JCODEMUNCH_LOG_LEVEL` | Log level: `DEBUG`, `INFO`, `WARNING`, `ERROR` (default: `WARNING`) | No |
373+
| `JCODEMUNCH_LOG_FILE` | Path to log file. If unset, logs go to stderr. Use a file to avoid polluting MCP stdio. | No |
353374

354375
### Community Savings Meter
355376

src/jcodemunch_mcp/server.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
import argparse
44
import asyncio
55
import json
6+
import logging
67
import os
8+
from pathlib import Path
79
from typing import Any, Optional
810

911
from mcp.server import Server
@@ -55,7 +57,7 @@ async def list_tools() -> list[Tool]:
5557
),
5658
Tool(
5759
name="index_folder",
58-
description="Index a local folder containing source code. Walks directory, parses ASTs, extracts symbols, and saves to local storage. Works with any folder containing supported language files.",
60+
description="Index a local folder containing source code. Response includes `discovery_skip_counts` (files filtered per reason), `no_symbols_count`/`no_symbols_files` (files with no extractable symbols) for diagnosing missing files.",
5961
inputSchema={
6062
"type": "object",
6163
"properties": {
@@ -385,7 +387,34 @@ def main(argv: Optional[list[str]] = None):
385387
prog="jcodemunch-mcp",
386388
description="Run the jCodeMunch MCP stdio server.",
387389
)
388-
parser.parse_args(argv)
390+
parser.add_argument(
391+
"--log-level",
392+
default=os.environ.get("JCODEMUNCH_LOG_LEVEL", "WARNING"),
393+
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
394+
help="Log level (also via JCODEMUNCH_LOG_LEVEL env var)",
395+
)
396+
parser.add_argument(
397+
"--log-file",
398+
default=os.environ.get("JCODEMUNCH_LOG_FILE"),
399+
help="Log file path (also via JCODEMUNCH_LOG_FILE env var). Defaults to stderr.",
400+
)
401+
args = parser.parse_args(argv)
402+
403+
log_level = getattr(logging, args.log_level)
404+
handlers: list[logging.Handler] = []
405+
if args.log_file:
406+
log_path = Path(args.log_file).expanduser()
407+
log_path.parent.mkdir(parents=True, exist_ok=True)
408+
handlers.append(logging.FileHandler(log_path))
409+
else:
410+
handlers.append(logging.StreamHandler())
411+
412+
logging.basicConfig(
413+
level=log_level,
414+
format="%(asctime)s %(name)s %(levelname)s %(message)s",
415+
handlers=handlers,
416+
)
417+
389418
asyncio.run(run_server())
390419

391420

src/jcodemunch_mcp/tools/index_folder.py

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
"""Index local folder tool - walk, parse, summarize, save."""
22

3+
import logging
34
import os
45
from pathlib import Path
56
from typing import Optional
67

78
import pathspec
89

10+
logger = logging.getLogger(__name__)
11+
912
from ..parser import parse_file, LANGUAGE_EXTENSIONS
1013
from ..security import (
1114
validate_path,
@@ -61,7 +64,7 @@ def discover_local_files(
6164
max_size: int = DEFAULT_MAX_FILE_SIZE,
6265
extra_ignore_patterns: Optional[list[str]] = None,
6366
follow_symlinks: bool = False,
64-
) -> tuple[list[Path], list[str]]:
67+
) -> tuple[list[Path], list[str], dict[str, int]]:
6568
"""Discover source files in a local folder with security filtering.
6669
6770
Args:
@@ -78,6 +81,20 @@ def discover_local_files(
7881
warnings = []
7982
root = folder_path.resolve()
8083

84+
skip_counts: dict[str, int] = {
85+
"symlink": 0,
86+
"symlink_escape": 0,
87+
"path_traversal": 0,
88+
"skip_pattern": 0,
89+
"gitignore": 0,
90+
"extra_ignore": 0,
91+
"secret": 0,
92+
"wrong_extension": 0,
93+
"too_large": 0,
94+
"unreadable": 0,
95+
"binary": 0,
96+
}
97+
8198
# Load .gitignore
8299
gitignore_spec = _load_gitignore(root)
83100

@@ -96,58 +113,85 @@ def discover_local_files(
96113

97114
# Symlink protection
98115
if not follow_symlinks and file_path.is_symlink():
116+
skip_counts["symlink"] += 1
117+
logger.debug("SKIP symlink: %s", file_path)
99118
continue
100119
if file_path.is_symlink() and is_symlink_escape(root, file_path):
120+
skip_counts["symlink_escape"] += 1
101121
warnings.append(f"Skipped symlink escape: {file_path}")
102122
continue
103123

104124
# Path traversal check
105125
if not validate_path(root, file_path):
126+
skip_counts["path_traversal"] += 1
106127
warnings.append(f"Skipped path traversal: {file_path}")
107128
continue
108129

109130
# Get relative path for pattern matching
110131
try:
111132
rel_path = file_path.relative_to(root).as_posix()
112133
except ValueError:
134+
skip_counts["path_traversal"] += 1
135+
logger.debug("SKIP relative_to_failed: %s", file_path)
113136
continue
114137

115138
# Skip patterns
116139
if should_skip_file(rel_path):
140+
skip_counts["skip_pattern"] += 1
141+
logger.debug("SKIP skip_pattern: %s", rel_path)
117142
continue
118143

119144
# .gitignore matching
120145
if gitignore_spec and gitignore_spec.match_file(rel_path):
146+
skip_counts["gitignore"] += 1
147+
logger.debug("SKIP gitignore: %s", rel_path)
121148
continue
122149

123150
# Extra ignore patterns
124151
if extra_spec and extra_spec.match_file(rel_path):
152+
skip_counts["extra_ignore"] += 1
153+
logger.debug("SKIP extra_ignore: %s", rel_path)
125154
continue
126155

127156
# Secret detection
128157
if is_secret_file(rel_path):
158+
skip_counts["secret"] += 1
129159
warnings.append(f"Skipped secret file: {rel_path}")
130160
continue
131161

132162
# Extension filter
133163
ext = file_path.suffix
134164
if ext not in LANGUAGE_EXTENSIONS:
165+
skip_counts["wrong_extension"] += 1
166+
logger.debug("SKIP wrong_extension: %s", rel_path)
135167
continue
136168

137169
# Size limit
138170
try:
139171
if file_path.stat().st_size > max_size:
172+
skip_counts["too_large"] += 1
173+
logger.debug("SKIP too_large: %s", rel_path)
140174
continue
141175
except OSError:
176+
skip_counts["unreadable"] += 1
177+
logger.debug("SKIP unreadable (stat failed): %s", rel_path)
142178
continue
143179

144180
# Binary detection (content sniff for files with source extensions)
145181
if is_binary_file(file_path):
182+
skip_counts["binary"] += 1
146183
warnings.append(f"Skipped binary file: {rel_path}")
147184
continue
148185

186+
logger.debug("ACCEPT: %s", rel_path)
149187
files.append(file_path)
150188

189+
logger.info(
190+
"Discovery complete — accepted: %d, skipped by reason: %s",
191+
len(files),
192+
skip_counts,
193+
)
194+
151195
# File count limit with prioritization
152196
if len(files) > max_files:
153197
# Prioritize: src/, lib/, pkg/, cmd/, internal/ first
@@ -169,7 +213,7 @@ def priority_key(file_path: Path) -> tuple:
169213
files.sort(key=priority_key)
170214
files = files[:max_files]
171215

172-
return files, warnings
216+
return files, warnings, skip_counts
173217

174218

175219
def index_folder(
@@ -206,12 +250,13 @@ def index_folder(
206250

207251
try:
208252
# Discover source files (with security filtering)
209-
source_files, discover_warnings = discover_local_files(
253+
source_files, discover_warnings, skip_counts = discover_local_files(
210254
folder_path,
211255
extra_ignore_patterns=extra_ignore_patterns,
212256
follow_symlinks=follow_symlinks,
213257
)
214258
warnings.extend(discover_warnings)
259+
logger.info("Discovery skip counts: %s", skip_counts)
215260

216261
if not source_files:
217262
return {"success": False, "error": "No source files found"}
@@ -259,6 +304,7 @@ def index_folder(
259304
languages: dict[str, int] = {}
260305
raw_files_subset: dict[str, str] = {}
261306

307+
incremental_no_symbols: list[str] = []
262308
for rel_path in files_to_parse:
263309
content = current_files[rel_path]
264310
ext = os.path.splitext(rel_path)[1]
@@ -270,8 +316,18 @@ def index_folder(
270316
if symbols:
271317
new_symbols.extend(symbols)
272318
raw_files_subset[rel_path] = content
319+
else:
320+
incremental_no_symbols.append(rel_path)
321+
logger.debug("NO SYMBOLS (incremental): %s", rel_path)
273322
except Exception as e:
274323
warnings.append(f"Failed to parse {rel_path}: {e}")
324+
logger.debug("PARSE ERROR (incremental): %s — %s", rel_path, e)
325+
326+
logger.info(
327+
"Incremental parsing — with symbols: %d, no symbols: %d",
328+
len(new_symbols),
329+
len(incremental_no_symbols),
330+
)
275331

276332
new_symbols = summarize_symbols(new_symbols, use_ai=use_ai_summaries)
277333

@@ -300,6 +356,9 @@ def index_folder(
300356
"changed": len(changed), "new": len(new), "deleted": len(deleted),
301357
"symbol_count": len(updated.symbols) if updated else 0,
302358
"indexed_at": updated.indexed_at if updated else "",
359+
"discovery_skip_counts": skip_counts,
360+
"no_symbols_count": len(incremental_no_symbols),
361+
"no_symbols_files": incremental_no_symbols[:50],
303362
}
304363
if warnings:
305364
result["warnings"] = warnings
@@ -311,6 +370,7 @@ def index_folder(
311370
raw_files = {}
312371
parsed_files = []
313372

373+
no_symbols_files: list[str] = []
314374
for rel_path, content in current_files.items():
315375
ext = os.path.splitext(rel_path)[1]
316376
language = LANGUAGE_EXTENSIONS.get(ext)
@@ -323,10 +383,20 @@ def index_folder(
323383
languages[language] = languages.get(language, 0) + 1
324384
raw_files[rel_path] = content
325385
parsed_files.append(rel_path)
386+
else:
387+
no_symbols_files.append(rel_path)
388+
logger.debug("NO SYMBOLS: %s", rel_path)
326389
except Exception as e:
327390
warnings.append(f"Failed to parse {rel_path}: {e}")
391+
logger.debug("PARSE ERROR: %s — %s", rel_path, e)
328392
continue
329393

394+
logger.info(
395+
"Parsing complete — with symbols: %d, no symbols: %d",
396+
len(parsed_files),
397+
len(no_symbols_files),
398+
)
399+
330400
if not all_symbols:
331401
return {"success": False, "error": "No symbols extracted from files"}
332402

@@ -352,6 +422,9 @@ def index_folder(
352422
"symbol_count": len(all_symbols),
353423
"languages": languages,
354424
"files": parsed_files[:20], # Limit files in response
425+
"discovery_skip_counts": skip_counts,
426+
"no_symbols_count": len(no_symbols_files),
427+
"no_symbols_files": no_symbols_files[:50], # Show up to 50 for inspection
355428
}
356429

357430
if warnings:

tests/test_security.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ def test_excludes_secret_files(self, tmp_path):
232232
(tmp_path / ".env").write_text("SECRET=foo\n")
233233
(tmp_path / "config.pem").write_text("-----BEGIN CERTIFICATE-----\n")
234234

235-
files, warnings = discover_local_files(tmp_path)
235+
files, warnings, _ = discover_local_files(tmp_path)
236236
rel_paths = [f.name for f in files]
237237
assert "main.py" in rel_paths
238238
assert ".env" not in rel_paths
@@ -247,7 +247,7 @@ def test_excludes_binary_files(self, tmp_path):
247247
binary = tmp_path / "bad.py"
248248
binary.write_bytes(b"import os\x00\nprint('hi')")
249249

250-
files, warnings = discover_local_files(tmp_path)
250+
files, warnings, _ = discover_local_files(tmp_path)
251251
names = [f.name for f in files]
252252
assert "good.py" in names
253253
assert "bad.py" not in names
@@ -260,7 +260,7 @@ def test_respects_gitignore(self, tmp_path):
260260
(tmp_path / "kept.py").write_text("x = 1\n")
261261
(tmp_path / "ignored.py").write_text("y = 2\n")
262262

263-
files, _ = discover_local_files(tmp_path)
263+
files, *_ = discover_local_files(tmp_path)
264264
names = [f.name for f in files]
265265
assert "kept.py" in names
266266
assert "ignored.py" not in names
@@ -272,7 +272,7 @@ def test_extra_ignore_patterns(self, tmp_path):
272272
(tmp_path / "main.py").write_text("x = 1\n")
273273
(tmp_path / "temp.py").write_text("y = 2\n")
274274

275-
files, _ = discover_local_files(tmp_path, extra_ignore_patterns=["temp.py"])
275+
files, *_ = discover_local_files(tmp_path, extra_ignore_patterns=["temp.py"])
276276
names = [f.name for f in files]
277277
assert "main.py" in names
278278
assert "temp.py" not in names
@@ -287,7 +287,7 @@ def test_symlinks_skipped_by_default(self, tmp_path):
287287
link = tmp_path / "link.py"
288288
link.symlink_to(real)
289289

290-
files, _ = discover_local_files(tmp_path, follow_symlinks=False)
290+
files, *_ = discover_local_files(tmp_path, follow_symlinks=False)
291291
names = [f.name for f in files]
292292
assert "real.py" in names
293293
assert "link.py" not in names

0 commit comments

Comments
 (0)