contextl 1.2.12 → 1.2.13
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.
- package/package.json +1 -1
- package/python/mcp_server.py +96 -23
package/package.json
CHANGED
package/python/mcp_server.py
CHANGED
|
@@ -11,16 +11,27 @@ Usage (the IDE does this for you, but you can test manually):
|
|
|
11
11
|
python mcp_server.py
|
|
12
12
|
|
|
13
13
|
Tools exposed:
|
|
14
|
-
query_repo
|
|
15
|
-
scan_repo
|
|
16
|
-
analyze_impact
|
|
14
|
+
query_repo — rank files by relevance to a natural-language query
|
|
15
|
+
scan_repo — list all source files the engine can see in a repo
|
|
16
|
+
analyze_impact — find every file affected by changing a target file
|
|
17
|
+
find_standalone_files — find unimported files with in-degree 0
|
|
18
|
+
export_obsidian_vault — export repo graph to an Obsidian markdown vault
|
|
19
|
+
|
|
20
|
+
Performance:
|
|
21
|
+
A shared graph cache is maintained per repo path + file mtime fingerprint.
|
|
22
|
+
The 4-step build pipeline (scan → parse → graph → query) runs ONCE per
|
|
23
|
+
session and is reused by all tool calls. Subsequent calls on an unchanged
|
|
24
|
+
repo return from cache in <1ms.
|
|
17
25
|
"""
|
|
18
26
|
|
|
19
|
-
import argparse
|
|
20
27
|
import asyncio
|
|
21
28
|
import json
|
|
29
|
+
import os
|
|
22
30
|
import sys
|
|
31
|
+
import threading
|
|
32
|
+
from dataclasses import dataclass
|
|
23
33
|
from pathlib import Path
|
|
34
|
+
from typing import Optional
|
|
24
35
|
|
|
25
36
|
import mcp.types as types
|
|
26
37
|
from mcp.server import Server
|
|
@@ -39,6 +50,76 @@ from standalone import find_standalone_files
|
|
|
39
50
|
from obsidian_export import export_obsidian_vault
|
|
40
51
|
|
|
41
52
|
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Shared graph cache — one pipeline build per repo per session
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
@dataclass
|
|
57
|
+
class _CacheEntry:
|
|
58
|
+
repo_path: str # resolved absolute path
|
|
59
|
+
mtime_hash: float # max mtime across all source files at build time
|
|
60
|
+
scan: object # ScanResult
|
|
61
|
+
repo_graph: object # RepoGraph
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_cache_lock = threading.Lock()
|
|
65
|
+
_cache: Optional[_CacheEntry] = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _compute_mtime_hash(repo_path: str) -> float:
|
|
69
|
+
"""Walk repo_path and return the maximum file modification time."""
|
|
70
|
+
max_mtime = 0.0
|
|
71
|
+
for dirpath, _, filenames in os.walk(repo_path):
|
|
72
|
+
# Skip hidden dirs and common noise dirs
|
|
73
|
+
dirpath_obj = Path(dirpath)
|
|
74
|
+
if any(part.startswith(".") or part in ("node_modules", "__pycache__", ".git", "dist", "build", "target")
|
|
75
|
+
for part in dirpath_obj.parts):
|
|
76
|
+
continue
|
|
77
|
+
for fname in filenames:
|
|
78
|
+
try:
|
|
79
|
+
mtime = os.path.getmtime(os.path.join(dirpath, fname))
|
|
80
|
+
if mtime > max_mtime:
|
|
81
|
+
max_mtime = mtime
|
|
82
|
+
except OSError:
|
|
83
|
+
pass
|
|
84
|
+
return max_mtime
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _get_graph(repo_path: str):
|
|
88
|
+
"""
|
|
89
|
+
Return (scan, repo_graph) for the given repo_path.
|
|
90
|
+
|
|
91
|
+
On first call (or after any source file changes): runs the full
|
|
92
|
+
4-step pipeline and stores the result in the module-level cache.
|
|
93
|
+
On subsequent calls with unchanged files: returns instantly from cache.
|
|
94
|
+
"""
|
|
95
|
+
global _cache
|
|
96
|
+
|
|
97
|
+
resolved = str(Path(repo_path).resolve())
|
|
98
|
+
mtime_hash = _compute_mtime_hash(resolved)
|
|
99
|
+
|
|
100
|
+
with _cache_lock:
|
|
101
|
+
if (_cache is not None
|
|
102
|
+
and _cache.repo_path == resolved
|
|
103
|
+
and _cache.mtime_hash == mtime_hash):
|
|
104
|
+
print("[contextl] graph cache hit", file=sys.stderr, flush=True)
|
|
105
|
+
return _cache.scan, _cache.repo_graph
|
|
106
|
+
|
|
107
|
+
# Cache miss — build the pipeline
|
|
108
|
+
print("[contextl] building graph...", file=sys.stderr, flush=True)
|
|
109
|
+
scan = scan_repo(resolved)
|
|
110
|
+
parse = parse_imports(scan)
|
|
111
|
+
repo_graph = build_graph(scan, parse)
|
|
112
|
+
|
|
113
|
+
_cache = _CacheEntry(
|
|
114
|
+
repo_path=resolved,
|
|
115
|
+
mtime_hash=mtime_hash,
|
|
116
|
+
scan=scan,
|
|
117
|
+
repo_graph=repo_graph,
|
|
118
|
+
)
|
|
119
|
+
print(f"[contextl] graph built: {scan.total_files} files", file=sys.stderr, flush=True)
|
|
120
|
+
return scan, repo_graph
|
|
121
|
+
|
|
122
|
+
|
|
42
123
|
# ---------------------------------------------------------------------------
|
|
43
124
|
# Server setup
|
|
44
125
|
# ---------------------------------------------------------------------------
|
|
@@ -231,10 +312,8 @@ async def _handle_query(args: dict) -> list[types.TextContent]:
|
|
|
231
312
|
|
|
232
313
|
|
|
233
314
|
def _run_query(repo_path: str, query_str: str, top_n: int) -> dict:
|
|
234
|
-
"""
|
|
235
|
-
scan =
|
|
236
|
-
parse = parse_imports(scan)
|
|
237
|
-
repo_graph = build_graph(scan, parse)
|
|
315
|
+
"""Uses cached graph — pipeline runs only on first call or after file changes."""
|
|
316
|
+
scan, repo_graph = _get_graph(repo_path)
|
|
238
317
|
ranked = engine_query(query_str, repo_graph, top_n=top_n)
|
|
239
318
|
|
|
240
319
|
return {
|
|
@@ -268,7 +347,8 @@ async def _handle_scan(args: dict) -> list[types.TextContent]:
|
|
|
268
347
|
|
|
269
348
|
|
|
270
349
|
def _run_scan(repo_path: str) -> dict:
|
|
271
|
-
|
|
350
|
+
"""Uses cached graph — pipeline runs only on first call or after file changes."""
|
|
351
|
+
scan, _ = _get_graph(repo_path)
|
|
272
352
|
return {
|
|
273
353
|
"repo": scan.root,
|
|
274
354
|
"total_files": scan.total_files,
|
|
@@ -294,10 +374,8 @@ async def _handle_impact(args: dict) -> list[types.TextContent]:
|
|
|
294
374
|
|
|
295
375
|
|
|
296
376
|
def _run_impact(repo_path: str, target_file: str, max_depth: int) -> dict:
|
|
297
|
-
"""
|
|
298
|
-
scan =
|
|
299
|
-
parse = parse_imports(scan)
|
|
300
|
-
repo_graph = build_graph(scan, parse)
|
|
377
|
+
"""Uses cached graph — pipeline runs only on first call or after file changes."""
|
|
378
|
+
scan, repo_graph = _get_graph(repo_path)
|
|
301
379
|
report = analyze_impact(target_file, repo_graph, max_depth=max_depth)
|
|
302
380
|
|
|
303
381
|
return {
|
|
@@ -334,10 +412,8 @@ async def _handle_standalone_files(args: dict) -> list[types.TextContent]:
|
|
|
334
412
|
|
|
335
413
|
|
|
336
414
|
def _run_standalone_files(repo_path: str) -> dict:
|
|
337
|
-
"""
|
|
338
|
-
scan =
|
|
339
|
-
parse = parse_imports(scan)
|
|
340
|
-
repo_graph = build_graph(scan, parse)
|
|
415
|
+
"""Uses cached graph — pipeline runs only on first call or after file changes."""
|
|
416
|
+
scan, repo_graph = _get_graph(repo_path)
|
|
341
417
|
standalone_files = find_standalone_files(repo_graph)
|
|
342
418
|
|
|
343
419
|
return {
|
|
@@ -362,13 +438,10 @@ async def _handle_export_obsidian(args: dict) -> list[types.TextContent]:
|
|
|
362
438
|
|
|
363
439
|
|
|
364
440
|
def _run_export_obsidian(repo_path: str, output_dir: str) -> dict:
|
|
365
|
-
"""
|
|
366
|
-
scan =
|
|
367
|
-
parse = parse_imports(scan)
|
|
368
|
-
repo_graph = build_graph(scan, parse)
|
|
369
|
-
|
|
441
|
+
"""Uses cached graph — pipeline runs only on first call or after file changes."""
|
|
442
|
+
scan, repo_graph = _get_graph(repo_path)
|
|
370
443
|
vault_path = export_obsidian_vault(repo_graph, output_dir)
|
|
371
|
-
|
|
444
|
+
|
|
372
445
|
return {
|
|
373
446
|
"repo": scan.root,
|
|
374
447
|
"vault_path": vault_path,
|