lean-lsp-mcp 0.17.1__py3-none-any.whl → 0.18.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lean_lsp_mcp/models.py CHANGED
@@ -134,11 +134,18 @@ class DeclarationInfo(BaseModel):
134
134
 
135
135
 
136
136
  class DiagnosticsResult(BaseModel):
137
- """Wrapper for diagnostic messages list."""
137
+ """Wrapper for diagnostic messages list with build status."""
138
138
 
139
+ success: bool = Field(
140
+ True, description="True if the queried file/range has no errors"
141
+ )
139
142
  items: List[DiagnosticMessage] = Field(
140
143
  default_factory=list, description="List of diagnostic messages"
141
144
  )
145
+ failed_dependencies: List[str] = Field(
146
+ default_factory=list,
147
+ description="File paths of dependencies that failed to build",
148
+ )
142
149
 
143
150
 
144
151
  class CompletionsResult(BaseModel):
@@ -1,11 +1,11 @@
1
1
  import re
2
2
  from typing import Dict, List, Optional, Tuple
3
+
3
4
  from leanclient import LeanLSPClient
4
5
  from leanclient.utils import DocumentContentChange
5
6
 
6
7
  from lean_lsp_mcp.models import FileOutline, OutlineEntry
7
8
 
8
-
9
9
  METHOD_KIND = {6, "method"}
10
10
  KIND_TAGS = {"namespace": "Ns"}
11
11
 
@@ -41,6 +41,10 @@ def _get_info_trees(
41
41
  for line in sorted(symbol_by_line.keys(), reverse=True)
42
42
  ],
43
43
  )
44
+
45
+ # Force file reload to reset diagnostics after the insert/revert cycle.
46
+ client.open_file(path, force_reopen=True)
47
+
44
48
  return info_trees
45
49
 
46
50
 
lean_lsp_mcp/server.py CHANGED
@@ -64,17 +64,38 @@ from lean_lsp_mcp.utils import (
64
64
  OutputCapture,
65
65
  check_lsp_response,
66
66
  deprecated,
67
+ extract_failed_dependency_paths,
67
68
  extract_goals_list,
68
69
  extract_range,
69
70
  filter_diagnostics_by_position,
70
71
  find_start_position,
71
72
  get_declaration_range,
73
+ is_build_stderr,
72
74
  )
73
75
 
74
76
  # LSP Diagnostic severity: 1=error, 2=warning, 3=info, 4=hint
75
77
  DIAGNOSTIC_SEVERITY: Dict[int, str] = {1: "error", 2: "warning", 3: "info", 4: "hint"}
76
78
 
77
79
 
80
+ async def _urlopen_json(req: urllib.request.Request, timeout: float):
81
+ """Run urllib.request.urlopen in a worker thread to avoid blocking the event loop."""
82
+
83
+ def _do_request():
84
+ with urllib.request.urlopen(req, timeout=timeout) as response:
85
+ return orjson.loads(response.read())
86
+
87
+ return await asyncio.to_thread(_do_request)
88
+
89
+
90
+ async def _safe_report_progress(
91
+ ctx: Context, *, progress: int, total: int, message: str
92
+ ) -> None:
93
+ try:
94
+ await ctx.report_progress(progress=progress, total=total, message=message)
95
+ except Exception:
96
+ return
97
+
98
+
78
99
  _LOG_LEVEL = os.environ.get("LEAN_LOG_LEVEL", "INFO")
79
100
  configure_logging("CRITICAL" if _LOG_LEVEL == "NONE" else _LOG_LEVEL)
80
101
  logger = get_logger(__name__)
@@ -164,8 +185,7 @@ mcp = FastMCP(**mcp_kwargs)
164
185
 
165
186
  def rate_limited(category: str, max_requests: int, per_seconds: int):
166
187
  def decorator(func):
167
- @functools.wraps(func)
168
- def wrapper(*args, **kwargs):
188
+ def _apply_rate_limit(args, kwargs):
169
189
  ctx = kwargs.get("ctx")
170
190
  if ctx is None:
171
191
  if not args:
@@ -181,11 +201,33 @@ def rate_limited(category: str, max_requests: int, per_seconds: int):
181
201
  if timestamp > current_time - per_seconds
182
202
  ]
183
203
  if len(rate_limit[category]) >= max_requests:
184
- return f"Tool limit exceeded: {max_requests} requests per {per_seconds} s. Try again later."
204
+ return (
205
+ False,
206
+ f"Tool limit exceeded: {max_requests} requests per {per_seconds} s. Try again later.",
207
+ )
185
208
  rate_limit[category].append(current_time)
186
- return func(*args, **kwargs)
209
+ return True, None
210
+
211
+ if asyncio.iscoroutinefunction(func):
212
+
213
+ @functools.wraps(func)
214
+ async def wrapper(*args, **kwargs):
215
+ allowed, msg = _apply_rate_limit(args, kwargs)
216
+ if not allowed:
217
+ return msg
218
+ return await func(*args, **kwargs)
219
+
220
+ else:
187
221
 
188
- wrapper.__doc__ = f"Limit: {max_requests}req/{per_seconds}s. " + wrapper.__doc__
222
+ @functools.wraps(func)
223
+ def wrapper(*args, **kwargs):
224
+ allowed, msg = _apply_rate_limit(args, kwargs)
225
+ if not allowed:
226
+ return msg
227
+ return func(*args, **kwargs)
228
+
229
+ doc = wrapper.__doc__ or ""
230
+ wrapper.__doc__ = f"Limit: {max_requests}req/{per_seconds}s. {doc}"
189
231
  return wrapper
190
232
 
191
233
  return decorator
@@ -375,6 +417,7 @@ def file_outline(
375
417
 
376
418
 
377
419
  def _to_diagnostic_messages(diagnostics: List[Dict]) -> List[DiagnosticMessage]:
420
+ """Convert LSP diagnostics to DiagnosticMessage models."""
378
421
  result = []
379
422
  for diag in diagnostics:
380
423
  r = diag.get("fullRange", diag.get("range"))
@@ -394,6 +437,52 @@ def _to_diagnostic_messages(diagnostics: List[Dict]) -> List[DiagnosticMessage]:
394
437
  return result
395
438
 
396
439
 
440
+ def _process_diagnostics(
441
+ diagnostics: List[Dict], build_success: bool
442
+ ) -> DiagnosticsResult:
443
+ """Process diagnostics, extracting dependency paths from build stderr.
444
+
445
+ Args:
446
+ diagnostics: List of diagnostic dicts from leanclient
447
+ build_success: Whether the build succeeded (from leanclient.DiagnosticsResult.success)
448
+ """
449
+ items = []
450
+ failed_deps: List[str] = []
451
+
452
+ for diag in diagnostics:
453
+ r = diag.get("fullRange", diag.get("range"))
454
+ if r is None:
455
+ continue
456
+
457
+ severity_int = diag.get("severity", 1)
458
+ message = diag.get("message", "")
459
+ line = r["start"]["line"] + 1
460
+ column = r["start"]["character"] + 1
461
+
462
+ # Check if this is a build failure at (1,1) - extract dependency paths, skip the item
463
+ if line == 1 and column == 1 and is_build_stderr(message):
464
+ failed_deps = extract_failed_dependency_paths(message)
465
+ continue # Don't include the build stderr blob as a diagnostic item
466
+
467
+ # Normal diagnostic from the queried file
468
+ items.append(
469
+ DiagnosticMessage(
470
+ severity=DIAGNOSTIC_SEVERITY.get(
471
+ severity_int, f"unknown({severity_int})"
472
+ ),
473
+ message=message,
474
+ line=line,
475
+ column=column,
476
+ )
477
+ )
478
+
479
+ return DiagnosticsResult(
480
+ success=build_success,
481
+ items=items,
482
+ failed_dependencies=failed_deps,
483
+ )
484
+
485
+
397
486
  @mcp.tool(
398
487
  "lean_diagnostic_messages",
399
488
  annotations=ToolAnnotations(
@@ -437,15 +526,14 @@ def diagnostic_messages(
437
526
  start_line_0 = (start_line - 1) if start_line is not None else None
438
527
  end_line_0 = (end_line - 1) if end_line is not None else None
439
528
 
440
- diagnostics = client.get_diagnostics(
529
+ result = client.get_diagnostics(
441
530
  rel_path,
442
531
  start_line=start_line_0,
443
532
  end_line=end_line_0,
444
533
  inactivity_timeout=15.0,
445
534
  )
446
- check_lsp_response(diagnostics, "get_diagnostics")
447
535
 
448
- return DiagnosticsResult(items=_to_diagnostic_messages(diagnostics))
536
+ return _process_diagnostics(result.diagnostics, result.success)
449
537
 
450
538
 
451
539
  @mcp.tool(
@@ -880,7 +968,7 @@ class LocalSearchError(Exception):
880
968
  openWorldHint=False,
881
969
  ),
882
970
  )
883
- def local_search(
971
+ async def local_search(
884
972
  ctx: Context,
885
973
  query: Annotated[str, Field(description="Declaration name or prefix")],
886
974
  limit: Annotated[int, Field(description="Max matches", ge=1)] = 10,
@@ -912,8 +1000,11 @@ def local_search(
912
1000
  )
913
1001
 
914
1002
  try:
915
- raw_results = lean_local_search(
916
- query=query.strip(), limit=limit, project_root=resolved_root
1003
+ raw_results = await asyncio.to_thread(
1004
+ lean_local_search,
1005
+ query=query.strip(),
1006
+ limit=limit,
1007
+ project_root=resolved_root,
917
1008
  )
918
1009
  results = [
919
1010
  LocalSearchResult(name=r["name"], kind=r["kind"], file=r["file"])
@@ -934,7 +1025,7 @@ def local_search(
934
1025
  ),
935
1026
  )
936
1027
  @rate_limited("leansearch", max_requests=3, per_seconds=30)
937
- def leansearch(
1028
+ async def leansearch(
938
1029
  ctx: Context,
939
1030
  query: Annotated[str, Field(description="Natural language or Lean term query")],
940
1031
  num_results: Annotated[int, Field(description="Max results", ge=1)] = 5,
@@ -954,8 +1045,10 @@ def leansearch(
954
1045
  method="POST",
955
1046
  )
956
1047
 
957
- with urllib.request.urlopen(req, timeout=10) as response:
958
- results = orjson.loads(response.read())
1048
+ await _safe_report_progress(
1049
+ ctx, progress=1, total=10, message="Awaiting response from leansearch.net"
1050
+ )
1051
+ results = await _urlopen_json(req, timeout=10)
959
1052
 
960
1053
  if not results or not results[0]:
961
1054
  return LeanSearchResults(items=[])
@@ -1029,7 +1122,13 @@ async def loogle(
1029
1122
  )
1030
1123
  rate_limit.append(now)
1031
1124
 
1032
- result = loogle_remote(query, num_results)
1125
+ await _safe_report_progress(
1126
+ ctx,
1127
+ progress=1,
1128
+ total=10,
1129
+ message="Awaiting response from loogle.lean-lang.org",
1130
+ )
1131
+ result = await asyncio.to_thread(loogle_remote, query, num_results)
1033
1132
  if isinstance(result, str):
1034
1133
  raise LeanToolError(result) # Error message from remote
1035
1134
  return LoogleResults(items=result)
@@ -1045,7 +1144,7 @@ async def loogle(
1045
1144
  ),
1046
1145
  )
1047
1146
  @rate_limited("leanfinder", max_requests=10, per_seconds=30)
1048
- def leanfinder(
1147
+ async def leanfinder(
1049
1148
  ctx: Context,
1050
1149
  query: Annotated[str, Field(description="Mathematical concept or proof state")],
1051
1150
  num_results: Annotated[int, Field(description="Max results", ge=1)] = 5,
@@ -1063,23 +1162,27 @@ def leanfinder(
1063
1162
  )
1064
1163
 
1065
1164
  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
- )
1165
+ await _safe_report_progress(
1166
+ ctx,
1167
+ progress=1,
1168
+ total=10,
1169
+ message="Awaiting response from Lean Finder (Hugging Face)",
1170
+ )
1171
+ data = await _urlopen_json(req, timeout=10)
1172
+ for result in data["results"]:
1173
+ if (
1174
+ "https://leanprover-community.github.io/mathlib4_docs" not in result["url"]
1175
+ ): # Only include mathlib4 results
1176
+ continue
1177
+ match = re.search(r"pattern=(.*?)#doc", result["url"])
1178
+ if match:
1179
+ results.append(
1180
+ LeanFinderResult(
1181
+ full_name=match.group(1),
1182
+ formal_statement=result["formal_statement"],
1183
+ informal_statement=result["informal_statement"],
1082
1184
  )
1185
+ )
1083
1186
 
1084
1187
  return LeanFinderResults(items=results)
1085
1188
 
@@ -1094,7 +1197,7 @@ def leanfinder(
1094
1197
  ),
1095
1198
  )
1096
1199
  @rate_limited("lean_state_search", max_requests=3, per_seconds=30)
1097
- def state_search(
1200
+ async def state_search(
1098
1201
  ctx: Context,
1099
1202
  file_path: Annotated[str, Field(description="Absolute path to Lean file")],
1100
1203
  line: Annotated[int, Field(description="Line number (1-indexed)", ge=1)],
@@ -1126,8 +1229,10 @@ def state_search(
1126
1229
  method="GET",
1127
1230
  )
1128
1231
 
1129
- with urllib.request.urlopen(req, timeout=10) as response:
1130
- results = orjson.loads(response.read())
1232
+ await _safe_report_progress(
1233
+ ctx, progress=1, total=10, message=f"Awaiting response from {url}"
1234
+ )
1235
+ results = await _urlopen_json(req, timeout=10)
1131
1236
 
1132
1237
  items = [StateSearchResult(name=r["name"]) for r in results]
1133
1238
  return StateSearchResults(items=items)
@@ -1143,7 +1248,7 @@ def state_search(
1143
1248
  ),
1144
1249
  )
1145
1250
  @rate_limited("hammer_premise", max_requests=3, per_seconds=30)
1146
- def hammer_premise(
1251
+ async def hammer_premise(
1147
1252
  ctx: Context,
1148
1253
  file_path: Annotated[str, Field(description="Absolute path to Lean file")],
1149
1254
  line: Annotated[int, Field(description="Line number (1-indexed)", ge=1)],
@@ -1186,8 +1291,10 @@ def hammer_premise(
1186
1291
  data=orjson.dumps(data),
1187
1292
  )
1188
1293
 
1189
- with urllib.request.urlopen(req, timeout=10) as response:
1190
- results = orjson.loads(response.read())
1294
+ await _safe_report_progress(
1295
+ ctx, progress=1, total=10, message=f"Awaiting response from {url}"
1296
+ )
1297
+ results = await _urlopen_json(req, timeout=10)
1191
1298
 
1192
1299
  items = [PremiseResult(name=r["name"]) for r in results]
1193
1300
  return PremiseResults(items=items)
lean_lsp_mcp/utils.py CHANGED
@@ -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.1
3
+ Version: 0.18.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.0
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">
@@ -0,0 +1,17 @@
1
+ lean_lsp_mcp/__init__.py,sha256=MN_bNFyb5-p33JWWGbrlUYBd1UUMQKtZYGC9KCh2mtM,1403
2
+ lean_lsp_mcp/__main__.py,sha256=XnpTzfJc0T-j9tHtdkA8ovTr1c139ffTewcJGhxYDaM,49
3
+ lean_lsp_mcp/client_utils.py,sha256=HgPuB35rMitn2Xm8SCAErsFLq15trB6VMz3FDFgmPd8,4897
4
+ lean_lsp_mcp/file_utils.py,sha256=kCTYQSfmV-R2cm_NCi_L8W5Dcsm0_rTOPpTtpyAin78,1365
5
+ lean_lsp_mcp/instructions.py,sha256=iJk_oD67tqNaC8K5OXEuXafULKSbbiHjZAsSRebOwdw,1904
6
+ lean_lsp_mcp/loogle.py,sha256=zUgnDWoTIqa4G6GXStAIxxJUR545YbU8Z-8KMjddKV0,15500
7
+ lean_lsp_mcp/models.py,sha256=WVW5K27m54W2THoAUMKvBC--qCSD6eSYjd9bcaiMk2s,6879
8
+ lean_lsp_mcp/outline_utils.py,sha256=i7xL27UO2rTT48IdKXkoMq5FVJNxyA3tPuQREOBf_gU,11105
9
+ lean_lsp_mcp/search_utils.py,sha256=MLqKGe4bhEvyfFLIBCmiDxkbcH4O5J3vl9mWnRSb_v0,6801
10
+ lean_lsp_mcp/server.py,sha256=UDTuHeRX_RUxsfLrR3PqGWnAYLvmEb89iZtq0dNqymU,43859
11
+ lean_lsp_mcp/utils.py,sha256=dGv84a4E-szOkQVYtSE-q9GbawpiVk47qvrkTN-Clts,13478
12
+ lean_lsp_mcp-0.18.0.dist-info/licenses/LICENSE,sha256=CQlxnf0tQyoVrBE93JYvAUYxv6Z5Yg6sX0pwogOkFvo,1071
13
+ lean_lsp_mcp-0.18.0.dist-info/METADATA,sha256=xXkTL1pV9lCCopASzat8OYGppv_GRT95GTVdMVccAAw,20838
14
+ lean_lsp_mcp-0.18.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
15
+ lean_lsp_mcp-0.18.0.dist-info/entry_points.txt,sha256=nQbvwctWkWD7I-2f4VrdVQBZYGUw8CnUnFC6QjXxOSE,51
16
+ lean_lsp_mcp-0.18.0.dist-info/top_level.txt,sha256=LGEK0lgMSNPIQ6mG8EO-adaZEGPi_0daDs004epOTF0,13
17
+ lean_lsp_mcp-0.18.0.dist-info/RECORD,,
@@ -1,17 +0,0 @@
1
- lean_lsp_mcp/__init__.py,sha256=MN_bNFyb5-p33JWWGbrlUYBd1UUMQKtZYGC9KCh2mtM,1403
2
- lean_lsp_mcp/__main__.py,sha256=XnpTzfJc0T-j9tHtdkA8ovTr1c139ffTewcJGhxYDaM,49
3
- lean_lsp_mcp/client_utils.py,sha256=HgPuB35rMitn2Xm8SCAErsFLq15trB6VMz3FDFgmPd8,4897
4
- lean_lsp_mcp/file_utils.py,sha256=kCTYQSfmV-R2cm_NCi_L8W5Dcsm0_rTOPpTtpyAin78,1365
5
- lean_lsp_mcp/instructions.py,sha256=iJk_oD67tqNaC8K5OXEuXafULKSbbiHjZAsSRebOwdw,1904
6
- lean_lsp_mcp/loogle.py,sha256=zUgnDWoTIqa4G6GXStAIxxJUR545YbU8Z-8KMjddKV0,15500
7
- lean_lsp_mcp/models.py,sha256=2pLmvNsrMdn4vO1k119Jw8gqYeaeGKewWW0q1TabBCY,6604
8
- lean_lsp_mcp/outline_utils.py,sha256=-eoZNbx2eaKaYmuyFJnwUMWP8I9YXNWusue_2OYpDBM,10981
9
- lean_lsp_mcp/search_utils.py,sha256=MLqKGe4bhEvyfFLIBCmiDxkbcH4O5J3vl9mWnRSb_v0,6801
10
- lean_lsp_mcp/server.py,sha256=1G7-FZz1TNJ7uU36eSTm9yponWyMO3QhQiyOhhTNOH8,40626
11
- lean_lsp_mcp/utils.py,sha256=MmGgdhrLEvCtRRVNgN_vflO9_A25h76QbJXhBD-OKt0,12721
12
- lean_lsp_mcp-0.17.1.dist-info/licenses/LICENSE,sha256=CQlxnf0tQyoVrBE93JYvAUYxv6Z5Yg6sX0pwogOkFvo,1071
13
- lean_lsp_mcp-0.17.1.dist-info/METADATA,sha256=K0Ygues0l2TNEY6nXV4uyEds2QZfMV2DQ0kwId1uBHQ,20787
14
- lean_lsp_mcp-0.17.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
15
- lean_lsp_mcp-0.17.1.dist-info/entry_points.txt,sha256=nQbvwctWkWD7I-2f4VrdVQBZYGUw8CnUnFC6QjXxOSE,51
16
- lean_lsp_mcp-0.17.1.dist-info/top_level.txt,sha256=LGEK0lgMSNPIQ6mG8EO-adaZEGPi_0daDs004epOTF0,13
17
- lean_lsp_mcp-0.17.1.dist-info/RECORD,,