lean-lsp-mcp 0.15.0__py3-none-any.whl → 0.16.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lean_lsp_mcp/instructions.py +31 -12
- lean_lsp_mcp/loogle.py +61 -8
- lean_lsp_mcp/models.py +120 -0
- lean_lsp_mcp/outline_utils.py +93 -0
- lean_lsp_mcp/server.py +652 -525
- lean_lsp_mcp/utils.py +31 -0
- {lean_lsp_mcp-0.15.0.dist-info → lean_lsp_mcp-0.16.1.dist-info}/METADATA +2 -1
- lean_lsp_mcp-0.16.1.dist-info/RECORD +17 -0
- lean_lsp_mcp-0.15.0.dist-info/RECORD +0 -16
- {lean_lsp_mcp-0.15.0.dist-info → lean_lsp_mcp-0.16.1.dist-info}/WHEEL +0 -0
- {lean_lsp_mcp-0.15.0.dist-info → lean_lsp_mcp-0.16.1.dist-info}/entry_points.txt +0 -0
- {lean_lsp_mcp-0.15.0.dist-info → lean_lsp_mcp-0.16.1.dist-info}/licenses/LICENSE +0 -0
- {lean_lsp_mcp-0.15.0.dist-info → lean_lsp_mcp-0.16.1.dist-info}/top_level.txt +0 -0
lean_lsp_mcp/server.py
CHANGED
|
@@ -2,20 +2,21 @@ import asyncio
|
|
|
2
2
|
import os
|
|
3
3
|
import re
|
|
4
4
|
import time
|
|
5
|
-
from typing import List, Optional, Dict
|
|
5
|
+
from typing import Annotated, List, Optional, Dict
|
|
6
6
|
from contextlib import asynccontextmanager
|
|
7
7
|
from collections.abc import AsyncIterator
|
|
8
8
|
from dataclasses import dataclass
|
|
9
9
|
import urllib
|
|
10
10
|
import orjson
|
|
11
11
|
import functools
|
|
12
|
-
import subprocess
|
|
13
12
|
import uuid
|
|
14
13
|
from pathlib import Path
|
|
15
14
|
|
|
15
|
+
from pydantic import BaseModel, Field
|
|
16
16
|
from mcp.server.fastmcp import Context, FastMCP
|
|
17
17
|
from mcp.server.fastmcp.utilities.logging import get_logger, configure_logging
|
|
18
18
|
from mcp.server.auth.settings import AuthSettings
|
|
19
|
+
from mcp.types import ToolAnnotations
|
|
19
20
|
from leanclient import LeanLSPClient, DocumentContentChange
|
|
20
21
|
|
|
21
22
|
from lean_lsp_mcp.client_utils import (
|
|
@@ -27,20 +28,51 @@ from lean_lsp_mcp.file_utils import get_file_contents
|
|
|
27
28
|
from lean_lsp_mcp.instructions import INSTRUCTIONS
|
|
28
29
|
from lean_lsp_mcp.search_utils import check_ripgrep_status, lean_local_search
|
|
29
30
|
from lean_lsp_mcp.loogle import LoogleManager, loogle_remote
|
|
30
|
-
from lean_lsp_mcp.outline_utils import
|
|
31
|
+
from lean_lsp_mcp.outline_utils import generate_outline_data
|
|
32
|
+
from lean_lsp_mcp.models import (
|
|
33
|
+
LocalSearchResult,
|
|
34
|
+
LeanSearchResult,
|
|
35
|
+
LoogleResult,
|
|
36
|
+
LeanFinderResult,
|
|
37
|
+
StateSearchResult,
|
|
38
|
+
PremiseResult,
|
|
39
|
+
DiagnosticMessage,
|
|
40
|
+
GoalState,
|
|
41
|
+
CompletionItem,
|
|
42
|
+
HoverInfo,
|
|
43
|
+
TermGoalState,
|
|
44
|
+
FileOutline,
|
|
45
|
+
AttemptResult,
|
|
46
|
+
BuildResult,
|
|
47
|
+
RunResult,
|
|
48
|
+
DeclarationInfo,
|
|
49
|
+
)
|
|
31
50
|
from lean_lsp_mcp.utils import (
|
|
51
|
+
COMPLETION_KIND,
|
|
32
52
|
OutputCapture,
|
|
33
53
|
deprecated,
|
|
34
54
|
extract_range,
|
|
35
55
|
filter_diagnostics_by_position,
|
|
36
56
|
find_start_position,
|
|
37
|
-
format_diagnostics,
|
|
38
57
|
format_goal,
|
|
39
|
-
format_line,
|
|
40
58
|
get_declaration_range,
|
|
41
59
|
OptionalTokenVerifier,
|
|
42
60
|
)
|
|
43
61
|
|
|
62
|
+
# LSP Diagnostic severity: 1=error, 2=warning, 3=info, 4=hint
|
|
63
|
+
DIAGNOSTIC_SEVERITY: Dict[int, str] = {1: "error", 2: "warning", 3: "info", 4: "hint"}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class LeanToolError(Exception):
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _to_json_array(items: List[BaseModel]) -> str:
|
|
71
|
+
"""Serialize list of models as JSON array (avoids FastMCP list flattening)."""
|
|
72
|
+
return orjson.dumps(
|
|
73
|
+
[item.model_dump() for item in items], option=orjson.OPT_INDENT_2
|
|
74
|
+
).decode()
|
|
75
|
+
|
|
44
76
|
|
|
45
77
|
_LOG_LEVEL = os.environ.get("LEAN_LOG_LEVEL", "INFO")
|
|
46
78
|
configure_logging("CRITICAL" if _LOG_LEVEL == "NONE" else _LOG_LEVEL)
|
|
@@ -50,7 +82,6 @@ logger = get_logger(__name__)
|
|
|
50
82
|
_RG_AVAILABLE, _RG_MESSAGE = check_ripgrep_status()
|
|
51
83
|
|
|
52
84
|
|
|
53
|
-
# Server and context
|
|
54
85
|
@dataclass
|
|
55
86
|
class AppContext:
|
|
56
87
|
lean_project_path: Path | None
|
|
@@ -130,7 +161,6 @@ if auth_token:
|
|
|
130
161
|
mcp = FastMCP(**mcp_kwargs)
|
|
131
162
|
|
|
132
163
|
|
|
133
|
-
# Rate limiting: n requests per m seconds
|
|
134
164
|
def rate_limited(category: str, max_requests: int, per_seconds: int):
|
|
135
165
|
def decorator(func):
|
|
136
166
|
@functools.wraps(func)
|
|
@@ -160,22 +190,27 @@ def rate_limited(category: str, max_requests: int, per_seconds: int):
|
|
|
160
190
|
return decorator
|
|
161
191
|
|
|
162
192
|
|
|
163
|
-
|
|
164
|
-
|
|
193
|
+
@mcp.tool(
|
|
194
|
+
"lean_build",
|
|
195
|
+
annotations=ToolAnnotations(
|
|
196
|
+
title="Build Project",
|
|
197
|
+
readOnlyHint=False,
|
|
198
|
+
destructiveHint=True,
|
|
199
|
+
idempotentHint=True,
|
|
200
|
+
openWorldHint=False,
|
|
201
|
+
),
|
|
202
|
+
)
|
|
165
203
|
async def lsp_build(
|
|
166
|
-
ctx: Context,
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
Returns:
|
|
177
|
-
str: Build output or error msg
|
|
178
|
-
"""
|
|
204
|
+
ctx: Context,
|
|
205
|
+
lean_project_path: Annotated[
|
|
206
|
+
Optional[str], Field(description="Path to Lean project")
|
|
207
|
+
] = None,
|
|
208
|
+
clean: Annotated[bool, Field(description="Run lake clean first (slow)")] = False,
|
|
209
|
+
output_lines: Annotated[
|
|
210
|
+
int, Field(description="Return last N lines of build log (0=none)")
|
|
211
|
+
] = 20,
|
|
212
|
+
) -> BuildResult:
|
|
213
|
+
"""Build the Lean project and restart LSP. Use only if needed (e.g. new imports)."""
|
|
179
214
|
if not lean_project_path:
|
|
180
215
|
lean_project_path_obj = ctx.request_context.lifespan_context.lean_project_path
|
|
181
216
|
else:
|
|
@@ -183,9 +218,13 @@ async def lsp_build(
|
|
|
183
218
|
ctx.request_context.lifespan_context.lean_project_path = lean_project_path_obj
|
|
184
219
|
|
|
185
220
|
if lean_project_path_obj is None:
|
|
186
|
-
|
|
221
|
+
raise LeanToolError(
|
|
222
|
+
"Lean project path not known yet. Provide `lean_project_path` explicitly or call another tool first."
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
log_lines: List[str] = []
|
|
226
|
+
errors: List[str] = []
|
|
187
227
|
|
|
188
|
-
build_output = ""
|
|
189
228
|
try:
|
|
190
229
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
191
230
|
if client:
|
|
@@ -193,13 +232,21 @@ async def lsp_build(
|
|
|
193
232
|
client.close()
|
|
194
233
|
|
|
195
234
|
if clean:
|
|
196
|
-
|
|
197
|
-
|
|
235
|
+
await ctx.report_progress(
|
|
236
|
+
progress=1, total=16, message="Running `lake clean`"
|
|
237
|
+
)
|
|
238
|
+
clean_proc = await asyncio.create_subprocess_exec(
|
|
239
|
+
"lake", "clean", cwd=lean_project_path_obj
|
|
240
|
+
)
|
|
241
|
+
await clean_proc.wait()
|
|
198
242
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
243
|
+
await ctx.report_progress(
|
|
244
|
+
progress=2, total=16, message="Running `lake exe cache get`"
|
|
245
|
+
)
|
|
246
|
+
cache_proc = await asyncio.create_subprocess_exec(
|
|
247
|
+
"lake", "exe", "cache", "get", cwd=lean_project_path_obj
|
|
202
248
|
)
|
|
249
|
+
await cache_proc.wait()
|
|
203
250
|
|
|
204
251
|
# Run build with progress reporting
|
|
205
252
|
process = await asyncio.create_subprocess_exec(
|
|
@@ -211,39 +258,35 @@ async def lsp_build(
|
|
|
211
258
|
stderr=asyncio.subprocess.STDOUT,
|
|
212
259
|
)
|
|
213
260
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
while True:
|
|
217
|
-
line = await process.stdout.readline()
|
|
218
|
-
if not line:
|
|
219
|
-
break
|
|
220
|
-
|
|
261
|
+
while line := await process.stdout.readline():
|
|
221
262
|
line_str = line.decode("utf-8", errors="replace").rstrip()
|
|
222
|
-
output_lines.append(line_str)
|
|
223
263
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
if match:
|
|
227
|
-
current_job = int(match.group(1))
|
|
228
|
-
total_jobs = int(match.group(2))
|
|
264
|
+
if line_str.startswith("trace:") or "LEAN_PATH=" in line_str:
|
|
265
|
+
continue
|
|
229
266
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
r"\[\d+/\d+\]\s+(.+?)(?:\s+\(\d+\.?\d*[ms]+\))?$", line_str
|
|
234
|
-
)
|
|
235
|
-
description = desc_match.group(1) if desc_match else "Building"
|
|
267
|
+
log_lines.append(line_str)
|
|
268
|
+
if "error" in line_str.lower():
|
|
269
|
+
errors.append(line_str)
|
|
236
270
|
|
|
237
|
-
|
|
271
|
+
# Parse progress: "[2/8] Building Foo (1.2s)" -> (2, 8, "Building Foo")
|
|
272
|
+
if m := re.search(
|
|
273
|
+
r"\[(\d+)/(\d+)\]\s*(.+?)(?:\s+\(\d+\.?\d*[ms]+\))?$", line_str
|
|
274
|
+
):
|
|
238
275
|
await ctx.report_progress(
|
|
239
|
-
progress=
|
|
276
|
+
progress=int(m.group(1)),
|
|
277
|
+
total=int(m.group(2)),
|
|
278
|
+
message=m.group(3) or "Building",
|
|
240
279
|
)
|
|
241
280
|
|
|
242
281
|
await process.wait()
|
|
243
282
|
|
|
244
283
|
if process.returncode != 0:
|
|
245
|
-
|
|
246
|
-
|
|
284
|
+
return BuildResult(
|
|
285
|
+
success=False,
|
|
286
|
+
output="\n".join(log_lines[-output_lines:]) if output_lines else "",
|
|
287
|
+
errors=errors
|
|
288
|
+
or [f"Build failed with return code {process.returncode}"],
|
|
289
|
+
)
|
|
247
290
|
|
|
248
291
|
# Start LSP client (without initial build since we just did it)
|
|
249
292
|
with OutputCapture():
|
|
@@ -252,29 +295,38 @@ async def lsp_build(
|
|
|
252
295
|
)
|
|
253
296
|
|
|
254
297
|
logger.info("Built project and re-started LSP client")
|
|
255
|
-
|
|
256
298
|
ctx.request_context.lifespan_context.client = client
|
|
257
|
-
build_output = "\n".join(output_lines)
|
|
258
|
-
return build_output
|
|
259
|
-
except Exception as e:
|
|
260
|
-
return f"Error during build:\n{str(e)}\n{build_output}"
|
|
261
|
-
|
|
262
299
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
300
|
+
return BuildResult(
|
|
301
|
+
success=True,
|
|
302
|
+
output="\n".join(log_lines[-output_lines:]) if output_lines else "",
|
|
303
|
+
errors=[],
|
|
304
|
+
)
|
|
268
305
|
|
|
269
|
-
|
|
306
|
+
except Exception as e:
|
|
307
|
+
return BuildResult(
|
|
308
|
+
success=False,
|
|
309
|
+
output="\n".join(log_lines[-output_lines:]) if output_lines else "",
|
|
310
|
+
errors=[str(e)],
|
|
311
|
+
)
|
|
270
312
|
|
|
271
|
-
Args:
|
|
272
|
-
file_path (str): Abs path to Lean file
|
|
273
|
-
annotate_lines (bool, optional): Annotate lines with line numbers. Defaults to True.
|
|
274
313
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
314
|
+
@mcp.tool(
|
|
315
|
+
"lean_file_contents",
|
|
316
|
+
annotations=ToolAnnotations(
|
|
317
|
+
title="File Contents (Deprecated)",
|
|
318
|
+
readOnlyHint=True,
|
|
319
|
+
idempotentHint=True,
|
|
320
|
+
openWorldHint=False,
|
|
321
|
+
),
|
|
322
|
+
)
|
|
323
|
+
@deprecated
|
|
324
|
+
def file_contents(
|
|
325
|
+
ctx: Context,
|
|
326
|
+
file_path: Annotated[str, Field(description="Absolute path to Lean file")],
|
|
327
|
+
annotate_lines: Annotated[bool, Field(description="Add line numbers")] = True,
|
|
328
|
+
) -> str:
|
|
329
|
+
"""DEPRECATED. Get file contents with optional line numbers."""
|
|
278
330
|
# Infer project path but do not start a client
|
|
279
331
|
if file_path.endswith(".lean"):
|
|
280
332
|
infer_project_path(ctx, file_path) # Silently fails for non-project files
|
|
@@ -297,53 +349,78 @@ def file_contents(ctx: Context, file_path: str, annotate_lines: bool = True) ->
|
|
|
297
349
|
return data
|
|
298
350
|
|
|
299
351
|
|
|
300
|
-
@mcp.tool(
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
""
|
|
352
|
+
@mcp.tool(
|
|
353
|
+
"lean_file_outline",
|
|
354
|
+
annotations=ToolAnnotations(
|
|
355
|
+
title="File Outline",
|
|
356
|
+
readOnlyHint=True,
|
|
357
|
+
idempotentHint=True,
|
|
358
|
+
openWorldHint=False,
|
|
359
|
+
),
|
|
360
|
+
)
|
|
361
|
+
def file_outline(
|
|
362
|
+
ctx: Context,
|
|
363
|
+
file_path: Annotated[str, Field(description="Absolute path to Lean file")],
|
|
364
|
+
) -> FileOutline:
|
|
365
|
+
"""Get imports and declarations with type signatures. Token-efficient."""
|
|
312
366
|
rel_path = setup_client_for_file(ctx, file_path)
|
|
313
367
|
if not rel_path:
|
|
314
|
-
|
|
368
|
+
raise LeanToolError(
|
|
369
|
+
"Invalid Lean file path: Unable to start LSP server or load file"
|
|
370
|
+
)
|
|
315
371
|
|
|
316
372
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
317
|
-
return
|
|
373
|
+
return generate_outline_data(client, rel_path)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _to_diagnostic_messages(diagnostics: List[Dict]) -> List[DiagnosticMessage]:
|
|
377
|
+
result = []
|
|
378
|
+
for diag in diagnostics:
|
|
379
|
+
r = diag.get("fullRange", diag.get("range"))
|
|
380
|
+
if r is None:
|
|
381
|
+
continue
|
|
382
|
+
severity_int = diag.get("severity", 1)
|
|
383
|
+
result.append(
|
|
384
|
+
DiagnosticMessage(
|
|
385
|
+
severity=DIAGNOSTIC_SEVERITY.get(
|
|
386
|
+
severity_int, f"unknown({severity_int})"
|
|
387
|
+
),
|
|
388
|
+
message=diag.get("message", ""),
|
|
389
|
+
line=r["start"]["line"] + 1,
|
|
390
|
+
column=r["start"]["character"] + 1,
|
|
391
|
+
)
|
|
392
|
+
)
|
|
393
|
+
return result
|
|
318
394
|
|
|
319
395
|
|
|
320
|
-
@mcp.tool(
|
|
396
|
+
@mcp.tool(
|
|
397
|
+
"lean_diagnostic_messages",
|
|
398
|
+
annotations=ToolAnnotations(
|
|
399
|
+
title="Diagnostics",
|
|
400
|
+
readOnlyHint=True,
|
|
401
|
+
idempotentHint=True,
|
|
402
|
+
openWorldHint=False,
|
|
403
|
+
),
|
|
404
|
+
)
|
|
321
405
|
def diagnostic_messages(
|
|
322
406
|
ctx: Context,
|
|
323
|
-
file_path: str,
|
|
324
|
-
start_line:
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
end_line (int, optional): End line (1-indexed). Filters to this line.
|
|
336
|
-
declaration_name (str, optional): Name of a specific theorem/lemma/definition.
|
|
337
|
-
If provided, only returns diagnostics within that declaration.
|
|
338
|
-
Takes precedence over start_line/end_line.
|
|
339
|
-
Slow, requires waiting for full file analysis.
|
|
340
|
-
|
|
341
|
-
Returns:
|
|
342
|
-
List[str] | str: Diagnostic msgs or error msg
|
|
343
|
-
"""
|
|
407
|
+
file_path: Annotated[str, Field(description="Absolute path to Lean file")],
|
|
408
|
+
start_line: Annotated[
|
|
409
|
+
Optional[int], Field(description="Filter from line", ge=1)
|
|
410
|
+
] = None,
|
|
411
|
+
end_line: Annotated[
|
|
412
|
+
Optional[int], Field(description="Filter to line", ge=1)
|
|
413
|
+
] = None,
|
|
414
|
+
declaration_name: Annotated[
|
|
415
|
+
Optional[str], Field(description="Filter to declaration (slow)")
|
|
416
|
+
] = None,
|
|
417
|
+
) -> str:
|
|
418
|
+
"""Get compiler diagnostics (errors, warnings, infos) for a Lean file."""
|
|
344
419
|
rel_path = setup_client_for_file(ctx, file_path)
|
|
345
420
|
if not rel_path:
|
|
346
|
-
|
|
421
|
+
raise LeanToolError(
|
|
422
|
+
"Invalid Lean file path: Unable to start LSP server or load file"
|
|
423
|
+
)
|
|
347
424
|
|
|
348
425
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
349
426
|
client.open_file(rel_path)
|
|
@@ -352,7 +429,7 @@ def diagnostic_messages(
|
|
|
352
429
|
if declaration_name:
|
|
353
430
|
decl_range = get_declaration_range(client, rel_path, declaration_name)
|
|
354
431
|
if decl_range is None:
|
|
355
|
-
|
|
432
|
+
raise LeanToolError(f"Declaration '{declaration_name}' not found in file.")
|
|
356
433
|
start_line, end_line = decl_range
|
|
357
434
|
|
|
358
435
|
# Convert 1-indexed to 0-indexed for leanclient
|
|
@@ -366,123 +443,144 @@ def diagnostic_messages(
|
|
|
366
443
|
inactivity_timeout=15.0,
|
|
367
444
|
)
|
|
368
445
|
|
|
369
|
-
return
|
|
370
|
-
|
|
446
|
+
return _to_json_array(_to_diagnostic_messages(diagnostics))
|
|
371
447
|
|
|
372
|
-
@mcp.tool("lean_goal")
|
|
373
|
-
def goal(ctx: Context, file_path: str, line: int, column: Optional[int] = None) -> str:
|
|
374
|
-
"""Get the proof goals (proof state) at a specific location in a Lean file.
|
|
375
448
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
449
|
+
@mcp.tool(
|
|
450
|
+
"lean_goal",
|
|
451
|
+
annotations=ToolAnnotations(
|
|
452
|
+
title="Proof Goals",
|
|
453
|
+
readOnlyHint=True,
|
|
454
|
+
idempotentHint=True,
|
|
455
|
+
openWorldHint=False,
|
|
456
|
+
),
|
|
457
|
+
)
|
|
458
|
+
def goal(
|
|
459
|
+
ctx: Context,
|
|
460
|
+
file_path: Annotated[str, Field(description="Absolute path to Lean file")],
|
|
461
|
+
line: Annotated[int, Field(description="Line number (1-indexed)", ge=1)],
|
|
462
|
+
column: Annotated[
|
|
463
|
+
Optional[int],
|
|
464
|
+
Field(description="Column (1-indexed). Omit for before/after", ge=1),
|
|
465
|
+
] = None,
|
|
466
|
+
) -> GoalState:
|
|
467
|
+
"""Get proof goals at a position. MOST IMPORTANT tool - use often!
|
|
468
|
+
|
|
469
|
+
Omit column to see goals_before (line start) and goals_after (line end),
|
|
470
|
+
showing how the tactic transforms the state. "no goals" = proof complete.
|
|
388
471
|
"""
|
|
389
472
|
rel_path = setup_client_for_file(ctx, file_path)
|
|
390
473
|
if not rel_path:
|
|
391
|
-
|
|
474
|
+
raise LeanToolError(
|
|
475
|
+
"Invalid Lean file path: Unable to start LSP server or load file"
|
|
476
|
+
)
|
|
392
477
|
|
|
393
478
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
394
479
|
client.open_file(rel_path)
|
|
395
480
|
content = client.get_file_content(rel_path)
|
|
481
|
+
lines = content.splitlines()
|
|
482
|
+
|
|
483
|
+
if line < 1 or line > len(lines):
|
|
484
|
+
raise LeanToolError(f"Line {line} out of range (file has {len(lines)} lines)")
|
|
485
|
+
|
|
486
|
+
line_context = lines[line - 1]
|
|
396
487
|
|
|
397
488
|
if column is None:
|
|
398
|
-
|
|
399
|
-
if line < 1 or line > len(lines):
|
|
400
|
-
return "Line number out of range. Try elsewhere?"
|
|
401
|
-
column_end = len(lines[line - 1])
|
|
489
|
+
column_end = len(line_context)
|
|
402
490
|
column_start = next(
|
|
403
|
-
(i for i, c in enumerate(
|
|
491
|
+
(i for i, c in enumerate(line_context) if not c.isspace()), 0
|
|
404
492
|
)
|
|
405
493
|
goal_start = client.get_goal(rel_path, line - 1, column_start)
|
|
406
494
|
goal_end = client.get_goal(rel_path, line - 1, column_end)
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
start_text = format_goal(goal_start, "No goals at line start.")
|
|
412
|
-
end_text = format_goal(goal_end, "No goals at line end.")
|
|
413
|
-
return f"Goals on line:\n{lines[line - 1]}\nBefore:\n{start_text}\nAfter:\n{end_text}"
|
|
414
|
-
|
|
495
|
+
before = format_goal(goal_start, None)
|
|
496
|
+
after = format_goal(goal_end, None)
|
|
497
|
+
goals = f"{before} → {after}" if before != after else after
|
|
498
|
+
return GoalState(line_context=line_context, goals=goals)
|
|
415
499
|
else:
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
500
|
+
goal_result = client.get_goal(rel_path, line - 1, column - 1)
|
|
501
|
+
return GoalState(
|
|
502
|
+
line_context=line_context, goals=format_goal(goal_result, None)
|
|
503
|
+
)
|
|
420
504
|
|
|
421
505
|
|
|
422
|
-
@mcp.tool(
|
|
506
|
+
@mcp.tool(
|
|
507
|
+
"lean_term_goal",
|
|
508
|
+
annotations=ToolAnnotations(
|
|
509
|
+
title="Term Goal",
|
|
510
|
+
readOnlyHint=True,
|
|
511
|
+
idempotentHint=True,
|
|
512
|
+
openWorldHint=False,
|
|
513
|
+
),
|
|
514
|
+
)
|
|
423
515
|
def term_goal(
|
|
424
|
-
ctx: Context,
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
Returns:
|
|
434
|
-
str: Expected type or error msg
|
|
435
|
-
"""
|
|
516
|
+
ctx: Context,
|
|
517
|
+
file_path: Annotated[str, Field(description="Absolute path to Lean file")],
|
|
518
|
+
line: Annotated[int, Field(description="Line number (1-indexed)", ge=1)],
|
|
519
|
+
column: Annotated[
|
|
520
|
+
Optional[int], Field(description="Column (defaults to end of line)", ge=1)
|
|
521
|
+
] = None,
|
|
522
|
+
) -> TermGoalState:
|
|
523
|
+
"""Get the expected type at a position."""
|
|
436
524
|
rel_path = setup_client_for_file(ctx, file_path)
|
|
437
525
|
if not rel_path:
|
|
438
|
-
|
|
526
|
+
raise LeanToolError(
|
|
527
|
+
"Invalid Lean file path: Unable to start LSP server or load file"
|
|
528
|
+
)
|
|
439
529
|
|
|
440
530
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
441
531
|
client.open_file(rel_path)
|
|
442
532
|
content = client.get_file_content(rel_path)
|
|
533
|
+
lines = content.splitlines()
|
|
534
|
+
|
|
535
|
+
if line < 1 or line > len(lines):
|
|
536
|
+
raise LeanToolError(f"Line {line} out of range (file has {len(lines)} lines)")
|
|
537
|
+
|
|
538
|
+
line_context = lines[line - 1]
|
|
443
539
|
if column is None:
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
540
|
+
column = len(line_context)
|
|
541
|
+
|
|
542
|
+
term_goal_result = client.get_term_goal(rel_path, line - 1, column - 1)
|
|
543
|
+
expected_type = None
|
|
544
|
+
if term_goal_result is not None:
|
|
545
|
+
rendered = term_goal_result.get("goal")
|
|
546
|
+
if rendered:
|
|
547
|
+
expected_type = rendered.replace("```lean\n", "").replace("\n```", "")
|
|
548
|
+
|
|
549
|
+
return TermGoalState(line_context=line_context, expected_type=expected_type)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
@mcp.tool(
|
|
553
|
+
"lean_hover_info",
|
|
554
|
+
annotations=ToolAnnotations(
|
|
555
|
+
title="Hover Info",
|
|
556
|
+
readOnlyHint=True,
|
|
557
|
+
idempotentHint=True,
|
|
558
|
+
openWorldHint=False,
|
|
559
|
+
),
|
|
560
|
+
)
|
|
561
|
+
def hover(
|
|
562
|
+
ctx: Context,
|
|
563
|
+
file_path: Annotated[str, Field(description="Absolute path to Lean file")],
|
|
564
|
+
line: Annotated[int, Field(description="Line number (1-indexed)", ge=1)],
|
|
565
|
+
column: Annotated[int, Field(description="Column at START of identifier", ge=1)],
|
|
566
|
+
) -> HoverInfo:
|
|
567
|
+
"""Get type signature and docs for a symbol. Essential for understanding APIs."""
|
|
471
568
|
rel_path = setup_client_for_file(ctx, file_path)
|
|
472
569
|
if not rel_path:
|
|
473
|
-
|
|
570
|
+
raise LeanToolError(
|
|
571
|
+
"Invalid Lean file path: Unable to start LSP server or load file"
|
|
572
|
+
)
|
|
474
573
|
|
|
475
574
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
476
575
|
client.open_file(rel_path)
|
|
477
576
|
file_content = client.get_file_content(rel_path)
|
|
478
577
|
hover_info = client.get_hover(rel_path, line - 1, column - 1)
|
|
479
578
|
if hover_info is None:
|
|
480
|
-
|
|
481
|
-
return f"No hover information at position:\n{f_line}\nTry elsewhere?"
|
|
579
|
+
raise LeanToolError(f"No hover information at line {line}, column {column}")
|
|
482
580
|
|
|
483
581
|
# Get the symbol and the hover information
|
|
484
582
|
h_range = hover_info.get("range")
|
|
485
|
-
symbol = extract_range(file_content, h_range)
|
|
583
|
+
symbol = extract_range(file_content, h_range) or ""
|
|
486
584
|
info = hover_info["contents"].get("value", "No hover information available.")
|
|
487
585
|
info = info.replace("```lean\n", "").replace("\n```", "").strip()
|
|
488
586
|
|
|
@@ -490,45 +588,58 @@ def hover(ctx: Context, file_path: str, line: int, column: int) -> str:
|
|
|
490
588
|
diagnostics = client.get_diagnostics(rel_path)
|
|
491
589
|
filtered = filter_diagnostics_by_position(diagnostics, line - 1, column - 1)
|
|
492
590
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
591
|
+
return HoverInfo(
|
|
592
|
+
symbol=symbol,
|
|
593
|
+
info=info,
|
|
594
|
+
diagnostics=_to_diagnostic_messages(filtered),
|
|
595
|
+
)
|
|
497
596
|
|
|
498
597
|
|
|
499
|
-
@mcp.tool(
|
|
598
|
+
@mcp.tool(
|
|
599
|
+
"lean_completions",
|
|
600
|
+
annotations=ToolAnnotations(
|
|
601
|
+
title="Completions",
|
|
602
|
+
readOnlyHint=True,
|
|
603
|
+
idempotentHint=True,
|
|
604
|
+
openWorldHint=False,
|
|
605
|
+
),
|
|
606
|
+
)
|
|
500
607
|
def completions(
|
|
501
|
-
ctx: Context,
|
|
608
|
+
ctx: Context,
|
|
609
|
+
file_path: Annotated[str, Field(description="Absolute path to Lean file")],
|
|
610
|
+
line: Annotated[int, Field(description="Line number (1-indexed)", ge=1)],
|
|
611
|
+
column: Annotated[int, Field(description="Column number (1-indexed)", ge=1)],
|
|
612
|
+
max_completions: Annotated[int, Field(description="Max completions", ge=1)] = 32,
|
|
502
613
|
) -> str:
|
|
503
|
-
"""Get
|
|
504
|
-
|
|
505
|
-
Only use this on INCOMPLETE lines/statements to check available identifiers and imports:
|
|
506
|
-
- Dot Completion: Displays relevant identifiers after a dot (e.g., `Nat.`, `x.`, or `Nat.ad`).
|
|
507
|
-
- Identifier Completion: Suggests matching identifiers after part of a name.
|
|
508
|
-
- Import Completion: Lists importable files after `import` at the beginning of a file.
|
|
509
|
-
|
|
510
|
-
Args:
|
|
511
|
-
file_path (str): Abs path to Lean file
|
|
512
|
-
line (int): Line number (1-indexed)
|
|
513
|
-
column (int): Column number (1-indexed)
|
|
514
|
-
max_completions (int, optional): Maximum number of completions to return. Defaults to 32
|
|
515
|
-
|
|
516
|
-
Returns:
|
|
517
|
-
str: List of possible completions or error msg
|
|
518
|
-
"""
|
|
614
|
+
"""Get IDE autocompletions. Use on INCOMPLETE code (after `.` or partial name)."""
|
|
519
615
|
rel_path = setup_client_for_file(ctx, file_path)
|
|
520
616
|
if not rel_path:
|
|
521
|
-
|
|
617
|
+
raise LeanToolError(
|
|
618
|
+
"Invalid Lean file path: Unable to start LSP server or load file"
|
|
619
|
+
)
|
|
522
620
|
|
|
523
621
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
524
622
|
client.open_file(rel_path)
|
|
525
623
|
content = client.get_file_content(rel_path)
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
624
|
+
raw_completions = client.get_completions(rel_path, line - 1, column - 1)
|
|
625
|
+
|
|
626
|
+
# Convert to CompletionItem models
|
|
627
|
+
items: List[CompletionItem] = []
|
|
628
|
+
for c in raw_completions:
|
|
629
|
+
if "label" not in c:
|
|
630
|
+
continue
|
|
631
|
+
kind_int = c.get("kind")
|
|
632
|
+
kind_str = COMPLETION_KIND.get(kind_int) if kind_int else None
|
|
633
|
+
items.append(
|
|
634
|
+
CompletionItem(
|
|
635
|
+
label=c["label"],
|
|
636
|
+
kind=kind_str,
|
|
637
|
+
detail=c.get("detail"),
|
|
638
|
+
)
|
|
639
|
+
)
|
|
529
640
|
|
|
530
|
-
if not
|
|
531
|
-
return
|
|
641
|
+
if not items:
|
|
642
|
+
return "[]"
|
|
532
643
|
|
|
533
644
|
# Find the sort term: The last word/identifier before the cursor
|
|
534
645
|
lines = content.splitlines()
|
|
@@ -541,104 +652,102 @@ def completions(
|
|
|
541
652
|
# Sort completions: prefix matches first, then contains, then alphabetical
|
|
542
653
|
if prefix:
|
|
543
654
|
|
|
544
|
-
def sort_key(item):
|
|
545
|
-
|
|
546
|
-
if
|
|
547
|
-
return (0,
|
|
548
|
-
elif prefix in
|
|
549
|
-
return (1,
|
|
655
|
+
def sort_key(item: CompletionItem):
|
|
656
|
+
label_lower = item.label.lower()
|
|
657
|
+
if label_lower.startswith(prefix):
|
|
658
|
+
return (0, label_lower)
|
|
659
|
+
elif prefix in label_lower:
|
|
660
|
+
return (1, label_lower)
|
|
550
661
|
else:
|
|
551
|
-
return (2,
|
|
662
|
+
return (2, label_lower)
|
|
552
663
|
|
|
553
|
-
|
|
664
|
+
items.sort(key=sort_key)
|
|
554
665
|
else:
|
|
555
|
-
|
|
666
|
+
items.sort(key=lambda x: x.label.lower())
|
|
556
667
|
|
|
557
668
|
# Truncate if too many results
|
|
558
|
-
|
|
559
|
-
remaining = len(formatted) - max_completions
|
|
560
|
-
formatted = formatted[:max_completions] + [
|
|
561
|
-
f"{remaining} more, keep typing to filter further"
|
|
562
|
-
]
|
|
563
|
-
completions_text = "\n".join(formatted)
|
|
564
|
-
return f"Completions at:\n{f_line}\n{completions_text}"
|
|
669
|
+
return _to_json_array(items[:max_completions])
|
|
565
670
|
|
|
566
671
|
|
|
567
|
-
@mcp.tool(
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
str
|
|
581
|
-
|
|
672
|
+
@mcp.tool(
|
|
673
|
+
"lean_declaration_file",
|
|
674
|
+
annotations=ToolAnnotations(
|
|
675
|
+
title="Declaration Source",
|
|
676
|
+
readOnlyHint=True,
|
|
677
|
+
idempotentHint=True,
|
|
678
|
+
openWorldHint=False,
|
|
679
|
+
),
|
|
680
|
+
)
|
|
681
|
+
def declaration_file(
|
|
682
|
+
ctx: Context,
|
|
683
|
+
file_path: Annotated[str, Field(description="Absolute path to Lean file")],
|
|
684
|
+
symbol: Annotated[
|
|
685
|
+
str, Field(description="Symbol (case sensitive, must be in file)")
|
|
686
|
+
],
|
|
687
|
+
) -> DeclarationInfo:
|
|
688
|
+
"""Get file where a symbol is declared. Symbol must be present in file first."""
|
|
582
689
|
rel_path = setup_client_for_file(ctx, file_path)
|
|
583
690
|
if not rel_path:
|
|
584
|
-
|
|
691
|
+
raise LeanToolError(
|
|
692
|
+
"Invalid Lean file path: Unable to start LSP server or load file"
|
|
693
|
+
)
|
|
585
694
|
|
|
586
695
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
587
696
|
client.open_file(rel_path)
|
|
588
697
|
orig_file_content = client.get_file_content(rel_path)
|
|
589
698
|
|
|
590
|
-
# Find the first occurence of the symbol (line and column) in the file
|
|
699
|
+
# Find the first occurence of the symbol (line and column) in the file
|
|
591
700
|
position = find_start_position(orig_file_content, symbol)
|
|
592
701
|
if not position:
|
|
593
|
-
|
|
702
|
+
raise LeanToolError(
|
|
703
|
+
f"Symbol `{symbol}` (case sensitive) not found in file. Add it first."
|
|
704
|
+
)
|
|
594
705
|
|
|
595
706
|
declaration = client.get_declarations(
|
|
596
707
|
rel_path, position["line"], position["column"]
|
|
597
708
|
)
|
|
598
709
|
|
|
599
710
|
if len(declaration) == 0:
|
|
600
|
-
|
|
711
|
+
raise LeanToolError(f"No declaration available for `{symbol}`.")
|
|
601
712
|
|
|
602
713
|
# Load the declaration file
|
|
603
|
-
|
|
604
|
-
uri =
|
|
605
|
-
if not uri:
|
|
606
|
-
uri = declaration.get("uri")
|
|
714
|
+
decl = declaration[0]
|
|
715
|
+
uri = decl.get("targetUri") or decl.get("uri")
|
|
607
716
|
|
|
608
717
|
abs_path = client._uri_to_abs(uri)
|
|
609
718
|
if not os.path.exists(abs_path):
|
|
610
|
-
|
|
719
|
+
raise LeanToolError(
|
|
720
|
+
f"Could not open declaration file `{abs_path}` for `{symbol}`."
|
|
721
|
+
)
|
|
611
722
|
|
|
612
723
|
file_content = get_file_contents(abs_path)
|
|
613
724
|
|
|
614
|
-
return
|
|
725
|
+
return DeclarationInfo(file_path=str(abs_path), content=file_content)
|
|
615
726
|
|
|
616
727
|
|
|
617
|
-
@mcp.tool(
|
|
728
|
+
@mcp.tool(
|
|
729
|
+
"lean_multi_attempt",
|
|
730
|
+
annotations=ToolAnnotations(
|
|
731
|
+
title="Multi-Attempt",
|
|
732
|
+
readOnlyHint=True,
|
|
733
|
+
idempotentHint=True,
|
|
734
|
+
openWorldHint=False,
|
|
735
|
+
),
|
|
736
|
+
)
|
|
618
737
|
def multi_attempt(
|
|
619
|
-
ctx: Context,
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
Note:
|
|
628
|
-
Only single-line, fully-indented snippets are supported.
|
|
629
|
-
Avoid comments for best results.
|
|
630
|
-
|
|
631
|
-
Args:
|
|
632
|
-
file_path (str): Abs path to Lean file
|
|
633
|
-
line (int): Line number (1-indexed)
|
|
634
|
-
snippets (List[str]): List of snippets (3+ are recommended)
|
|
635
|
-
|
|
636
|
-
Returns:
|
|
637
|
-
List[str] | str: Diagnostics and goal states or error msg
|
|
638
|
-
"""
|
|
738
|
+
ctx: Context,
|
|
739
|
+
file_path: Annotated[str, Field(description="Absolute path to Lean file")],
|
|
740
|
+
line: Annotated[int, Field(description="Line number (1-indexed)", ge=1)],
|
|
741
|
+
snippets: Annotated[
|
|
742
|
+
List[str], Field(description="Tactics to try (3+ recommended)")
|
|
743
|
+
],
|
|
744
|
+
) -> str:
|
|
745
|
+
"""Try multiple tactics without modifying file. Returns goal state for each."""
|
|
639
746
|
rel_path = setup_client_for_file(ctx, file_path)
|
|
640
747
|
if not rel_path:
|
|
641
|
-
|
|
748
|
+
raise LeanToolError(
|
|
749
|
+
"Invalid Lean file path: Unable to start LSP server or load file"
|
|
750
|
+
)
|
|
642
751
|
|
|
643
752
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
644
753
|
client.open_file(rel_path)
|
|
@@ -646,7 +755,7 @@ def multi_attempt(
|
|
|
646
755
|
try:
|
|
647
756
|
client.open_file(rel_path)
|
|
648
757
|
|
|
649
|
-
results = []
|
|
758
|
+
results: List[AttemptResult] = []
|
|
650
759
|
# Avoid mutating caller-provided snippets; normalize locally per attempt
|
|
651
760
|
for snippet in snippets:
|
|
652
761
|
snippet_str = snippet.rstrip("\n")
|
|
@@ -660,13 +769,19 @@ def multi_attempt(
|
|
|
660
769
|
# Apply the change to the file, capture diagnostics and goal state
|
|
661
770
|
client.update_file(rel_path, [change])
|
|
662
771
|
diag = client.get_diagnostics(rel_path)
|
|
663
|
-
|
|
772
|
+
filtered_diag = filter_diagnostics_by_position(diag, line - 1, None)
|
|
664
773
|
# Use the snippet text length without any trailing newline for the column
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
results.append(
|
|
774
|
+
goal_result = client.get_goal(rel_path, line - 1, len(snippet_str))
|
|
775
|
+
goal_state = format_goal(goal_result, None)
|
|
776
|
+
results.append(
|
|
777
|
+
AttemptResult(
|
|
778
|
+
snippet=snippet_str,
|
|
779
|
+
goal_state=goal_state,
|
|
780
|
+
diagnostics=_to_diagnostic_messages(filtered_diag),
|
|
781
|
+
)
|
|
782
|
+
)
|
|
668
783
|
|
|
669
|
-
return results
|
|
784
|
+
return _to_json_array(results)
|
|
670
785
|
finally:
|
|
671
786
|
try:
|
|
672
787
|
client.close_files([rel_path])
|
|
@@ -676,23 +791,26 @@ def multi_attempt(
|
|
|
676
791
|
)
|
|
677
792
|
|
|
678
793
|
|
|
679
|
-
@mcp.tool(
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
794
|
+
@mcp.tool(
|
|
795
|
+
"lean_run_code",
|
|
796
|
+
annotations=ToolAnnotations(
|
|
797
|
+
title="Run Code",
|
|
798
|
+
readOnlyHint=True,
|
|
799
|
+
idempotentHint=True,
|
|
800
|
+
openWorldHint=False,
|
|
801
|
+
),
|
|
802
|
+
)
|
|
803
|
+
def run_code(
|
|
804
|
+
ctx: Context,
|
|
805
|
+
code: Annotated[str, Field(description="Self-contained Lean code with imports")],
|
|
806
|
+
) -> RunResult:
|
|
807
|
+
"""Run a code snippet and return diagnostics. Must include all imports."""
|
|
692
808
|
lifespan_context = ctx.request_context.lifespan_context
|
|
693
809
|
lean_project_path = lifespan_context.lean_project_path
|
|
694
810
|
if lean_project_path is None:
|
|
695
|
-
|
|
811
|
+
raise LeanToolError(
|
|
812
|
+
"No valid Lean project path found. Run another tool first to set it up."
|
|
813
|
+
)
|
|
696
814
|
|
|
697
815
|
# Use a unique snippet filename to avoid collisions under concurrency
|
|
698
816
|
rel_path = f"_mcp_snippet_{uuid.uuid4().hex}.lean"
|
|
@@ -702,12 +820,10 @@ def run_code(ctx: Context, code: str) -> List[str] | str:
|
|
|
702
820
|
with open(abs_path, "w", encoding="utf-8") as f:
|
|
703
821
|
f.write(code)
|
|
704
822
|
except Exception as e:
|
|
705
|
-
|
|
823
|
+
raise LeanToolError(f"Error writing code snippet: {e}")
|
|
706
824
|
|
|
707
825
|
client: LeanLSPClient | None = lifespan_context.client
|
|
708
|
-
|
|
709
|
-
close_error: str | None = None
|
|
710
|
-
remove_error: str | None = None
|
|
826
|
+
raw_diagnostics: List[Dict] = []
|
|
711
827
|
opened_file = False
|
|
712
828
|
|
|
713
829
|
try:
|
|
@@ -715,62 +831,57 @@ def run_code(ctx: Context, code: str) -> List[str] | str:
|
|
|
715
831
|
startup_client(ctx)
|
|
716
832
|
client = lifespan_context.client
|
|
717
833
|
if client is None:
|
|
718
|
-
|
|
834
|
+
raise LeanToolError("Failed to initialize Lean client for run_code.")
|
|
719
835
|
|
|
720
|
-
assert client is not None
|
|
836
|
+
assert client is not None
|
|
721
837
|
client.open_file(rel_path)
|
|
722
838
|
opened_file = True
|
|
723
|
-
|
|
724
|
-
client.get_diagnostics(rel_path, inactivity_timeout=15.0)
|
|
725
|
-
)
|
|
839
|
+
raw_diagnostics = client.get_diagnostics(rel_path, inactivity_timeout=15.0)
|
|
726
840
|
finally:
|
|
727
841
|
if opened_file:
|
|
728
842
|
try:
|
|
729
843
|
client.close_files([rel_path])
|
|
730
|
-
except Exception as exc:
|
|
731
|
-
close_error = str(exc)
|
|
844
|
+
except Exception as exc:
|
|
732
845
|
logger.warning("Failed to close `%s` after run_code: %s", rel_path, exc)
|
|
733
846
|
try:
|
|
734
847
|
os.remove(abs_path)
|
|
735
848
|
except FileNotFoundError:
|
|
736
849
|
pass
|
|
737
850
|
except Exception as e:
|
|
738
|
-
remove_error = str(e)
|
|
739
851
|
logger.warning(
|
|
740
852
|
"Failed to remove temporary Lean snippet `%s`: %s", abs_path, e
|
|
741
853
|
)
|
|
742
854
|
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
if close_error:
|
|
746
|
-
return f"Error closing temporary Lean document `{rel_path}`:\n{close_error}"
|
|
747
|
-
|
|
748
|
-
return (
|
|
749
|
-
diagnostics
|
|
750
|
-
if diagnostics
|
|
751
|
-
else "No diagnostics found for the code snippet (compiled successfully)."
|
|
752
|
-
)
|
|
855
|
+
diagnostics = _to_diagnostic_messages(raw_diagnostics)
|
|
856
|
+
has_errors = any(d.severity == "error" for d in diagnostics)
|
|
753
857
|
|
|
858
|
+
return RunResult(success=not has_errors, diagnostics=diagnostics)
|
|
754
859
|
|
|
755
|
-
@mcp.tool("lean_local_search")
|
|
756
|
-
def local_search(
|
|
757
|
-
ctx: Context, query: str, limit: int = 10, project_root: str | None = None
|
|
758
|
-
) -> List[Dict[str, str]] | str:
|
|
759
|
-
"""Confirm declarations exist in the current workspace to prevent hallucinating APIs.
|
|
760
860
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
The index spans theorems, lemmas, defs, classes, instances, structures, inductives, abbrevs, and opaque decls.
|
|
861
|
+
class LocalSearchError(Exception):
|
|
862
|
+
pass
|
|
764
863
|
|
|
765
|
-
Args:
|
|
766
|
-
query (str): Declaration name or prefix.
|
|
767
|
-
limit (int): Max matches to return (default 10).
|
|
768
864
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
865
|
+
@mcp.tool(
|
|
866
|
+
"lean_local_search",
|
|
867
|
+
annotations=ToolAnnotations(
|
|
868
|
+
title="Local Search",
|
|
869
|
+
readOnlyHint=True,
|
|
870
|
+
idempotentHint=True,
|
|
871
|
+
openWorldHint=False,
|
|
872
|
+
),
|
|
873
|
+
)
|
|
874
|
+
def local_search(
|
|
875
|
+
ctx: Context,
|
|
876
|
+
query: Annotated[str, Field(description="Declaration name or prefix")],
|
|
877
|
+
limit: Annotated[int, Field(description="Max matches", ge=1)] = 10,
|
|
878
|
+
project_root: Annotated[
|
|
879
|
+
Optional[str], Field(description="Project root (inferred if omitted)")
|
|
880
|
+
] = None,
|
|
881
|
+
) -> str:
|
|
882
|
+
"""Fast local search to verify declarations exist. Use BEFORE trying a lemma name."""
|
|
772
883
|
if not _RG_AVAILABLE:
|
|
773
|
-
|
|
884
|
+
raise LocalSearchError(_RG_MESSAGE)
|
|
774
885
|
|
|
775
886
|
lifespan = ctx.request_context.lifespan_context
|
|
776
887
|
stored_root = lifespan.lean_project_path
|
|
@@ -778,92 +889,101 @@ def local_search(
|
|
|
778
889
|
if project_root:
|
|
779
890
|
try:
|
|
780
891
|
resolved_root = Path(project_root).expanduser().resolve()
|
|
781
|
-
except OSError as exc:
|
|
782
|
-
|
|
892
|
+
except OSError as exc:
|
|
893
|
+
raise LocalSearchError(f"Invalid project root '{project_root}': {exc}")
|
|
783
894
|
if not resolved_root.exists():
|
|
784
|
-
|
|
895
|
+
raise LocalSearchError(f"Project root '{project_root}' does not exist.")
|
|
785
896
|
lifespan.lean_project_path = resolved_root
|
|
786
897
|
else:
|
|
787
898
|
resolved_root = stored_root
|
|
788
899
|
|
|
789
900
|
if resolved_root is None:
|
|
790
|
-
|
|
901
|
+
raise LocalSearchError(
|
|
902
|
+
"Lean project path not set. Call a file-based tool first."
|
|
903
|
+
)
|
|
791
904
|
|
|
792
905
|
try:
|
|
793
|
-
|
|
906
|
+
raw_results = lean_local_search(
|
|
794
907
|
query=query.strip(), limit=limit, project_root=resolved_root
|
|
795
908
|
)
|
|
909
|
+
results = [
|
|
910
|
+
LocalSearchResult(name=r["name"], kind=r["kind"], file=r["file"])
|
|
911
|
+
for r in raw_results
|
|
912
|
+
]
|
|
913
|
+
return _to_json_array(results)
|
|
796
914
|
except RuntimeError as exc:
|
|
797
|
-
|
|
915
|
+
raise LocalSearchError(f"Search failed: {exc}")
|
|
798
916
|
|
|
799
917
|
|
|
800
|
-
@mcp.tool(
|
|
918
|
+
@mcp.tool(
|
|
919
|
+
"lean_leansearch",
|
|
920
|
+
annotations=ToolAnnotations(
|
|
921
|
+
title="LeanSearch",
|
|
922
|
+
readOnlyHint=True,
|
|
923
|
+
idempotentHint=True,
|
|
924
|
+
openWorldHint=True,
|
|
925
|
+
),
|
|
926
|
+
)
|
|
801
927
|
@rate_limited("leansearch", max_requests=3, per_seconds=30)
|
|
802
|
-
def leansearch(
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
- Concept names: "Cauchy Schwarz"
|
|
809
|
-
- Lean identifiers: "List.sum", "Finset induction"
|
|
810
|
-
- Lean term: "{f : A → B} {g : B → A} (hf : Injective f) (hg : Injective g) : ∃ h, Bijective h"
|
|
811
|
-
|
|
812
|
-
Args:
|
|
813
|
-
query (str): Search query
|
|
814
|
-
num_results (int, optional): Max results. Defaults to 5.
|
|
815
|
-
|
|
816
|
-
Returns:
|
|
817
|
-
List[Dict] | str: Search results or error msg
|
|
818
|
-
"""
|
|
819
|
-
try:
|
|
820
|
-
headers = {"User-Agent": "lean-lsp-mcp/0.1", "Content-Type": "application/json"}
|
|
821
|
-
payload = orjson.dumps({"num_results": str(num_results), "query": [query]})
|
|
822
|
-
|
|
823
|
-
req = urllib.request.Request(
|
|
824
|
-
"https://leansearch.net/search",
|
|
825
|
-
data=payload,
|
|
826
|
-
headers=headers,
|
|
827
|
-
method="POST",
|
|
828
|
-
)
|
|
829
|
-
|
|
830
|
-
with urllib.request.urlopen(req, timeout=20) as response:
|
|
831
|
-
results = orjson.loads(response.read())
|
|
832
|
-
|
|
833
|
-
if not results or not results[0]:
|
|
834
|
-
return "No results found."
|
|
835
|
-
results = results[0][:num_results]
|
|
836
|
-
results = [r["result"] for r in results]
|
|
837
|
-
|
|
838
|
-
for result in results:
|
|
839
|
-
result.pop("docstring")
|
|
840
|
-
result["module_name"] = ".".join(result["module_name"])
|
|
841
|
-
result["name"] = ".".join(result["name"])
|
|
842
|
-
|
|
843
|
-
return results
|
|
844
|
-
except Exception as e:
|
|
845
|
-
return f"leansearch error:\n{str(e)}"
|
|
928
|
+
def leansearch(
|
|
929
|
+
ctx: Context,
|
|
930
|
+
query: Annotated[str, Field(description="Natural language or Lean term query")],
|
|
931
|
+
num_results: Annotated[int, Field(description="Max results", ge=1)] = 5,
|
|
932
|
+
) -> str:
|
|
933
|
+
"""Search Mathlib via leansearch.net using natural language.
|
|
846
934
|
|
|
935
|
+
Examples: "sum of two even numbers is even", "Cauchy-Schwarz inequality",
|
|
936
|
+
"{f : A → B} (hf : Injective f) : ∃ g, LeftInverse g f"
|
|
937
|
+
"""
|
|
938
|
+
headers = {"User-Agent": "lean-lsp-mcp/0.1", "Content-Type": "application/json"}
|
|
939
|
+
payload = orjson.dumps({"num_results": str(num_results), "query": [query]})
|
|
940
|
+
|
|
941
|
+
req = urllib.request.Request(
|
|
942
|
+
"https://leansearch.net/search",
|
|
943
|
+
data=payload,
|
|
944
|
+
headers=headers,
|
|
945
|
+
method="POST",
|
|
946
|
+
)
|
|
847
947
|
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
"""Search for definitions and theorems using loogle.
|
|
948
|
+
with urllib.request.urlopen(req, timeout=20) as response:
|
|
949
|
+
results = orjson.loads(response.read())
|
|
851
950
|
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
- By lemma name: "differ" # finds lemmas with "differ" in the name
|
|
855
|
-
- By subexpression: _ * (_ ^ _) # finds lemmas with a product and power
|
|
856
|
-
- Non-linear: Real.sqrt ?a * Real.sqrt ?a
|
|
857
|
-
- By type shape: (?a -> ?b) -> List ?a -> List ?b
|
|
858
|
-
- By conclusion: |- tsum _ = _ * tsum _
|
|
859
|
-
- By conclusion w/hyps: |- _ < _ → tsum _ < tsum _
|
|
951
|
+
if not results or not results[0]:
|
|
952
|
+
return "[]"
|
|
860
953
|
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
954
|
+
raw_results = [r["result"] for r in results[0][:num_results]]
|
|
955
|
+
items = [
|
|
956
|
+
LeanSearchResult(
|
|
957
|
+
name=".".join(r["name"]),
|
|
958
|
+
module_name=".".join(r["module_name"]),
|
|
959
|
+
kind=r.get("kind"),
|
|
960
|
+
type=r.get("type"),
|
|
961
|
+
)
|
|
962
|
+
for r in raw_results
|
|
963
|
+
]
|
|
964
|
+
return _to_json_array(items)
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
@mcp.tool(
|
|
968
|
+
"lean_loogle",
|
|
969
|
+
annotations=ToolAnnotations(
|
|
970
|
+
title="Loogle",
|
|
971
|
+
readOnlyHint=True,
|
|
972
|
+
idempotentHint=True,
|
|
973
|
+
openWorldHint=True,
|
|
974
|
+
),
|
|
975
|
+
)
|
|
976
|
+
async def loogle(
|
|
977
|
+
ctx: Context,
|
|
978
|
+
query: Annotated[
|
|
979
|
+
str, Field(description="Type pattern, constant, or name substring")
|
|
980
|
+
],
|
|
981
|
+
num_results: Annotated[int, Field(description="Max results", ge=1)] = 8,
|
|
982
|
+
) -> str:
|
|
983
|
+
"""Search Mathlib by type signature via loogle.lean-lang.org.
|
|
864
984
|
|
|
865
|
-
|
|
866
|
-
|
|
985
|
+
Examples: `Real.sin`, `"comm"`, `(?a → ?b) → List ?a → List ?b`,
|
|
986
|
+
`_ * (_ ^ _)`, `|- _ < _ → _ + 1 < _ + 1`
|
|
867
987
|
"""
|
|
868
988
|
app_ctx: AppContext = ctx.request_context.lifespan_context
|
|
869
989
|
|
|
@@ -871,9 +991,17 @@ async def loogle(ctx: Context, query: str, num_results: int = 8) -> List[dict] |
|
|
|
871
991
|
if app_ctx.loogle_local_available and app_ctx.loogle_manager:
|
|
872
992
|
try:
|
|
873
993
|
results = await app_ctx.loogle_manager.query(query, num_results)
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
994
|
+
if not results:
|
|
995
|
+
return "No results found."
|
|
996
|
+
items = [
|
|
997
|
+
LoogleResult(
|
|
998
|
+
name=r.get("name", ""),
|
|
999
|
+
type=r.get("type", ""),
|
|
1000
|
+
module=r.get("module", ""),
|
|
1001
|
+
)
|
|
1002
|
+
for r in results
|
|
1003
|
+
]
|
|
1004
|
+
return _to_json_array(items)
|
|
877
1005
|
except Exception as e:
|
|
878
1006
|
logger.warning(f"Local loogle failed: {e}, falling back to remote")
|
|
879
1007
|
|
|
@@ -885,142 +1013,145 @@ async def loogle(ctx: Context, query: str, num_results: int = 8) -> List[dict] |
|
|
|
885
1013
|
return "Rate limit exceeded: 3 requests per 30s. Use --loogle-local to avoid limits."
|
|
886
1014
|
rate_limit.append(now)
|
|
887
1015
|
|
|
888
|
-
|
|
1016
|
+
result = loogle_remote(query, num_results)
|
|
1017
|
+
if isinstance(result, str):
|
|
1018
|
+
return result # Error message
|
|
1019
|
+
return _to_json_array(result)
|
|
889
1020
|
|
|
890
1021
|
|
|
891
|
-
@mcp.tool(
|
|
1022
|
+
@mcp.tool(
|
|
1023
|
+
"lean_leanfinder",
|
|
1024
|
+
annotations=ToolAnnotations(
|
|
1025
|
+
title="Lean Finder",
|
|
1026
|
+
readOnlyHint=True,
|
|
1027
|
+
idempotentHint=True,
|
|
1028
|
+
openWorldHint=True,
|
|
1029
|
+
),
|
|
1030
|
+
)
|
|
892
1031
|
@rate_limited("leanfinder", max_requests=10, per_seconds=30)
|
|
893
|
-
def leanfinder(
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
- Proof state. For better results, enter a proof state followed by how you want to transform the proof state.
|
|
900
|
-
- Statement definition: Fragment or the whole statement definition.
|
|
901
|
-
|
|
902
|
-
Tips: Multiple targeted queries beat one complex query.
|
|
903
|
-
|
|
904
|
-
Args:
|
|
905
|
-
query (str): Mathematical concept or proof state
|
|
906
|
-
num_results (int, optional): Max results. Defaults to 5.
|
|
1032
|
+
def leanfinder(
|
|
1033
|
+
ctx: Context,
|
|
1034
|
+
query: Annotated[str, Field(description="Mathematical concept or proof state")],
|
|
1035
|
+
num_results: Annotated[int, Field(description="Max results", ge=1)] = 5,
|
|
1036
|
+
) -> str:
|
|
1037
|
+
"""Semantic search by mathematical meaning via Lean Finder.
|
|
907
1038
|
|
|
908
|
-
|
|
909
|
-
|
|
1039
|
+
Examples: "commutativity of addition on natural numbers",
|
|
1040
|
+
"I have h : n < m and need n + 1 < m + 1", proof state text.
|
|
910
1041
|
"""
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
req = urllib.request.Request(
|
|
918
|
-
request_url, data=payload, headers=headers, method="POST"
|
|
919
|
-
)
|
|
1042
|
+
headers = {"User-Agent": "lean-lsp-mcp/0.1", "Content-Type": "application/json"}
|
|
1043
|
+
request_url = "https://bxrituxuhpc70w8w.us-east-1.aws.endpoints.huggingface.cloud"
|
|
1044
|
+
payload = orjson.dumps({"inputs": query, "top_k": int(num_results)})
|
|
1045
|
+
req = urllib.request.Request(
|
|
1046
|
+
request_url, data=payload, headers=headers, method="POST"
|
|
1047
|
+
)
|
|
920
1048
|
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
1049
|
+
results: List[LeanFinderResult] = []
|
|
1050
|
+
with urllib.request.urlopen(req, timeout=30) as response:
|
|
1051
|
+
data = orjson.loads(response.read())
|
|
1052
|
+
for result in data["results"]:
|
|
1053
|
+
if (
|
|
1054
|
+
"https://leanprover-community.github.io/mathlib4_docs"
|
|
1055
|
+
not in result["url"]
|
|
1056
|
+
): # Only include mathlib4 results
|
|
1057
|
+
continue
|
|
1058
|
+
match = re.search(r"pattern=(.*?)#doc", result["url"])
|
|
1059
|
+
if match:
|
|
1060
|
+
results.append(
|
|
1061
|
+
LeanFinderResult(
|
|
1062
|
+
full_name=match.group(1),
|
|
1063
|
+
formal_statement=result["formal_statement"],
|
|
1064
|
+
informal_statement=result["informal_statement"],
|
|
1065
|
+
)
|
|
1066
|
+
)
|
|
1067
|
+
|
|
1068
|
+
return _to_json_array(results)
|
|
941
1069
|
|
|
942
1070
|
|
|
943
|
-
@mcp.tool(
|
|
1071
|
+
@mcp.tool(
|
|
1072
|
+
"lean_state_search",
|
|
1073
|
+
annotations=ToolAnnotations(
|
|
1074
|
+
title="State Search",
|
|
1075
|
+
readOnlyHint=True,
|
|
1076
|
+
idempotentHint=True,
|
|
1077
|
+
openWorldHint=True,
|
|
1078
|
+
),
|
|
1079
|
+
)
|
|
944
1080
|
@rate_limited("lean_state_search", max_requests=3, per_seconds=30)
|
|
945
1081
|
def state_search(
|
|
946
|
-
ctx: Context,
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
file_path (str): Abs path to Lean file
|
|
954
|
-
line (int): Line number (1-indexed)
|
|
955
|
-
column (int): Column number (1-indexed)
|
|
956
|
-
num_results (int, optional): Max results. Defaults to 5.
|
|
957
|
-
|
|
958
|
-
Returns:
|
|
959
|
-
List | str: Search results or error msg
|
|
960
|
-
"""
|
|
1082
|
+
ctx: Context,
|
|
1083
|
+
file_path: Annotated[str, Field(description="Absolute path to Lean file")],
|
|
1084
|
+
line: Annotated[int, Field(description="Line number (1-indexed)", ge=1)],
|
|
1085
|
+
column: Annotated[int, Field(description="Column number (1-indexed)", ge=1)],
|
|
1086
|
+
num_results: Annotated[int, Field(description="Max results", ge=1)] = 5,
|
|
1087
|
+
) -> str:
|
|
1088
|
+
"""Find lemmas to close the goal at a position. Searches premise-search.com."""
|
|
961
1089
|
rel_path = setup_client_for_file(ctx, file_path)
|
|
962
1090
|
if not rel_path:
|
|
963
|
-
|
|
1091
|
+
raise LeanToolError(
|
|
1092
|
+
"Invalid Lean file path: Unable to start LSP server or load file"
|
|
1093
|
+
)
|
|
964
1094
|
|
|
965
1095
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
966
1096
|
client.open_file(rel_path)
|
|
967
|
-
file_contents = client.get_file_content(rel_path)
|
|
968
1097
|
goal = client.get_goal(rel_path, line - 1, column - 1)
|
|
969
1098
|
|
|
970
|
-
f_line = format_line(file_contents, line, column)
|
|
971
1099
|
if not goal or not goal.get("goals"):
|
|
972
|
-
|
|
1100
|
+
raise LeanToolError(
|
|
1101
|
+
f"No goals found at line {line}, column {column}. Try a different position or check if the proof is complete."
|
|
1102
|
+
)
|
|
973
1103
|
|
|
974
|
-
|
|
1104
|
+
goal_str = urllib.parse.quote(goal["goals"][0])
|
|
975
1105
|
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
)
|
|
1106
|
+
url = os.getenv("LEAN_STATE_SEARCH_URL", "https://premise-search.com")
|
|
1107
|
+
req = urllib.request.Request(
|
|
1108
|
+
f"{url}/api/search?query={goal_str}&results={num_results}&rev=v4.22.0",
|
|
1109
|
+
headers={"User-Agent": "lean-lsp-mcp/0.1"},
|
|
1110
|
+
method="GET",
|
|
1111
|
+
)
|
|
983
1112
|
|
|
984
|
-
|
|
985
|
-
|
|
1113
|
+
with urllib.request.urlopen(req, timeout=20) as response:
|
|
1114
|
+
results = orjson.loads(response.read())
|
|
986
1115
|
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
# Very dirty type mix
|
|
990
|
-
results.insert(0, f"Results for line:\n{f_line}")
|
|
991
|
-
return results
|
|
992
|
-
except Exception as e:
|
|
993
|
-
return f"lean state search error:\n{str(e)}"
|
|
1116
|
+
items = [StateSearchResult(name=r["name"]) for r in results]
|
|
1117
|
+
return _to_json_array(items)
|
|
994
1118
|
|
|
995
1119
|
|
|
996
|
-
@mcp.tool(
|
|
1120
|
+
@mcp.tool(
|
|
1121
|
+
"lean_hammer_premise",
|
|
1122
|
+
annotations=ToolAnnotations(
|
|
1123
|
+
title="Hammer Premises",
|
|
1124
|
+
readOnlyHint=True,
|
|
1125
|
+
idempotentHint=True,
|
|
1126
|
+
openWorldHint=True,
|
|
1127
|
+
),
|
|
1128
|
+
)
|
|
997
1129
|
@rate_limited("hammer_premise", max_requests=3, per_seconds=30)
|
|
998
1130
|
def hammer_premise(
|
|
999
|
-
ctx: Context,
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
Returns:
|
|
1010
|
-
List[str] | str: List of relevant premises or error message
|
|
1131
|
+
ctx: Context,
|
|
1132
|
+
file_path: Annotated[str, Field(description="Absolute path to Lean file")],
|
|
1133
|
+
line: Annotated[int, Field(description="Line number (1-indexed)", ge=1)],
|
|
1134
|
+
column: Annotated[int, Field(description="Column number (1-indexed)", ge=1)],
|
|
1135
|
+
num_results: Annotated[int, Field(description="Max results", ge=1)] = 32,
|
|
1136
|
+
) -> str:
|
|
1137
|
+
"""Get premise suggestions for automation tactics at a goal position.
|
|
1138
|
+
|
|
1139
|
+
Returns lemma names to try with `simp only [...]`, `aesop`, or as hints.
|
|
1011
1140
|
"""
|
|
1012
1141
|
rel_path = setup_client_for_file(ctx, file_path)
|
|
1013
1142
|
if not rel_path:
|
|
1014
|
-
|
|
1143
|
+
raise LeanToolError(
|
|
1144
|
+
"Invalid Lean file path: Unable to start LSP server or load file"
|
|
1145
|
+
)
|
|
1015
1146
|
|
|
1016
1147
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
1017
1148
|
client.open_file(rel_path)
|
|
1018
|
-
file_contents = client.get_file_content(rel_path)
|
|
1019
1149
|
goal = client.get_goal(rel_path, line - 1, column - 1)
|
|
1020
1150
|
|
|
1021
|
-
f_line = format_line(file_contents, line, column)
|
|
1022
1151
|
if not goal or not goal.get("goals"):
|
|
1023
|
-
|
|
1152
|
+
raise LeanToolError(
|
|
1153
|
+
f"No goals found at line {line}, column {column}. Try a different position or check if the proof is complete."
|
|
1154
|
+
)
|
|
1024
1155
|
|
|
1025
1156
|
data = {
|
|
1026
1157
|
"state": goal["goals"][0],
|
|
@@ -1028,26 +1159,22 @@ def hammer_premise(
|
|
|
1028
1159
|
"k": num_results,
|
|
1029
1160
|
}
|
|
1030
1161
|
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
)
|
|
1162
|
+
url = os.getenv("LEAN_HAMMER_URL", "http://leanpremise.net")
|
|
1163
|
+
req = urllib.request.Request(
|
|
1164
|
+
url + "/retrieve",
|
|
1165
|
+
headers={
|
|
1166
|
+
"User-Agent": "lean-lsp-mcp/0.1",
|
|
1167
|
+
"Content-Type": "application/json",
|
|
1168
|
+
},
|
|
1169
|
+
method="POST",
|
|
1170
|
+
data=orjson.dumps(data),
|
|
1171
|
+
)
|
|
1042
1172
|
|
|
1043
|
-
|
|
1044
|
-
|
|
1173
|
+
with urllib.request.urlopen(req, timeout=20) as response:
|
|
1174
|
+
results = orjson.loads(response.read())
|
|
1045
1175
|
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
return results
|
|
1049
|
-
except Exception as e:
|
|
1050
|
-
return f"lean hammer premise error:\n{str(e)}"
|
|
1176
|
+
items = [PremiseResult(name=r["name"]) for r in results]
|
|
1177
|
+
return _to_json_array(items)
|
|
1051
1178
|
|
|
1052
1179
|
|
|
1053
1180
|
if __name__ == "__main__":
|