lean-lsp-mcp 0.16.1__py3-none-any.whl → 0.17.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/instructions.py +3 -0
- lean_lsp_mcp/loogle.py +99 -18
- lean_lsp_mcp/models.py +88 -3
- lean_lsp_mcp/search_utils.py +121 -27
- lean_lsp_mcp/server.py +92 -76
- lean_lsp_mcp/utils.py +34 -6
- {lean_lsp_mcp-0.16.1.dist-info → lean_lsp_mcp-0.17.0.dist-info}/METADATA +3 -4
- lean_lsp_mcp-0.17.0.dist-info/RECORD +17 -0
- lean_lsp_mcp-0.16.1.dist-info/RECORD +0 -17
- {lean_lsp_mcp-0.16.1.dist-info → lean_lsp_mcp-0.17.0.dist-info}/WHEEL +0 -0
- {lean_lsp_mcp-0.16.1.dist-info → lean_lsp_mcp-0.17.0.dist-info}/entry_points.txt +0 -0
- {lean_lsp_mcp-0.16.1.dist-info → lean_lsp_mcp-0.17.0.dist-info}/licenses/LICENSE +0 -0
- {lean_lsp_mcp-0.16.1.dist-info → lean_lsp_mcp-0.17.0.dist-info}/top_level.txt +0 -0
lean_lsp_mcp/instructions.py
CHANGED
|
@@ -33,4 +33,7 @@ After finding a name: lean_local_search to verify, lean_hover_info for signature
|
|
|
33
33
|
|
|
34
34
|
## Return Formats
|
|
35
35
|
List tools return JSON arrays. Empty = `[]`.
|
|
36
|
+
|
|
37
|
+
## Error Handling
|
|
38
|
+
Check `isError` in responses: `true` means failure (timeout/LSP error), while `[]` with `isError: false` means no results found.
|
|
36
39
|
"""
|
lean_lsp_mcp/loogle.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
import hashlib
|
|
6
7
|
import json
|
|
7
8
|
import logging
|
|
8
9
|
import os
|
|
@@ -34,7 +35,7 @@ def loogle_remote(query: str, num_results: int) -> list[LoogleResult] | str:
|
|
|
34
35
|
f"https://loogle.lean-lang.org/json?q={urllib.parse.quote(query)}",
|
|
35
36
|
headers={"User-Agent": "lean-lsp-mcp/0.1"},
|
|
36
37
|
)
|
|
37
|
-
with urllib.request.urlopen(req, timeout=
|
|
38
|
+
with urllib.request.urlopen(req, timeout=10) as response:
|
|
38
39
|
results = orjson.loads(response.read())
|
|
39
40
|
if "hits" not in results:
|
|
40
41
|
return "No results found."
|
|
@@ -52,18 +53,25 @@ def loogle_remote(query: str, num_results: int) -> list[LoogleResult] | str:
|
|
|
52
53
|
|
|
53
54
|
|
|
54
55
|
class LoogleManager:
|
|
55
|
-
"""Manages local loogle installation and async subprocess.
|
|
56
|
+
"""Manages local loogle installation and async subprocess.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
cache_dir: Directory for loogle repo and indices (default: ~/.cache/lean-lsp-mcp/loogle)
|
|
60
|
+
project_path: Optional Lean project path to index its .lake/packages dependencies
|
|
61
|
+
"""
|
|
56
62
|
|
|
57
63
|
REPO_URL = "https://github.com/nomeata/loogle.git"
|
|
58
64
|
READY_SIGNAL = "Loogle is ready."
|
|
59
65
|
|
|
60
|
-
def __init__(self, cache_dir: Path | None = None):
|
|
66
|
+
def __init__(self, cache_dir: Path | None = None, project_path: Path | None = None):
|
|
61
67
|
self.cache_dir = cache_dir or get_cache_dir()
|
|
62
68
|
self.repo_dir = self.cache_dir / "repo"
|
|
63
69
|
self.index_dir = self.cache_dir / "index"
|
|
70
|
+
self.project_path = project_path
|
|
64
71
|
self.process: asyncio.subprocess.Process | None = None
|
|
65
72
|
self._ready = False
|
|
66
73
|
self._lock = asyncio.Lock()
|
|
74
|
+
self._extra_paths: list[Path] = []
|
|
67
75
|
|
|
68
76
|
@property
|
|
69
77
|
def binary_path(self) -> Path:
|
|
@@ -181,21 +189,54 @@ class LoogleManager:
|
|
|
181
189
|
return False, err
|
|
182
190
|
return True, ""
|
|
183
191
|
|
|
192
|
+
def _discover_project_paths(self) -> list[Path]:
|
|
193
|
+
"""Find .lake/packages lib paths from the user's project."""
|
|
194
|
+
if not self.project_path:
|
|
195
|
+
return []
|
|
196
|
+
paths = []
|
|
197
|
+
# Check packages directory
|
|
198
|
+
lake_packages = self.project_path / ".lake" / "packages"
|
|
199
|
+
if lake_packages.exists():
|
|
200
|
+
for pkg_dir in lake_packages.iterdir():
|
|
201
|
+
if not pkg_dir.is_dir():
|
|
202
|
+
continue
|
|
203
|
+
lib_path = pkg_dir / ".lake" / "build" / "lib" / "lean"
|
|
204
|
+
if lib_path.exists():
|
|
205
|
+
paths.append(lib_path)
|
|
206
|
+
# Also add the project's own build output
|
|
207
|
+
project_lib = self.project_path / ".lake" / "build" / "lib" / "lean"
|
|
208
|
+
if project_lib.exists():
|
|
209
|
+
paths.append(project_lib)
|
|
210
|
+
return sorted(paths)
|
|
211
|
+
|
|
184
212
|
def _get_index_path(self) -> Path:
|
|
185
|
-
|
|
213
|
+
base = f"mathlib-{self._get_mathlib_version()}"
|
|
214
|
+
if self._extra_paths:
|
|
215
|
+
# Include hash of extra paths for project-specific index
|
|
216
|
+
paths_str = ":".join(str(p) for p in sorted(self._extra_paths))
|
|
217
|
+
path_hash = hashlib.sha256(paths_str.encode()).hexdigest()[:8]
|
|
218
|
+
return self.index_dir / f"{base}-{path_hash}.idx"
|
|
219
|
+
return self.index_dir / f"{base}.idx"
|
|
186
220
|
|
|
187
221
|
def _cleanup_old_indices(self) -> None:
|
|
188
|
-
"""Remove old index files from previous mathlib versions.
|
|
222
|
+
"""Remove old index files from previous mathlib versions.
|
|
223
|
+
|
|
224
|
+
Cleans up both mathlib-only indexes (mathlib-<version>.idx) and
|
|
225
|
+
project-specific indexes (mathlib-<version>-<hash>.idx) that don't
|
|
226
|
+
match the current mathlib version.
|
|
227
|
+
"""
|
|
189
228
|
if not self.index_dir.exists():
|
|
190
229
|
return
|
|
191
|
-
|
|
230
|
+
current_mathlib = f"mathlib-{self._get_mathlib_version()}"
|
|
192
231
|
for idx in self.index_dir.glob("*.idx"):
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
232
|
+
# Keep indexes with current mathlib version (both base and project-specific)
|
|
233
|
+
if idx.name.startswith(current_mathlib):
|
|
234
|
+
continue
|
|
235
|
+
try:
|
|
236
|
+
idx.unlink()
|
|
237
|
+
logger.info(f"Removed old index: {idx.name}")
|
|
238
|
+
except Exception:
|
|
239
|
+
pass
|
|
199
240
|
|
|
200
241
|
def _build_index(self) -> Path | None:
|
|
201
242
|
index_path = self._get_index_path()
|
|
@@ -205,17 +246,37 @@ class LoogleManager:
|
|
|
205
246
|
return None
|
|
206
247
|
self.index_dir.mkdir(parents=True, exist_ok=True)
|
|
207
248
|
self._cleanup_old_indices()
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
249
|
+
|
|
250
|
+
# Build command with extra paths
|
|
251
|
+
cmd = [str(self.binary_path), "--write-index", str(index_path), "--json"]
|
|
252
|
+
for path in self._extra_paths:
|
|
253
|
+
cmd.extend(["--path", str(path)])
|
|
254
|
+
cmd.append("") # Empty query for index building
|
|
255
|
+
|
|
256
|
+
if self._extra_paths:
|
|
257
|
+
logger.info(
|
|
258
|
+
f"Building search index with {len(self._extra_paths)} extra paths..."
|
|
213
259
|
)
|
|
260
|
+
else:
|
|
261
|
+
logger.info("Building search index...")
|
|
262
|
+
try:
|
|
263
|
+
self._run(cmd, timeout=600)
|
|
214
264
|
return index_path if index_path.exists() else None
|
|
215
265
|
except Exception as e:
|
|
216
266
|
logger.error(f"Index build error: {e}")
|
|
217
267
|
return None
|
|
218
268
|
|
|
269
|
+
def set_project_path(self, project_path: Path | None) -> bool:
|
|
270
|
+
"""Update project path and rediscover extra paths. Returns True if paths changed."""
|
|
271
|
+
self.project_path = project_path
|
|
272
|
+
new_paths = self._discover_project_paths()
|
|
273
|
+
if new_paths != self._extra_paths:
|
|
274
|
+
self._extra_paths = new_paths
|
|
275
|
+
if new_paths:
|
|
276
|
+
logger.info(f"Discovered {len(new_paths)} project library paths")
|
|
277
|
+
return True
|
|
278
|
+
return False
|
|
279
|
+
|
|
219
280
|
def ensure_installed(self) -> bool:
|
|
220
281
|
ok, err = self._check_prerequisites()
|
|
221
282
|
if not ok:
|
|
@@ -223,6 +284,10 @@ class LoogleManager:
|
|
|
223
284
|
return False
|
|
224
285
|
if not self._clone_repo() or not self._build_loogle():
|
|
225
286
|
return False
|
|
287
|
+
# Discover project paths before building index
|
|
288
|
+
self._extra_paths = self._discover_project_paths()
|
|
289
|
+
if self._extra_paths:
|
|
290
|
+
logger.info(f"Indexing {len(self._extra_paths)} project library paths")
|
|
226
291
|
if not self._build_index():
|
|
227
292
|
logger.warning("Index build failed, loogle will build on startup")
|
|
228
293
|
return self.is_installed
|
|
@@ -234,10 +299,26 @@ class LoogleManager:
|
|
|
234
299
|
if not ok:
|
|
235
300
|
logger.error(f"Loogle environment check failed: {err}")
|
|
236
301
|
return False
|
|
302
|
+
|
|
303
|
+
# Check if project paths changed and we need to rebuild index
|
|
304
|
+
if self.project_path:
|
|
305
|
+
new_paths = self._discover_project_paths()
|
|
306
|
+
if new_paths != self._extra_paths:
|
|
307
|
+
self._extra_paths = new_paths
|
|
308
|
+
# Build new index if paths changed
|
|
309
|
+
self._build_index()
|
|
310
|
+
|
|
237
311
|
cmd = [str(self.binary_path), "--json", "--interactive"]
|
|
238
312
|
if (idx := self._get_index_path()).exists():
|
|
239
313
|
cmd.extend(["--read-index", str(idx)])
|
|
240
|
-
|
|
314
|
+
# Add extra paths for runtime search (in case not all are indexed)
|
|
315
|
+
for path in self._extra_paths:
|
|
316
|
+
cmd.extend(["--path", str(path)])
|
|
317
|
+
|
|
318
|
+
if self._extra_paths:
|
|
319
|
+
logger.info(f"Starting loogle with {len(self._extra_paths)} extra paths...")
|
|
320
|
+
else:
|
|
321
|
+
logger.info("Starting loogle subprocess...")
|
|
241
322
|
try:
|
|
242
323
|
self.process = await asyncio.create_subprocess_exec(
|
|
243
324
|
*cmd,
|
lean_lsp_mcp/models.py
CHANGED
|
@@ -46,7 +46,15 @@ class DiagnosticMessage(BaseModel):
|
|
|
46
46
|
|
|
47
47
|
class GoalState(BaseModel):
|
|
48
48
|
line_context: str = Field(description="Source line where goals were queried")
|
|
49
|
-
goals: str = Field(
|
|
49
|
+
goals: Optional[List[str]] = Field(
|
|
50
|
+
None, description="Goal list at specified column position"
|
|
51
|
+
)
|
|
52
|
+
goals_before: Optional[List[str]] = Field(
|
|
53
|
+
None, description="Goals at line start (when column omitted)"
|
|
54
|
+
)
|
|
55
|
+
goals_after: Optional[List[str]] = Field(
|
|
56
|
+
None, description="Goals at line end (when column omitted)"
|
|
57
|
+
)
|
|
50
58
|
|
|
51
59
|
|
|
52
60
|
class CompletionItem(BaseModel):
|
|
@@ -94,8 +102,8 @@ class FileOutline(BaseModel):
|
|
|
94
102
|
|
|
95
103
|
class AttemptResult(BaseModel):
|
|
96
104
|
snippet: str = Field(description="Code snippet that was tried")
|
|
97
|
-
|
|
98
|
-
|
|
105
|
+
goals: List[str] = Field(
|
|
106
|
+
default_factory=list, description="Goal list after applying snippet"
|
|
99
107
|
)
|
|
100
108
|
diagnostics: List[DiagnosticMessage] = Field(
|
|
101
109
|
default_factory=list, description="Diagnostics for this attempt"
|
|
@@ -118,3 +126,80 @@ class RunResult(BaseModel):
|
|
|
118
126
|
class DeclarationInfo(BaseModel):
|
|
119
127
|
file_path: str = Field(description="Path to declaration file")
|
|
120
128
|
content: str = Field(description="File content")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# Wrapper models for list-returning tools
|
|
132
|
+
# FastMCP flattens bare lists into separate TextContent blocks, causing serialization issues.
|
|
133
|
+
# Wrapping in a model ensures proper JSON serialization.
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class DiagnosticsResult(BaseModel):
|
|
137
|
+
"""Wrapper for diagnostic messages list."""
|
|
138
|
+
|
|
139
|
+
items: List[DiagnosticMessage] = Field(
|
|
140
|
+
default_factory=list, description="List of diagnostic messages"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class CompletionsResult(BaseModel):
|
|
145
|
+
"""Wrapper for completions list."""
|
|
146
|
+
|
|
147
|
+
items: List[CompletionItem] = Field(
|
|
148
|
+
default_factory=list, description="List of completion items"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class MultiAttemptResult(BaseModel):
|
|
153
|
+
"""Wrapper for multi-attempt results list."""
|
|
154
|
+
|
|
155
|
+
items: List[AttemptResult] = Field(
|
|
156
|
+
default_factory=list, description="List of attempt results"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class LocalSearchResults(BaseModel):
|
|
161
|
+
"""Wrapper for local search results list."""
|
|
162
|
+
|
|
163
|
+
items: List[LocalSearchResult] = Field(
|
|
164
|
+
default_factory=list, description="List of local search results"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class LeanSearchResults(BaseModel):
|
|
169
|
+
"""Wrapper for LeanSearch results list."""
|
|
170
|
+
|
|
171
|
+
items: List[LeanSearchResult] = Field(
|
|
172
|
+
default_factory=list, description="List of LeanSearch results"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class LoogleResults(BaseModel):
|
|
177
|
+
"""Wrapper for Loogle results list."""
|
|
178
|
+
|
|
179
|
+
items: List[LoogleResult] = Field(
|
|
180
|
+
default_factory=list, description="List of Loogle results"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class LeanFinderResults(BaseModel):
|
|
185
|
+
"""Wrapper for Lean Finder results list."""
|
|
186
|
+
|
|
187
|
+
items: List[LeanFinderResult] = Field(
|
|
188
|
+
default_factory=list, description="List of Lean Finder results"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class StateSearchResults(BaseModel):
|
|
193
|
+
"""Wrapper for state search results list."""
|
|
194
|
+
|
|
195
|
+
items: List[StateSearchResult] = Field(
|
|
196
|
+
default_factory=list, description="List of state search results"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class PremiseResults(BaseModel):
|
|
201
|
+
"""Wrapper for premise results list."""
|
|
202
|
+
|
|
203
|
+
items: List[PremiseResult] = Field(
|
|
204
|
+
default_factory=list, description="List of premise results"
|
|
205
|
+
)
|
lean_lsp_mcp/search_utils.py
CHANGED
|
@@ -8,6 +8,7 @@ import platform
|
|
|
8
8
|
import re
|
|
9
9
|
import shutil
|
|
10
10
|
import subprocess
|
|
11
|
+
import threading
|
|
11
12
|
from orjson import loads as _json_loads
|
|
12
13
|
from pathlib import Path
|
|
13
14
|
|
|
@@ -27,6 +28,21 @@ _PLATFORM_INSTRUCTIONS: dict[str, Iterable[str]] = {
|
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
|
|
31
|
+
def _create_ripgrep_process(command: list[str], *, cwd: str) -> subprocess.Popen[str]:
|
|
32
|
+
"""Spawn ripgrep and return a process with line-streaming stdout.
|
|
33
|
+
|
|
34
|
+
Separated for test monkeypatching and to allow early termination once we
|
|
35
|
+
have enough matches.
|
|
36
|
+
"""
|
|
37
|
+
return subprocess.Popen(
|
|
38
|
+
command,
|
|
39
|
+
stdout=subprocess.PIPE,
|
|
40
|
+
stderr=subprocess.PIPE,
|
|
41
|
+
text=True,
|
|
42
|
+
cwd=cwd,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
30
46
|
def check_ripgrep_status() -> tuple[bool, str]:
|
|
31
47
|
"""Check whether ``rg`` is available on PATH and return status + message."""
|
|
32
48
|
|
|
@@ -84,38 +100,116 @@ def lean_local_search(
|
|
|
84
100
|
if lean_src := _get_lean_src_search_path():
|
|
85
101
|
command.append(lean_src)
|
|
86
102
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
matches = []
|
|
90
|
-
for line in result.stdout.splitlines():
|
|
91
|
-
if not line or (event := _json_loads(line)).get("type") != "match":
|
|
92
|
-
continue
|
|
103
|
+
process = _create_ripgrep_process(command, cwd=str(root))
|
|
93
104
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
abs_path = (
|
|
102
|
-
file_path if file_path.is_absolute() else (root / file_path).resolve()
|
|
103
|
-
)
|
|
105
|
+
matches: list[dict[str, str]] = []
|
|
106
|
+
stderr_text = ""
|
|
107
|
+
terminated_early = False
|
|
108
|
+
stderr_chunks: list[str] = []
|
|
109
|
+
stderr_chars = 0
|
|
110
|
+
stderr_truncated = False
|
|
111
|
+
max_stderr_chars = 100_000
|
|
104
112
|
|
|
113
|
+
def _drain_stderr(pipe) -> None:
|
|
114
|
+
nonlocal stderr_chars, stderr_truncated
|
|
105
115
|
try:
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
116
|
+
for err_line in pipe:
|
|
117
|
+
if stderr_chars < max_stderr_chars:
|
|
118
|
+
stderr_chunks.append(err_line)
|
|
119
|
+
stderr_chars += len(err_line)
|
|
120
|
+
else:
|
|
121
|
+
stderr_truncated = True
|
|
122
|
+
except Exception:
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
stderr_thread: threading.Thread | None = None
|
|
126
|
+
if process.stderr is not None:
|
|
127
|
+
stderr_thread = threading.Thread(
|
|
128
|
+
target=_drain_stderr,
|
|
129
|
+
args=(process.stderr,),
|
|
130
|
+
name="lean-local-search-rg-stderr",
|
|
131
|
+
daemon=True,
|
|
132
|
+
)
|
|
133
|
+
stderr_thread.start()
|
|
111
134
|
|
|
112
|
-
|
|
113
|
-
|
|
135
|
+
try:
|
|
136
|
+
stdout = process.stdout
|
|
137
|
+
if stdout is None:
|
|
138
|
+
raise RuntimeError("ripgrep did not provide stdout pipe")
|
|
139
|
+
|
|
140
|
+
for line in stdout:
|
|
141
|
+
if not line or (event := _json_loads(line)).get("type") != "match":
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
data = event["data"]
|
|
145
|
+
parts = data["lines"]["text"].lstrip().split(maxsplit=2)
|
|
146
|
+
if len(parts) < 2:
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
decl_kind, decl_name = parts[0], parts[1].rstrip(":")
|
|
150
|
+
file_path = Path(data["path"]["text"])
|
|
151
|
+
abs_path = (
|
|
152
|
+
file_path if file_path.is_absolute() else (root / file_path).resolve()
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
display_path = str(abs_path.relative_to(root))
|
|
157
|
+
except ValueError:
|
|
158
|
+
display_path = str(file_path)
|
|
159
|
+
|
|
160
|
+
matches.append({"name": decl_name, "kind": decl_kind, "file": display_path})
|
|
161
|
+
|
|
162
|
+
if len(matches) >= limit:
|
|
163
|
+
terminated_early = True
|
|
164
|
+
try:
|
|
165
|
+
process.terminate()
|
|
166
|
+
except Exception:
|
|
167
|
+
pass
|
|
168
|
+
break
|
|
114
169
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
170
|
+
try:
|
|
171
|
+
if terminated_early:
|
|
172
|
+
process.wait(timeout=5)
|
|
173
|
+
else:
|
|
174
|
+
process.wait()
|
|
175
|
+
except subprocess.TimeoutExpired:
|
|
176
|
+
process.kill()
|
|
177
|
+
process.wait()
|
|
178
|
+
finally:
|
|
179
|
+
if process.returncode is None:
|
|
180
|
+
try:
|
|
181
|
+
process.terminate()
|
|
182
|
+
except Exception:
|
|
183
|
+
pass
|
|
184
|
+
try:
|
|
185
|
+
process.wait(timeout=5)
|
|
186
|
+
except Exception:
|
|
187
|
+
try:
|
|
188
|
+
process.kill()
|
|
189
|
+
except Exception:
|
|
190
|
+
pass
|
|
191
|
+
try:
|
|
192
|
+
process.wait(timeout=5)
|
|
193
|
+
except Exception:
|
|
194
|
+
pass
|
|
195
|
+
if stderr_thread is not None:
|
|
196
|
+
stderr_thread.join(timeout=1)
|
|
197
|
+
if process.stdout is not None:
|
|
198
|
+
process.stdout.close()
|
|
199
|
+
if process.stderr is not None:
|
|
200
|
+
process.stderr.close()
|
|
201
|
+
|
|
202
|
+
if stderr_chunks:
|
|
203
|
+
stderr_text = "".join(stderr_chunks)
|
|
204
|
+
if stderr_truncated:
|
|
205
|
+
stderr_text += "\n[stderr truncated]"
|
|
206
|
+
|
|
207
|
+
returncode = process.returncode if process.returncode is not None else 0
|
|
208
|
+
|
|
209
|
+
if returncode not in (0, 1) and not matches:
|
|
210
|
+
error_msg = f"ripgrep exited with code {returncode}"
|
|
211
|
+
if stderr_text:
|
|
212
|
+
error_msg += f"\n{stderr_text}"
|
|
119
213
|
raise RuntimeError(error_msg)
|
|
120
214
|
|
|
121
215
|
return matches
|
lean_lsp_mcp/server.py
CHANGED
|
@@ -1,79 +1,80 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import functools
|
|
2
3
|
import os
|
|
3
4
|
import re
|
|
4
5
|
import time
|
|
5
|
-
from typing import Annotated, List, Optional, Dict
|
|
6
|
-
from contextlib import asynccontextmanager
|
|
7
|
-
from collections.abc import AsyncIterator
|
|
8
|
-
from dataclasses import dataclass
|
|
9
6
|
import urllib
|
|
10
|
-
import orjson
|
|
11
|
-
import functools
|
|
12
7
|
import uuid
|
|
8
|
+
from collections.abc import AsyncIterator
|
|
9
|
+
from contextlib import asynccontextmanager
|
|
10
|
+
from dataclasses import dataclass
|
|
13
11
|
from pathlib import Path
|
|
12
|
+
from typing import Annotated, Dict, List, Optional
|
|
14
13
|
|
|
15
|
-
|
|
16
|
-
from
|
|
17
|
-
from mcp.server.fastmcp.utilities.logging import get_logger, configure_logging
|
|
14
|
+
import orjson
|
|
15
|
+
from leanclient import DocumentContentChange, LeanLSPClient
|
|
18
16
|
from mcp.server.auth.settings import AuthSettings
|
|
17
|
+
from mcp.server.fastmcp import Context, FastMCP
|
|
18
|
+
from mcp.server.fastmcp.utilities.logging import configure_logging, get_logger
|
|
19
19
|
from mcp.types import ToolAnnotations
|
|
20
|
-
from
|
|
20
|
+
from pydantic import Field
|
|
21
21
|
|
|
22
22
|
from lean_lsp_mcp.client_utils import (
|
|
23
|
+
infer_project_path,
|
|
23
24
|
setup_client_for_file,
|
|
24
25
|
startup_client,
|
|
25
|
-
infer_project_path,
|
|
26
26
|
)
|
|
27
27
|
from lean_lsp_mcp.file_utils import get_file_contents
|
|
28
28
|
from lean_lsp_mcp.instructions import INSTRUCTIONS
|
|
29
|
-
from lean_lsp_mcp.search_utils import check_ripgrep_status, lean_local_search
|
|
30
29
|
from lean_lsp_mcp.loogle import LoogleManager, loogle_remote
|
|
31
|
-
from lean_lsp_mcp.outline_utils import generate_outline_data
|
|
32
30
|
from lean_lsp_mcp.models import (
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
PremiseResult,
|
|
31
|
+
AttemptResult,
|
|
32
|
+
BuildResult,
|
|
33
|
+
CompletionItem,
|
|
34
|
+
CompletionsResult,
|
|
35
|
+
DeclarationInfo,
|
|
39
36
|
DiagnosticMessage,
|
|
37
|
+
# Wrapper models for list-returning tools
|
|
38
|
+
DiagnosticsResult,
|
|
39
|
+
FileOutline,
|
|
40
40
|
GoalState,
|
|
41
|
-
CompletionItem,
|
|
42
41
|
HoverInfo,
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
42
|
+
LeanFinderResult,
|
|
43
|
+
LeanFinderResults,
|
|
44
|
+
LeanSearchResult,
|
|
45
|
+
LeanSearchResults,
|
|
46
|
+
LocalSearchResult,
|
|
47
|
+
LocalSearchResults,
|
|
48
|
+
LoogleResult,
|
|
49
|
+
LoogleResults,
|
|
50
|
+
MultiAttemptResult,
|
|
51
|
+
PremiseResult,
|
|
52
|
+
PremiseResults,
|
|
47
53
|
RunResult,
|
|
48
|
-
|
|
54
|
+
StateSearchResult,
|
|
55
|
+
StateSearchResults,
|
|
56
|
+
TermGoalState,
|
|
49
57
|
)
|
|
58
|
+
from lean_lsp_mcp.outline_utils import generate_outline_data
|
|
59
|
+
from lean_lsp_mcp.search_utils import check_ripgrep_status, lean_local_search
|
|
50
60
|
from lean_lsp_mcp.utils import (
|
|
51
61
|
COMPLETION_KIND,
|
|
62
|
+
LeanToolError,
|
|
63
|
+
OptionalTokenVerifier,
|
|
52
64
|
OutputCapture,
|
|
65
|
+
check_lsp_response,
|
|
53
66
|
deprecated,
|
|
67
|
+
extract_goals_list,
|
|
54
68
|
extract_range,
|
|
55
69
|
filter_diagnostics_by_position,
|
|
56
70
|
find_start_position,
|
|
57
|
-
format_goal,
|
|
58
71
|
get_declaration_range,
|
|
59
|
-
OptionalTokenVerifier,
|
|
60
72
|
)
|
|
61
73
|
|
|
62
74
|
# LSP Diagnostic severity: 1=error, 2=warning, 3=info, 4=hint
|
|
63
75
|
DIAGNOSTIC_SEVERITY: Dict[int, str] = {1: "error", 2: "warning", 3: "info", 4: "hint"}
|
|
64
76
|
|
|
65
77
|
|
|
66
|
-
class LeanToolError(Exception):
|
|
67
|
-
pass
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def _to_json_array(items: List[BaseModel]) -> str:
|
|
71
|
-
"""Serialize list of models as JSON array (avoids FastMCP list flattening)."""
|
|
72
|
-
return orjson.dumps(
|
|
73
|
-
[item.model_dump() for item in items], option=orjson.OPT_INDENT_2
|
|
74
|
-
).decode()
|
|
75
|
-
|
|
76
|
-
|
|
77
78
|
_LOG_LEVEL = os.environ.get("LEAN_LOG_LEVEL", "INFO")
|
|
78
79
|
configure_logging("CRITICAL" if _LOG_LEVEL == "NONE" else _LOG_LEVEL)
|
|
79
80
|
logger = get_logger(__name__)
|
|
@@ -107,7 +108,7 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
|
|
|
107
108
|
# Initialize local loogle if enabled via env var or CLI
|
|
108
109
|
if os.environ.get("LEAN_LOOGLE_LOCAL", "").lower() in ("1", "true", "yes"):
|
|
109
110
|
logger.info("Local loogle enabled, initializing...")
|
|
110
|
-
loogle_manager = LoogleManager()
|
|
111
|
+
loogle_manager = LoogleManager(project_path=lean_project_path)
|
|
111
112
|
if loogle_manager.ensure_installed():
|
|
112
113
|
if await loogle_manager.start():
|
|
113
114
|
loogle_local_available = True
|
|
@@ -414,7 +415,7 @@ def diagnostic_messages(
|
|
|
414
415
|
declaration_name: Annotated[
|
|
415
416
|
Optional[str], Field(description="Filter to declaration (slow)")
|
|
416
417
|
] = None,
|
|
417
|
-
) ->
|
|
418
|
+
) -> DiagnosticsResult:
|
|
418
419
|
"""Get compiler diagnostics (errors, warnings, infos) for a Lean file."""
|
|
419
420
|
rel_path = setup_client_for_file(ctx, file_path)
|
|
420
421
|
if not rel_path:
|
|
@@ -442,8 +443,9 @@ def diagnostic_messages(
|
|
|
442
443
|
end_line=end_line_0,
|
|
443
444
|
inactivity_timeout=15.0,
|
|
444
445
|
)
|
|
446
|
+
check_lsp_response(diagnostics, "get_diagnostics")
|
|
445
447
|
|
|
446
|
-
return
|
|
448
|
+
return DiagnosticsResult(items=_to_diagnostic_messages(diagnostics))
|
|
447
449
|
|
|
448
450
|
|
|
449
451
|
@mcp.tool(
|
|
@@ -491,15 +493,18 @@ def goal(
|
|
|
491
493
|
(i for i, c in enumerate(line_context) if not c.isspace()), 0
|
|
492
494
|
)
|
|
493
495
|
goal_start = client.get_goal(rel_path, line - 1, column_start)
|
|
496
|
+
check_lsp_response(goal_start, "get_goal", allow_none=True)
|
|
494
497
|
goal_end = client.get_goal(rel_path, line - 1, column_end)
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
498
|
+
return GoalState(
|
|
499
|
+
line_context=line_context,
|
|
500
|
+
goals_before=extract_goals_list(goal_start),
|
|
501
|
+
goals_after=extract_goals_list(goal_end),
|
|
502
|
+
)
|
|
499
503
|
else:
|
|
500
504
|
goal_result = client.get_goal(rel_path, line - 1, column - 1)
|
|
505
|
+
check_lsp_response(goal_result, "get_goal", allow_none=True)
|
|
501
506
|
return GoalState(
|
|
502
|
-
line_context=line_context, goals=
|
|
507
|
+
line_context=line_context, goals=extract_goals_list(goal_result)
|
|
503
508
|
)
|
|
504
509
|
|
|
505
510
|
|
|
@@ -540,6 +545,7 @@ def term_goal(
|
|
|
540
545
|
column = len(line_context)
|
|
541
546
|
|
|
542
547
|
term_goal_result = client.get_term_goal(rel_path, line - 1, column - 1)
|
|
548
|
+
check_lsp_response(term_goal_result, "get_term_goal", allow_none=True)
|
|
543
549
|
expected_type = None
|
|
544
550
|
if term_goal_result is not None:
|
|
545
551
|
rendered = term_goal_result.get("goal")
|
|
@@ -575,6 +581,7 @@ def hover(
|
|
|
575
581
|
client.open_file(rel_path)
|
|
576
582
|
file_content = client.get_file_content(rel_path)
|
|
577
583
|
hover_info = client.get_hover(rel_path, line - 1, column - 1)
|
|
584
|
+
check_lsp_response(hover_info, "get_hover", allow_none=True)
|
|
578
585
|
if hover_info is None:
|
|
579
586
|
raise LeanToolError(f"No hover information at line {line}, column {column}")
|
|
580
587
|
|
|
@@ -586,6 +593,7 @@ def hover(
|
|
|
586
593
|
|
|
587
594
|
# Add diagnostics if available
|
|
588
595
|
diagnostics = client.get_diagnostics(rel_path)
|
|
596
|
+
check_lsp_response(diagnostics, "get_diagnostics")
|
|
589
597
|
filtered = filter_diagnostics_by_position(diagnostics, line - 1, column - 1)
|
|
590
598
|
|
|
591
599
|
return HoverInfo(
|
|
@@ -610,7 +618,7 @@ def completions(
|
|
|
610
618
|
line: Annotated[int, Field(description="Line number (1-indexed)", ge=1)],
|
|
611
619
|
column: Annotated[int, Field(description="Column number (1-indexed)", ge=1)],
|
|
612
620
|
max_completions: Annotated[int, Field(description="Max completions", ge=1)] = 32,
|
|
613
|
-
) ->
|
|
621
|
+
) -> CompletionsResult:
|
|
614
622
|
"""Get IDE autocompletions. Use on INCOMPLETE code (after `.` or partial name)."""
|
|
615
623
|
rel_path = setup_client_for_file(ctx, file_path)
|
|
616
624
|
if not rel_path:
|
|
@@ -622,6 +630,7 @@ def completions(
|
|
|
622
630
|
client.open_file(rel_path)
|
|
623
631
|
content = client.get_file_content(rel_path)
|
|
624
632
|
raw_completions = client.get_completions(rel_path, line - 1, column - 1)
|
|
633
|
+
check_lsp_response(raw_completions, "get_completions")
|
|
625
634
|
|
|
626
635
|
# Convert to CompletionItem models
|
|
627
636
|
items: List[CompletionItem] = []
|
|
@@ -639,7 +648,7 @@ def completions(
|
|
|
639
648
|
)
|
|
640
649
|
|
|
641
650
|
if not items:
|
|
642
|
-
return
|
|
651
|
+
return CompletionsResult(items=[])
|
|
643
652
|
|
|
644
653
|
# Find the sort term: The last word/identifier before the cursor
|
|
645
654
|
lines = content.splitlines()
|
|
@@ -666,7 +675,7 @@ def completions(
|
|
|
666
675
|
items.sort(key=lambda x: x.label.lower())
|
|
667
676
|
|
|
668
677
|
# Truncate if too many results
|
|
669
|
-
return
|
|
678
|
+
return CompletionsResult(items=items[:max_completions])
|
|
670
679
|
|
|
671
680
|
|
|
672
681
|
@mcp.tool(
|
|
@@ -741,7 +750,7 @@ def multi_attempt(
|
|
|
741
750
|
snippets: Annotated[
|
|
742
751
|
List[str], Field(description="Tactics to try (3+ recommended)")
|
|
743
752
|
],
|
|
744
|
-
) ->
|
|
753
|
+
) -> MultiAttemptResult:
|
|
745
754
|
"""Try multiple tactics without modifying file. Returns goal state for each."""
|
|
746
755
|
rel_path = setup_client_for_file(ctx, file_path)
|
|
747
756
|
if not rel_path:
|
|
@@ -753,8 +762,6 @@ def multi_attempt(
|
|
|
753
762
|
client.open_file(rel_path)
|
|
754
763
|
|
|
755
764
|
try:
|
|
756
|
-
client.open_file(rel_path)
|
|
757
|
-
|
|
758
765
|
results: List[AttemptResult] = []
|
|
759
766
|
# Avoid mutating caller-provided snippets; normalize locally per attempt
|
|
760
767
|
for snippet in snippets:
|
|
@@ -769,19 +776,20 @@ def multi_attempt(
|
|
|
769
776
|
# Apply the change to the file, capture diagnostics and goal state
|
|
770
777
|
client.update_file(rel_path, [change])
|
|
771
778
|
diag = client.get_diagnostics(rel_path)
|
|
779
|
+
check_lsp_response(diag, "get_diagnostics")
|
|
772
780
|
filtered_diag = filter_diagnostics_by_position(diag, line - 1, None)
|
|
773
781
|
# Use the snippet text length without any trailing newline for the column
|
|
774
782
|
goal_result = client.get_goal(rel_path, line - 1, len(snippet_str))
|
|
775
|
-
|
|
783
|
+
goals = extract_goals_list(goal_result)
|
|
776
784
|
results.append(
|
|
777
785
|
AttemptResult(
|
|
778
786
|
snippet=snippet_str,
|
|
779
|
-
|
|
787
|
+
goals=goals,
|
|
780
788
|
diagnostics=_to_diagnostic_messages(filtered_diag),
|
|
781
789
|
)
|
|
782
790
|
)
|
|
783
791
|
|
|
784
|
-
return
|
|
792
|
+
return MultiAttemptResult(items=results)
|
|
785
793
|
finally:
|
|
786
794
|
try:
|
|
787
795
|
client.close_files([rel_path])
|
|
@@ -837,6 +845,7 @@ def run_code(
|
|
|
837
845
|
client.open_file(rel_path)
|
|
838
846
|
opened_file = True
|
|
839
847
|
raw_diagnostics = client.get_diagnostics(rel_path, inactivity_timeout=15.0)
|
|
848
|
+
check_lsp_response(raw_diagnostics, "get_diagnostics")
|
|
840
849
|
finally:
|
|
841
850
|
if opened_file:
|
|
842
851
|
try:
|
|
@@ -878,7 +887,7 @@ def local_search(
|
|
|
878
887
|
project_root: Annotated[
|
|
879
888
|
Optional[str], Field(description="Project root (inferred if omitted)")
|
|
880
889
|
] = None,
|
|
881
|
-
) ->
|
|
890
|
+
) -> LocalSearchResults:
|
|
882
891
|
"""Fast local search to verify declarations exist. Use BEFORE trying a lemma name."""
|
|
883
892
|
if not _RG_AVAILABLE:
|
|
884
893
|
raise LocalSearchError(_RG_MESSAGE)
|
|
@@ -910,7 +919,7 @@ def local_search(
|
|
|
910
919
|
LocalSearchResult(name=r["name"], kind=r["kind"], file=r["file"])
|
|
911
920
|
for r in raw_results
|
|
912
921
|
]
|
|
913
|
-
return
|
|
922
|
+
return LocalSearchResults(items=results)
|
|
914
923
|
except RuntimeError as exc:
|
|
915
924
|
raise LocalSearchError(f"Search failed: {exc}")
|
|
916
925
|
|
|
@@ -929,7 +938,7 @@ def leansearch(
|
|
|
929
938
|
ctx: Context,
|
|
930
939
|
query: Annotated[str, Field(description="Natural language or Lean term query")],
|
|
931
940
|
num_results: Annotated[int, Field(description="Max results", ge=1)] = 5,
|
|
932
|
-
) ->
|
|
941
|
+
) -> LeanSearchResults:
|
|
933
942
|
"""Search Mathlib via leansearch.net using natural language.
|
|
934
943
|
|
|
935
944
|
Examples: "sum of two even numbers is even", "Cauchy-Schwarz inequality",
|
|
@@ -945,11 +954,11 @@ def leansearch(
|
|
|
945
954
|
method="POST",
|
|
946
955
|
)
|
|
947
956
|
|
|
948
|
-
with urllib.request.urlopen(req, timeout=
|
|
957
|
+
with urllib.request.urlopen(req, timeout=10) as response:
|
|
949
958
|
results = orjson.loads(response.read())
|
|
950
959
|
|
|
951
960
|
if not results or not results[0]:
|
|
952
|
-
return
|
|
961
|
+
return LeanSearchResults(items=[])
|
|
953
962
|
|
|
954
963
|
raw_results = [r["result"] for r in results[0][:num_results]]
|
|
955
964
|
items = [
|
|
@@ -961,7 +970,7 @@ def leansearch(
|
|
|
961
970
|
)
|
|
962
971
|
for r in raw_results
|
|
963
972
|
]
|
|
964
|
-
return
|
|
973
|
+
return LeanSearchResults(items=items)
|
|
965
974
|
|
|
966
975
|
|
|
967
976
|
@mcp.tool(
|
|
@@ -979,7 +988,7 @@ async def loogle(
|
|
|
979
988
|
str, Field(description="Type pattern, constant, or name substring")
|
|
980
989
|
],
|
|
981
990
|
num_results: Annotated[int, Field(description="Max results", ge=1)] = 8,
|
|
982
|
-
) ->
|
|
991
|
+
) -> LoogleResults:
|
|
983
992
|
"""Search Mathlib by type signature via loogle.lean-lang.org.
|
|
984
993
|
|
|
985
994
|
Examples: `Real.sin`, `"comm"`, `(?a → ?b) → List ?a → List ?b`,
|
|
@@ -989,10 +998,15 @@ async def loogle(
|
|
|
989
998
|
|
|
990
999
|
# Try local loogle first if available (no rate limiting)
|
|
991
1000
|
if app_ctx.loogle_local_available and app_ctx.loogle_manager:
|
|
1001
|
+
# Update project path if it changed (adds new library paths)
|
|
1002
|
+
if app_ctx.lean_project_path != app_ctx.loogle_manager.project_path:
|
|
1003
|
+
if app_ctx.loogle_manager.set_project_path(app_ctx.lean_project_path):
|
|
1004
|
+
# Restart to pick up new paths
|
|
1005
|
+
await app_ctx.loogle_manager.stop()
|
|
992
1006
|
try:
|
|
993
1007
|
results = await app_ctx.loogle_manager.query(query, num_results)
|
|
994
1008
|
if not results:
|
|
995
|
-
return
|
|
1009
|
+
return LoogleResults(items=[])
|
|
996
1010
|
items = [
|
|
997
1011
|
LoogleResult(
|
|
998
1012
|
name=r.get("name", ""),
|
|
@@ -1001,7 +1015,7 @@ async def loogle(
|
|
|
1001
1015
|
)
|
|
1002
1016
|
for r in results
|
|
1003
1017
|
]
|
|
1004
|
-
return
|
|
1018
|
+
return LoogleResults(items=items)
|
|
1005
1019
|
except Exception as e:
|
|
1006
1020
|
logger.warning(f"Local loogle failed: {e}, falling back to remote")
|
|
1007
1021
|
|
|
@@ -1010,13 +1024,15 @@ async def loogle(
|
|
|
1010
1024
|
now = int(time.time())
|
|
1011
1025
|
rate_limit[:] = [t for t in rate_limit if now - t < 30]
|
|
1012
1026
|
if len(rate_limit) >= 3:
|
|
1013
|
-
|
|
1027
|
+
raise LeanToolError(
|
|
1028
|
+
"Rate limit exceeded: 3 requests per 30s. Use --loogle-local to avoid limits."
|
|
1029
|
+
)
|
|
1014
1030
|
rate_limit.append(now)
|
|
1015
1031
|
|
|
1016
1032
|
result = loogle_remote(query, num_results)
|
|
1017
1033
|
if isinstance(result, str):
|
|
1018
|
-
|
|
1019
|
-
return
|
|
1034
|
+
raise LeanToolError(result) # Error message from remote
|
|
1035
|
+
return LoogleResults(items=result)
|
|
1020
1036
|
|
|
1021
1037
|
|
|
1022
1038
|
@mcp.tool(
|
|
@@ -1033,7 +1049,7 @@ def leanfinder(
|
|
|
1033
1049
|
ctx: Context,
|
|
1034
1050
|
query: Annotated[str, Field(description="Mathematical concept or proof state")],
|
|
1035
1051
|
num_results: Annotated[int, Field(description="Max results", ge=1)] = 5,
|
|
1036
|
-
) ->
|
|
1052
|
+
) -> LeanFinderResults:
|
|
1037
1053
|
"""Semantic search by mathematical meaning via Lean Finder.
|
|
1038
1054
|
|
|
1039
1055
|
Examples: "commutativity of addition on natural numbers",
|
|
@@ -1047,7 +1063,7 @@ def leanfinder(
|
|
|
1047
1063
|
)
|
|
1048
1064
|
|
|
1049
1065
|
results: List[LeanFinderResult] = []
|
|
1050
|
-
with urllib.request.urlopen(req, timeout=
|
|
1066
|
+
with urllib.request.urlopen(req, timeout=10) as response:
|
|
1051
1067
|
data = orjson.loads(response.read())
|
|
1052
1068
|
for result in data["results"]:
|
|
1053
1069
|
if (
|
|
@@ -1065,7 +1081,7 @@ def leanfinder(
|
|
|
1065
1081
|
)
|
|
1066
1082
|
)
|
|
1067
1083
|
|
|
1068
|
-
return
|
|
1084
|
+
return LeanFinderResults(items=results)
|
|
1069
1085
|
|
|
1070
1086
|
|
|
1071
1087
|
@mcp.tool(
|
|
@@ -1084,7 +1100,7 @@ def state_search(
|
|
|
1084
1100
|
line: Annotated[int, Field(description="Line number (1-indexed)", ge=1)],
|
|
1085
1101
|
column: Annotated[int, Field(description="Column number (1-indexed)", ge=1)],
|
|
1086
1102
|
num_results: Annotated[int, Field(description="Max results", ge=1)] = 5,
|
|
1087
|
-
) ->
|
|
1103
|
+
) -> StateSearchResults:
|
|
1088
1104
|
"""Find lemmas to close the goal at a position. Searches premise-search.com."""
|
|
1089
1105
|
rel_path = setup_client_for_file(ctx, file_path)
|
|
1090
1106
|
if not rel_path:
|
|
@@ -1110,11 +1126,11 @@ def state_search(
|
|
|
1110
1126
|
method="GET",
|
|
1111
1127
|
)
|
|
1112
1128
|
|
|
1113
|
-
with urllib.request.urlopen(req, timeout=
|
|
1129
|
+
with urllib.request.urlopen(req, timeout=10) as response:
|
|
1114
1130
|
results = orjson.loads(response.read())
|
|
1115
1131
|
|
|
1116
1132
|
items = [StateSearchResult(name=r["name"]) for r in results]
|
|
1117
|
-
return
|
|
1133
|
+
return StateSearchResults(items=items)
|
|
1118
1134
|
|
|
1119
1135
|
|
|
1120
1136
|
@mcp.tool(
|
|
@@ -1133,7 +1149,7 @@ def hammer_premise(
|
|
|
1133
1149
|
line: Annotated[int, Field(description="Line number (1-indexed)", ge=1)],
|
|
1134
1150
|
column: Annotated[int, Field(description="Column number (1-indexed)", ge=1)],
|
|
1135
1151
|
num_results: Annotated[int, Field(description="Max results", ge=1)] = 32,
|
|
1136
|
-
) ->
|
|
1152
|
+
) -> PremiseResults:
|
|
1137
1153
|
"""Get premise suggestions for automation tactics at a goal position.
|
|
1138
1154
|
|
|
1139
1155
|
Returns lemma names to try with `simp only [...]`, `aesop`, or as hints.
|
|
@@ -1170,11 +1186,11 @@ def hammer_premise(
|
|
|
1170
1186
|
data=orjson.dumps(data),
|
|
1171
1187
|
)
|
|
1172
1188
|
|
|
1173
|
-
with urllib.request.urlopen(req, timeout=
|
|
1189
|
+
with urllib.request.urlopen(req, timeout=10) as response:
|
|
1174
1190
|
results = orjson.loads(response.read())
|
|
1175
1191
|
|
|
1176
1192
|
items = [PremiseResult(name=r["name"]) for r in results]
|
|
1177
|
-
return
|
|
1193
|
+
return PremiseResults(items=items)
|
|
1178
1194
|
|
|
1179
1195
|
|
|
1180
1196
|
if __name__ == "__main__":
|
lean_lsp_mcp/utils.py
CHANGED
|
@@ -2,11 +2,39 @@ import os
|
|
|
2
2
|
import secrets
|
|
3
3
|
import sys
|
|
4
4
|
import tempfile
|
|
5
|
-
from typing import List, Dict, Optional, Callable
|
|
5
|
+
from typing import Any, List, Dict, Optional, Callable
|
|
6
6
|
|
|
7
7
|
from mcp.server.auth.provider import AccessToken, TokenVerifier
|
|
8
8
|
|
|
9
9
|
|
|
10
|
+
class LeanToolError(Exception):
|
|
11
|
+
"""Exception raised when a Lean MCP tool operation fails."""
|
|
12
|
+
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def check_lsp_response(
|
|
17
|
+
response: Any, operation: str, *, allow_none: bool = False
|
|
18
|
+
) -> Any:
|
|
19
|
+
"""Check an LSP response for error patterns and raise if found.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
response: The response from a leanclient LSP operation
|
|
23
|
+
operation: Human-readable description of the operation
|
|
24
|
+
allow_none: If False (default), None raises LeanToolError (timeout).
|
|
25
|
+
If True, None is allowed (for operations where None is valid).
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
LeanToolError: If response indicates failure
|
|
29
|
+
"""
|
|
30
|
+
if response is None and not allow_none:
|
|
31
|
+
raise LeanToolError(f"LSP timeout during {operation}")
|
|
32
|
+
if isinstance(response, dict) and "error" in response:
|
|
33
|
+
msg = response["error"].get("message", "unknown error")
|
|
34
|
+
raise LeanToolError(f"LSP error during {operation}: {msg}")
|
|
35
|
+
return response
|
|
36
|
+
|
|
37
|
+
|
|
10
38
|
class OutputCapture:
|
|
11
39
|
"""Capture any output to stdout and stderr at the file descriptor level."""
|
|
12
40
|
|
|
@@ -80,11 +108,11 @@ def format_diagnostics(diagnostics: List[Dict], select_line: int = -1) -> List[s
|
|
|
80
108
|
return msgs
|
|
81
109
|
|
|
82
110
|
|
|
83
|
-
def
|
|
84
|
-
if
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
return
|
|
111
|
+
def extract_goals_list(goal_response: dict | None) -> List[str]:
|
|
112
|
+
"""Extract goals list from LSP response, returning empty list if no goals."""
|
|
113
|
+
if goal_response is None:
|
|
114
|
+
return []
|
|
115
|
+
return goal_response.get("goals", [])
|
|
88
116
|
|
|
89
117
|
|
|
90
118
|
def _utf16_index_to_py_index(text: str, utf16_index: int) -> int | None:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lean-lsp-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.17.0
|
|
4
4
|
Summary: Lean Theorem Prover MCP
|
|
5
5
|
Author-email: Oliver Dressler <hey@oli.show>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -8,9 +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.
|
|
13
|
-
Requires-Dist: mcp[cli]>=1.22.0
|
|
11
|
+
Requires-Dist: leanclient==0.7.0
|
|
12
|
+
Requires-Dist: mcp[cli]==1.24.0
|
|
14
13
|
Requires-Dist: orjson>=3.11.1
|
|
15
14
|
Provides-Extra: lint
|
|
16
15
|
Requires-Dist: ruff>=0.2.0; extra == "lint"
|
|
@@ -0,0 +1,17 @@
|
|
|
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=iJk_oD67tqNaC8K5OXEuXafULKSbbiHjZAsSRebOwdw,1904
|
|
6
|
+
lean_lsp_mcp/loogle.py,sha256=zUgnDWoTIqa4G6GXStAIxxJUR545YbU8Z-8KMjddKV0,15500
|
|
7
|
+
lean_lsp_mcp/models.py,sha256=2pLmvNsrMdn4vO1k119Jw8gqYeaeGKewWW0q1TabBCY,6604
|
|
8
|
+
lean_lsp_mcp/outline_utils.py,sha256=-eoZNbx2eaKaYmuyFJnwUMWP8I9YXNWusue_2OYpDBM,10981
|
|
9
|
+
lean_lsp_mcp/search_utils.py,sha256=MLqKGe4bhEvyfFLIBCmiDxkbcH4O5J3vl9mWnRSb_v0,6801
|
|
10
|
+
lean_lsp_mcp/server.py,sha256=1G7-FZz1TNJ7uU36eSTm9yponWyMO3QhQiyOhhTNOH8,40626
|
|
11
|
+
lean_lsp_mcp/utils.py,sha256=MmGgdhrLEvCtRRVNgN_vflO9_A25h76QbJXhBD-OKt0,12721
|
|
12
|
+
lean_lsp_mcp-0.17.0.dist-info/licenses/LICENSE,sha256=CQlxnf0tQyoVrBE93JYvAUYxv6Z5Yg6sX0pwogOkFvo,1071
|
|
13
|
+
lean_lsp_mcp-0.17.0.dist-info/METADATA,sha256=W23YEfDLfEd_C7YdjynKhmyId5wSYfqdoZ8CyVoeCzo,20787
|
|
14
|
+
lean_lsp_mcp-0.17.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
15
|
+
lean_lsp_mcp-0.17.0.dist-info/entry_points.txt,sha256=nQbvwctWkWD7I-2f4VrdVQBZYGUw8CnUnFC6QjXxOSE,51
|
|
16
|
+
lean_lsp_mcp-0.17.0.dist-info/top_level.txt,sha256=LGEK0lgMSNPIQ6mG8EO-adaZEGPi_0daDs004epOTF0,13
|
|
17
|
+
lean_lsp_mcp-0.17.0.dist-info/RECORD,,
|
|
@@ -1,17 +0,0 @@
|
|
|
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=S1y834V8v-SFSYJlxxy6Dj-Z0szMyEBT5SkEyM6Npr8,1756
|
|
6
|
-
lean_lsp_mcp/loogle.py,sha256=ChybtPM8jOxP8s28358yNqcLiYvGlQqkAEFFLzR87Zw,11971
|
|
7
|
-
lean_lsp_mcp/models.py,sha256=M8CmTg0_NL7KwcQ7UX_Zk7ZG1zXoWLINr41NPs_no2Y,4301
|
|
8
|
-
lean_lsp_mcp/outline_utils.py,sha256=-eoZNbx2eaKaYmuyFJnwUMWP8I9YXNWusue_2OYpDBM,10981
|
|
9
|
-
lean_lsp_mcp/search_utils.py,sha256=X2LPynDNLi767UDxbxHpMccOkbnfKJKv_HxvRNxIXM4,3984
|
|
10
|
-
lean_lsp_mcp/server.py,sha256=8QEkRlbLEKif4NfagD2l1min1w1IFF7Jw1TUUOK-xFg,39481
|
|
11
|
-
lean_lsp_mcp/utils.py,sha256=355kzyB3dkwU7_4Mfcg--JXEorFaE2gtqs6-HbH5rRE,11722
|
|
12
|
-
lean_lsp_mcp-0.16.1.dist-info/licenses/LICENSE,sha256=CQlxnf0tQyoVrBE93JYvAUYxv6Z5Yg6sX0pwogOkFvo,1071
|
|
13
|
-
lean_lsp_mcp-0.16.1.dist-info/METADATA,sha256=nwfjUYlagiUb49IkT3AWoOmJwH4fnwJ5ElyvD9V8zGw,20819
|
|
14
|
-
lean_lsp_mcp-0.16.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
15
|
-
lean_lsp_mcp-0.16.1.dist-info/entry_points.txt,sha256=nQbvwctWkWD7I-2f4VrdVQBZYGUw8CnUnFC6QjXxOSE,51
|
|
16
|
-
lean_lsp_mcp-0.16.1.dist-info/top_level.txt,sha256=LGEK0lgMSNPIQ6mG8EO-adaZEGPi_0daDs004epOTF0,13
|
|
17
|
-
lean_lsp_mcp-0.16.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|