lean-lsp-mcp 0.14.0__py3-none-any.whl → 0.15.0__py3-none-any.whl
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.
- lean_lsp_mcp/__init__.py +19 -0
- lean_lsp_mcp/loogle.py +276 -0
- lean_lsp_mcp/outline_utils.py +97 -59
- lean_lsp_mcp/server.py +44 -18
- {lean_lsp_mcp-0.14.0.dist-info → lean_lsp_mcp-0.15.0.dist-info}/METADATA +24 -3
- lean_lsp_mcp-0.15.0.dist-info/RECORD +16 -0
- lean_lsp_mcp-0.14.0.dist-info/RECORD +0 -15
- {lean_lsp_mcp-0.14.0.dist-info → lean_lsp_mcp-0.15.0.dist-info}/WHEEL +0 -0
- {lean_lsp_mcp-0.14.0.dist-info → lean_lsp_mcp-0.15.0.dist-info}/entry_points.txt +0 -0
- {lean_lsp_mcp-0.14.0.dist-info → lean_lsp_mcp-0.15.0.dist-info}/licenses/LICENSE +0 -0
- {lean_lsp_mcp-0.14.0.dist-info → lean_lsp_mcp-0.15.0.dist-info}/top_level.txt +0 -0
lean_lsp_mcp/__init__.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import argparse
|
|
2
|
+
import os
|
|
2
3
|
|
|
3
4
|
from lean_lsp_mcp.server import mcp
|
|
4
5
|
|
|
@@ -24,7 +25,25 @@ def main():
|
|
|
24
25
|
default=8000,
|
|
25
26
|
help="Host port for transport",
|
|
26
27
|
)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"--loogle-local",
|
|
30
|
+
action="store_true",
|
|
31
|
+
help="Enable local loogle (auto-installs on first run, ~5-10 min). "
|
|
32
|
+
"Avoids rate limits and network dependencies.",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--loogle-cache-dir",
|
|
36
|
+
type=str,
|
|
37
|
+
help="Override loogle cache location (default: ~/.cache/lean-lsp-mcp/loogle)",
|
|
38
|
+
)
|
|
27
39
|
args = parser.parse_args()
|
|
40
|
+
|
|
41
|
+
# Set env vars from CLI args (CLI takes precedence over env vars)
|
|
42
|
+
if args.loogle_local:
|
|
43
|
+
os.environ["LEAN_LOOGLE_LOCAL"] = "true"
|
|
44
|
+
if args.loogle_cache_dir:
|
|
45
|
+
os.environ["LEAN_LOOGLE_CACHE_DIR"] = args.loogle_cache_dir
|
|
46
|
+
|
|
28
47
|
mcp.settings.host = args.host
|
|
29
48
|
mcp.settings.port = args.port
|
|
30
49
|
mcp.run(transport=args.transport)
|
lean_lsp_mcp/loogle.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Loogle search - local subprocess and remote API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import urllib.parse
|
|
12
|
+
import urllib.request
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import orjson
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_cache_dir() -> Path:
|
|
22
|
+
if d := os.environ.get("LEAN_LOOGLE_CACHE_DIR"):
|
|
23
|
+
return Path(d)
|
|
24
|
+
xdg = os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")
|
|
25
|
+
return Path(xdg) / "lean-lsp-mcp" / "loogle"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def loogle_remote(query: str, num_results: int) -> list[dict] | str:
|
|
29
|
+
"""Query the remote loogle API."""
|
|
30
|
+
try:
|
|
31
|
+
req = urllib.request.Request(
|
|
32
|
+
f"https://loogle.lean-lang.org/json?q={urllib.parse.quote(query)}",
|
|
33
|
+
headers={"User-Agent": "lean-lsp-mcp/0.1"},
|
|
34
|
+
)
|
|
35
|
+
with urllib.request.urlopen(req, timeout=20) as response:
|
|
36
|
+
results = orjson.loads(response.read())
|
|
37
|
+
if "hits" not in results:
|
|
38
|
+
return "No results found."
|
|
39
|
+
results = results["hits"][:num_results]
|
|
40
|
+
for r in results:
|
|
41
|
+
r.pop("doc", None)
|
|
42
|
+
return results
|
|
43
|
+
except Exception as e:
|
|
44
|
+
return f"loogle error:\n{e}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class LoogleManager:
|
|
48
|
+
"""Manages local loogle installation and async subprocess."""
|
|
49
|
+
|
|
50
|
+
REPO_URL = "https://github.com/nomeata/loogle.git"
|
|
51
|
+
READY_SIGNAL = "Loogle is ready."
|
|
52
|
+
|
|
53
|
+
def __init__(self, cache_dir: Path | None = None):
|
|
54
|
+
self.cache_dir = cache_dir or get_cache_dir()
|
|
55
|
+
self.repo_dir = self.cache_dir / "repo"
|
|
56
|
+
self.index_dir = self.cache_dir / "index"
|
|
57
|
+
self.process: asyncio.subprocess.Process | None = None
|
|
58
|
+
self._ready = False
|
|
59
|
+
self._lock = asyncio.Lock()
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def binary_path(self) -> Path:
|
|
63
|
+
return self.repo_dir / ".lake" / "build" / "bin" / "loogle"
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def is_installed(self) -> bool:
|
|
67
|
+
return self.binary_path.exists()
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def is_running(self) -> bool:
|
|
71
|
+
return (
|
|
72
|
+
self._ready and self.process is not None and self.process.returncode is None
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def _check_prerequisites(self) -> tuple[bool, str]:
|
|
76
|
+
if not shutil.which("git"):
|
|
77
|
+
return False, "git not found in PATH"
|
|
78
|
+
if not shutil.which("lake"):
|
|
79
|
+
return (
|
|
80
|
+
False,
|
|
81
|
+
"lake not found (install elan: https://github.com/leanprover/elan)",
|
|
82
|
+
)
|
|
83
|
+
return True, ""
|
|
84
|
+
|
|
85
|
+
def _run(
|
|
86
|
+
self, cmd: list[str], timeout: int = 300, cwd: Path | None = None
|
|
87
|
+
) -> subprocess.CompletedProcess:
|
|
88
|
+
env = os.environ.copy()
|
|
89
|
+
env["LAKE_ARTIFACT_CACHE"] = "false"
|
|
90
|
+
return subprocess.run(
|
|
91
|
+
cmd,
|
|
92
|
+
capture_output=True,
|
|
93
|
+
text=True,
|
|
94
|
+
timeout=timeout,
|
|
95
|
+
cwd=cwd or self.repo_dir,
|
|
96
|
+
env=env,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def _clone_repo(self) -> bool:
|
|
100
|
+
if self.repo_dir.exists():
|
|
101
|
+
return True
|
|
102
|
+
logger.info(f"Cloning loogle to {self.repo_dir}...")
|
|
103
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
try:
|
|
105
|
+
r = self._run(
|
|
106
|
+
["git", "clone", "--depth", "1", self.REPO_URL, str(self.repo_dir)],
|
|
107
|
+
cwd=self.cache_dir,
|
|
108
|
+
)
|
|
109
|
+
if r.returncode != 0:
|
|
110
|
+
logger.error(f"Clone failed: {r.stderr}")
|
|
111
|
+
return False
|
|
112
|
+
return True
|
|
113
|
+
except Exception as e:
|
|
114
|
+
logger.error(f"Clone error: {e}")
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
def _build_loogle(self) -> bool:
|
|
118
|
+
if self.is_installed:
|
|
119
|
+
return True
|
|
120
|
+
if not self.repo_dir.exists():
|
|
121
|
+
return False
|
|
122
|
+
logger.info("Downloading mathlib cache...")
|
|
123
|
+
try:
|
|
124
|
+
self._run(["lake", "exe", "cache", "get"], timeout=600)
|
|
125
|
+
except Exception as e:
|
|
126
|
+
logger.warning(f"Cache download: {e}")
|
|
127
|
+
logger.info("Building loogle...")
|
|
128
|
+
try:
|
|
129
|
+
return self._run(["lake", "build"], timeout=900).returncode == 0
|
|
130
|
+
except Exception as e:
|
|
131
|
+
logger.error(f"Build error: {e}")
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
def _get_mathlib_version(self) -> str:
|
|
135
|
+
try:
|
|
136
|
+
manifest = json.loads((self.repo_dir / "lake-manifest.json").read_text())
|
|
137
|
+
for pkg in manifest.get("packages", []):
|
|
138
|
+
if pkg.get("name") == "mathlib":
|
|
139
|
+
return pkg.get("rev", "unknown")[:12]
|
|
140
|
+
except Exception:
|
|
141
|
+
pass
|
|
142
|
+
return "unknown"
|
|
143
|
+
|
|
144
|
+
def _get_index_path(self) -> Path:
|
|
145
|
+
return self.index_dir / f"mathlib-{self._get_mathlib_version()}.idx"
|
|
146
|
+
|
|
147
|
+
def _cleanup_old_indices(self) -> None:
|
|
148
|
+
"""Remove old index files from previous mathlib versions."""
|
|
149
|
+
if not self.index_dir.exists():
|
|
150
|
+
return
|
|
151
|
+
current = self._get_index_path()
|
|
152
|
+
for idx in self.index_dir.glob("*.idx"):
|
|
153
|
+
if idx != current:
|
|
154
|
+
try:
|
|
155
|
+
idx.unlink()
|
|
156
|
+
logger.info(f"Removed old index: {idx.name}")
|
|
157
|
+
except Exception:
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
def _build_index(self) -> Path | None:
|
|
161
|
+
index_path = self._get_index_path()
|
|
162
|
+
if index_path.exists():
|
|
163
|
+
return index_path
|
|
164
|
+
if not self.is_installed:
|
|
165
|
+
return None
|
|
166
|
+
self.index_dir.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
self._cleanup_old_indices()
|
|
168
|
+
logger.info("Building search index...")
|
|
169
|
+
try:
|
|
170
|
+
self._run(
|
|
171
|
+
[str(self.binary_path), "--write-index", str(index_path), "--json", ""],
|
|
172
|
+
timeout=600,
|
|
173
|
+
)
|
|
174
|
+
return index_path if index_path.exists() else None
|
|
175
|
+
except Exception as e:
|
|
176
|
+
logger.error(f"Index build error: {e}")
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
def ensure_installed(self) -> bool:
|
|
180
|
+
ok, err = self._check_prerequisites()
|
|
181
|
+
if not ok:
|
|
182
|
+
logger.warning(f"Prerequisites: {err}")
|
|
183
|
+
return False
|
|
184
|
+
if not self._clone_repo() or not self._build_loogle():
|
|
185
|
+
return False
|
|
186
|
+
if not self._build_index():
|
|
187
|
+
logger.warning("Index build failed, loogle will build on startup")
|
|
188
|
+
return self.is_installed
|
|
189
|
+
|
|
190
|
+
async def start(self) -> bool:
|
|
191
|
+
if self.process is not None and self.process.returncode is None:
|
|
192
|
+
return self._ready
|
|
193
|
+
if not self.is_installed:
|
|
194
|
+
return False
|
|
195
|
+
cmd = [str(self.binary_path), "--json", "--interactive"]
|
|
196
|
+
if (idx := self._get_index_path()).exists():
|
|
197
|
+
cmd.extend(["--read-index", str(idx)])
|
|
198
|
+
logger.info("Starting loogle subprocess...")
|
|
199
|
+
try:
|
|
200
|
+
self.process = await asyncio.create_subprocess_exec(
|
|
201
|
+
*cmd,
|
|
202
|
+
stdin=asyncio.subprocess.PIPE,
|
|
203
|
+
stdout=asyncio.subprocess.PIPE,
|
|
204
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
205
|
+
cwd=self.repo_dir,
|
|
206
|
+
)
|
|
207
|
+
line = await asyncio.wait_for(self.process.stdout.readline(), timeout=120)
|
|
208
|
+
if self.READY_SIGNAL in line.decode():
|
|
209
|
+
self._ready = True
|
|
210
|
+
logger.info("Loogle ready")
|
|
211
|
+
return True
|
|
212
|
+
return False
|
|
213
|
+
except asyncio.TimeoutError:
|
|
214
|
+
logger.error("Loogle startup timeout")
|
|
215
|
+
return False
|
|
216
|
+
except Exception as e:
|
|
217
|
+
logger.error(f"Start failed: {e}")
|
|
218
|
+
return False
|
|
219
|
+
|
|
220
|
+
async def query(self, q: str, num_results: int = 8) -> list[dict[str, Any]]:
|
|
221
|
+
async with self._lock:
|
|
222
|
+
# Try up to 2 attempts (initial + one restart)
|
|
223
|
+
for attempt in range(2):
|
|
224
|
+
if (
|
|
225
|
+
not self._ready
|
|
226
|
+
or self.process is None
|
|
227
|
+
or self.process.returncode is not None
|
|
228
|
+
):
|
|
229
|
+
if attempt > 0:
|
|
230
|
+
raise RuntimeError("Loogle subprocess not ready")
|
|
231
|
+
self._ready = False
|
|
232
|
+
if not await self.start():
|
|
233
|
+
raise RuntimeError("Failed to start loogle")
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
self.process.stdin.write(f"{q}\n".encode())
|
|
238
|
+
await self.process.stdin.drain()
|
|
239
|
+
line = await asyncio.wait_for(
|
|
240
|
+
self.process.stdout.readline(), timeout=30
|
|
241
|
+
)
|
|
242
|
+
response = json.loads(line.decode())
|
|
243
|
+
if err := response.get("error"):
|
|
244
|
+
logger.warning(f"Query error: {err}")
|
|
245
|
+
return []
|
|
246
|
+
return [
|
|
247
|
+
{
|
|
248
|
+
"name": h.get("name", ""),
|
|
249
|
+
"type": h.get("type", ""),
|
|
250
|
+
"module": h.get("module", ""),
|
|
251
|
+
"doc": h.get("doc"),
|
|
252
|
+
}
|
|
253
|
+
for h in response.get("hits", [])[:num_results]
|
|
254
|
+
]
|
|
255
|
+
except asyncio.TimeoutError:
|
|
256
|
+
raise RuntimeError("Query timeout") from None
|
|
257
|
+
except json.JSONDecodeError as e:
|
|
258
|
+
raise RuntimeError(f"Invalid response: {e}") from e
|
|
259
|
+
|
|
260
|
+
raise RuntimeError("Loogle subprocess not ready")
|
|
261
|
+
|
|
262
|
+
async def stop(self) -> None:
|
|
263
|
+
if self.process:
|
|
264
|
+
try:
|
|
265
|
+
self.process.terminate()
|
|
266
|
+
await asyncio.wait_for(self.process.wait(), timeout=5)
|
|
267
|
+
except asyncio.TimeoutError:
|
|
268
|
+
self.process.kill()
|
|
269
|
+
try:
|
|
270
|
+
await asyncio.wait_for(self.process.wait(), timeout=2)
|
|
271
|
+
except asyncio.TimeoutError:
|
|
272
|
+
pass
|
|
273
|
+
except Exception:
|
|
274
|
+
pass
|
|
275
|
+
self.process = None
|
|
276
|
+
self._ready = False
|
lean_lsp_mcp/outline_utils.py
CHANGED
|
@@ -8,38 +8,45 @@ METHOD_KIND = {6, "method"}
|
|
|
8
8
|
KIND_TAGS = {"namespace": "Ns"}
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
def _get_info_trees(
|
|
11
|
+
def _get_info_trees(
|
|
12
|
+
client: LeanLSPClient, path: str, symbols: List[Dict]
|
|
13
|
+
) -> Dict[str, str]:
|
|
12
14
|
"""Insert #info_trees commands, collect diagnostics, then revert changes."""
|
|
13
15
|
if not symbols:
|
|
14
16
|
return {}
|
|
15
17
|
|
|
16
18
|
symbol_by_line = {}
|
|
17
19
|
changes = []
|
|
18
|
-
for i, sym in enumerate(sorted(symbols, key=lambda s: s[
|
|
19
|
-
line = sym[
|
|
20
|
-
symbol_by_line[line] = sym[
|
|
20
|
+
for i, sym in enumerate(sorted(symbols, key=lambda s: s["range"]["start"]["line"])):
|
|
21
|
+
line = sym["range"]["start"]["line"] + i
|
|
22
|
+
symbol_by_line[line] = sym["name"]
|
|
21
23
|
changes.append(DocumentContentChange("#info_trees in\n", [line, 0], [line, 0]))
|
|
22
24
|
|
|
23
25
|
client.update_file(path, changes)
|
|
24
26
|
diagnostics = client.get_diagnostics(path)
|
|
25
27
|
|
|
26
28
|
info_trees = {
|
|
27
|
-
symbol_by_line[diag[
|
|
29
|
+
symbol_by_line[diag["range"]["start"]["line"]]: diag["message"]
|
|
28
30
|
for diag in diagnostics
|
|
29
|
-
if diag[
|
|
31
|
+
if diag["severity"] == 3 and diag["range"]["start"]["line"] in symbol_by_line
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
# Revert in reverse order
|
|
33
|
-
client.update_file(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
client.update_file(
|
|
36
|
+
path,
|
|
37
|
+
[
|
|
38
|
+
DocumentContentChange("", [line, 0], [line + 1, 0])
|
|
39
|
+
for line in sorted(symbol_by_line.keys(), reverse=True)
|
|
40
|
+
],
|
|
41
|
+
)
|
|
37
42
|
return info_trees
|
|
38
43
|
|
|
39
44
|
|
|
40
45
|
def _extract_type(info: str, name: str) -> Optional[str]:
|
|
41
46
|
"""Extract type signature from info tree message."""
|
|
42
|
-
if m := re.search(
|
|
47
|
+
if m := re.search(
|
|
48
|
+
rf" • \[Term\] {re.escape(name)} \(isBinder := true\) : ([^@]+) @", info
|
|
49
|
+
):
|
|
43
50
|
return m.group(1).strip()
|
|
44
51
|
return None
|
|
45
52
|
|
|
@@ -47,14 +54,16 @@ def _extract_type(info: str, name: str) -> Optional[str]:
|
|
|
47
54
|
def _extract_fields(info: str, name: str) -> List[Tuple[str, str]]:
|
|
48
55
|
"""Extract structure/class fields from info tree message."""
|
|
49
56
|
fields = []
|
|
50
|
-
for pattern in [rf
|
|
51
|
-
for m in re.finditer(
|
|
57
|
+
for pattern in [rf"{re.escape(name)}\.(\w+)", rf"@{re.escape(name)}\.(\w+)"]:
|
|
58
|
+
for m in re.finditer(
|
|
59
|
+
rf" • \[Term\] {pattern} \(isBinder := true\) : (.+?) @", info
|
|
60
|
+
):
|
|
52
61
|
field_name, full_type = m.groups()
|
|
53
62
|
# Clean up the type signature
|
|
54
|
-
if
|
|
55
|
-
field_type = full_type[full_type.rfind(
|
|
56
|
-
elif
|
|
57
|
-
field_type = full_type.split(
|
|
63
|
+
if "]" in full_type:
|
|
64
|
+
field_type = full_type[full_type.rfind("]") + 1 :].lstrip("→ ").strip()
|
|
65
|
+
elif " → " in full_type:
|
|
66
|
+
field_type = full_type.split(" → ")[-1].strip()
|
|
58
67
|
else:
|
|
59
68
|
field_type = full_type.strip()
|
|
60
69
|
fields.append((field_name, field_type))
|
|
@@ -68,51 +77,61 @@ def _extract_declarations(content: str, start: int, end: int) -> List[Dict]:
|
|
|
68
77
|
|
|
69
78
|
while i < min(end, len(lines)):
|
|
70
79
|
line = lines[i].strip()
|
|
71
|
-
for keyword in [
|
|
80
|
+
for keyword in ["theorem", "lemma", "def"]:
|
|
72
81
|
if line.startswith(f"{keyword} "):
|
|
73
|
-
name = line[len(keyword):].strip().split()[0]
|
|
74
|
-
if name and not name.startswith(
|
|
82
|
+
name = line[len(keyword) :].strip().split()[0]
|
|
83
|
+
if name and not name.startswith("_"):
|
|
75
84
|
# Collect until :=
|
|
76
85
|
decl_lines = [line]
|
|
77
86
|
j = i + 1
|
|
78
|
-
while j < min(end, len(lines)) and
|
|
79
|
-
if (next_line := lines[j].strip()) and not next_line.startswith(
|
|
87
|
+
while j < min(end, len(lines)) and ":=" not in " ".join(decl_lines):
|
|
88
|
+
if (next_line := lines[j].strip()) and not next_line.startswith(
|
|
89
|
+
"--"
|
|
90
|
+
):
|
|
80
91
|
decl_lines.append(next_line)
|
|
81
92
|
j += 1
|
|
82
93
|
|
|
83
94
|
# Extract signature (everything before :=, minus keyword and name)
|
|
84
|
-
full_decl =
|
|
95
|
+
full_decl = " ".join(decl_lines)
|
|
85
96
|
type_sig = None
|
|
86
|
-
if
|
|
87
|
-
sig_part =
|
|
97
|
+
if ":=" in full_decl:
|
|
98
|
+
sig_part = (
|
|
99
|
+
full_decl.split(":=", 1)[0].strip()[len(keyword) :].strip()
|
|
100
|
+
)
|
|
88
101
|
if sig_part.startswith(name):
|
|
89
|
-
type_sig = sig_part[len(name):].strip()
|
|
90
|
-
|
|
91
|
-
decls.append(
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
102
|
+
type_sig = sig_part[len(name) :].strip()
|
|
103
|
+
|
|
104
|
+
decls.append(
|
|
105
|
+
{
|
|
106
|
+
"name": name,
|
|
107
|
+
"kind": "method",
|
|
108
|
+
"range": {
|
|
109
|
+
"start": {"line": i, "character": 0},
|
|
110
|
+
"end": {"line": i, "character": len(lines[i])},
|
|
111
|
+
},
|
|
112
|
+
"_keyword": keyword,
|
|
113
|
+
"_type": type_sig,
|
|
114
|
+
}
|
|
115
|
+
)
|
|
99
116
|
break
|
|
100
117
|
i += 1
|
|
101
118
|
return decls
|
|
102
119
|
|
|
103
120
|
|
|
104
|
-
def _flatten_symbols(
|
|
121
|
+
def _flatten_symbols(
|
|
122
|
+
symbols: List[Dict], indent: int = 0, content: str = ""
|
|
123
|
+
) -> List[Tuple[Dict, int]]:
|
|
105
124
|
"""Recursively flatten symbol hierarchy, extracting declarations from namespaces."""
|
|
106
125
|
result = []
|
|
107
126
|
for sym in symbols:
|
|
108
127
|
result.append((sym, indent))
|
|
109
|
-
children = sym.get(
|
|
128
|
+
children = sym.get("children", [])
|
|
110
129
|
|
|
111
130
|
# Extract theorem/lemma/def from namespace bodies
|
|
112
|
-
if content and sym.get(
|
|
113
|
-
ns_range = sym[
|
|
114
|
-
ns_start = ns_range[
|
|
115
|
-
ns_end = ns_range[
|
|
131
|
+
if content and sym.get("kind") == "namespace":
|
|
132
|
+
ns_range = sym["range"]
|
|
133
|
+
ns_start = ns_range["start"]["line"]
|
|
134
|
+
ns_end = ns_range["end"]["line"]
|
|
116
135
|
children = children + _extract_declarations(content, ns_start, ns_end)
|
|
117
136
|
|
|
118
137
|
if children:
|
|
@@ -120,32 +139,36 @@ def _flatten_symbols(symbols: List[Dict], indent: int = 0, content: str = "") ->
|
|
|
120
139
|
return result
|
|
121
140
|
|
|
122
141
|
|
|
123
|
-
def _detect_tag(
|
|
142
|
+
def _detect_tag(
|
|
143
|
+
name: str, kind: str, type_sig: str, has_fields: bool, keyword: Optional[str]
|
|
144
|
+
) -> str:
|
|
124
145
|
"""Determine the appropriate tag for a symbol."""
|
|
125
146
|
if has_fields:
|
|
126
|
-
return "Class" if
|
|
147
|
+
return "Class" if "→" in type_sig else "Struct"
|
|
127
148
|
if name == "example":
|
|
128
149
|
return "Ex"
|
|
129
|
-
if keyword in {
|
|
150
|
+
if keyword in {"theorem", "lemma"}:
|
|
130
151
|
return "Thm"
|
|
131
|
-
if type_sig and any(marker in type_sig for marker in [
|
|
152
|
+
if type_sig and any(marker in type_sig for marker in ["∀", "="]):
|
|
132
153
|
return "Thm"
|
|
133
|
-
if type_sig and
|
|
154
|
+
if type_sig and "→" in type_sig.replace(" → ", "", 1): # More than one arrow
|
|
134
155
|
return "Thm"
|
|
135
156
|
return KIND_TAGS.get(kind, "Def")
|
|
136
157
|
|
|
137
158
|
|
|
138
159
|
def _format_symbol(sym: Dict, type_sigs: Dict, fields_map: Dict, indent: int) -> str:
|
|
139
160
|
"""Format a single symbol with its type signature and fields."""
|
|
140
|
-
name = sym[
|
|
141
|
-
type_sig = sym.get(
|
|
161
|
+
name = sym["name"]
|
|
162
|
+
type_sig = sym.get("_type") or type_sigs.get(name, "")
|
|
142
163
|
fields = fields_map.get(name, [])
|
|
143
164
|
|
|
144
|
-
tag = _detect_tag(
|
|
165
|
+
tag = _detect_tag(
|
|
166
|
+
name, sym.get("kind", ""), type_sig, bool(fields), sym.get("_keyword")
|
|
167
|
+
)
|
|
145
168
|
prefix = "\t" * indent
|
|
146
169
|
|
|
147
|
-
start = sym[
|
|
148
|
-
end = sym[
|
|
170
|
+
start = sym["range"]["start"]["line"] + 1
|
|
171
|
+
end = sym["range"]["end"]["line"] + 1
|
|
149
172
|
line_info = f"L{start}" if start == end else f"L{start}-{end}"
|
|
150
173
|
|
|
151
174
|
result = f"{prefix}[{tag}: {line_info}] {name}"
|
|
@@ -164,8 +187,11 @@ def generate_outline(client: LeanLSPClient, path: str) -> str:
|
|
|
164
187
|
content = client.get_file_content(path)
|
|
165
188
|
|
|
166
189
|
# Extract imports
|
|
167
|
-
imports = [
|
|
168
|
-
|
|
190
|
+
imports = [
|
|
191
|
+
line.strip()[7:]
|
|
192
|
+
for line in content.splitlines()
|
|
193
|
+
if line.strip().startswith("import ")
|
|
194
|
+
]
|
|
169
195
|
|
|
170
196
|
symbols = client.get_document_symbols(path)
|
|
171
197
|
if not symbols and not imports:
|
|
@@ -175,14 +201,24 @@ def generate_outline(client: LeanLSPClient, path: str) -> str:
|
|
|
175
201
|
all_symbols = _flatten_symbols(symbols, content=content)
|
|
176
202
|
|
|
177
203
|
# Get info trees only for LSP symbols (not extracted declarations)
|
|
178
|
-
lsp_methods = [
|
|
204
|
+
lsp_methods = [
|
|
205
|
+
s
|
|
206
|
+
for s, _ in all_symbols
|
|
207
|
+
if s.get("kind") in METHOD_KIND and "_keyword" not in s
|
|
208
|
+
]
|
|
179
209
|
info_trees = _get_info_trees(client, path, lsp_methods)
|
|
180
210
|
|
|
181
211
|
# Extract type signatures and fields from info trees
|
|
182
|
-
type_sigs = {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
212
|
+
type_sigs = {
|
|
213
|
+
name: sig
|
|
214
|
+
for name, info in info_trees.items()
|
|
215
|
+
if (sig := _extract_type(info, name))
|
|
216
|
+
}
|
|
217
|
+
fields_map = {
|
|
218
|
+
name: fields
|
|
219
|
+
for name, info in info_trees.items()
|
|
220
|
+
if (fields := _extract_fields(info, name))
|
|
221
|
+
}
|
|
186
222
|
|
|
187
223
|
# Build output
|
|
188
224
|
parts = []
|
|
@@ -193,7 +229,9 @@ def generate_outline(client: LeanLSPClient, path: str) -> str:
|
|
|
193
229
|
declarations = [
|
|
194
230
|
_format_symbol(sym, type_sigs, fields_map, indent)
|
|
195
231
|
for sym, indent in all_symbols
|
|
196
|
-
if sym.get(
|
|
232
|
+
if sym.get("kind") in METHOD_KIND
|
|
233
|
+
or sym.get("_keyword")
|
|
234
|
+
or sym.get("kind") == "namespace"
|
|
197
235
|
]
|
|
198
236
|
parts.append("## Declarations\n" + "".join(declarations).rstrip())
|
|
199
237
|
|
lean_lsp_mcp/server.py
CHANGED
|
@@ -26,6 +26,7 @@ from lean_lsp_mcp.client_utils import (
|
|
|
26
26
|
from lean_lsp_mcp.file_utils import get_file_contents
|
|
27
27
|
from lean_lsp_mcp.instructions import INSTRUCTIONS
|
|
28
28
|
from lean_lsp_mcp.search_utils import check_ripgrep_status, lean_local_search
|
|
29
|
+
from lean_lsp_mcp.loogle import LoogleManager, loogle_remote
|
|
29
30
|
from lean_lsp_mcp.outline_utils import generate_outline
|
|
30
31
|
from lean_lsp_mcp.utils import (
|
|
31
32
|
OutputCapture,
|
|
@@ -56,10 +57,15 @@ class AppContext:
|
|
|
56
57
|
client: LeanLSPClient | None
|
|
57
58
|
rate_limit: Dict[str, List[int]]
|
|
58
59
|
lean_search_available: bool
|
|
60
|
+
loogle_manager: LoogleManager | None = None
|
|
61
|
+
loogle_local_available: bool = False
|
|
59
62
|
|
|
60
63
|
|
|
61
64
|
@asynccontextmanager
|
|
62
65
|
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
|
|
66
|
+
loogle_manager: LoogleManager | None = None
|
|
67
|
+
loogle_local_available = False
|
|
68
|
+
|
|
63
69
|
try:
|
|
64
70
|
lean_project_path_str = os.environ.get("LEAN_PROJECT_PATH", "").strip()
|
|
65
71
|
if not lean_project_path_str:
|
|
@@ -67,6 +73,19 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
|
|
|
67
73
|
else:
|
|
68
74
|
lean_project_path = Path(lean_project_path_str).resolve()
|
|
69
75
|
|
|
76
|
+
# Initialize local loogle if enabled via env var or CLI
|
|
77
|
+
if os.environ.get("LEAN_LOOGLE_LOCAL", "").lower() in ("1", "true", "yes"):
|
|
78
|
+
logger.info("Local loogle enabled, initializing...")
|
|
79
|
+
loogle_manager = LoogleManager()
|
|
80
|
+
if loogle_manager.ensure_installed():
|
|
81
|
+
if await loogle_manager.start():
|
|
82
|
+
loogle_local_available = True
|
|
83
|
+
logger.info("Local loogle started successfully")
|
|
84
|
+
else:
|
|
85
|
+
logger.warning("Local loogle failed to start, will use remote API")
|
|
86
|
+
else:
|
|
87
|
+
logger.warning("Local loogle installation failed, will use remote API")
|
|
88
|
+
|
|
70
89
|
context = AppContext(
|
|
71
90
|
lean_project_path=lean_project_path,
|
|
72
91
|
client=None,
|
|
@@ -78,6 +97,8 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
|
|
|
78
97
|
"hammer_premise": [],
|
|
79
98
|
},
|
|
80
99
|
lean_search_available=_RG_AVAILABLE,
|
|
100
|
+
loogle_manager=loogle_manager,
|
|
101
|
+
loogle_local_available=loogle_local_available,
|
|
81
102
|
)
|
|
82
103
|
yield context
|
|
83
104
|
finally:
|
|
@@ -86,6 +107,9 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
|
|
|
86
107
|
if context.client:
|
|
87
108
|
context.client.close()
|
|
88
109
|
|
|
110
|
+
if loogle_manager:
|
|
111
|
+
await loogle_manager.stop()
|
|
112
|
+
|
|
89
113
|
|
|
90
114
|
mcp_kwargs = dict(
|
|
91
115
|
name="Lean LSP",
|
|
@@ -322,6 +346,7 @@ def diagnostic_messages(
|
|
|
322
346
|
return "Invalid Lean file path: Unable to start LSP server or load file"
|
|
323
347
|
|
|
324
348
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
349
|
+
client.open_file(rel_path)
|
|
325
350
|
|
|
326
351
|
# If declaration_name is provided, get its range and use that for filtering
|
|
327
352
|
if declaration_name:
|
|
@@ -821,8 +846,7 @@ def leansearch(ctx: Context, query: str, num_results: int = 5) -> List[Dict] | s
|
|
|
821
846
|
|
|
822
847
|
|
|
823
848
|
@mcp.tool("lean_loogle")
|
|
824
|
-
|
|
825
|
-
def loogle(ctx: Context, query: str, num_results: int = 8) -> List[dict] | str:
|
|
849
|
+
async def loogle(ctx: Context, query: str, num_results: int = 8) -> List[dict] | str:
|
|
826
850
|
"""Search for definitions and theorems using loogle.
|
|
827
851
|
|
|
828
852
|
Query patterns:
|
|
@@ -841,25 +865,27 @@ def loogle(ctx: Context, query: str, num_results: int = 8) -> List[dict] | str:
|
|
|
841
865
|
Returns:
|
|
842
866
|
List[dict] | str: Search results or error msg
|
|
843
867
|
"""
|
|
844
|
-
|
|
845
|
-
req = urllib.request.Request(
|
|
846
|
-
f"https://loogle.lean-lang.org/json?q={urllib.parse.quote(query)}",
|
|
847
|
-
headers={"User-Agent": "lean-lsp-mcp/0.1"},
|
|
848
|
-
method="GET",
|
|
849
|
-
)
|
|
868
|
+
app_ctx: AppContext = ctx.request_context.lifespan_context
|
|
850
869
|
|
|
851
|
-
|
|
852
|
-
|
|
870
|
+
# Try local loogle first if available (no rate limiting)
|
|
871
|
+
if app_ctx.loogle_local_available and app_ctx.loogle_manager:
|
|
872
|
+
try:
|
|
873
|
+
results = await app_ctx.loogle_manager.query(query, num_results)
|
|
874
|
+
for result in results:
|
|
875
|
+
result.pop("doc", None)
|
|
876
|
+
return results if results else "No results found."
|
|
877
|
+
except Exception as e:
|
|
878
|
+
logger.warning(f"Local loogle failed: {e}, falling back to remote")
|
|
853
879
|
|
|
854
|
-
|
|
855
|
-
|
|
880
|
+
# Fall back to remote (with rate limiting)
|
|
881
|
+
rate_limit = app_ctx.rate_limit["loogle"]
|
|
882
|
+
now = int(time.time())
|
|
883
|
+
rate_limit[:] = [t for t in rate_limit if now - t < 30]
|
|
884
|
+
if len(rate_limit) >= 3:
|
|
885
|
+
return "Rate limit exceeded: 3 requests per 30s. Use --loogle-local to avoid limits."
|
|
886
|
+
rate_limit.append(now)
|
|
856
887
|
|
|
857
|
-
|
|
858
|
-
for result in results:
|
|
859
|
-
result.pop("doc", None)
|
|
860
|
-
return results
|
|
861
|
-
except Exception as e:
|
|
862
|
-
return f"loogle error:\n{str(e)}"
|
|
888
|
+
return loogle_remote(query, num_results)
|
|
863
889
|
|
|
864
890
|
|
|
865
891
|
@mcp.tool("lean_leanfinder")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lean-lsp-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.15.0
|
|
4
4
|
Summary: Lean Theorem Prover MCP
|
|
5
5
|
Author-email: Oliver Dressler <hey@oli.show>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -8,8 +8,8 @@ Project-URL: Repository, https://github.com/oOo0oOo/lean-lsp-mcp
|
|
|
8
8
|
Requires-Python: >=3.10
|
|
9
9
|
Description-Content-Type: text/markdown
|
|
10
10
|
License-File: LICENSE
|
|
11
|
-
Requires-Dist: leanclient==0.
|
|
12
|
-
Requires-Dist: mcp[cli]==1.
|
|
11
|
+
Requires-Dist: leanclient==0.6.1
|
|
12
|
+
Requires-Dist: mcp[cli]==1.23.1
|
|
13
13
|
Requires-Dist: orjson>=3.11.1
|
|
14
14
|
Provides-Extra: lint
|
|
15
15
|
Requires-Dist: ruff>=0.2.0; extra == "lint"
|
|
@@ -348,6 +348,7 @@ Search for Lean definitions and theorems using [loogle.lean-lang.org](https://lo
|
|
|
348
348
|
|
|
349
349
|
- Supports queries by constant, lemma name, subexpression, type, or conclusion.
|
|
350
350
|
- Example: `Real.sin`, `"differ"`, `_ * (_ ^ _)`, `(?a -> ?b) -> List ?a -> List ?b`, `|- tsum _ = _ * tsum _`
|
|
351
|
+
- **Local mode available**: Use `--loogle-local` to run loogle locally (avoids rate limits, see [Local Loogle](#local-loogle) section)
|
|
351
352
|
|
|
352
353
|
<details>
|
|
353
354
|
<summary>Example output (`Real.sin`)</summary>
|
|
@@ -464,6 +465,8 @@ This MCP server works out-of-the-box without any configuration. However, a few o
|
|
|
464
465
|
- `LEAN_LSP_MCP_TOKEN`: Secret token for bearer authentication when using `streamable-http` or `sse` transport.
|
|
465
466
|
- `LEAN_STATE_SEARCH_URL`: URL for a self-hosted [premise-search.com](https://premise-search.com) instance.
|
|
466
467
|
- `LEAN_HAMMER_URL`: URL for a self-hosted [Lean Hammer Premise Search](https://github.com/hanwenzhu/lean-premise-server) instance.
|
|
468
|
+
- `LEAN_LOOGLE_LOCAL`: Set to `true`, `1`, or `yes` to enable local loogle (see [Local Loogle](#local-loogle) section).
|
|
469
|
+
- `LEAN_LOOGLE_CACHE_DIR`: Override the cache directory for local loogle (default: `~/.cache/lean-lsp-mcp/loogle`).
|
|
467
470
|
|
|
468
471
|
You can also often set these environment variables in your MCP client configuration:
|
|
469
472
|
<details>
|
|
@@ -519,6 +522,24 @@ uvx lean-lsp-mcp --transport streamable-http
|
|
|
519
522
|
|
|
520
523
|
Clients should then include the token in the `Authorization` header.
|
|
521
524
|
|
|
525
|
+
### Local Loogle
|
|
526
|
+
|
|
527
|
+
Run loogle locally to avoid the remote API's rate limit (3 req/30s). First run takes ~5-10 minutes to build; subsequent runs start in seconds.
|
|
528
|
+
|
|
529
|
+
```bash
|
|
530
|
+
# Enable via CLI
|
|
531
|
+
uvx lean-lsp-mcp --loogle-local
|
|
532
|
+
|
|
533
|
+
# Or via environment variable
|
|
534
|
+
export LEAN_LOOGLE_LOCAL=true
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
**Requirements:** `git`, `lake` ([elan](https://github.com/leanprover/elan)), ~2GB disk space.
|
|
538
|
+
|
|
539
|
+
**Note:** Local loogle is currently only supported on Unix systems (Linux/macOS). Windows users should use WSL or the remote API.
|
|
540
|
+
|
|
541
|
+
Falls back to remote API if local loogle fails.
|
|
542
|
+
|
|
522
543
|
## Notes on MCP Security
|
|
523
544
|
|
|
524
545
|
There are many valid security concerns with the Model Context Protocol (MCP) in general!
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
lean_lsp_mcp/__init__.py,sha256=MN_bNFyb5-p33JWWGbrlUYBd1UUMQKtZYGC9KCh2mtM,1403
|
|
2
|
+
lean_lsp_mcp/__main__.py,sha256=XnpTzfJc0T-j9tHtdkA8ovTr1c139ffTewcJGhxYDaM,49
|
|
3
|
+
lean_lsp_mcp/client_utils.py,sha256=HgPuB35rMitn2Xm8SCAErsFLq15trB6VMz3FDFgmPd8,4897
|
|
4
|
+
lean_lsp_mcp/file_utils.py,sha256=kCTYQSfmV-R2cm_NCi_L8W5Dcsm0_rTOPpTtpyAin78,1365
|
|
5
|
+
lean_lsp_mcp/instructions.py,sha256=GUOCDILr5N4H_kNE5hiXtzy4Sz9tu-BnE7Y0ktXIF9M,955
|
|
6
|
+
lean_lsp_mcp/loogle.py,sha256=NEs8b_P3RQ4EnWJG3LF9RLevsLS0F80T_uebEickx5c,9789
|
|
7
|
+
lean_lsp_mcp/outline_utils.py,sha256=sZ5q19yxheWuDfb3w8FNfuL1ACGJ9ipqRsIQc-gb1SE,8249
|
|
8
|
+
lean_lsp_mcp/search_utils.py,sha256=X2LPynDNLi767UDxbxHpMccOkbnfKJKv_HxvRNxIXM4,3984
|
|
9
|
+
lean_lsp_mcp/server.py,sha256=m78vXmnVO3qUu6BIEsyOPhefeu6apU8EEZ7VIcVsTDU,39009
|
|
10
|
+
lean_lsp_mcp/utils.py,sha256=qY2Ef82SmD46y0IgyX1jimigkgr6Q8-Hrme-yUYSBGo,11094
|
|
11
|
+
lean_lsp_mcp-0.15.0.dist-info/licenses/LICENSE,sha256=CQlxnf0tQyoVrBE93JYvAUYxv6Z5Yg6sX0pwogOkFvo,1071
|
|
12
|
+
lean_lsp_mcp-0.15.0.dist-info/METADATA,sha256=KtGWheExi2HzDKDwExeed8CRsa2k-0LHAYv8uRdhYBA,20787
|
|
13
|
+
lean_lsp_mcp-0.15.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
14
|
+
lean_lsp_mcp-0.15.0.dist-info/entry_points.txt,sha256=nQbvwctWkWD7I-2f4VrdVQBZYGUw8CnUnFC6QjXxOSE,51
|
|
15
|
+
lean_lsp_mcp-0.15.0.dist-info/top_level.txt,sha256=LGEK0lgMSNPIQ6mG8EO-adaZEGPi_0daDs004epOTF0,13
|
|
16
|
+
lean_lsp_mcp-0.15.0.dist-info/RECORD,,
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
lean_lsp_mcp/__init__.py,sha256=lxqDq0G_sI2iu2Nniy-pTW7BE9Ux7ZXeDoGf0OAWIDc,763
|
|
2
|
-
lean_lsp_mcp/__main__.py,sha256=XnpTzfJc0T-j9tHtdkA8ovTr1c139ffTewcJGhxYDaM,49
|
|
3
|
-
lean_lsp_mcp/client_utils.py,sha256=HgPuB35rMitn2Xm8SCAErsFLq15trB6VMz3FDFgmPd8,4897
|
|
4
|
-
lean_lsp_mcp/file_utils.py,sha256=kCTYQSfmV-R2cm_NCi_L8W5Dcsm0_rTOPpTtpyAin78,1365
|
|
5
|
-
lean_lsp_mcp/instructions.py,sha256=GUOCDILr5N4H_kNE5hiXtzy4Sz9tu-BnE7Y0ktXIF9M,955
|
|
6
|
-
lean_lsp_mcp/outline_utils.py,sha256=bXBpLp_QnxmvwoP2y1juCYog2eln6329MAKuOXOz0-E,7807
|
|
7
|
-
lean_lsp_mcp/search_utils.py,sha256=X2LPynDNLi767UDxbxHpMccOkbnfKJKv_HxvRNxIXM4,3984
|
|
8
|
-
lean_lsp_mcp/server.py,sha256=qf0iRVeWrrvX91EmJsgbx7DW8kwn28zMs1WyfkxCh5A,37644
|
|
9
|
-
lean_lsp_mcp/utils.py,sha256=qY2Ef82SmD46y0IgyX1jimigkgr6Q8-Hrme-yUYSBGo,11094
|
|
10
|
-
lean_lsp_mcp-0.14.0.dist-info/licenses/LICENSE,sha256=CQlxnf0tQyoVrBE93JYvAUYxv6Z5Yg6sX0pwogOkFvo,1071
|
|
11
|
-
lean_lsp_mcp-0.14.0.dist-info/METADATA,sha256=8GGWXkG-1GH4Vyzo32QFITO1OhJrumsijULxDILlvcI,19855
|
|
12
|
-
lean_lsp_mcp-0.14.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
13
|
-
lean_lsp_mcp-0.14.0.dist-info/entry_points.txt,sha256=nQbvwctWkWD7I-2f4VrdVQBZYGUw8CnUnFC6QjXxOSE,51
|
|
14
|
-
lean_lsp_mcp-0.14.0.dist-info/top_level.txt,sha256=LGEK0lgMSNPIQ6mG8EO-adaZEGPi_0daDs004epOTF0,13
|
|
15
|
-
lean_lsp_mcp-0.14.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|