symgrep-codesearch 0.7.1__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.1 → symgrep_codesearch-0.7.2}/PKG-INFO +1 -1
  2. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/pyproject.toml +1 -1
  3. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/mcp_server.py +134 -32
  4. symgrep_codesearch-0.7.2/src/symgrep/runtime_paths.py +26 -0
  5. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/semantic.py +15 -9
  6. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep_codesearch.egg-info/PKG-INFO +1 -1
  7. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep_codesearch.egg-info/SOURCES.txt +3 -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.1 → symgrep_codesearch-0.7.2}/LICENSE +0 -0
  11. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/README.md +0 -0
  12. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/setup.cfg +0 -0
  13. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/__init__.py +0 -0
  14. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/archives.py +0 -0
  15. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/blocks.py +0 -0
  16. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/classfile.py +0 -0
  17. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/cli.py +0 -0
  18. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/data_symbols.py +0 -0
  19. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/docsymbols.py +0 -0
  20. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/enclosing.py +0 -0
  21. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/glob_recall.py +0 -0
  22. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/mcp_structured.py +0 -0
  23. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/report.py +0 -0
  24. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/resolve.py +0 -0
  25. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/resolve_js.py +0 -0
  26. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/resolve_python.py +0 -0
  27. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/ripgrep.py +0 -0
  28. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/structural.py +0 -0
  29. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/structure.py +0 -0
  30. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep_codesearch.egg-info/dependency_links.txt +0 -0
  31. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep_codesearch.egg-info/entry_points.txt +0 -0
  32. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep_codesearch.egg-info/requires.txt +0 -0
  33. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep_codesearch.egg-info/top_level.txt +0 -0
  34. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_blocks_extraction.py +0 -0
  35. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_blocks_retrieval.py +0 -0
  36. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_bulk_describe.py +0 -0
  37. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_classfile.py +0 -0
  38. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_data_symbols.py +0 -0
  39. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_describe_lane_confinement.py +0 -0
  40. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_doc_symbols.py +0 -0
  41. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_file_structure.py +0 -0
  42. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_freshness.py +0 -0
  43. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_honest_recall.py +0 -0
  44. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_http_ensure.py +0 -0
  45. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_index_reconstruct.py +0 -0
  46. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_index_scope.py +0 -0
  47. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_index_worker.py +0 -0
  48. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_mcp_structured.py +0 -0
  49. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_mcp_version.py +0 -0
  50. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_name_uniqueness.py +0 -0
  51. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_nudge_classify.py +0 -0
  52. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_nudge_runaway.py +0 -0
  53. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_report_filters.py +0 -0
  54. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_resolve.py +0 -0
  55. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_resolve_js.py +0 -0
  56. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_resolve_python.py +0 -0
  57. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_resolve_r2.py +0 -0
  58. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_review_regressions.py +0 -0
  59. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_semantic_freshness_signal.py +0 -0
  60. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_semantic_glob_lang.py +0 -0
  61. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_sites_and_symbols.py +0 -0
  62. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_structural.py +0 -0
  63. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_symbol_vocabulary.py +0 -0
  64. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_ts_fallback_loud.py +0 -0
  65. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_ts_symbol_coverage.py +0 -0
  66. {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_usage.py +0 -0
  67. {symgrep_codesearch-0.7.1 → 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.1
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
@@ -31,7 +31,7 @@ name = "symgrep-codesearch"
31
31
  # semantic search. Minor bump (dependency surface changed; no breaking import/CLI/MCP change).
32
32
  # 0.5.0: added the public `file_structure` library API (+ its MCP tool and `symgrep structure` CLI), import
33
33
  # module-name normalization, and the FileStructure `connects` list (structure schema v2).
34
- version = "0.7.1"
34
+ version = "0.7.2"
35
35
  description = "Code search with honest recall — structure-aware grep that tells you what it does not know, plus search by meaning"
36
36
  readme = "README.md"
37
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,6 +31,87 @@ 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)
74
+
75
+
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)
104
+ try:
105
+ tool_result = invocation()
106
+ except Exception:
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
31
115
 
32
116
 
33
117
  def _package_version() -> str:
@@ -194,15 +278,24 @@ def _run_fastmcp(http=None) -> bool:
194
278
  roots: directories/files to search (absolute paths preferred).
195
279
  globs: rg --glob filters, e.g. ["*.py"].
196
280
  """
197
- return _search(pattern, roots, case_insensitive, fixed_string,
198
- multiline, globs, include_ignored, max_sites, semantic, k, json_output, structured,
199
- 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))
200
292
 
201
293
  @mcp.tool(description="Structural symbols of ONE file: every function/class with its line span and "
202
294
  "basis (ast/treesitter/…). The AST layer without an in-process import.")
203
295
  def file_symbols(path: str) -> str:
204
296
  from .mcp_structured import structured_file_symbols
205
- 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)))
206
299
 
207
300
  @mcp.tool(description="Mechanical STRUCTURE of ONE file, from the same grammar parse: {schema_version, "
208
301
  "language, basis, functions:[{name,signature,start,end}], classes, imports, "
@@ -212,13 +305,15 @@ def _run_fastmcp(http=None) -> bool:
212
305
  "caller's agentic layer).")
213
306
  def file_structure(path: str) -> str:
214
307
  from .mcp_structured import structured_file_structure
215
- 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)))
216
310
 
217
311
  @mcp.tool(description="The roots the semantic index covers, so an un-hit query reads as 'not indexed "
218
312
  "here' vs 'indexed, nothing near'.")
219
313
  def indexed_roots() -> str:
220
314
  from .mcp_structured import structured_indexed_roots
221
- return json.dumps(structured_indexed_roots())
315
+ return _invoke_logged_mcp_tool(
316
+ "indexed_roots", {}, lambda: json.dumps(structured_indexed_roots()))
222
317
 
223
318
  if http:
224
319
  host, port = http
@@ -305,26 +400,26 @@ def _fallback_handle(req):
305
400
  name = params.get("name")
306
401
  a = params.get("arguments", {})
307
402
  if name == "search":
308
- text = _search(a["pattern"], a.get("roots"),
309
- a.get("case_insensitive", False),
310
- a.get("fixed_string", False),
311
- a.get("multiline", False),
312
- a.get("globs"), a.get("include_ignored", False),
313
- a.get("max_sites", DEFAULT_MAX_SITES),
314
- a.get("semantic", False), a.get("k", 8),
315
- a.get("json_output", False), a.get("structured", False),
316
- a.get("structural", False), a.get("lang"),
317
- a.get("usage", False), a.get("resolve", False),
318
- 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")))
319
411
  elif name == "file_symbols":
320
412
  from .mcp_structured import structured_file_symbols
321
- 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"])))
322
415
  elif name == "file_structure":
323
416
  from .mcp_structured import structured_file_structure
324
- 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"])))
325
419
  elif name == "indexed_roots":
326
420
  from .mcp_structured import structured_indexed_roots
327
- text = json.dumps(structured_indexed_roots())
421
+ text = _invoke_logged_mcp_tool(
422
+ name, a, lambda: json.dumps(structured_indexed_roots()))
328
423
  else:
329
424
  raise ValueError(f"unknown tool {name!r}")
330
425
  return {"content": [{"type": "text", "text": text}]}
@@ -343,12 +438,16 @@ def _run_fallback() -> None:
343
438
  except json.JSONDecodeError:
344
439
  # JSON-RPC 2.0: a parse error is answered (-32700), not dropped — a client that sent
345
440
  # malformed JSON and hears nothing waits forever.
346
- print(json.dumps({"jsonrpc": "2.0", "id": None,
347
- "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)
348
445
  continue
349
446
  if not isinstance(req, dict):
350
- print(json.dumps({"jsonrpc": "2.0", "id": None,
351
- "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)
352
451
  continue
353
452
  rid = req.get("id")
354
453
  try:
@@ -356,17 +455,18 @@ def _run_fallback() -> None:
356
455
  if result is None and rid is not None:
357
456
  # a REQUEST (has id) for a method we don't serve gets -32601, not silence;
358
457
  # notifications (no id) legitimately get nothing.
359
- print(json.dumps({"jsonrpc": "2.0", "id": rid,
360
- "error": {"code": -32601,
361
- "message": f"method not found: {req.get('method')!r}"}}),
362
- 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)
363
462
  continue
364
463
  except Exception as e:
464
+ _mcp_request_logger().exception("tool=jsonrpc args={method=%r} raised", req.get("method"))
365
465
  if rid is not None:
366
- print(json.dumps({"jsonrpc": "2.0", "id": rid,
367
- "error": {"code": -32603,
368
- "message": f"{type(e).__name__}: {e}"}}),
369
- 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)
370
470
  continue
371
471
  if rid is not None and result is not None:
372
472
  print(json.dumps({"jsonrpc": "2.0", "id": rid, "result": result}),
@@ -383,6 +483,8 @@ def main() -> None:
383
483
  sys.exit(2)
384
484
  host = os.environ.get("SYMGREP_MCP_HOST", HTTP_HOST_DEFAULT)
385
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")
386
488
  # WARM SHARED DAEMON = SINGLETON (one per port): every session CONNECTS to this warm HTTP server via urllib
387
489
  # instead of spawning a stdio copy. A second start detects the incumbent (flock via spendguard's single-instance
388
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.1
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,9 @@ 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
44
47
  tests/test_mcp_version.py
45
48
  tests/test_name_uniqueness.py
46
49
  tests/test_nudge_classify.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()