symgrep-codesearch 0.7.0__tar.gz → 0.7.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/PKG-INFO +1 -1
  2. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/pyproject.toml +6 -1
  3. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/src/symgrep/mcp_server.py +149 -38
  4. symgrep_codesearch-0.7.2/src/symgrep/runtime_paths.py +26 -0
  5. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/src/symgrep/semantic.py +15 -9
  6. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/src/symgrep_codesearch.egg-info/PKG-INFO +1 -1
  7. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/src/symgrep_codesearch.egg-info/SOURCES.txt +4 -0
  8. symgrep_codesearch-0.7.2/tests/test_mcp_error_log.py +56 -0
  9. symgrep_codesearch-0.7.2/tests/test_mcp_thread_safety.py +57 -0
  10. symgrep_codesearch-0.7.2/tests/test_mcp_version.py +72 -0
  11. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/LICENSE +0 -0
  12. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/README.md +0 -0
  13. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/setup.cfg +0 -0
  14. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/src/symgrep/__init__.py +0 -0
  15. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/src/symgrep/archives.py +0 -0
  16. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/src/symgrep/blocks.py +0 -0
  17. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/src/symgrep/classfile.py +0 -0
  18. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/src/symgrep/cli.py +0 -0
  19. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/src/symgrep/data_symbols.py +0 -0
  20. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/src/symgrep/docsymbols.py +0 -0
  21. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/src/symgrep/enclosing.py +0 -0
  22. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/src/symgrep/glob_recall.py +0 -0
  23. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/src/symgrep/mcp_structured.py +0 -0
  24. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/src/symgrep/report.py +0 -0
  25. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/src/symgrep/resolve.py +0 -0
  26. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/src/symgrep/resolve_js.py +0 -0
  27. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/src/symgrep/resolve_python.py +0 -0
  28. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/src/symgrep/ripgrep.py +0 -0
  29. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/src/symgrep/structural.py +0 -0
  30. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/src/symgrep/structure.py +0 -0
  31. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/src/symgrep_codesearch.egg-info/dependency_links.txt +0 -0
  32. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/src/symgrep_codesearch.egg-info/entry_points.txt +0 -0
  33. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/src/symgrep_codesearch.egg-info/requires.txt +0 -0
  34. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/src/symgrep_codesearch.egg-info/top_level.txt +0 -0
  35. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/tests/test_blocks_extraction.py +0 -0
  36. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/tests/test_blocks_retrieval.py +0 -0
  37. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/tests/test_bulk_describe.py +0 -0
  38. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/tests/test_classfile.py +0 -0
  39. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/tests/test_data_symbols.py +0 -0
  40. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/tests/test_describe_lane_confinement.py +0 -0
  41. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/tests/test_doc_symbols.py +0 -0
  42. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/tests/test_file_structure.py +0 -0
  43. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/tests/test_freshness.py +0 -0
  44. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/tests/test_honest_recall.py +0 -0
  45. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/tests/test_http_ensure.py +0 -0
  46. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/tests/test_index_reconstruct.py +0 -0
  47. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/tests/test_index_scope.py +0 -0
  48. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/tests/test_index_worker.py +0 -0
  49. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/tests/test_mcp_structured.py +0 -0
  50. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/tests/test_name_uniqueness.py +0 -0
  51. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/tests/test_nudge_classify.py +0 -0
  52. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/tests/test_nudge_runaway.py +0 -0
  53. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/tests/test_report_filters.py +0 -0
  54. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/tests/test_resolve.py +0 -0
  55. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/tests/test_resolve_js.py +0 -0
  56. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/tests/test_resolve_python.py +0 -0
  57. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/tests/test_resolve_r2.py +0 -0
  58. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/tests/test_review_regressions.py +0 -0
  59. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/tests/test_semantic_freshness_signal.py +0 -0
  60. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/tests/test_semantic_glob_lang.py +0 -0
  61. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/tests/test_sites_and_symbols.py +0 -0
  62. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/tests/test_structural.py +0 -0
  63. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/tests/test_symbol_vocabulary.py +0 -0
  64. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/tests/test_ts_fallback_loud.py +0 -0
  65. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/tests/test_ts_symbol_coverage.py +0 -0
  66. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/tests/test_usage.py +0 -0
  67. {symgrep_codesearch-0.7.0 → symgrep_codesearch-0.7.2}/tests/test_veccore_adoption.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: symgrep-codesearch
3
- Version: 0.7.0
3
+ Version: 0.7.2
4
4
  Summary: Code search with honest recall — structure-aware grep that tells you what it does not know, plus search by meaning
5
5
  License-Expression: MIT
6
6
  Project-URL: Repository, https://github.com/ashdamle/symgrep
@@ -7,6 +7,11 @@ build-backend = "setuptools.build_meta"
7
7
  # Semgrep team. The IMPORT name is unchanged — `import symgrep` — so consumers (honestreview, spendguard,
8
8
  # 7thsense, warden) are unaffected by the distribution rename. The package dir stays src/symgrep.
9
9
  name = "symgrep-codesearch"
10
+ # 0.7.1: honesty fix — the MCP daemon now reports its TRUE version. _package_version() looked up the IMPORT
11
+ # name ("symgrep") instead of the DISTRIBUTION name ("symgrep-codesearch"), so importlib.metadata could not
12
+ # find the dist and serverInfo.version silently fell through to "0.0.0+unpackaged" (or matched a stale
13
+ # editable "symgrep" 0.3.0 dist-info) — a daemon running 0.7.0 would not say 0.7.0. Now queries
14
+ # symgrep-codesearch first, then the legacy import name, then the honest fallback; test_mcp_version pins it.
10
15
  # 0.6.4: grammar fix — C# (.cs) now parses. The tree-sitter-language-pack migration left C# pointing at the
11
16
  # legacy grammar name "c_sharp"; the maintained pack's manifest calls it "csharp", so `get_parser("c_sharp")`
12
17
  # raised TreeSitterUnavailable and every .cs file silently degraded to nearest_decl. enclosing._TS_PACK_NAME now
@@ -26,7 +31,7 @@ name = "symgrep-codesearch"
26
31
  # semantic search. Minor bump (dependency surface changed; no breaking import/CLI/MCP change).
27
32
  # 0.5.0: added the public `file_structure` library API (+ its MCP tool and `symgrep structure` CLI), import
28
33
  # module-name normalization, and the FileStructure `connects` list (structure schema v2).
29
- version = "0.7.0"
34
+ version = "0.7.2"
30
35
  description = "Code search with honest recall — structure-aware grep that tells you what it does not know, plus search by meaning"
31
36
  readme = "README.md"
32
37
  # 3.11+ : the maintained grammar pack (tree-sitter-language-pack, a CORE dep below) ships abi3 wheels for
@@ -14,6 +14,8 @@ importing symgrep in-process into a venv that lacks fastembed. Loopback only, by
14
14
  """
15
15
 
16
16
  import json
17
+ import logging
18
+ from logging.handlers import RotatingFileHandler
17
19
  import os
18
20
  import sys
19
21
  import traceback
@@ -21,6 +23,7 @@ from typing import List, Optional
21
23
 
22
24
  from .report import DEFAULT_MAX_SITES, render_report
23
25
  from .cli import build_report
26
+ from .runtime_paths import resolved_mcp_log_path
24
27
 
25
28
  PROTOCOL_VERSION_FALLBACK = "2024-11-05"
26
29
  HTTP_HOST_DEFAULT = "127.0.0.1" # $SYMGREP_MCP_HOST — loopback unless deliberately widened
@@ -28,16 +31,106 @@ HTTP_HOST_DEFAULT = "127.0.0.1" # $SYMGREP_MCP_HOST — loopback unless d
28
31
  # engine): 4900 ccwatch · 4910 7thsense · 4911 symgrep. Override via $SYMGREP_MCP_PORT.
29
32
  HTTP_PORT_DEFAULT = 4911
30
33
  HTTP_PATH = "/mcp" # mirrors ccwatch's POST /mcp convention
34
+ MCP_LOG_MAX_BYTES_DEFAULT = 5 * 1024 * 1024
35
+ MCP_LOG_BACKUP_COUNT_DEFAULT = 3
36
+ MCP_REQUEST_LOGGER_NAME = "symgrep.mcp.requests"
37
+
38
+
39
+ def _mcp_request_logger() -> logging.Logger:
40
+ """Return the rotating request logger, rebinding it when SYMGREP_LOG changes at runtime."""
41
+ log_path = resolved_mcp_log_path()
42
+ log_path.parent.mkdir(parents=True, exist_ok=True)
43
+ request_logger = logging.getLogger(MCP_REQUEST_LOGGER_NAME)
44
+ request_logger.setLevel(logging.INFO)
45
+ request_logger.propagate = False
46
+ current_path = getattr(request_logger, "_symgrep_log_path", None)
47
+ if current_path != str(log_path):
48
+ for existing_handler in list(request_logger.handlers):
49
+ existing_handler.close()
50
+ request_logger.removeHandler(existing_handler)
51
+ rotating_handler = RotatingFileHandler(
52
+ log_path, maxBytes=int(os.environ.get("SYMGREP_LOG_MAX_BYTES", MCP_LOG_MAX_BYTES_DEFAULT)),
53
+ backupCount=int(os.environ.get("SYMGREP_LOG_BACKUP_COUNT", MCP_LOG_BACKUP_COUNT_DEFAULT)),
54
+ encoding="utf-8")
55
+ rotating_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
56
+ request_logger.addHandler(rotating_handler)
57
+ request_logger._symgrep_log_path = str(log_path)
58
+ return request_logger
59
+
60
+
61
+ def _summarize_mcp_tool_arguments(tool_arguments: dict) -> str:
62
+ """Create a compact mechanical summary without dropping the pattern or single-file path."""
63
+ parts = []
64
+ for argument_name in sorted(tool_arguments):
65
+ argument_value = tool_arguments[argument_name]
66
+ if argument_name == "roots" and isinstance(argument_value, list):
67
+ rendered_value = f"{len(argument_value)} root(s)"
68
+ elif argument_value in (None, False, [], ""):
69
+ continue
70
+ else:
71
+ rendered_value = repr(argument_value)
72
+ parts.append(f"{argument_name}={rendered_value}")
73
+ return ", ".join(parts)
31
74
 
32
75
 
33
- def _package_version() -> str:
34
- """One source of truth: pyproject's version via importlib.metadata (the fallback used to hardcode
35
- '0.2.0' while the package said 0.3.0)."""
76
+ def _returned_mcp_error_details(tool_result):
77
+ """Parse only server-authored error fields/prefixes; normal search verdicts are not errors."""
78
+ parsed_result = tool_result
79
+ if isinstance(tool_result, str):
80
+ try:
81
+ parsed_result = json.loads(tool_result)
82
+ except (TypeError, json.JSONDecodeError):
83
+ if tool_result.startswith("semantic search UNAVAILABLE"):
84
+ return "unavailable", tool_result
85
+ return None
86
+ if not isinstance(parsed_result, dict):
87
+ return None
88
+ status = parsed_result.get("status")
89
+ if status in ("unavailable", "no_index"):
90
+ return parsed_result.get("reason") or status, parsed_result.get("message") or repr(parsed_result)
91
+ if "error" in parsed_result:
92
+ error_value = parsed_result["error"]
93
+ if isinstance(error_value, dict):
94
+ return error_value.get("code", "error"), error_value.get("message") or repr(error_value)
95
+ return "error", str(error_value)
96
+ if parsed_result.get("refused") is True:
97
+ return "refused", parsed_result.get("message") or repr(parsed_result)
98
+ return None
99
+
100
+
101
+ def _invoke_logged_mcp_tool(tool_name: str, tool_arguments: dict, invocation):
102
+ """Run one tool call and make every raised or returned error durable in the MCP log."""
103
+ argument_summary = _summarize_mcp_tool_arguments(tool_arguments)
36
104
  try:
37
- from importlib.metadata import version
38
- return version("symgrep")
105
+ tool_result = invocation()
39
106
  except Exception:
40
- return "0.0.0+unpackaged"
107
+ _mcp_request_logger().exception("tool=%s args={%s} raised", tool_name, argument_summary)
108
+ raise
109
+ returned_error = _returned_mcp_error_details(tool_result)
110
+ if returned_error is not None:
111
+ error_code, error_message = returned_error
112
+ _mcp_request_logger().warning("tool=%s args={%s} returned error code=%s message=%s",
113
+ tool_name, argument_summary, error_code, error_message)
114
+ return tool_result
115
+
116
+
117
+ def _package_version() -> str:
118
+ """The TRUE installed version of this package — serverInfo.version must not lie about what is running.
119
+
120
+ One source of truth: pyproject's version via importlib.metadata (the fallback used to hardcode '0.2.0'
121
+ while the package said 0.3.0). The lookup key is the DISTRIBUTION name, which is NOT the import name:
122
+ you `pip install symgrep-codesearch` but `import symgrep` (the bare `symgrep` PyPI name is parked by the
123
+ Semgrep team). `version("symgrep")` therefore does not resolve to this dist — it fell through to the
124
+ unpackaged fallback, or worse matched a STALE editable `symgrep` dist-info left from an old 0.3.0
125
+ install — so the daemon reported a version that was not the one it was running. Query the real dist name
126
+ first, then the legacy import name (so a pre-rename install still resolves), then an honest fallback."""
127
+ from importlib.metadata import version, PackageNotFoundError
128
+ for dist in ("symgrep-codesearch", "symgrep"):
129
+ try:
130
+ return version(dist)
131
+ except PackageNotFoundError:
132
+ continue
133
+ return "0.0.0+unpackaged"
41
134
 
42
135
  TOOL_DESCRIPTION = (
43
136
  "Structure-aware code search — use this INSTEAD of plain Grep when exploring "
@@ -185,15 +278,24 @@ def _run_fastmcp(http=None) -> bool:
185
278
  roots: directories/files to search (absolute paths preferred).
186
279
  globs: rg --glob filters, e.g. ["*.py"].
187
280
  """
188
- return _search(pattern, roots, case_insensitive, fixed_string,
189
- multiline, globs, include_ignored, max_sites, semantic, k, json_output, structured,
190
- structural, lang, usage, resolve, unit, kind)
281
+ tool_arguments = {"pattern": pattern, "roots": roots, "case_insensitive": case_insensitive,
282
+ "fixed_string": fixed_string, "multiline": multiline, "globs": globs,
283
+ "include_ignored": include_ignored, "max_sites": max_sites, "semantic": semantic,
284
+ "k": k, "json_output": json_output, "structured": structured,
285
+ "structural": structural, "lang": lang, "usage": usage, "resolve": resolve,
286
+ "unit": unit, "kind": kind}
287
+ return _invoke_logged_mcp_tool(
288
+ "search", tool_arguments,
289
+ lambda: _search(pattern, roots, case_insensitive, fixed_string, multiline, globs,
290
+ include_ignored, max_sites, semantic, k, json_output, structured,
291
+ structural, lang, usage, resolve, unit, kind))
191
292
 
192
293
  @mcp.tool(description="Structural symbols of ONE file: every function/class with its line span and "
193
294
  "basis (ast/treesitter/…). The AST layer without an in-process import.")
194
295
  def file_symbols(path: str) -> str:
195
296
  from .mcp_structured import structured_file_symbols
196
- return json.dumps(structured_file_symbols(path))
297
+ return _invoke_logged_mcp_tool(
298
+ "file_symbols", {"path": path}, lambda: json.dumps(structured_file_symbols(path)))
197
299
 
198
300
  @mcp.tool(description="Mechanical STRUCTURE of ONE file, from the same grammar parse: {schema_version, "
199
301
  "language, basis, functions:[{name,signature,start,end}], classes, imports, "
@@ -203,13 +305,15 @@ def _run_fastmcp(http=None) -> bool:
203
305
  "caller's agentic layer).")
204
306
  def file_structure(path: str) -> str:
205
307
  from .mcp_structured import structured_file_structure
206
- return json.dumps(structured_file_structure(path))
308
+ return _invoke_logged_mcp_tool(
309
+ "file_structure", {"path": path}, lambda: json.dumps(structured_file_structure(path)))
207
310
 
208
311
  @mcp.tool(description="The roots the semantic index covers, so an un-hit query reads as 'not indexed "
209
312
  "here' vs 'indexed, nothing near'.")
210
313
  def indexed_roots() -> str:
211
314
  from .mcp_structured import structured_indexed_roots
212
- return json.dumps(structured_indexed_roots())
315
+ return _invoke_logged_mcp_tool(
316
+ "indexed_roots", {}, lambda: json.dumps(structured_indexed_roots()))
213
317
 
214
318
  if http:
215
319
  host, port = http
@@ -296,26 +400,26 @@ def _fallback_handle(req):
296
400
  name = params.get("name")
297
401
  a = params.get("arguments", {})
298
402
  if name == "search":
299
- text = _search(a["pattern"], a.get("roots"),
300
- a.get("case_insensitive", False),
301
- a.get("fixed_string", False),
302
- a.get("multiline", False),
303
- a.get("globs"), a.get("include_ignored", False),
304
- a.get("max_sites", DEFAULT_MAX_SITES),
305
- a.get("semantic", False), a.get("k", 8),
306
- a.get("json_output", False), a.get("structured", False),
307
- a.get("structural", False), a.get("lang"),
308
- a.get("usage", False), a.get("resolve", False),
309
- a.get("unit", "symbol"), a.get("kind"))
403
+ text = _invoke_logged_mcp_tool(
404
+ name, a, lambda: _search(
405
+ a["pattern"], a.get("roots"), a.get("case_insensitive", False),
406
+ a.get("fixed_string", False), a.get("multiline", False), a.get("globs"),
407
+ a.get("include_ignored", False), a.get("max_sites", DEFAULT_MAX_SITES),
408
+ a.get("semantic", False), a.get("k", 8), a.get("json_output", False),
409
+ a.get("structured", False), a.get("structural", False), a.get("lang"),
410
+ a.get("usage", False), a.get("resolve", False), a.get("unit", "symbol"), a.get("kind")))
310
411
  elif name == "file_symbols":
311
412
  from .mcp_structured import structured_file_symbols
312
- text = json.dumps(structured_file_symbols(a["path"]))
413
+ text = _invoke_logged_mcp_tool(
414
+ name, a, lambda: json.dumps(structured_file_symbols(a["path"])))
313
415
  elif name == "file_structure":
314
416
  from .mcp_structured import structured_file_structure
315
- text = json.dumps(structured_file_structure(a["path"]))
417
+ text = _invoke_logged_mcp_tool(
418
+ name, a, lambda: json.dumps(structured_file_structure(a["path"])))
316
419
  elif name == "indexed_roots":
317
420
  from .mcp_structured import structured_indexed_roots
318
- text = json.dumps(structured_indexed_roots())
421
+ text = _invoke_logged_mcp_tool(
422
+ name, a, lambda: json.dumps(structured_indexed_roots()))
319
423
  else:
320
424
  raise ValueError(f"unknown tool {name!r}")
321
425
  return {"content": [{"type": "text", "text": text}]}
@@ -334,12 +438,16 @@ def _run_fallback() -> None:
334
438
  except json.JSONDecodeError:
335
439
  # JSON-RPC 2.0: a parse error is answered (-32700), not dropped — a client that sent
336
440
  # malformed JSON and hears nothing waits forever.
337
- print(json.dumps({"jsonrpc": "2.0", "id": None,
338
- "error": {"code": -32700, "message": "parse error"}}), flush=True)
441
+ parse_error = {"code": -32700, "message": "parse error"}
442
+ _mcp_request_logger().warning("tool=jsonrpc args={} returned error code=%s message=%s",
443
+ parse_error["code"], parse_error["message"])
444
+ print(json.dumps({"jsonrpc": "2.0", "id": None, "error": parse_error}), flush=True)
339
445
  continue
340
446
  if not isinstance(req, dict):
341
- print(json.dumps({"jsonrpc": "2.0", "id": None,
342
- "error": {"code": -32600, "message": "invalid request"}}), flush=True)
447
+ invalid_request_error = {"code": -32600, "message": "invalid request"}
448
+ _mcp_request_logger().warning("tool=jsonrpc args={} returned error code=%s message=%s",
449
+ invalid_request_error["code"], invalid_request_error["message"])
450
+ print(json.dumps({"jsonrpc": "2.0", "id": None, "error": invalid_request_error}), flush=True)
343
451
  continue
344
452
  rid = req.get("id")
345
453
  try:
@@ -347,17 +455,18 @@ def _run_fallback() -> None:
347
455
  if result is None and rid is not None:
348
456
  # a REQUEST (has id) for a method we don't serve gets -32601, not silence;
349
457
  # notifications (no id) legitimately get nothing.
350
- print(json.dumps({"jsonrpc": "2.0", "id": rid,
351
- "error": {"code": -32601,
352
- "message": f"method not found: {req.get('method')!r}"}}),
353
- flush=True)
458
+ method_error = {"code": -32601, "message": f"method not found: {req.get('method')!r}"}
459
+ _mcp_request_logger().warning("tool=jsonrpc args={method=%r} returned error code=%s message=%s",
460
+ req.get("method"), method_error["code"], method_error["message"])
461
+ print(json.dumps({"jsonrpc": "2.0", "id": rid, "error": method_error}), flush=True)
354
462
  continue
355
463
  except Exception as e:
464
+ _mcp_request_logger().exception("tool=jsonrpc args={method=%r} raised", req.get("method"))
356
465
  if rid is not None:
357
- print(json.dumps({"jsonrpc": "2.0", "id": rid,
358
- "error": {"code": -32603,
359
- "message": f"{type(e).__name__}: {e}"}}),
360
- flush=True)
466
+ internal_error = {"code": -32603, "message": f"{type(e).__name__}: {e}"}
467
+ _mcp_request_logger().warning("tool=jsonrpc args={method=%r} returned error code=%s message=%s",
468
+ req.get("method"), internal_error["code"], internal_error["message"])
469
+ print(json.dumps({"jsonrpc": "2.0", "id": rid, "error": internal_error}), flush=True)
361
470
  continue
362
471
  if rid is not None and result is not None:
363
472
  print(json.dumps({"jsonrpc": "2.0", "id": rid, "result": result}),
@@ -374,6 +483,8 @@ def main() -> None:
374
483
  sys.exit(2)
375
484
  host = os.environ.get("SYMGREP_MCP_HOST", HTTP_HOST_DEFAULT)
376
485
  port = int(os.environ.get("SYMGREP_MCP_PORT", HTTP_PORT_DEFAULT))
486
+ _mcp_request_logger() # create the rotating request log before serving
487
+ sys.stderr.write(f"symgrep-mcp: error log {resolved_mcp_log_path()}\n")
377
488
  # WARM SHARED DAEMON = SINGLETON (one per port): every session CONNECTS to this warm HTTP server via urllib
378
489
  # instead of spawning a stdio copy. A second start detects the incumbent (flock via spendguard's single-instance
379
490
  # primitive) and exits 0 — never races FastMCP into a bind crash — so a supervisor can (re)start it idempotently.
@@ -0,0 +1,26 @@
1
+ """Shared runtime paths used by the server and its lifecycle hooks."""
2
+
3
+ import os
4
+ import pathlib
5
+
6
+
7
+ SYMGREP_CACHE_DIRECTORY_ENV = "SYMGREP_CACHE_DIR"
8
+ SYMGREP_LOG_PATH_ENV = "SYMGREP_LOG"
9
+ SYMGREP_CACHE_DIRECTORY_NAME = "symgrep"
10
+ SYMGREP_MCP_LOG_FILENAME = "mcp.log"
11
+
12
+
13
+ def resolved_symgrep_cache_directory() -> pathlib.Path:
14
+ """Return symgrep's configured cache directory, using the repository's existing user-cache default."""
15
+ configured = os.environ.get(SYMGREP_CACHE_DIRECTORY_ENV)
16
+ if configured:
17
+ return pathlib.Path(configured).expanduser()
18
+ return pathlib.Path.home() / ".cache" / SYMGREP_CACHE_DIRECTORY_NAME
19
+
20
+
21
+ def resolved_mcp_log_path() -> pathlib.Path:
22
+ """Return the request/crash log path shared by the MCP process and its ensure hook."""
23
+ configured = os.environ.get(SYMGREP_LOG_PATH_ENV)
24
+ if configured:
25
+ return pathlib.Path(configured).expanduser()
26
+ return resolved_symgrep_cache_directory() / SYMGREP_MCP_LOG_FILENAME
@@ -47,6 +47,8 @@ import sys
47
47
  import threading
48
48
  from typing import List, Optional, Tuple
49
49
 
50
+ from .runtime_paths import resolved_symgrep_cache_directory
51
+
50
52
  MODEL = os.environ.get("SYMGREP_EMBED_MODEL", "BAAI/bge-small-en-v1.5")
51
53
  DIM = 384 # bge-small-en-v1.5 geometry; veccore's ensure() guards it (DimMismatch)
52
54
  # The model is encoded in the COLLECTION name, so changing SYMGREP_EMBED_MODEL means a NEW collection — never
@@ -62,7 +64,7 @@ def index_path() -> pathlib.Path:
62
64
  import, so a test or caller setting it afterwards silently hit the real index (flagged by the seam
63
65
  axis as a one-sided `config:INDEX` contract, 2026-08-19)."""
64
66
  return pathlib.Path(os.environ.get("SYMGREP_INDEX")
65
- or (pathlib.Path.home() / ".cache" / "symgrep" / "semantic.sqlite"))
67
+ or (resolved_symgrep_cache_directory() / "semantic.sqlite"))
66
68
 
67
69
 
68
70
  class Unavailable(RuntimeError):
@@ -106,15 +108,15 @@ def _veccore_present() -> bool:
106
108
  return importlib.util.find_spec("veccore") is not None
107
109
 
108
110
 
109
- # store+core are cached PER index path; the loaded embedder is a process-wide singleton. Guarded by a lock so
110
- # concurrent first calls (e.g. a threaded MCP server) build exactly one embedder + one store per path.
111
+ # SQLite-backed store+core pairs are cached per thread and index path, so a daemon worker never reuses a
112
+ # connection created by another worker. Thread-local lifetime also releases a worker's cache when it exits.
111
113
  _EMBEDDER = [None]
112
- _CORES = {}
114
+ _SEMANTIC_THREAD_STATE = threading.local()
113
115
  _CACHE_LOCK = threading.Lock()
114
116
 
115
117
 
116
118
  def _core_for(path_str):
117
- """(store, core) for the index at `path_str`, built once and cached under `_CACHE_LOCK`.
119
+ """(store, core) for the index at `path_str`, built once per calling thread under `_CACHE_LOCK`.
118
120
 
119
121
  CACHE STALENESS, explicitly: the cache is PROCESS-LIVED and keyed by the index PATH — every symgrep CLI run
120
122
  is a fresh process, so it starts empty. Within one process a store's sqlite connection SEES other
@@ -124,12 +126,16 @@ def _core_for(path_str):
124
126
  Tests point $SYMGREP_INDEX at distinct temp paths, so each gets its own entry. The embedder is
125
127
  path-independent (a reload per call was catastrophically slow), so it is shared. Maps veccore's store-open
126
128
  failure to symgrep's index_unusable; a missing veccore surfaced earlier as Unavailable(no_embedder)."""
127
- hit = _CORES.get(path_str)
129
+ thread_cores = getattr(_SEMANTIC_THREAD_STATE, "cores_by_index_path", None)
130
+ if thread_cores is None:
131
+ thread_cores = {}
132
+ _SEMANTIC_THREAD_STATE.cores_by_index_path = thread_cores
133
+ hit = thread_cores.get(path_str)
128
134
  if hit is not None:
129
135
  return hit
130
136
  vc = _veccore()
131
137
  with _CACHE_LOCK: # one builder wins; a racing caller re-reads the cache
132
- hit = _CORES.get(path_str)
138
+ hit = thread_cores.get(path_str)
133
139
  if hit is not None:
134
140
  return hit
135
141
  try:
@@ -145,8 +151,8 @@ def _core_for(path_str):
145
151
  core.register(vc.CollectionSpec(name=COLLECTION, backend="sqlite_numpy", embedder=f"fastembed:{MODEL}",
146
152
  dim=DIM, payload_schema=("qual", "path", "lineno", "what", "stamp",
147
153
  "unit", "parent_key", "block_start", "block_end")))
148
- _CORES[path_str] = (store, core)
149
- return _CORES[path_str]
154
+ thread_cores[path_str] = (store, core) # sqlite connections stay owned by their worker thread
155
+ return thread_cores[path_str]
150
156
 
151
157
 
152
158
  def _embedder():
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: symgrep-codesearch
3
- Version: 0.7.0
3
+ Version: 0.7.2
4
4
  Summary: Code search with honest recall — structure-aware grep that tells you what it does not know, plus search by meaning
5
5
  License-Expression: MIT
6
6
  Project-URL: Repository, https://github.com/ashdamle/symgrep
@@ -17,6 +17,7 @@ src/symgrep/resolve.py
17
17
  src/symgrep/resolve_js.py
18
18
  src/symgrep/resolve_python.py
19
19
  src/symgrep/ripgrep.py
20
+ src/symgrep/runtime_paths.py
20
21
  src/symgrep/semantic.py
21
22
  src/symgrep/structural.py
22
23
  src/symgrep/structure.py
@@ -40,7 +41,10 @@ tests/test_http_ensure.py
40
41
  tests/test_index_reconstruct.py
41
42
  tests/test_index_scope.py
42
43
  tests/test_index_worker.py
44
+ tests/test_mcp_error_log.py
43
45
  tests/test_mcp_structured.py
46
+ tests/test_mcp_thread_safety.py
47
+ tests/test_mcp_version.py
44
48
  tests/test_name_uniqueness.py
45
49
  tests/test_nudge_classify.py
46
50
  tests/test_nudge_runaway.py
@@ -0,0 +1,56 @@
1
+ """Regression: MCP refusals and exceptions are durable in the configured rotating error log."""
2
+
3
+ import logging
4
+ import os
5
+ import pathlib
6
+ import sys
7
+ import tempfile
8
+
9
+ HERE = os.path.dirname(os.path.abspath(__file__))
10
+ sys.path.insert(0, os.path.join(os.path.dirname(HERE), "src"))
11
+ sys.path.insert(0, HERE)
12
+ from checklib import Checks # noqa: E402
13
+
14
+ from symgrep import mcp_server # noqa: E402
15
+
16
+ _t = Checks("test_mcp_error_log")
17
+ check = _t.check
18
+
19
+ temporary_directory = tempfile.TemporaryDirectory(prefix="symgrep-mcp-log-")
20
+ configured_log = pathlib.Path(temporary_directory.name) / "request-errors.log"
21
+ saved_log_path = os.environ.get("SYMGREP_LOG")
22
+ os.environ["SYMGREP_LOG"] = str(configured_log)
23
+
24
+ try:
25
+ returned_message = "semantic index is unavailable for this regression"
26
+ returned_payload = {"status": "unavailable", "reason": "no_index", "message": returned_message}
27
+ mcp_server._invoke_logged_mcp_tool(
28
+ "search", {"pattern": "find capability", "roots": [temporary_directory.name]},
29
+ lambda: __import__("json").dumps(returned_payload))
30
+
31
+ def raise_request_regression_error():
32
+ raise RuntimeError("request regression exploded")
33
+
34
+ try:
35
+ mcp_server._invoke_logged_mcp_tool(
36
+ "file_symbols", {"path": pathlib.Path(temporary_directory.name) / "broken.py"},
37
+ raise_request_regression_error)
38
+ except RuntimeError:
39
+ pass
40
+
41
+ for handler in logging.getLogger(mcp_server.MCP_REQUEST_LOGGER_NAME).handlers:
42
+ handler.flush()
43
+ logged = configured_log.read_text(encoding="utf-8")
44
+ check("returned MCP refusal is logged with tool, args, returned code, and exact message",
45
+ "tool=search" in logged and "pattern='find capability'" in logged
46
+ and "code=no_index" in logged and returned_message in logged, logged)
47
+ check("raised MCP exception is logged with its complete traceback",
48
+ "tool=file_symbols" in logged and "Traceback (most recent call last)" in logged
49
+ and "raise_request_regression_error" in logged and "request regression exploded" in logged, logged)
50
+ finally:
51
+ if saved_log_path is None:
52
+ os.environ.pop("SYMGREP_LOG", None)
53
+ else:
54
+ os.environ["SYMGREP_LOG"] = saved_log_path
55
+
56
+ _t.finish()
@@ -0,0 +1,57 @@
1
+ """Regression: semantic daemon workers never share a thread-bound SQLite store."""
2
+
3
+ import os
4
+ import sys
5
+ import tempfile
6
+ import threading
7
+
8
+ HERE = os.path.dirname(os.path.abspath(__file__))
9
+ sys.path.insert(0, os.path.join(os.path.dirname(HERE), "src"))
10
+ sys.path.insert(0, HERE)
11
+ from checklib import Checks # noqa: E402
12
+
13
+ from symgrep import semantic # noqa: E402
14
+
15
+ _t = Checks("test_mcp_thread_safety")
16
+ check = _t.check
17
+
18
+ try:
19
+ semantic._embedder()
20
+ except semantic.Unavailable:
21
+ _t.skip("veccore/fastembed absent — threaded semantic-store regression not exercised here")
22
+ else:
23
+ temporary_directory = tempfile.TemporaryDirectory(prefix="symgrep-thread-store-")
24
+ index_file = os.path.join(temporary_directory.name, "semantic.sqlite")
25
+ workers_ready = threading.Barrier(2)
26
+ worker_results = []
27
+ worker_failures = []
28
+ result_lock = threading.Lock()
29
+
30
+ def exercise_thread_owned_semantic_store():
31
+ try:
32
+ first_store, first_core = semantic._core_for(index_file)
33
+ second_store, second_core = semantic._core_for(index_file)
34
+ workers_ready.wait()
35
+ first_store.existing_ids(semantic.COLLECTION)
36
+ with result_lock:
37
+ worker_results.append((threading.get_ident(), first_store, first_core,
38
+ first_store is second_store and first_core is second_core))
39
+ except Exception as error:
40
+ with result_lock:
41
+ worker_failures.append(error)
42
+
43
+ worker_threads = [threading.Thread(target=exercise_thread_owned_semantic_store) for _ in range(2)]
44
+ for worker_thread in worker_threads:
45
+ worker_thread.start()
46
+ for worker_thread in worker_threads:
47
+ worker_thread.join()
48
+
49
+ check("two daemon-style worker threads can use the same semantic index without SQLite thread errors",
50
+ not worker_failures and len(worker_results) == 2,
51
+ "\n".join(f"{type(error).__name__}: {error}" for error in worker_failures))
52
+ check("each worker reuses its own store/core pair within that thread",
53
+ len(worker_results) == 2 and all(result[3] for result in worker_results), str(worker_results))
54
+ check("different worker threads receive different SQLite-backed stores for the same index path",
55
+ len(worker_results) == 2 and worker_results[0][1] is not worker_results[1][1], str(worker_results))
56
+
57
+ _t.finish()
@@ -0,0 +1,72 @@
1
+ """serverInfo.version must be TRUE — _package_version() reports the real installed version, not a lie.
2
+
3
+ This is an honesty guard, not a cosmetic one. The MCP daemon advertises _package_version() as its
4
+ serverInfo.version (mcp_server._run_fastmcp and _fallback_handle); a consumer reads it to know which engine
5
+ answered. The bug it pins: the DISTRIBUTION name is `symgrep-codesearch` but the lookup used the IMPORT name
6
+ `symgrep`, so importlib.metadata could not find the dist and the daemon fell through to "0.0.0+unpackaged"
7
+ (or matched a STALE editable `symgrep` 0.3.0 dist-info) — reporting a version it was not running.
8
+
9
+ Two claims:
10
+ 1. When the package IS installed (CI does `pip install -e .`), _package_version() returns the REAL
11
+ distribution version — never the unpackaged fallback — and that value equals what importlib.metadata
12
+ reports for the installed distribution.
13
+ 2. In that editable/source checkout, the reported version MATCHES pyproject's version (the SSOT). Before
14
+ the fix this failed: version("symgrep") → "0.0.0+unpackaged" != pyproject's "0.7.0".
15
+
16
+ Run: python tests/test_mcp_version.py (no pytest dependency, like the CLI itself)
17
+ """
18
+ import os
19
+ import sys
20
+ import tomllib
21
+ from importlib.metadata import version as _dist_version, PackageNotFoundError
22
+
23
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
24
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
25
+ from checklib import Checks # noqa: E402
26
+ _t = Checks("test_mcp_version")
27
+ check = _t.check
28
+
29
+ from symgrep.mcp_server import _package_version # noqa: E402
30
+
31
+ UNPACKAGED = "0.0.0+unpackaged"
32
+
33
+ # pyproject's version is the single source of truth — read it, never hardcode the number here.
34
+ _PYPROJECT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "pyproject.toml")
35
+ with open(_PYPROJECT, "rb") as fh:
36
+ pyproject_version = tomllib.load(fh)["project"]["version"]
37
+
38
+ # The distribution name is `symgrep-codesearch`; the legacy/import name `symgrep` is a different (parked)
39
+ # project and must never be what we report. Discover whether EITHER dist is installed in this interpreter.
40
+ installed_version = None
41
+ installed_dist = None
42
+ for _dist in ("symgrep-codesearch", "symgrep"):
43
+ try:
44
+ installed_version = _dist_version(_dist)
45
+ installed_dist = _dist
46
+ break
47
+ except PackageNotFoundError:
48
+ continue
49
+
50
+ reported = _package_version()
51
+
52
+ if installed_version is not None:
53
+ # Claim 1: the reported version is the real installed version, not the fallback.
54
+ check("reported version is NOT the unpackaged fallback when the package is installed",
55
+ reported != UNPACKAGED, f"reported={reported!r}")
56
+ check(f"reported version == installed distribution version ({installed_dist})",
57
+ reported == installed_version, f"reported={reported!r} installed={installed_version!r}")
58
+ # Claim 2: an editable/source checkout (CI's `pip install -e .`) advertises pyproject's version. This is
59
+ # the assertion that would have CAUGHT the bug — version('symgrep') returned the fallback, not 0.7.0.
60
+ check("reported version matches pyproject (SSOT) in an editable/source checkout",
61
+ reported == pyproject_version, f"reported={reported!r} pyproject={pyproject_version!r}")
62
+ else:
63
+ # No install in this interpreter (e.g. a bare source run via PYTHONPATH). The installed-version claims
64
+ # cannot be verified here — record a VISIBLE skip so the un-run claim never masquerades as green. CI's
65
+ # `pip install -e .` job is where claims 1 and 2 actually execute.
66
+ _t.skip("neither symgrep-codesearch nor symgrep is installed in this interpreter; "
67
+ "install-version claims not verified (they run under CI's `pip install -e .`)")
68
+ # Still assert the one thing that IS true with no install: the fallback is reached honestly and exactly.
69
+ check("with no installed distribution, the fallback is the honest unpackaged sentinel",
70
+ reported == UNPACKAGED, f"reported={reported!r}")
71
+
72
+ _t.finish()