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.
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/PKG-INFO +1 -1
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/pyproject.toml +1 -1
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/mcp_server.py +134 -32
- symgrep_codesearch-0.7.2/src/symgrep/runtime_paths.py +26 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/semantic.py +15 -9
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep_codesearch.egg-info/PKG-INFO +1 -1
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep_codesearch.egg-info/SOURCES.txt +3 -0
- symgrep_codesearch-0.7.2/tests/test_mcp_error_log.py +56 -0
- symgrep_codesearch-0.7.2/tests/test_mcp_thread_safety.py +57 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/LICENSE +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/README.md +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/setup.cfg +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/__init__.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/archives.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/blocks.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/classfile.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/cli.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/data_symbols.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/docsymbols.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/enclosing.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/glob_recall.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/mcp_structured.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/report.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/resolve.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/resolve_js.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/resolve_python.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/ripgrep.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/structural.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep/structure.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep_codesearch.egg-info/dependency_links.txt +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep_codesearch.egg-info/entry_points.txt +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep_codesearch.egg-info/requires.txt +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep_codesearch.egg-info/top_level.txt +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_blocks_extraction.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_blocks_retrieval.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_bulk_describe.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_classfile.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_data_symbols.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_describe_lane_confinement.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_doc_symbols.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_file_structure.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_freshness.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_honest_recall.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_http_ensure.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_index_reconstruct.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_index_scope.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_index_worker.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_mcp_structured.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_mcp_version.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_name_uniqueness.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_nudge_classify.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_nudge_runaway.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_report_filters.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_resolve.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_resolve_js.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_resolve_python.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_resolve_r2.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_review_regressions.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_semantic_freshness_signal.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_semantic_glob_lang.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_sites_and_symbols.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_structural.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_symbol_vocabulary.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_ts_fallback_loud.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_ts_symbol_coverage.py +0 -0
- {symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_usage.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
347
|
-
|
|
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
|
-
|
|
351
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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 (
|
|
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
|
|
110
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
149
|
-
return
|
|
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():
|
{symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep_codesearch.egg-info/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: symgrep-codesearch
|
|
3
|
-
Version: 0.7.
|
|
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
|
{symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep_codesearch.egg-info/SOURCES.txt
RENAMED
|
@@ -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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep_codesearch.egg-info/requires.txt
RENAMED
|
File without changes
|
{symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/src/symgrep_codesearch.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_describe_lane_confinement.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{symgrep_codesearch-0.7.1 → symgrep_codesearch-0.7.2}/tests/test_semantic_freshness_signal.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|