lean-lsp-mcp 0.17.2__tar.gz → 0.19.0__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.17.2/src/lean_lsp_mcp.egg-info → lean_lsp_mcp-0.19.0}/PKG-INFO +24 -2
- {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/README.md +21 -0
- {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/pyproject.toml +3 -3
- {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/instructions.py +1 -0
- {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/models.py +29 -1
- lean_lsp_mcp-0.19.0/src/lean_lsp_mcp/profile_utils.py +232 -0
- {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/server.py +196 -38
- {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/utils.py +25 -0
- {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0/src/lean_lsp_mcp.egg-info}/PKG-INFO +24 -2
- {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp.egg-info/SOURCES.txt +2 -0
- {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp.egg-info/requires.txt +2 -1
- lean_lsp_mcp-0.19.0/tests/test_profile.py +103 -0
- {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/LICENSE +0 -0
- {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/setup.cfg +0 -0
- {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/__init__.py +0 -0
- {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/__main__.py +0 -0
- {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/client_utils.py +0 -0
- {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/file_utils.py +0 -0
- {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/loogle.py +0 -0
- {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/outline_utils.py +0 -0
- {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/search_utils.py +0 -0
- {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp.egg-info/dependency_links.txt +0 -0
- {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp.egg-info/entry_points.txt +0 -0
- {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp.egg-info/top_level.txt +0 -0
- {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/tests/test_diagnostic_line_range.py +0 -0
- {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/tests/test_editor_tools.py +0 -0
- {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/tests/test_error_handling.py +0 -0
- {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/tests/test_file_caching.py +0 -0
- {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/tests/test_logging.py +0 -0
- {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/tests/test_misc_tools.py +0 -0
- {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/tests/test_outline.py +0 -0
- {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/tests/test_project_tools.py +0 -0
- {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/tests/test_search_tools.py +0 -0
- {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/tests/test_structured_output.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lean-lsp-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.19.0
|
|
4
4
|
Summary: Lean Theorem Prover MCP
|
|
5
5
|
Author-email: Oliver Dressler <hey@oli.show>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -8,7 +8,7 @@ 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.
|
|
11
|
+
Requires-Dist: leanclient==0.9.1
|
|
12
12
|
Requires-Dist: mcp[cli]==1.25.0
|
|
13
13
|
Requires-Dist: orjson>=3.11.1
|
|
14
14
|
Provides-Extra: lint
|
|
@@ -18,6 +18,7 @@ Requires-Dist: ruff>=0.2.0; extra == "dev"
|
|
|
18
18
|
Requires-Dist: pytest>=8.3; extra == "dev"
|
|
19
19
|
Requires-Dist: anyio>=4.4; extra == "dev"
|
|
20
20
|
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest-timeout>=2.3; extra == "dev"
|
|
21
22
|
Dynamic: license-file
|
|
22
23
|
|
|
23
24
|
<h1 align="center">
|
|
@@ -298,6 +299,27 @@ h_neq : ¬P.card = 2 ^ (Fintype.card S - 1)
|
|
|
298
299
|
```
|
|
299
300
|
</details>
|
|
300
301
|
|
|
302
|
+
#### lean_profile_proof
|
|
303
|
+
|
|
304
|
+
Profile a theorem to identify slow tactics. Runs `lean --profile` on an isolated copy of the theorem and returns per-line timing data.
|
|
305
|
+
|
|
306
|
+
<details>
|
|
307
|
+
<summary>Example output (profiling a theorem using simp)</summary>
|
|
308
|
+
|
|
309
|
+
```json
|
|
310
|
+
{
|
|
311
|
+
"ms": 42.5,
|
|
312
|
+
"lines": [
|
|
313
|
+
{"line": 7, "ms": 38.2, "text": "simp [add_comm, add_assoc]"}
|
|
314
|
+
],
|
|
315
|
+
"categories": {
|
|
316
|
+
"simp": 35.1,
|
|
317
|
+
"typeclass inference": 4.2
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
</details>
|
|
322
|
+
|
|
301
323
|
### Local Search Tools
|
|
302
324
|
|
|
303
325
|
#### lean_local_search
|
|
@@ -276,6 +276,27 @@ h_neq : ¬P.card = 2 ^ (Fintype.card S - 1)
|
|
|
276
276
|
```
|
|
277
277
|
</details>
|
|
278
278
|
|
|
279
|
+
#### lean_profile_proof
|
|
280
|
+
|
|
281
|
+
Profile a theorem to identify slow tactics. Runs `lean --profile` on an isolated copy of the theorem and returns per-line timing data.
|
|
282
|
+
|
|
283
|
+
<details>
|
|
284
|
+
<summary>Example output (profiling a theorem using simp)</summary>
|
|
285
|
+
|
|
286
|
+
```json
|
|
287
|
+
{
|
|
288
|
+
"ms": 42.5,
|
|
289
|
+
"lines": [
|
|
290
|
+
{"line": 7, "ms": 38.2, "text": "simp [add_comm, add_assoc]"}
|
|
291
|
+
],
|
|
292
|
+
"categories": {
|
|
293
|
+
"simp": 35.1,
|
|
294
|
+
"typeclass inference": 4.2
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
</details>
|
|
299
|
+
|
|
279
300
|
### Local Search Tools
|
|
280
301
|
|
|
281
302
|
#### lean_local_search
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "lean-lsp-mcp"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.19.0"
|
|
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
|
-
dependencies = ["leanclient==0.
|
|
9
|
+
dependencies = ["leanclient==0.9.1", "mcp[cli]==1.25.0", "orjson>=3.11.1"]
|
|
10
10
|
|
|
11
11
|
[project.urls]
|
|
12
12
|
Repository = "https://github.com/oOo0oOo/lean-lsp-mcp"
|
|
13
13
|
|
|
14
14
|
[project.optional-dependencies]
|
|
15
15
|
lint = ["ruff>=0.2.0"]
|
|
16
|
-
dev = ["ruff>=0.2.0", "pytest>=8.3", "anyio>=4.4", "pytest-asyncio>=0.23"]
|
|
16
|
+
dev = ["ruff>=0.2.0", "pytest>=8.3", "anyio>=4.4", "pytest-asyncio>=0.23", "pytest-timeout>=2.3"]
|
|
17
17
|
|
|
18
18
|
[tool.pytest.ini_options]
|
|
19
19
|
asyncio_mode = "auto"
|
|
@@ -13,6 +13,7 @@ INSTRUCTIONS = """## General Rules
|
|
|
13
13
|
- **lean_declaration_file**: Get declaration source. Use sparingly (large output).
|
|
14
14
|
- **lean_run_code**: Run standalone snippet. Use rarely.
|
|
15
15
|
- **lean_build**: Rebuild + restart LSP. Only if needed (new imports). SLOW!
|
|
16
|
+
- **lean_profile_proof**: Profile a theorem for performance. Shows tactic hotspots. SLOW!
|
|
16
17
|
|
|
17
18
|
## Search Tools (rate limited)
|
|
18
19
|
- **lean_leansearch** (3/30s): Natural language → mathlib
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Pydantic models for MCP tool structured outputs."""
|
|
2
2
|
|
|
3
3
|
from typing import List, Optional
|
|
4
|
+
|
|
4
5
|
from pydantic import BaseModel, Field
|
|
5
6
|
|
|
6
7
|
|
|
@@ -134,11 +135,18 @@ class DeclarationInfo(BaseModel):
|
|
|
134
135
|
|
|
135
136
|
|
|
136
137
|
class DiagnosticsResult(BaseModel):
|
|
137
|
-
"""Wrapper for diagnostic messages list."""
|
|
138
|
+
"""Wrapper for diagnostic messages list with build status."""
|
|
138
139
|
|
|
140
|
+
success: bool = Field(
|
|
141
|
+
True, description="True if the queried file/range has no errors"
|
|
142
|
+
)
|
|
139
143
|
items: List[DiagnosticMessage] = Field(
|
|
140
144
|
default_factory=list, description="List of diagnostic messages"
|
|
141
145
|
)
|
|
146
|
+
failed_dependencies: List[str] = Field(
|
|
147
|
+
default_factory=list,
|
|
148
|
+
description="File paths of dependencies that failed to build",
|
|
149
|
+
)
|
|
142
150
|
|
|
143
151
|
|
|
144
152
|
class CompletionsResult(BaseModel):
|
|
@@ -203,3 +211,23 @@ class PremiseResults(BaseModel):
|
|
|
203
211
|
items: List[PremiseResult] = Field(
|
|
204
212
|
default_factory=list, description="List of premise results"
|
|
205
213
|
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class LineProfile(BaseModel):
|
|
217
|
+
"""Timing for a single source line."""
|
|
218
|
+
|
|
219
|
+
line: int = Field(description="Source line number (1-indexed)")
|
|
220
|
+
ms: float = Field(description="Time in milliseconds")
|
|
221
|
+
text: str = Field(description="Source line content (truncated)")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class ProofProfileResult(BaseModel):
|
|
225
|
+
"""Profiling result for a theorem."""
|
|
226
|
+
|
|
227
|
+
ms: float = Field(description="Total elaboration time in ms")
|
|
228
|
+
lines: List[LineProfile] = Field(
|
|
229
|
+
default_factory=list, description="Time per source line (>1% of total)"
|
|
230
|
+
)
|
|
231
|
+
categories: dict[str, float] = Field(
|
|
232
|
+
default_factory=dict, description="Cumulative time by category in ms"
|
|
233
|
+
)
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Lean proof profiling via CLI trace output."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import tempfile
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from lean_lsp_mcp.models import LineProfile, ProofProfileResult
|
|
11
|
+
|
|
12
|
+
_TRACE_RE = re.compile(r"^(\s*)\[([^\]]+)\]\s+\[([\d.]+)\]\s+(.+)$")
|
|
13
|
+
_CUMULATIVE_RE = re.compile(r"^\s+(\S+(?:\s+\S+)*)\s+([\d.]+)(ms|s)$")
|
|
14
|
+
_DECL_RE = re.compile(r"^\s*(?:private\s+)?(theorem|lemma|def)\s+(\S+)")
|
|
15
|
+
_HEADER_RE = re.compile(r"^(import|open|set_option|universe|variable)\s")
|
|
16
|
+
_SKIP_CATEGORIES = {"import", "initialization", "parsing", "interpretation", "linting"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _find_header_end(lines: list[str]) -> int:
|
|
20
|
+
"""Find where imports/header ends and declarations begin."""
|
|
21
|
+
header_end, in_block = 0, False
|
|
22
|
+
for i, line in enumerate(lines):
|
|
23
|
+
s = line.strip()
|
|
24
|
+
if "/-" in line:
|
|
25
|
+
in_block = True
|
|
26
|
+
if "-/" in line:
|
|
27
|
+
in_block = False
|
|
28
|
+
if in_block or not s or s.startswith("--") or _HEADER_RE.match(line):
|
|
29
|
+
header_end = i + 1
|
|
30
|
+
elif s.startswith(("namespace", "section")):
|
|
31
|
+
header_end = i + 1
|
|
32
|
+
elif _DECL_RE.match(line) or s.startswith(("@[", "private ", "protected ")):
|
|
33
|
+
break
|
|
34
|
+
else:
|
|
35
|
+
header_end = i + 1
|
|
36
|
+
return header_end
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _find_theorem_end(lines: list[str], start: int) -> int:
|
|
40
|
+
"""Find where theorem ends (next declaration or EOF)."""
|
|
41
|
+
for i in range(start + 1, len(lines)):
|
|
42
|
+
if _DECL_RE.match(lines[i]):
|
|
43
|
+
return i
|
|
44
|
+
return len(lines)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _extract_theorem_source(lines: list[str], target_line: int) -> tuple[str, str, int]:
|
|
48
|
+
"""Extract imports/header + single theorem. Returns (source, name, theorem_start_in_source)."""
|
|
49
|
+
m = _DECL_RE.match(lines[target_line - 1])
|
|
50
|
+
if not m:
|
|
51
|
+
raise ValueError(f"No theorem/lemma/def at line {target_line}")
|
|
52
|
+
|
|
53
|
+
header_end = _find_header_end(lines)
|
|
54
|
+
theorem_end = _find_theorem_end(lines, target_line - 1)
|
|
55
|
+
|
|
56
|
+
header = "\n".join(lines[:header_end])
|
|
57
|
+
theorem = "\n".join(lines[target_line - 1 : theorem_end])
|
|
58
|
+
return f"{header}\n\n{theorem}\n", m.group(2), header_end + 2
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _parse_output(
|
|
62
|
+
output: str,
|
|
63
|
+
) -> tuple[list[tuple[int, str, float, str]], dict[str, float]]:
|
|
64
|
+
"""Parse trace output into (traces, cumulative). Traces are (depth, cls, ms, msg)."""
|
|
65
|
+
traces, cumulative, in_cumulative = [], {}, False
|
|
66
|
+
|
|
67
|
+
for line in output.splitlines():
|
|
68
|
+
if "cumulative profiling times:" in line:
|
|
69
|
+
in_cumulative = True
|
|
70
|
+
elif in_cumulative and (m := _CUMULATIVE_RE.match(line)):
|
|
71
|
+
cat, val, unit = m.groups()
|
|
72
|
+
cumulative[cat] = float(val) * (1000 if unit == "s" else 1)
|
|
73
|
+
elif not in_cumulative and (m := _TRACE_RE.match(line)):
|
|
74
|
+
indent, cls, time_s, msg = m.groups()
|
|
75
|
+
traces.append((len(indent) // 2, cls, float(time_s) * 1000, msg))
|
|
76
|
+
|
|
77
|
+
return traces, cumulative
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _build_proof_items(
|
|
81
|
+
source_lines: list[str], proof_start: int
|
|
82
|
+
) -> list[tuple[int, str, bool]]:
|
|
83
|
+
"""Build list of (line_no, content, is_bullet) for proof lines."""
|
|
84
|
+
items = []
|
|
85
|
+
for i in range(proof_start, len(source_lines)):
|
|
86
|
+
s = source_lines[i].strip()
|
|
87
|
+
if s and not s.startswith("--"):
|
|
88
|
+
is_bullet = s[0] in "·*-"
|
|
89
|
+
items.append((i + 1, s.lstrip("·*- \t"), is_bullet))
|
|
90
|
+
return items
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _match_line(
|
|
94
|
+
tactic: str, is_bullet: bool, items: list[tuple[int, str, bool]], used: set[int]
|
|
95
|
+
) -> int | None:
|
|
96
|
+
"""Find matching source line for a tactic trace. Returns line number or None."""
|
|
97
|
+
for ln, content, src_bullet in items:
|
|
98
|
+
if ln in used:
|
|
99
|
+
continue
|
|
100
|
+
if is_bullet and src_bullet:
|
|
101
|
+
return ln
|
|
102
|
+
if (
|
|
103
|
+
not is_bullet
|
|
104
|
+
and content
|
|
105
|
+
and (tactic.startswith(content[:25]) or content.startswith(tactic[:25]))
|
|
106
|
+
):
|
|
107
|
+
return ln
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _extract_line_times(
|
|
112
|
+
traces: list[tuple[int, str, float, str]],
|
|
113
|
+
name: str,
|
|
114
|
+
proof_items: list[tuple[int, str, bool]],
|
|
115
|
+
) -> tuple[dict[int, float], float]:
|
|
116
|
+
"""Extract per-line timing from traces."""
|
|
117
|
+
line_times: dict[int, float] = defaultdict(float)
|
|
118
|
+
total, value_depth, in_value, tactic_depth = 0.0, 0, False, None
|
|
119
|
+
name_re = re.compile(rf"\b{re.escape(name)}\b")
|
|
120
|
+
used: set[int] = set()
|
|
121
|
+
|
|
122
|
+
for depth, cls, ms, msg in traces:
|
|
123
|
+
if cls == "Elab.definition.value" and name_re.search(msg):
|
|
124
|
+
in_value, value_depth, total = True, depth, ms
|
|
125
|
+
elif cls == "Elab.async" and f"proof of {name}" in msg:
|
|
126
|
+
total = max(total, ms)
|
|
127
|
+
elif in_value:
|
|
128
|
+
if depth <= value_depth:
|
|
129
|
+
break
|
|
130
|
+
if cls == "Elab.step" and not msg.startswith("expected type:"):
|
|
131
|
+
tactic_depth = tactic_depth or depth
|
|
132
|
+
if depth == tactic_depth:
|
|
133
|
+
tactic = msg.split("\n")[0].strip().lstrip("·*- \t")
|
|
134
|
+
if ln := _match_line(tactic, not tactic, proof_items, used):
|
|
135
|
+
line_times[ln] += ms
|
|
136
|
+
used.add(ln)
|
|
137
|
+
|
|
138
|
+
return dict(line_times), total
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _filter_categories(cumulative: dict[str, float]) -> dict[str, float]:
|
|
142
|
+
"""Filter to relevant categories >= 1ms."""
|
|
143
|
+
return {
|
|
144
|
+
k: round(v, 1)
|
|
145
|
+
for k, v in sorted(cumulative.items(), key=lambda x: -x[1])
|
|
146
|
+
if k not in _SKIP_CATEGORIES and v >= 1.0
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def _run_lean_profile(file_path: Path, project_path: Path, timeout: float) -> str:
|
|
151
|
+
"""Run lean --profile, return output."""
|
|
152
|
+
proc = await asyncio.create_subprocess_exec(
|
|
153
|
+
"lake",
|
|
154
|
+
"env",
|
|
155
|
+
"lean",
|
|
156
|
+
"--profile",
|
|
157
|
+
"-Dtrace.profiler=true",
|
|
158
|
+
"-Dtrace.profiler.threshold=0",
|
|
159
|
+
str(file_path.resolve()),
|
|
160
|
+
stdout=asyncio.subprocess.PIPE,
|
|
161
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
162
|
+
cwd=project_path.resolve(),
|
|
163
|
+
env=os.environ.copy(),
|
|
164
|
+
)
|
|
165
|
+
try:
|
|
166
|
+
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
167
|
+
return stdout.decode("utf-8", errors="replace")
|
|
168
|
+
except asyncio.TimeoutError:
|
|
169
|
+
proc.kill()
|
|
170
|
+
await proc.wait()
|
|
171
|
+
raise TimeoutError(f"Profiling timed out after {timeout}s")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _find_proof_start(source_lines: list[str]) -> int:
|
|
175
|
+
"""Find line after ':= by' in source."""
|
|
176
|
+
for i, line in enumerate(source_lines):
|
|
177
|
+
if ":= by" in line or line.rstrip().endswith(" by"):
|
|
178
|
+
return i + 1
|
|
179
|
+
raise ValueError("No 'by' proof found in theorem")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def profile_theorem(
|
|
183
|
+
file_path: Path,
|
|
184
|
+
theorem_line: int,
|
|
185
|
+
project_path: Path,
|
|
186
|
+
timeout: float = 60.0,
|
|
187
|
+
top_n: int = 5,
|
|
188
|
+
) -> ProofProfileResult:
|
|
189
|
+
"""Profile a theorem via `lean --profile`. Returns per-line timing data."""
|
|
190
|
+
lines = file_path.read_text().splitlines()
|
|
191
|
+
if not (0 < theorem_line <= len(lines)):
|
|
192
|
+
raise ValueError(f"Line {theorem_line} out of range")
|
|
193
|
+
|
|
194
|
+
source, name, src_start = _extract_theorem_source(lines, theorem_line)
|
|
195
|
+
source_lines = source.splitlines()
|
|
196
|
+
line_offset = theorem_line - src_start
|
|
197
|
+
proof_start = _find_proof_start(source_lines)
|
|
198
|
+
proof_items = _build_proof_items(source_lines, proof_start)
|
|
199
|
+
|
|
200
|
+
with tempfile.NamedTemporaryFile(
|
|
201
|
+
mode="w", suffix=".lean", dir=project_path, delete=False
|
|
202
|
+
) as f:
|
|
203
|
+
f.write(source)
|
|
204
|
+
temp_path = Path(f.name)
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
output = await _run_lean_profile(temp_path, project_path, timeout)
|
|
208
|
+
finally:
|
|
209
|
+
temp_path.unlink(missing_ok=True)
|
|
210
|
+
|
|
211
|
+
traces, cumulative = _parse_output(output)
|
|
212
|
+
line_times, total = _extract_line_times(traces, name, proof_items)
|
|
213
|
+
|
|
214
|
+
top_lines = sorted(
|
|
215
|
+
[(ln, ms) for ln, ms in line_times.items() if ms >= total * 0.01],
|
|
216
|
+
key=lambda x: -x[1],
|
|
217
|
+
)[:top_n]
|
|
218
|
+
|
|
219
|
+
return ProofProfileResult(
|
|
220
|
+
ms=round(total, 1),
|
|
221
|
+
lines=[
|
|
222
|
+
LineProfile(
|
|
223
|
+
line=ln + line_offset,
|
|
224
|
+
ms=round(ms, 1),
|
|
225
|
+
text=source_lines[ln - 1].strip()[:60]
|
|
226
|
+
if ln <= len(source_lines)
|
|
227
|
+
else "",
|
|
228
|
+
)
|
|
229
|
+
for ln, ms in top_lines
|
|
230
|
+
],
|
|
231
|
+
categories=_filter_categories(cumulative),
|
|
232
|
+
)
|
|
@@ -50,6 +50,7 @@ from lean_lsp_mcp.models import (
|
|
|
50
50
|
MultiAttemptResult,
|
|
51
51
|
PremiseResult,
|
|
52
52
|
PremiseResults,
|
|
53
|
+
ProofProfileResult,
|
|
53
54
|
RunResult,
|
|
54
55
|
StateSearchResult,
|
|
55
56
|
StateSearchResults,
|
|
@@ -64,17 +65,38 @@ from lean_lsp_mcp.utils import (
|
|
|
64
65
|
OutputCapture,
|
|
65
66
|
check_lsp_response,
|
|
66
67
|
deprecated,
|
|
68
|
+
extract_failed_dependency_paths,
|
|
67
69
|
extract_goals_list,
|
|
68
70
|
extract_range,
|
|
69
71
|
filter_diagnostics_by_position,
|
|
70
72
|
find_start_position,
|
|
71
73
|
get_declaration_range,
|
|
74
|
+
is_build_stderr,
|
|
72
75
|
)
|
|
73
76
|
|
|
74
77
|
# LSP Diagnostic severity: 1=error, 2=warning, 3=info, 4=hint
|
|
75
78
|
DIAGNOSTIC_SEVERITY: Dict[int, str] = {1: "error", 2: "warning", 3: "info", 4: "hint"}
|
|
76
79
|
|
|
77
80
|
|
|
81
|
+
async def _urlopen_json(req: urllib.request.Request, timeout: float):
|
|
82
|
+
"""Run urllib.request.urlopen in a worker thread to avoid blocking the event loop."""
|
|
83
|
+
|
|
84
|
+
def _do_request():
|
|
85
|
+
with urllib.request.urlopen(req, timeout=timeout) as response:
|
|
86
|
+
return orjson.loads(response.read())
|
|
87
|
+
|
|
88
|
+
return await asyncio.to_thread(_do_request)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def _safe_report_progress(
|
|
92
|
+
ctx: Context, *, progress: int, total: int, message: str
|
|
93
|
+
) -> None:
|
|
94
|
+
try:
|
|
95
|
+
await ctx.report_progress(progress=progress, total=total, message=message)
|
|
96
|
+
except Exception:
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
|
|
78
100
|
_LOG_LEVEL = os.environ.get("LEAN_LOG_LEVEL", "INFO")
|
|
79
101
|
configure_logging("CRITICAL" if _LOG_LEVEL == "NONE" else _LOG_LEVEL)
|
|
80
102
|
logger = get_logger(__name__)
|
|
@@ -164,8 +186,7 @@ mcp = FastMCP(**mcp_kwargs)
|
|
|
164
186
|
|
|
165
187
|
def rate_limited(category: str, max_requests: int, per_seconds: int):
|
|
166
188
|
def decorator(func):
|
|
167
|
-
|
|
168
|
-
def wrapper(*args, **kwargs):
|
|
189
|
+
def _apply_rate_limit(args, kwargs):
|
|
169
190
|
ctx = kwargs.get("ctx")
|
|
170
191
|
if ctx is None:
|
|
171
192
|
if not args:
|
|
@@ -181,11 +202,33 @@ def rate_limited(category: str, max_requests: int, per_seconds: int):
|
|
|
181
202
|
if timestamp > current_time - per_seconds
|
|
182
203
|
]
|
|
183
204
|
if len(rate_limit[category]) >= max_requests:
|
|
184
|
-
return
|
|
205
|
+
return (
|
|
206
|
+
False,
|
|
207
|
+
f"Tool limit exceeded: {max_requests} requests per {per_seconds} s. Try again later.",
|
|
208
|
+
)
|
|
185
209
|
rate_limit[category].append(current_time)
|
|
186
|
-
return
|
|
210
|
+
return True, None
|
|
211
|
+
|
|
212
|
+
if asyncio.iscoroutinefunction(func):
|
|
187
213
|
|
|
188
|
-
|
|
214
|
+
@functools.wraps(func)
|
|
215
|
+
async def wrapper(*args, **kwargs):
|
|
216
|
+
allowed, msg = _apply_rate_limit(args, kwargs)
|
|
217
|
+
if not allowed:
|
|
218
|
+
return msg
|
|
219
|
+
return await func(*args, **kwargs)
|
|
220
|
+
|
|
221
|
+
else:
|
|
222
|
+
|
|
223
|
+
@functools.wraps(func)
|
|
224
|
+
def wrapper(*args, **kwargs):
|
|
225
|
+
allowed, msg = _apply_rate_limit(args, kwargs)
|
|
226
|
+
if not allowed:
|
|
227
|
+
return msg
|
|
228
|
+
return func(*args, **kwargs)
|
|
229
|
+
|
|
230
|
+
doc = wrapper.__doc__ or ""
|
|
231
|
+
wrapper.__doc__ = f"Limit: {max_requests}req/{per_seconds}s. {doc}"
|
|
189
232
|
return wrapper
|
|
190
233
|
|
|
191
234
|
return decorator
|
|
@@ -375,6 +418,7 @@ def file_outline(
|
|
|
375
418
|
|
|
376
419
|
|
|
377
420
|
def _to_diagnostic_messages(diagnostics: List[Dict]) -> List[DiagnosticMessage]:
|
|
421
|
+
"""Convert LSP diagnostics to DiagnosticMessage models."""
|
|
378
422
|
result = []
|
|
379
423
|
for diag in diagnostics:
|
|
380
424
|
r = diag.get("fullRange", diag.get("range"))
|
|
@@ -394,6 +438,52 @@ def _to_diagnostic_messages(diagnostics: List[Dict]) -> List[DiagnosticMessage]:
|
|
|
394
438
|
return result
|
|
395
439
|
|
|
396
440
|
|
|
441
|
+
def _process_diagnostics(
|
|
442
|
+
diagnostics: List[Dict], build_success: bool
|
|
443
|
+
) -> DiagnosticsResult:
|
|
444
|
+
"""Process diagnostics, extracting dependency paths from build stderr.
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
diagnostics: List of diagnostic dicts from leanclient
|
|
448
|
+
build_success: Whether the build succeeded (from leanclient.DiagnosticsResult.success)
|
|
449
|
+
"""
|
|
450
|
+
items = []
|
|
451
|
+
failed_deps: List[str] = []
|
|
452
|
+
|
|
453
|
+
for diag in diagnostics:
|
|
454
|
+
r = diag.get("fullRange", diag.get("range"))
|
|
455
|
+
if r is None:
|
|
456
|
+
continue
|
|
457
|
+
|
|
458
|
+
severity_int = diag.get("severity", 1)
|
|
459
|
+
message = diag.get("message", "")
|
|
460
|
+
line = r["start"]["line"] + 1
|
|
461
|
+
column = r["start"]["character"] + 1
|
|
462
|
+
|
|
463
|
+
# Check if this is a build failure at (1,1) - extract dependency paths, skip the item
|
|
464
|
+
if line == 1 and column == 1 and is_build_stderr(message):
|
|
465
|
+
failed_deps = extract_failed_dependency_paths(message)
|
|
466
|
+
continue # Don't include the build stderr blob as a diagnostic item
|
|
467
|
+
|
|
468
|
+
# Normal diagnostic from the queried file
|
|
469
|
+
items.append(
|
|
470
|
+
DiagnosticMessage(
|
|
471
|
+
severity=DIAGNOSTIC_SEVERITY.get(
|
|
472
|
+
severity_int, f"unknown({severity_int})"
|
|
473
|
+
),
|
|
474
|
+
message=message,
|
|
475
|
+
line=line,
|
|
476
|
+
column=column,
|
|
477
|
+
)
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
return DiagnosticsResult(
|
|
481
|
+
success=build_success,
|
|
482
|
+
items=items,
|
|
483
|
+
failed_dependencies=failed_deps,
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
|
|
397
487
|
@mcp.tool(
|
|
398
488
|
"lean_diagnostic_messages",
|
|
399
489
|
annotations=ToolAnnotations(
|
|
@@ -437,15 +527,14 @@ def diagnostic_messages(
|
|
|
437
527
|
start_line_0 = (start_line - 1) if start_line is not None else None
|
|
438
528
|
end_line_0 = (end_line - 1) if end_line is not None else None
|
|
439
529
|
|
|
440
|
-
|
|
530
|
+
result = client.get_diagnostics(
|
|
441
531
|
rel_path,
|
|
442
532
|
start_line=start_line_0,
|
|
443
533
|
end_line=end_line_0,
|
|
444
534
|
inactivity_timeout=15.0,
|
|
445
535
|
)
|
|
446
|
-
check_lsp_response(diagnostics, "get_diagnostics")
|
|
447
536
|
|
|
448
|
-
return
|
|
537
|
+
return _process_diagnostics(result.diagnostics, result.success)
|
|
449
538
|
|
|
450
539
|
|
|
451
540
|
@mcp.tool(
|
|
@@ -880,7 +969,7 @@ class LocalSearchError(Exception):
|
|
|
880
969
|
openWorldHint=False,
|
|
881
970
|
),
|
|
882
971
|
)
|
|
883
|
-
def local_search(
|
|
972
|
+
async def local_search(
|
|
884
973
|
ctx: Context,
|
|
885
974
|
query: Annotated[str, Field(description="Declaration name or prefix")],
|
|
886
975
|
limit: Annotated[int, Field(description="Max matches", ge=1)] = 10,
|
|
@@ -912,8 +1001,11 @@ def local_search(
|
|
|
912
1001
|
)
|
|
913
1002
|
|
|
914
1003
|
try:
|
|
915
|
-
raw_results =
|
|
916
|
-
|
|
1004
|
+
raw_results = await asyncio.to_thread(
|
|
1005
|
+
lean_local_search,
|
|
1006
|
+
query=query.strip(),
|
|
1007
|
+
limit=limit,
|
|
1008
|
+
project_root=resolved_root,
|
|
917
1009
|
)
|
|
918
1010
|
results = [
|
|
919
1011
|
LocalSearchResult(name=r["name"], kind=r["kind"], file=r["file"])
|
|
@@ -934,7 +1026,7 @@ def local_search(
|
|
|
934
1026
|
),
|
|
935
1027
|
)
|
|
936
1028
|
@rate_limited("leansearch", max_requests=3, per_seconds=30)
|
|
937
|
-
def leansearch(
|
|
1029
|
+
async def leansearch(
|
|
938
1030
|
ctx: Context,
|
|
939
1031
|
query: Annotated[str, Field(description="Natural language or Lean term query")],
|
|
940
1032
|
num_results: Annotated[int, Field(description="Max results", ge=1)] = 5,
|
|
@@ -954,8 +1046,10 @@ def leansearch(
|
|
|
954
1046
|
method="POST",
|
|
955
1047
|
)
|
|
956
1048
|
|
|
957
|
-
|
|
958
|
-
|
|
1049
|
+
await _safe_report_progress(
|
|
1050
|
+
ctx, progress=1, total=10, message="Awaiting response from leansearch.net"
|
|
1051
|
+
)
|
|
1052
|
+
results = await _urlopen_json(req, timeout=10)
|
|
959
1053
|
|
|
960
1054
|
if not results or not results[0]:
|
|
961
1055
|
return LeanSearchResults(items=[])
|
|
@@ -1029,7 +1123,13 @@ async def loogle(
|
|
|
1029
1123
|
)
|
|
1030
1124
|
rate_limit.append(now)
|
|
1031
1125
|
|
|
1032
|
-
|
|
1126
|
+
await _safe_report_progress(
|
|
1127
|
+
ctx,
|
|
1128
|
+
progress=1,
|
|
1129
|
+
total=10,
|
|
1130
|
+
message="Awaiting response from loogle.lean-lang.org",
|
|
1131
|
+
)
|
|
1132
|
+
result = await asyncio.to_thread(loogle_remote, query, num_results)
|
|
1033
1133
|
if isinstance(result, str):
|
|
1034
1134
|
raise LeanToolError(result) # Error message from remote
|
|
1035
1135
|
return LoogleResults(items=result)
|
|
@@ -1045,7 +1145,7 @@ async def loogle(
|
|
|
1045
1145
|
),
|
|
1046
1146
|
)
|
|
1047
1147
|
@rate_limited("leanfinder", max_requests=10, per_seconds=30)
|
|
1048
|
-
def leanfinder(
|
|
1148
|
+
async def leanfinder(
|
|
1049
1149
|
ctx: Context,
|
|
1050
1150
|
query: Annotated[str, Field(description="Mathematical concept or proof state")],
|
|
1051
1151
|
num_results: Annotated[int, Field(description="Max results", ge=1)] = 5,
|
|
@@ -1063,23 +1163,27 @@ def leanfinder(
|
|
|
1063
1163
|
)
|
|
1064
1164
|
|
|
1065
1165
|
results: List[LeanFinderResult] = []
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1166
|
+
await _safe_report_progress(
|
|
1167
|
+
ctx,
|
|
1168
|
+
progress=1,
|
|
1169
|
+
total=10,
|
|
1170
|
+
message="Awaiting response from Lean Finder (Hugging Face)",
|
|
1171
|
+
)
|
|
1172
|
+
data = await _urlopen_json(req, timeout=10)
|
|
1173
|
+
for result in data["results"]:
|
|
1174
|
+
if (
|
|
1175
|
+
"https://leanprover-community.github.io/mathlib4_docs" not in result["url"]
|
|
1176
|
+
): # Only include mathlib4 results
|
|
1177
|
+
continue
|
|
1178
|
+
match = re.search(r"pattern=(.*?)#doc", result["url"])
|
|
1179
|
+
if match:
|
|
1180
|
+
results.append(
|
|
1181
|
+
LeanFinderResult(
|
|
1182
|
+
full_name=match.group(1),
|
|
1183
|
+
formal_statement=result["formal_statement"],
|
|
1184
|
+
informal_statement=result["informal_statement"],
|
|
1082
1185
|
)
|
|
1186
|
+
)
|
|
1083
1187
|
|
|
1084
1188
|
return LeanFinderResults(items=results)
|
|
1085
1189
|
|
|
@@ -1094,7 +1198,7 @@ def leanfinder(
|
|
|
1094
1198
|
),
|
|
1095
1199
|
)
|
|
1096
1200
|
@rate_limited("lean_state_search", max_requests=3, per_seconds=30)
|
|
1097
|
-
def state_search(
|
|
1201
|
+
async def state_search(
|
|
1098
1202
|
ctx: Context,
|
|
1099
1203
|
file_path: Annotated[str, Field(description="Absolute path to Lean file")],
|
|
1100
1204
|
line: Annotated[int, Field(description="Line number (1-indexed)", ge=1)],
|
|
@@ -1126,8 +1230,10 @@ def state_search(
|
|
|
1126
1230
|
method="GET",
|
|
1127
1231
|
)
|
|
1128
1232
|
|
|
1129
|
-
|
|
1130
|
-
|
|
1233
|
+
await _safe_report_progress(
|
|
1234
|
+
ctx, progress=1, total=10, message=f"Awaiting response from {url}"
|
|
1235
|
+
)
|
|
1236
|
+
results = await _urlopen_json(req, timeout=10)
|
|
1131
1237
|
|
|
1132
1238
|
items = [StateSearchResult(name=r["name"]) for r in results]
|
|
1133
1239
|
return StateSearchResults(items=items)
|
|
@@ -1143,7 +1249,7 @@ def state_search(
|
|
|
1143
1249
|
),
|
|
1144
1250
|
)
|
|
1145
1251
|
@rate_limited("hammer_premise", max_requests=3, per_seconds=30)
|
|
1146
|
-
def hammer_premise(
|
|
1252
|
+
async def hammer_premise(
|
|
1147
1253
|
ctx: Context,
|
|
1148
1254
|
file_path: Annotated[str, Field(description="Absolute path to Lean file")],
|
|
1149
1255
|
line: Annotated[int, Field(description="Line number (1-indexed)", ge=1)],
|
|
@@ -1186,12 +1292,64 @@ def hammer_premise(
|
|
|
1186
1292
|
data=orjson.dumps(data),
|
|
1187
1293
|
)
|
|
1188
1294
|
|
|
1189
|
-
|
|
1190
|
-
|
|
1295
|
+
await _safe_report_progress(
|
|
1296
|
+
ctx, progress=1, total=10, message=f"Awaiting response from {url}"
|
|
1297
|
+
)
|
|
1298
|
+
results = await _urlopen_json(req, timeout=10)
|
|
1191
1299
|
|
|
1192
1300
|
items = [PremiseResult(name=r["name"]) for r in results]
|
|
1193
1301
|
return PremiseResults(items=items)
|
|
1194
1302
|
|
|
1195
1303
|
|
|
1304
|
+
@mcp.tool(
|
|
1305
|
+
"lean_profile_proof",
|
|
1306
|
+
annotations=ToolAnnotations(
|
|
1307
|
+
title="Profile Proof",
|
|
1308
|
+
readOnlyHint=True,
|
|
1309
|
+
idempotentHint=True,
|
|
1310
|
+
openWorldHint=False,
|
|
1311
|
+
),
|
|
1312
|
+
)
|
|
1313
|
+
async def profile_proof(
|
|
1314
|
+
ctx: Context,
|
|
1315
|
+
file_path: Annotated[str, Field(description="Absolute path to Lean file")],
|
|
1316
|
+
line: Annotated[
|
|
1317
|
+
int, Field(description="Line where theorem starts (1-indexed)", ge=1)
|
|
1318
|
+
],
|
|
1319
|
+
top_n: Annotated[
|
|
1320
|
+
int, Field(description="Number of slowest lines to return", ge=1)
|
|
1321
|
+
] = 5,
|
|
1322
|
+
timeout: Annotated[float, Field(description="Max seconds to wait", ge=1)] = 60.0,
|
|
1323
|
+
) -> ProofProfileResult:
|
|
1324
|
+
"""Run `lean --profile` on a theorem. Returns per-line timing and categories."""
|
|
1325
|
+
from lean_lsp_mcp.profile_utils import profile_theorem
|
|
1326
|
+
|
|
1327
|
+
# Get project path
|
|
1328
|
+
lifespan = ctx.request_context.lifespan_context
|
|
1329
|
+
project_path = lifespan.lean_project_path
|
|
1330
|
+
|
|
1331
|
+
if not project_path:
|
|
1332
|
+
infer_project_path(ctx, file_path)
|
|
1333
|
+
project_path = lifespan.lean_project_path
|
|
1334
|
+
|
|
1335
|
+
if not project_path:
|
|
1336
|
+
raise LeanToolError("Lean project not found")
|
|
1337
|
+
|
|
1338
|
+
file_path_obj = Path(file_path)
|
|
1339
|
+
if not file_path_obj.exists():
|
|
1340
|
+
raise LeanToolError(f"File not found: {file_path}")
|
|
1341
|
+
|
|
1342
|
+
try:
|
|
1343
|
+
return await profile_theorem(
|
|
1344
|
+
file_path=file_path_obj,
|
|
1345
|
+
theorem_line=line,
|
|
1346
|
+
project_path=project_path,
|
|
1347
|
+
timeout=timeout,
|
|
1348
|
+
top_n=top_n,
|
|
1349
|
+
)
|
|
1350
|
+
except (ValueError, TimeoutError) as e:
|
|
1351
|
+
raise LeanToolError(str(e)) from e
|
|
1352
|
+
|
|
1353
|
+
|
|
1196
1354
|
if __name__ == "__main__":
|
|
1197
1355
|
mcp.run()
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import os
|
|
2
|
+
import re
|
|
2
3
|
import secrets
|
|
3
4
|
import sys
|
|
4
5
|
import tempfile
|
|
@@ -7,6 +8,30 @@ from typing import Any, List, Dict, Optional, Callable
|
|
|
7
8
|
from mcp.server.auth.provider import AccessToken, TokenVerifier
|
|
8
9
|
|
|
9
10
|
|
|
11
|
+
# Pattern to extract file paths from build stderr: "error: path/file.lean:line:col: message"
|
|
12
|
+
BUILD_ERROR_FILE_PATTERN = re.compile(
|
|
13
|
+
r"^(?:error|warning):\s*([^\s:]+\.lean):\d+:\d+:",
|
|
14
|
+
re.MULTILINE | re.IGNORECASE,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def extract_failed_dependency_paths(message: str) -> List[str]:
|
|
19
|
+
"""Extract unique file paths from lake build stderr output.
|
|
20
|
+
|
|
21
|
+
Returns sorted list of .lean file paths that had errors/warnings.
|
|
22
|
+
"""
|
|
23
|
+
paths = set(BUILD_ERROR_FILE_PATTERN.findall(message))
|
|
24
|
+
return sorted(paths)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_build_stderr(message: str) -> bool:
|
|
28
|
+
"""Check if message looks like lake build stderr output."""
|
|
29
|
+
return (
|
|
30
|
+
"lake setup-file" in message
|
|
31
|
+
or BUILD_ERROR_FILE_PATTERN.search(message) is not None
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
10
35
|
class LeanToolError(Exception):
|
|
11
36
|
"""Exception raised when a Lean MCP tool operation fails."""
|
|
12
37
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lean-lsp-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.19.0
|
|
4
4
|
Summary: Lean Theorem Prover MCP
|
|
5
5
|
Author-email: Oliver Dressler <hey@oli.show>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -8,7 +8,7 @@ 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.
|
|
11
|
+
Requires-Dist: leanclient==0.9.1
|
|
12
12
|
Requires-Dist: mcp[cli]==1.25.0
|
|
13
13
|
Requires-Dist: orjson>=3.11.1
|
|
14
14
|
Provides-Extra: lint
|
|
@@ -18,6 +18,7 @@ Requires-Dist: ruff>=0.2.0; extra == "dev"
|
|
|
18
18
|
Requires-Dist: pytest>=8.3; extra == "dev"
|
|
19
19
|
Requires-Dist: anyio>=4.4; extra == "dev"
|
|
20
20
|
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest-timeout>=2.3; extra == "dev"
|
|
21
22
|
Dynamic: license-file
|
|
22
23
|
|
|
23
24
|
<h1 align="center">
|
|
@@ -298,6 +299,27 @@ h_neq : ¬P.card = 2 ^ (Fintype.card S - 1)
|
|
|
298
299
|
```
|
|
299
300
|
</details>
|
|
300
301
|
|
|
302
|
+
#### lean_profile_proof
|
|
303
|
+
|
|
304
|
+
Profile a theorem to identify slow tactics. Runs `lean --profile` on an isolated copy of the theorem and returns per-line timing data.
|
|
305
|
+
|
|
306
|
+
<details>
|
|
307
|
+
<summary>Example output (profiling a theorem using simp)</summary>
|
|
308
|
+
|
|
309
|
+
```json
|
|
310
|
+
{
|
|
311
|
+
"ms": 42.5,
|
|
312
|
+
"lines": [
|
|
313
|
+
{"line": 7, "ms": 38.2, "text": "simp [add_comm, add_assoc]"}
|
|
314
|
+
],
|
|
315
|
+
"categories": {
|
|
316
|
+
"simp": 35.1,
|
|
317
|
+
"typeclass inference": 4.2
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
</details>
|
|
322
|
+
|
|
301
323
|
### Local Search Tools
|
|
302
324
|
|
|
303
325
|
#### lean_local_search
|
|
@@ -9,6 +9,7 @@ src/lean_lsp_mcp/instructions.py
|
|
|
9
9
|
src/lean_lsp_mcp/loogle.py
|
|
10
10
|
src/lean_lsp_mcp/models.py
|
|
11
11
|
src/lean_lsp_mcp/outline_utils.py
|
|
12
|
+
src/lean_lsp_mcp/profile_utils.py
|
|
12
13
|
src/lean_lsp_mcp/search_utils.py
|
|
13
14
|
src/lean_lsp_mcp/server.py
|
|
14
15
|
src/lean_lsp_mcp/utils.py
|
|
@@ -25,6 +26,7 @@ tests/test_file_caching.py
|
|
|
25
26
|
tests/test_logging.py
|
|
26
27
|
tests/test_misc_tools.py
|
|
27
28
|
tests/test_outline.py
|
|
29
|
+
tests/test_profile.py
|
|
28
30
|
tests/test_project_tools.py
|
|
29
31
|
tests/test_search_tools.py
|
|
30
32
|
tests/test_structured_output.py
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Integration tests for lean_profile_proof tool.
|
|
2
|
+
|
|
3
|
+
These tests run actual Lean profiling and verify the output structure.
|
|
4
|
+
If Lean's profiler format changes, these tests should fail.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from lean_lsp_mcp.profile_utils import profile_theorem
|
|
12
|
+
from tests.helpers.mcp_client import MCPToolError, result_json
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestProfileTheorem:
|
|
16
|
+
"""Direct API tests - run real Lean profiling."""
|
|
17
|
+
|
|
18
|
+
@pytest.fixture
|
|
19
|
+
def profile_file(self, test_project_path: Path) -> Path:
|
|
20
|
+
return test_project_path / "ProfileTest.lean"
|
|
21
|
+
|
|
22
|
+
@pytest.mark.asyncio
|
|
23
|
+
async def test_profiles_rw_theorem(self, profile_file: Path):
|
|
24
|
+
"""Line 3: theorem simple_by using rw tactic."""
|
|
25
|
+
profile = await profile_theorem(
|
|
26
|
+
profile_file, theorem_line=3, project_path=profile_file.parent
|
|
27
|
+
)
|
|
28
|
+
assert profile.ms > 0
|
|
29
|
+
assert len(profile.categories) > 0
|
|
30
|
+
# Should extract timing for line 4 (rw)
|
|
31
|
+
if profile.lines:
|
|
32
|
+
ln = profile.lines[0]
|
|
33
|
+
assert ln.line == 4 # rw is on line 4
|
|
34
|
+
assert "rw" in ln.text
|
|
35
|
+
|
|
36
|
+
@pytest.mark.asyncio
|
|
37
|
+
async def test_profiles_simp_theorem(self, profile_file: Path):
|
|
38
|
+
"""Line 6: theorem simp_test using simp tactic."""
|
|
39
|
+
profile = await profile_theorem(
|
|
40
|
+
profile_file, theorem_line=6, project_path=profile_file.parent
|
|
41
|
+
)
|
|
42
|
+
assert profile.ms > 0
|
|
43
|
+
assert "simp" in profile.categories
|
|
44
|
+
if profile.lines:
|
|
45
|
+
assert "simp" in profile.lines[0].text
|
|
46
|
+
|
|
47
|
+
@pytest.mark.asyncio
|
|
48
|
+
async def test_profiles_omega_theorem(self, profile_file: Path):
|
|
49
|
+
"""Line 9: theorem omega_test using omega tactic."""
|
|
50
|
+
profile = await profile_theorem(
|
|
51
|
+
profile_file, theorem_line=9, project_path=profile_file.parent
|
|
52
|
+
)
|
|
53
|
+
assert profile.ms > 0
|
|
54
|
+
if profile.lines:
|
|
55
|
+
ln = profile.lines[0]
|
|
56
|
+
assert ln.line == 10 # omega is on line 10
|
|
57
|
+
assert "omega" in ln.text
|
|
58
|
+
assert ln.ms > 0
|
|
59
|
+
|
|
60
|
+
@pytest.mark.asyncio
|
|
61
|
+
async def test_invalid_line_raises(self, profile_file: Path):
|
|
62
|
+
with pytest.raises(ValueError):
|
|
63
|
+
await profile_theorem(
|
|
64
|
+
profile_file, theorem_line=999, project_path=profile_file.parent
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TestProfileProofTool:
|
|
69
|
+
"""MCP tool tests."""
|
|
70
|
+
|
|
71
|
+
@pytest.mark.asyncio
|
|
72
|
+
async def test_returns_structured_profile(
|
|
73
|
+
self, mcp_client_factory, test_project_path: Path
|
|
74
|
+
):
|
|
75
|
+
async with mcp_client_factory() as client:
|
|
76
|
+
result = await client.call_tool(
|
|
77
|
+
"lean_profile_proof",
|
|
78
|
+
{
|
|
79
|
+
"file_path": str(test_project_path / "ProfileTest.lean"),
|
|
80
|
+
"line": 6,
|
|
81
|
+
},
|
|
82
|
+
)
|
|
83
|
+
data = result_json(result)
|
|
84
|
+
assert data["ms"] > 0
|
|
85
|
+
assert "simp" in data["categories"]
|
|
86
|
+
# Verify line structure
|
|
87
|
+
if data["lines"]:
|
|
88
|
+
ln = data["lines"][0]
|
|
89
|
+
assert "text" in ln and "ms" in ln and "line" in ln
|
|
90
|
+
|
|
91
|
+
@pytest.mark.asyncio
|
|
92
|
+
async def test_error_on_invalid_line(
|
|
93
|
+
self, mcp_client_factory, test_project_path: Path
|
|
94
|
+
):
|
|
95
|
+
async with mcp_client_factory() as client:
|
|
96
|
+
with pytest.raises(MCPToolError):
|
|
97
|
+
await client.call_tool(
|
|
98
|
+
"lean_profile_proof",
|
|
99
|
+
{
|
|
100
|
+
"file_path": str(test_project_path / "ProfileTest.lean"),
|
|
101
|
+
"line": 999,
|
|
102
|
+
},
|
|
103
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|