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.
Files changed (34) hide show
  1. {lean_lsp_mcp-0.17.2/src/lean_lsp_mcp.egg-info → lean_lsp_mcp-0.19.0}/PKG-INFO +24 -2
  2. {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/README.md +21 -0
  3. {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/pyproject.toml +3 -3
  4. {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/instructions.py +1 -0
  5. {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/models.py +29 -1
  6. lean_lsp_mcp-0.19.0/src/lean_lsp_mcp/profile_utils.py +232 -0
  7. {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/server.py +196 -38
  8. {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/utils.py +25 -0
  9. {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0/src/lean_lsp_mcp.egg-info}/PKG-INFO +24 -2
  10. {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp.egg-info/SOURCES.txt +2 -0
  11. {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp.egg-info/requires.txt +2 -1
  12. lean_lsp_mcp-0.19.0/tests/test_profile.py +103 -0
  13. {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/LICENSE +0 -0
  14. {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/setup.cfg +0 -0
  15. {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/__init__.py +0 -0
  16. {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/__main__.py +0 -0
  17. {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/client_utils.py +0 -0
  18. {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/file_utils.py +0 -0
  19. {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/loogle.py +0 -0
  20. {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/outline_utils.py +0 -0
  21. {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/search_utils.py +0 -0
  22. {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp.egg-info/dependency_links.txt +0 -0
  23. {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp.egg-info/entry_points.txt +0 -0
  24. {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp.egg-info/top_level.txt +0 -0
  25. {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/tests/test_diagnostic_line_range.py +0 -0
  26. {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/tests/test_editor_tools.py +0 -0
  27. {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/tests/test_error_handling.py +0 -0
  28. {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/tests/test_file_caching.py +0 -0
  29. {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/tests/test_logging.py +0 -0
  30. {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/tests/test_misc_tools.py +0 -0
  31. {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/tests/test_outline.py +0 -0
  32. {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/tests/test_project_tools.py +0 -0
  33. {lean_lsp_mcp-0.17.2 → lean_lsp_mcp-0.19.0}/tests/test_search_tools.py +0 -0
  34. {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.17.2
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.8.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.17.2"
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.8.0", "mcp[cli]==1.25.0", "orjson>=3.11.1"]
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
- @functools.wraps(func)
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 f"Tool limit exceeded: {max_requests} requests per {per_seconds} s. Try again later."
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 func(*args, **kwargs)
210
+ return True, None
211
+
212
+ if asyncio.iscoroutinefunction(func):
187
213
 
188
- wrapper.__doc__ = f"Limit: {max_requests}req/{per_seconds}s. " + wrapper.__doc__
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
- diagnostics = client.get_diagnostics(
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 DiagnosticsResult(items=_to_diagnostic_messages(diagnostics))
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 = lean_local_search(
916
- query=query.strip(), limit=limit, project_root=resolved_root
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
- with urllib.request.urlopen(req, timeout=10) as response:
958
- results = orjson.loads(response.read())
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
- result = loogle_remote(query, num_results)
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
- with urllib.request.urlopen(req, timeout=10) as response:
1067
- data = orjson.loads(response.read())
1068
- for result in data["results"]:
1069
- if (
1070
- "https://leanprover-community.github.io/mathlib4_docs"
1071
- not in result["url"]
1072
- ): # Only include mathlib4 results
1073
- continue
1074
- match = re.search(r"pattern=(.*?)#doc", result["url"])
1075
- if match:
1076
- results.append(
1077
- LeanFinderResult(
1078
- full_name=match.group(1),
1079
- formal_statement=result["formal_statement"],
1080
- informal_statement=result["informal_statement"],
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
- with urllib.request.urlopen(req, timeout=10) as response:
1130
- results = orjson.loads(response.read())
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
- with urllib.request.urlopen(req, timeout=10) as response:
1190
- results = orjson.loads(response.read())
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.17.2
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.8.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
@@ -1,4 +1,4 @@
1
- leanclient==0.8.0
1
+ leanclient==0.9.1
2
2
  mcp[cli]==1.25.0
3
3
  orjson>=3.11.1
4
4
 
@@ -7,6 +7,7 @@ ruff>=0.2.0
7
7
  pytest>=8.3
8
8
  anyio>=4.4
9
9
  pytest-asyncio>=0.23
10
+ pytest-timeout>=2.3
10
11
 
11
12
  [lint]
12
13
  ruff>=0.2.0
@@ -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