lean-lsp-mcp 0.16.0__tar.gz → 0.16.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.
- {lean_lsp_mcp-0.16.0/src/lean_lsp_mcp.egg-info → lean_lsp_mcp-0.16.2}/PKG-INFO +3 -4
- {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/pyproject.toml +3 -3
- {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/models.py +77 -0
- lean_lsp_mcp-0.16.2/src/lean_lsp_mcp/search_utils.py +236 -0
- {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/server.py +74 -65
- {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2/src/lean_lsp_mcp.egg-info}/PKG-INFO +3 -4
- {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp.egg-info/SOURCES.txt +2 -1
- {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp.egg-info/requires.txt +2 -3
- {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/tests/test_diagnostic_line_range.py +55 -41
- {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/tests/test_search_tools.py +20 -10
- lean_lsp_mcp-0.16.2/tests/test_structured_output.py +124 -0
- lean_lsp_mcp-0.16.0/src/lean_lsp_mcp/search_utils.py +0 -142
- {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/LICENSE +0 -0
- {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/README.md +0 -0
- {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/setup.cfg +0 -0
- {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/__init__.py +0 -0
- {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/__main__.py +0 -0
- {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/client_utils.py +0 -0
- {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/file_utils.py +0 -0
- {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/instructions.py +0 -0
- {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/loogle.py +0 -0
- {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/outline_utils.py +0 -0
- {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/utils.py +0 -0
- {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp.egg-info/dependency_links.txt +0 -0
- {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp.egg-info/entry_points.txt +0 -0
- {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp.egg-info/top_level.txt +0 -0
- {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/tests/test_editor_tools.py +0 -0
- {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/tests/test_file_caching.py +0 -0
- {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/tests/test_logging.py +0 -0
- {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/tests/test_misc_tools.py +0 -0
- {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/tests/test_outline.py +0 -0
- {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/tests/test_project_tools.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lean-lsp-mcp
|
|
3
|
-
Version: 0.16.
|
|
3
|
+
Version: 0.16.2
|
|
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.6.
|
|
12
|
-
Requires-Dist: mcp[cli]==1.
|
|
13
|
-
Requires-Dist: mcp[cli]>=1.22.0
|
|
11
|
+
Requires-Dist: leanclient==0.6.2
|
|
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"
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "lean-lsp-mcp"
|
|
3
|
-
version = "0.16.
|
|
3
|
+
version = "0.16.2"
|
|
4
4
|
description = "Lean Theorem Prover MCP"
|
|
5
5
|
authors = [{name="Oliver Dressler", email="hey@oli.show"}]
|
|
6
6
|
readme = "README.md"
|
|
7
7
|
requires-python = ">=3.10"
|
|
8
8
|
license = "MIT"
|
|
9
9
|
dependencies = [
|
|
10
|
-
"leanclient==0.6.
|
|
11
|
-
"mcp[cli]==1.
|
|
10
|
+
"leanclient==0.6.2",
|
|
11
|
+
"mcp[cli]==1.24.0",
|
|
12
12
|
"orjson>=3.11.1",
|
|
13
13
|
]
|
|
14
14
|
|
|
@@ -118,3 +118,80 @@ class RunResult(BaseModel):
|
|
|
118
118
|
class DeclarationInfo(BaseModel):
|
|
119
119
|
file_path: str = Field(description="Path to declaration file")
|
|
120
120
|
content: str = Field(description="File content")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# Wrapper models for list-returning tools
|
|
124
|
+
# FastMCP flattens bare lists into separate TextContent blocks, causing serialization issues.
|
|
125
|
+
# Wrapping in a model ensures proper JSON serialization.
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class DiagnosticsResult(BaseModel):
|
|
129
|
+
"""Wrapper for diagnostic messages list."""
|
|
130
|
+
|
|
131
|
+
items: List[DiagnosticMessage] = Field(
|
|
132
|
+
default_factory=list, description="List of diagnostic messages"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class CompletionsResult(BaseModel):
|
|
137
|
+
"""Wrapper for completions list."""
|
|
138
|
+
|
|
139
|
+
items: List[CompletionItem] = Field(
|
|
140
|
+
default_factory=list, description="List of completion items"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class MultiAttemptResult(BaseModel):
|
|
145
|
+
"""Wrapper for multi-attempt results list."""
|
|
146
|
+
|
|
147
|
+
items: List[AttemptResult] = Field(
|
|
148
|
+
default_factory=list, description="List of attempt results"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class LocalSearchResults(BaseModel):
|
|
153
|
+
"""Wrapper for local search results list."""
|
|
154
|
+
|
|
155
|
+
items: List[LocalSearchResult] = Field(
|
|
156
|
+
default_factory=list, description="List of local search results"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class LeanSearchResults(BaseModel):
|
|
161
|
+
"""Wrapper for LeanSearch results list."""
|
|
162
|
+
|
|
163
|
+
items: List[LeanSearchResult] = Field(
|
|
164
|
+
default_factory=list, description="List of LeanSearch results"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class LoogleResults(BaseModel):
|
|
169
|
+
"""Wrapper for Loogle results list."""
|
|
170
|
+
|
|
171
|
+
items: List[LoogleResult] = Field(
|
|
172
|
+
default_factory=list, description="List of Loogle results"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class LeanFinderResults(BaseModel):
|
|
177
|
+
"""Wrapper for Lean Finder results list."""
|
|
178
|
+
|
|
179
|
+
items: List[LeanFinderResult] = Field(
|
|
180
|
+
default_factory=list, description="List of Lean Finder results"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class StateSearchResults(BaseModel):
|
|
185
|
+
"""Wrapper for state search results list."""
|
|
186
|
+
|
|
187
|
+
items: List[StateSearchResult] = Field(
|
|
188
|
+
default_factory=list, description="List of state search results"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class PremiseResults(BaseModel):
|
|
193
|
+
"""Wrapper for premise results list."""
|
|
194
|
+
|
|
195
|
+
items: List[PremiseResult] = Field(
|
|
196
|
+
default_factory=list, description="List of premise results"
|
|
197
|
+
)
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Utilities for Lean search tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
import platform
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import threading
|
|
12
|
+
from orjson import loads as _json_loads
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
INSTALL_URL = "https://github.com/BurntSushi/ripgrep#installation"
|
|
17
|
+
|
|
18
|
+
_PLATFORM_INSTRUCTIONS: dict[str, Iterable[str]] = {
|
|
19
|
+
"Windows": (
|
|
20
|
+
"winget install BurntSushi.ripgrep.MSVC",
|
|
21
|
+
"choco install ripgrep",
|
|
22
|
+
),
|
|
23
|
+
"Darwin": ("brew install ripgrep",),
|
|
24
|
+
"Linux": (
|
|
25
|
+
"sudo apt-get install ripgrep",
|
|
26
|
+
"sudo dnf install ripgrep",
|
|
27
|
+
),
|
|
28
|
+
}
|
|
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
|
+
|
|
46
|
+
def check_ripgrep_status() -> tuple[bool, str]:
|
|
47
|
+
"""Check whether ``rg`` is available on PATH and return status + message."""
|
|
48
|
+
|
|
49
|
+
if shutil.which("rg"):
|
|
50
|
+
return True, ""
|
|
51
|
+
|
|
52
|
+
system = platform.system()
|
|
53
|
+
platform_instructions = _PLATFORM_INSTRUCTIONS.get(
|
|
54
|
+
system, ("Check alternative installation methods.",)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
lines = [
|
|
58
|
+
"ripgrep (rg) was not found on your PATH. The lean_local_search tool uses ripgrep for fast declaration search.",
|
|
59
|
+
"",
|
|
60
|
+
"Installation options:",
|
|
61
|
+
*(f" - {item}" for item in platform_instructions),
|
|
62
|
+
f"More installation options: {INSTALL_URL}",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
return False, "\n".join(lines)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def lean_local_search(
|
|
69
|
+
query: str,
|
|
70
|
+
limit: int = 32,
|
|
71
|
+
project_root: Path | None = None,
|
|
72
|
+
) -> list[dict[str, str]]:
|
|
73
|
+
"""Search Lean declarations matching ``query`` using ripgrep; results include theorems, lemmas, defs, classes, instances, structures, inductives, abbrevs, and opaque decls."""
|
|
74
|
+
root = (project_root or Path.cwd()).resolve()
|
|
75
|
+
|
|
76
|
+
pattern = (
|
|
77
|
+
rf"^\s*(?:theorem|lemma|def|axiom|class|instance|structure|inductive|abbrev|opaque)\s+"
|
|
78
|
+
rf"(?:[A-Za-z0-9_'.]+\.)*{re.escape(query)}[A-Za-z0-9_'.]*(?:\s|:)"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
command = [
|
|
82
|
+
"rg",
|
|
83
|
+
"--json",
|
|
84
|
+
"--no-ignore",
|
|
85
|
+
"--smart-case",
|
|
86
|
+
"--hidden",
|
|
87
|
+
"--color",
|
|
88
|
+
"never",
|
|
89
|
+
"--no-messages",
|
|
90
|
+
"-g",
|
|
91
|
+
"*.lean",
|
|
92
|
+
"-g",
|
|
93
|
+
"!.git/**",
|
|
94
|
+
"-g",
|
|
95
|
+
"!.lake/build/**",
|
|
96
|
+
pattern,
|
|
97
|
+
str(root),
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
if lean_src := _get_lean_src_search_path():
|
|
101
|
+
command.append(lean_src)
|
|
102
|
+
|
|
103
|
+
process = _create_ripgrep_process(command, cwd=str(root))
|
|
104
|
+
|
|
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
|
|
112
|
+
|
|
113
|
+
def _drain_stderr(pipe) -> None:
|
|
114
|
+
nonlocal stderr_chars, stderr_truncated
|
|
115
|
+
try:
|
|
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()
|
|
134
|
+
|
|
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
|
|
169
|
+
|
|
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}"
|
|
213
|
+
raise RuntimeError(error_msg)
|
|
214
|
+
|
|
215
|
+
return matches
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@lru_cache(maxsize=1)
|
|
219
|
+
def _get_lean_src_search_path() -> str | None:
|
|
220
|
+
"""Return the Lean stdlib directory, if available (cache once)."""
|
|
221
|
+
try:
|
|
222
|
+
completed = subprocess.run(
|
|
223
|
+
["lean", "--print-prefix"], capture_output=True, text=True
|
|
224
|
+
)
|
|
225
|
+
except (FileNotFoundError, subprocess.CalledProcessError):
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
prefix = completed.stdout.strip()
|
|
229
|
+
if not prefix:
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
candidate = Path(prefix).expanduser().resolve() / "src"
|
|
233
|
+
if candidate.exists():
|
|
234
|
+
return str(candidate)
|
|
235
|
+
|
|
236
|
+
return None
|