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