lean-lsp-mcp 0.14.1__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/__init__.py +19 -0
- lean_lsp_mcp/instructions.py +31 -12
- lean_lsp_mcp/loogle.py +329 -0
- lean_lsp_mcp/models.py +120 -0
- lean_lsp_mcp/outline_utils.py +190 -59
- lean_lsp_mcp/server.py +666 -519
- lean_lsp_mcp/utils.py +31 -0
- {lean_lsp_mcp-0.14.1.dist-info → lean_lsp_mcp-0.16.0.dist-info}/METADATA +25 -3
- lean_lsp_mcp-0.16.0.dist-info/RECORD +17 -0
- lean_lsp_mcp-0.14.1.dist-info/RECORD +0 -15
- {lean_lsp_mcp-0.14.1.dist-info → lean_lsp_mcp-0.16.0.dist-info}/WHEEL +0 -0
- {lean_lsp_mcp-0.14.1.dist-info → lean_lsp_mcp-0.16.0.dist-info}/entry_points.txt +0 -0
- {lean_lsp_mcp-0.14.1.dist-info → lean_lsp_mcp-0.16.0.dist-info}/licenses/LICENSE +0 -0
- {lean_lsp_mcp-0.14.1.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 (
|
|
@@ -26,20 +28,52 @@ from lean_lsp_mcp.client_utils import (
|
|
|
26
28
|
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
|
-
from lean_lsp_mcp.
|
|
31
|
+
from lean_lsp_mcp.loogle import LoogleManager, loogle_remote
|
|
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
|
+
)
|
|
30
51
|
from lean_lsp_mcp.utils import (
|
|
52
|
+
COMPLETION_KIND,
|
|
31
53
|
OutputCapture,
|
|
32
54
|
deprecated,
|
|
33
55
|
extract_range,
|
|
34
56
|
filter_diagnostics_by_position,
|
|
35
57
|
find_start_position,
|
|
36
|
-
format_diagnostics,
|
|
37
58
|
format_goal,
|
|
38
|
-
format_line,
|
|
39
59
|
get_declaration_range,
|
|
40
60
|
OptionalTokenVerifier,
|
|
41
61
|
)
|
|
42
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
|
+
|
|
43
77
|
|
|
44
78
|
_LOG_LEVEL = os.environ.get("LEAN_LOG_LEVEL", "INFO")
|
|
45
79
|
configure_logging("CRITICAL" if _LOG_LEVEL == "NONE" else _LOG_LEVEL)
|
|
@@ -49,17 +83,21 @@ logger = get_logger(__name__)
|
|
|
49
83
|
_RG_AVAILABLE, _RG_MESSAGE = check_ripgrep_status()
|
|
50
84
|
|
|
51
85
|
|
|
52
|
-
# Server and context
|
|
53
86
|
@dataclass
|
|
54
87
|
class AppContext:
|
|
55
88
|
lean_project_path: Path | None
|
|
56
89
|
client: LeanLSPClient | None
|
|
57
90
|
rate_limit: Dict[str, List[int]]
|
|
58
91
|
lean_search_available: bool
|
|
92
|
+
loogle_manager: LoogleManager | None = None
|
|
93
|
+
loogle_local_available: bool = False
|
|
59
94
|
|
|
60
95
|
|
|
61
96
|
@asynccontextmanager
|
|
62
97
|
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
|
|
98
|
+
loogle_manager: LoogleManager | None = None
|
|
99
|
+
loogle_local_available = False
|
|
100
|
+
|
|
63
101
|
try:
|
|
64
102
|
lean_project_path_str = os.environ.get("LEAN_PROJECT_PATH", "").strip()
|
|
65
103
|
if not lean_project_path_str:
|
|
@@ -67,6 +105,19 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
|
|
|
67
105
|
else:
|
|
68
106
|
lean_project_path = Path(lean_project_path_str).resolve()
|
|
69
107
|
|
|
108
|
+
# Initialize local loogle if enabled via env var or CLI
|
|
109
|
+
if os.environ.get("LEAN_LOOGLE_LOCAL", "").lower() in ("1", "true", "yes"):
|
|
110
|
+
logger.info("Local loogle enabled, initializing...")
|
|
111
|
+
loogle_manager = LoogleManager()
|
|
112
|
+
if loogle_manager.ensure_installed():
|
|
113
|
+
if await loogle_manager.start():
|
|
114
|
+
loogle_local_available = True
|
|
115
|
+
logger.info("Local loogle started successfully")
|
|
116
|
+
else:
|
|
117
|
+
logger.warning("Local loogle failed to start, will use remote API")
|
|
118
|
+
else:
|
|
119
|
+
logger.warning("Local loogle installation failed, will use remote API")
|
|
120
|
+
|
|
70
121
|
context = AppContext(
|
|
71
122
|
lean_project_path=lean_project_path,
|
|
72
123
|
client=None,
|
|
@@ -78,6 +129,8 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
|
|
|
78
129
|
"hammer_premise": [],
|
|
79
130
|
},
|
|
80
131
|
lean_search_available=_RG_AVAILABLE,
|
|
132
|
+
loogle_manager=loogle_manager,
|
|
133
|
+
loogle_local_available=loogle_local_available,
|
|
81
134
|
)
|
|
82
135
|
yield context
|
|
83
136
|
finally:
|
|
@@ -86,6 +139,9 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
|
|
|
86
139
|
if context.client:
|
|
87
140
|
context.client.close()
|
|
88
141
|
|
|
142
|
+
if loogle_manager:
|
|
143
|
+
await loogle_manager.stop()
|
|
144
|
+
|
|
89
145
|
|
|
90
146
|
mcp_kwargs = dict(
|
|
91
147
|
name="Lean LSP",
|
|
@@ -106,7 +162,6 @@ if auth_token:
|
|
|
106
162
|
mcp = FastMCP(**mcp_kwargs)
|
|
107
163
|
|
|
108
164
|
|
|
109
|
-
# Rate limiting: n requests per m seconds
|
|
110
165
|
def rate_limited(category: str, max_requests: int, per_seconds: int):
|
|
111
166
|
def decorator(func):
|
|
112
167
|
@functools.wraps(func)
|
|
@@ -136,22 +191,24 @@ def rate_limited(category: str, max_requests: int, per_seconds: int):
|
|
|
136
191
|
return decorator
|
|
137
192
|
|
|
138
193
|
|
|
139
|
-
|
|
140
|
-
|
|
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
|
+
)
|
|
141
204
|
async def lsp_build(
|
|
142
|
-
ctx: Context,
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
lean_project_path (str, optional): Path to the Lean project. If not provided, it will be inferred from previous tool calls.
|
|
150
|
-
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.
|
|
151
|
-
|
|
152
|
-
Returns:
|
|
153
|
-
str: Build output or error msg
|
|
154
|
-
"""
|
|
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)."""
|
|
155
212
|
if not lean_project_path:
|
|
156
213
|
lean_project_path_obj = ctx.request_context.lifespan_context.lean_project_path
|
|
157
214
|
else:
|
|
@@ -159,9 +216,13 @@ async def lsp_build(
|
|
|
159
216
|
ctx.request_context.lifespan_context.lean_project_path = lean_project_path_obj
|
|
160
217
|
|
|
161
218
|
if lean_project_path_obj is None:
|
|
162
|
-
|
|
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] = []
|
|
163
225
|
|
|
164
|
-
build_output = ""
|
|
165
226
|
try:
|
|
166
227
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
167
228
|
if client:
|
|
@@ -187,8 +248,6 @@ async def lsp_build(
|
|
|
187
248
|
stderr=asyncio.subprocess.STDOUT,
|
|
188
249
|
)
|
|
189
250
|
|
|
190
|
-
output_lines = []
|
|
191
|
-
|
|
192
251
|
while True:
|
|
193
252
|
line = await process.stdout.readline()
|
|
194
253
|
if not line:
|
|
@@ -197,6 +256,10 @@ async def lsp_build(
|
|
|
197
256
|
line_str = line.decode("utf-8", errors="replace").rstrip()
|
|
198
257
|
output_lines.append(line_str)
|
|
199
258
|
|
|
259
|
+
# Collect error lines
|
|
260
|
+
if "error" in line_str.lower():
|
|
261
|
+
errors.append(line_str)
|
|
262
|
+
|
|
200
263
|
# Parse progress: look for pattern like "[2/8]" or "[10/100]"
|
|
201
264
|
match = re.search(r"\[(\d+)/(\d+)\]", line_str)
|
|
202
265
|
if match:
|
|
@@ -204,13 +267,11 @@ async def lsp_build(
|
|
|
204
267
|
total_jobs = int(match.group(2))
|
|
205
268
|
|
|
206
269
|
# Extract what's being built
|
|
207
|
-
# Line format: "ℹ [2/8] Built TestLeanBuild.Basic (1.6s)"
|
|
208
270
|
desc_match = re.search(
|
|
209
271
|
r"\[\d+/\d+\]\s+(.+?)(?:\s+\(\d+\.?\d*[ms]+\))?$", line_str
|
|
210
272
|
)
|
|
211
273
|
description = desc_match.group(1) if desc_match else "Building"
|
|
212
274
|
|
|
213
|
-
# Report progress using dynamic totals from Lake
|
|
214
275
|
await ctx.report_progress(
|
|
215
276
|
progress=current_job, total=total_jobs, message=description
|
|
216
277
|
)
|
|
@@ -218,8 +279,12 @@ async def lsp_build(
|
|
|
218
279
|
await process.wait()
|
|
219
280
|
|
|
220
281
|
if process.returncode != 0:
|
|
221
|
-
|
|
222
|
-
|
|
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
|
+
)
|
|
223
288
|
|
|
224
289
|
# Start LSP client (without initial build since we just did it)
|
|
225
290
|
with OutputCapture():
|
|
@@ -228,29 +293,34 @@ async def lsp_build(
|
|
|
228
293
|
)
|
|
229
294
|
|
|
230
295
|
logger.info("Built project and re-started LSP client")
|
|
231
|
-
|
|
232
296
|
ctx.request_context.lifespan_context.client = client
|
|
233
|
-
build_output = "\n".join(output_lines)
|
|
234
|
-
return build_output
|
|
235
|
-
except Exception as e:
|
|
236
|
-
return f"Error during build:\n{str(e)}\n{build_output}"
|
|
237
297
|
|
|
298
|
+
return BuildResult(success=True, output="\n".join(output_lines), errors=[])
|
|
238
299
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
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
|
+
)
|
|
246
306
|
|
|
247
|
-
Args:
|
|
248
|
-
file_path (str): Abs path to Lean file
|
|
249
|
-
annotate_lines (bool, optional): Annotate lines with line numbers. Defaults to True.
|
|
250
307
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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."""
|
|
254
324
|
# Infer project path but do not start a client
|
|
255
325
|
if file_path.endswith(".lean"):
|
|
256
326
|
infer_project_path(ctx, file_path) # Silently fails for non-project files
|
|
@@ -273,61 +343,87 @@ def file_contents(ctx: Context, file_path: str, annotate_lines: bool = True) ->
|
|
|
273
343
|
return data
|
|
274
344
|
|
|
275
345
|
|
|
276
|
-
@mcp.tool(
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
""
|
|
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."""
|
|
288
360
|
rel_path = setup_client_for_file(ctx, file_path)
|
|
289
361
|
if not rel_path:
|
|
290
|
-
|
|
362
|
+
raise LeanToolError(
|
|
363
|
+
"Invalid Lean file path: Unable to start LSP server or load file"
|
|
364
|
+
)
|
|
291
365
|
|
|
292
366
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
293
|
-
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
|
|
294
388
|
|
|
295
389
|
|
|
296
|
-
@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
|
+
)
|
|
297
399
|
def diagnostic_messages(
|
|
298
400
|
ctx: Context,
|
|
299
|
-
file_path: str,
|
|
300
|
-
start_line:
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
end_line (int, optional): End line (1-indexed). Filters to this line.
|
|
312
|
-
declaration_name (str, optional): Name of a specific theorem/lemma/definition.
|
|
313
|
-
If provided, only returns diagnostics within that declaration.
|
|
314
|
-
Takes precedence over start_line/end_line.
|
|
315
|
-
Slow, requires waiting for full file analysis.
|
|
316
|
-
|
|
317
|
-
Returns:
|
|
318
|
-
List[str] | str: Diagnostic msgs or error msg
|
|
319
|
-
"""
|
|
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."""
|
|
320
413
|
rel_path = setup_client_for_file(ctx, file_path)
|
|
321
414
|
if not rel_path:
|
|
322
|
-
|
|
415
|
+
raise LeanToolError(
|
|
416
|
+
"Invalid Lean file path: Unable to start LSP server or load file"
|
|
417
|
+
)
|
|
323
418
|
|
|
324
419
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
420
|
+
client.open_file(rel_path)
|
|
325
421
|
|
|
326
422
|
# If declaration_name is provided, get its range and use that for filtering
|
|
327
423
|
if declaration_name:
|
|
328
424
|
decl_range = get_declaration_range(client, rel_path, declaration_name)
|
|
329
425
|
if decl_range is None:
|
|
330
|
-
|
|
426
|
+
raise LeanToolError(f"Declaration '{declaration_name}' not found in file.")
|
|
331
427
|
start_line, end_line = decl_range
|
|
332
428
|
|
|
333
429
|
# Convert 1-indexed to 0-indexed for leanclient
|
|
@@ -341,123 +437,144 @@ def diagnostic_messages(
|
|
|
341
437
|
inactivity_timeout=15.0,
|
|
342
438
|
)
|
|
343
439
|
|
|
344
|
-
return
|
|
345
|
-
|
|
440
|
+
return _to_json_array(_to_diagnostic_messages(diagnostics))
|
|
346
441
|
|
|
347
|
-
@mcp.tool("lean_goal")
|
|
348
|
-
def goal(ctx: Context, file_path: str, line: int, column: Optional[int] = None) -> str:
|
|
349
|
-
"""Get the proof goals (proof state) at a specific location in a Lean file.
|
|
350
442
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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.
|
|
363
465
|
"""
|
|
364
466
|
rel_path = setup_client_for_file(ctx, file_path)
|
|
365
467
|
if not rel_path:
|
|
366
|
-
|
|
468
|
+
raise LeanToolError(
|
|
469
|
+
"Invalid Lean file path: Unable to start LSP server or load file"
|
|
470
|
+
)
|
|
367
471
|
|
|
368
472
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
369
473
|
client.open_file(rel_path)
|
|
370
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]
|
|
371
481
|
|
|
372
482
|
if column is None:
|
|
373
|
-
|
|
374
|
-
if line < 1 or line > len(lines):
|
|
375
|
-
return "Line number out of range. Try elsewhere?"
|
|
376
|
-
column_end = len(lines[line - 1])
|
|
483
|
+
column_end = len(line_context)
|
|
377
484
|
column_start = next(
|
|
378
|
-
(i for i, c in enumerate(
|
|
485
|
+
(i for i, c in enumerate(line_context) if not c.isspace()), 0
|
|
379
486
|
)
|
|
380
487
|
goal_start = client.get_goal(rel_path, line - 1, column_start)
|
|
381
488
|
goal_end = client.get_goal(rel_path, line - 1, column_end)
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
start_text = format_goal(goal_start, "No goals at line start.")
|
|
387
|
-
end_text = format_goal(goal_end, "No goals at line end.")
|
|
388
|
-
return f"Goals on line:\n{lines[line - 1]}\nBefore:\n{start_text}\nAfter:\n{end_text}"
|
|
389
|
-
|
|
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)
|
|
390
493
|
else:
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
+
)
|
|
395
498
|
|
|
396
499
|
|
|
397
|
-
@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
|
+
)
|
|
398
509
|
def term_goal(
|
|
399
|
-
ctx: Context,
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
Returns:
|
|
409
|
-
str: Expected type or error msg
|
|
410
|
-
"""
|
|
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."""
|
|
411
518
|
rel_path = setup_client_for_file(ctx, file_path)
|
|
412
519
|
if not rel_path:
|
|
413
|
-
|
|
520
|
+
raise LeanToolError(
|
|
521
|
+
"Invalid Lean file path: Unable to start LSP server or load file"
|
|
522
|
+
)
|
|
414
523
|
|
|
415
524
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
416
525
|
client.open_file(rel_path)
|
|
417
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]
|
|
418
533
|
if column is None:
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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."""
|
|
446
562
|
rel_path = setup_client_for_file(ctx, file_path)
|
|
447
563
|
if not rel_path:
|
|
448
|
-
|
|
564
|
+
raise LeanToolError(
|
|
565
|
+
"Invalid Lean file path: Unable to start LSP server or load file"
|
|
566
|
+
)
|
|
449
567
|
|
|
450
568
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
451
569
|
client.open_file(rel_path)
|
|
452
570
|
file_content = client.get_file_content(rel_path)
|
|
453
571
|
hover_info = client.get_hover(rel_path, line - 1, column - 1)
|
|
454
572
|
if hover_info is None:
|
|
455
|
-
|
|
456
|
-
return f"No hover information at position:\n{f_line}\nTry elsewhere?"
|
|
573
|
+
raise LeanToolError(f"No hover information at line {line}, column {column}")
|
|
457
574
|
|
|
458
575
|
# Get the symbol and the hover information
|
|
459
576
|
h_range = hover_info.get("range")
|
|
460
|
-
symbol = extract_range(file_content, h_range)
|
|
577
|
+
symbol = extract_range(file_content, h_range) or ""
|
|
461
578
|
info = hover_info["contents"].get("value", "No hover information available.")
|
|
462
579
|
info = info.replace("```lean\n", "").replace("\n```", "").strip()
|
|
463
580
|
|
|
@@ -465,45 +582,58 @@ def hover(ctx: Context, file_path: str, line: int, column: int) -> str:
|
|
|
465
582
|
diagnostics = client.get_diagnostics(rel_path)
|
|
466
583
|
filtered = filter_diagnostics_by_position(diagnostics, line - 1, column - 1)
|
|
467
584
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
585
|
+
return HoverInfo(
|
|
586
|
+
symbol=symbol,
|
|
587
|
+
info=info,
|
|
588
|
+
diagnostics=_to_diagnostic_messages(filtered),
|
|
589
|
+
)
|
|
472
590
|
|
|
473
591
|
|
|
474
|
-
@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
|
+
)
|
|
475
601
|
def completions(
|
|
476
|
-
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,
|
|
477
607
|
) -> str:
|
|
478
|
-
"""Get
|
|
479
|
-
|
|
480
|
-
Only use this on INCOMPLETE lines/statements to check available identifiers and imports:
|
|
481
|
-
- Dot Completion: Displays relevant identifiers after a dot (e.g., `Nat.`, `x.`, or `Nat.ad`).
|
|
482
|
-
- Identifier Completion: Suggests matching identifiers after part of a name.
|
|
483
|
-
- Import Completion: Lists importable files after `import` at the beginning of a file.
|
|
484
|
-
|
|
485
|
-
Args:
|
|
486
|
-
file_path (str): Abs path to Lean file
|
|
487
|
-
line (int): Line number (1-indexed)
|
|
488
|
-
column (int): Column number (1-indexed)
|
|
489
|
-
max_completions (int, optional): Maximum number of completions to return. Defaults to 32
|
|
490
|
-
|
|
491
|
-
Returns:
|
|
492
|
-
str: List of possible completions or error msg
|
|
493
|
-
"""
|
|
608
|
+
"""Get IDE autocompletions. Use on INCOMPLETE code (after `.` or partial name)."""
|
|
494
609
|
rel_path = setup_client_for_file(ctx, file_path)
|
|
495
610
|
if not rel_path:
|
|
496
|
-
|
|
611
|
+
raise LeanToolError(
|
|
612
|
+
"Invalid Lean file path: Unable to start LSP server or load file"
|
|
613
|
+
)
|
|
497
614
|
|
|
498
615
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
499
616
|
client.open_file(rel_path)
|
|
500
617
|
content = client.get_file_content(rel_path)
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
+
)
|
|
504
634
|
|
|
505
|
-
if not
|
|
506
|
-
return
|
|
635
|
+
if not items:
|
|
636
|
+
return "[]"
|
|
507
637
|
|
|
508
638
|
# Find the sort term: The last word/identifier before the cursor
|
|
509
639
|
lines = content.splitlines()
|
|
@@ -516,104 +646,102 @@ def completions(
|
|
|
516
646
|
# Sort completions: prefix matches first, then contains, then alphabetical
|
|
517
647
|
if prefix:
|
|
518
648
|
|
|
519
|
-
def sort_key(item):
|
|
520
|
-
|
|
521
|
-
if
|
|
522
|
-
return (0,
|
|
523
|
-
elif prefix in
|
|
524
|
-
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)
|
|
525
655
|
else:
|
|
526
|
-
return (2,
|
|
656
|
+
return (2, label_lower)
|
|
527
657
|
|
|
528
|
-
|
|
658
|
+
items.sort(key=sort_key)
|
|
529
659
|
else:
|
|
530
|
-
|
|
660
|
+
items.sort(key=lambda x: x.label.lower())
|
|
531
661
|
|
|
532
662
|
# Truncate if too many results
|
|
533
|
-
|
|
534
|
-
remaining = len(formatted) - max_completions
|
|
535
|
-
formatted = formatted[:max_completions] + [
|
|
536
|
-
f"{remaining} more, keep typing to filter further"
|
|
537
|
-
]
|
|
538
|
-
completions_text = "\n".join(formatted)
|
|
539
|
-
return f"Completions at:\n{f_line}\n{completions_text}"
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
@mcp.tool("lean_declaration_file")
|
|
543
|
-
def declaration_file(ctx: Context, file_path: str, symbol: str) -> str:
|
|
544
|
-
"""Get the file contents where a symbol/lemma/class/structure is declared.
|
|
663
|
+
return _to_json_array(items[:max_completions])
|
|
545
664
|
|
|
546
|
-
Note:
|
|
547
|
-
Symbol must be present in the file! Add if necessary!
|
|
548
|
-
Lean files can be large, use `lean_hover_info` before this tool.
|
|
549
665
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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."""
|
|
557
683
|
rel_path = setup_client_for_file(ctx, file_path)
|
|
558
684
|
if not rel_path:
|
|
559
|
-
|
|
685
|
+
raise LeanToolError(
|
|
686
|
+
"Invalid Lean file path: Unable to start LSP server or load file"
|
|
687
|
+
)
|
|
560
688
|
|
|
561
689
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
562
690
|
client.open_file(rel_path)
|
|
563
691
|
orig_file_content = client.get_file_content(rel_path)
|
|
564
692
|
|
|
565
|
-
# 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
|
|
566
694
|
position = find_start_position(orig_file_content, symbol)
|
|
567
695
|
if not position:
|
|
568
|
-
|
|
696
|
+
raise LeanToolError(
|
|
697
|
+
f"Symbol `{symbol}` (case sensitive) not found in file. Add it first."
|
|
698
|
+
)
|
|
569
699
|
|
|
570
700
|
declaration = client.get_declarations(
|
|
571
701
|
rel_path, position["line"], position["column"]
|
|
572
702
|
)
|
|
573
703
|
|
|
574
704
|
if len(declaration) == 0:
|
|
575
|
-
|
|
705
|
+
raise LeanToolError(f"No declaration available for `{symbol}`.")
|
|
576
706
|
|
|
577
707
|
# Load the declaration file
|
|
578
|
-
|
|
579
|
-
uri =
|
|
580
|
-
if not uri:
|
|
581
|
-
uri = declaration.get("uri")
|
|
708
|
+
decl = declaration[0]
|
|
709
|
+
uri = decl.get("targetUri") or decl.get("uri")
|
|
582
710
|
|
|
583
711
|
abs_path = client._uri_to_abs(uri)
|
|
584
712
|
if not os.path.exists(abs_path):
|
|
585
|
-
|
|
713
|
+
raise LeanToolError(
|
|
714
|
+
f"Could not open declaration file `{abs_path}` for `{symbol}`."
|
|
715
|
+
)
|
|
586
716
|
|
|
587
717
|
file_content = get_file_contents(abs_path)
|
|
588
718
|
|
|
589
|
-
return
|
|
719
|
+
return DeclarationInfo(file_path=str(abs_path), content=file_content)
|
|
590
720
|
|
|
591
721
|
|
|
592
|
-
@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
|
+
)
|
|
593
731
|
def multi_attempt(
|
|
594
|
-
ctx: Context,
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
Note:
|
|
603
|
-
Only single-line, fully-indented snippets are supported.
|
|
604
|
-
Avoid comments for best results.
|
|
605
|
-
|
|
606
|
-
Args:
|
|
607
|
-
file_path (str): Abs path to Lean file
|
|
608
|
-
line (int): Line number (1-indexed)
|
|
609
|
-
snippets (List[str]): List of snippets (3+ are recommended)
|
|
610
|
-
|
|
611
|
-
Returns:
|
|
612
|
-
List[str] | str: Diagnostics and goal states or error msg
|
|
613
|
-
"""
|
|
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."""
|
|
614
740
|
rel_path = setup_client_for_file(ctx, file_path)
|
|
615
741
|
if not rel_path:
|
|
616
|
-
|
|
742
|
+
raise LeanToolError(
|
|
743
|
+
"Invalid Lean file path: Unable to start LSP server or load file"
|
|
744
|
+
)
|
|
617
745
|
|
|
618
746
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
619
747
|
client.open_file(rel_path)
|
|
@@ -621,7 +749,7 @@ def multi_attempt(
|
|
|
621
749
|
try:
|
|
622
750
|
client.open_file(rel_path)
|
|
623
751
|
|
|
624
|
-
results = []
|
|
752
|
+
results: List[AttemptResult] = []
|
|
625
753
|
# Avoid mutating caller-provided snippets; normalize locally per attempt
|
|
626
754
|
for snippet in snippets:
|
|
627
755
|
snippet_str = snippet.rstrip("\n")
|
|
@@ -635,13 +763,19 @@ def multi_attempt(
|
|
|
635
763
|
# Apply the change to the file, capture diagnostics and goal state
|
|
636
764
|
client.update_file(rel_path, [change])
|
|
637
765
|
diag = client.get_diagnostics(rel_path)
|
|
638
|
-
|
|
766
|
+
filtered_diag = filter_diagnostics_by_position(diag, line - 1, None)
|
|
639
767
|
# Use the snippet text length without any trailing newline for the column
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
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
|
+
)
|
|
643
777
|
|
|
644
|
-
return results
|
|
778
|
+
return _to_json_array(results)
|
|
645
779
|
finally:
|
|
646
780
|
try:
|
|
647
781
|
client.close_files([rel_path])
|
|
@@ -651,23 +785,26 @@ def multi_attempt(
|
|
|
651
785
|
)
|
|
652
786
|
|
|
653
787
|
|
|
654
|
-
@mcp.tool(
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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."""
|
|
667
802
|
lifespan_context = ctx.request_context.lifespan_context
|
|
668
803
|
lean_project_path = lifespan_context.lean_project_path
|
|
669
804
|
if lean_project_path is None:
|
|
670
|
-
|
|
805
|
+
raise LeanToolError(
|
|
806
|
+
"No valid Lean project path found. Run another tool first to set it up."
|
|
807
|
+
)
|
|
671
808
|
|
|
672
809
|
# Use a unique snippet filename to avoid collisions under concurrency
|
|
673
810
|
rel_path = f"_mcp_snippet_{uuid.uuid4().hex}.lean"
|
|
@@ -677,12 +814,10 @@ def run_code(ctx: Context, code: str) -> List[str] | str:
|
|
|
677
814
|
with open(abs_path, "w", encoding="utf-8") as f:
|
|
678
815
|
f.write(code)
|
|
679
816
|
except Exception as e:
|
|
680
|
-
|
|
817
|
+
raise LeanToolError(f"Error writing code snippet: {e}")
|
|
681
818
|
|
|
682
819
|
client: LeanLSPClient | None = lifespan_context.client
|
|
683
|
-
|
|
684
|
-
close_error: str | None = None
|
|
685
|
-
remove_error: str | None = None
|
|
820
|
+
raw_diagnostics: List[Dict] = []
|
|
686
821
|
opened_file = False
|
|
687
822
|
|
|
688
823
|
try:
|
|
@@ -690,62 +825,57 @@ def run_code(ctx: Context, code: str) -> List[str] | str:
|
|
|
690
825
|
startup_client(ctx)
|
|
691
826
|
client = lifespan_context.client
|
|
692
827
|
if client is None:
|
|
693
|
-
|
|
828
|
+
raise LeanToolError("Failed to initialize Lean client for run_code.")
|
|
694
829
|
|
|
695
|
-
assert client is not None
|
|
830
|
+
assert client is not None
|
|
696
831
|
client.open_file(rel_path)
|
|
697
832
|
opened_file = True
|
|
698
|
-
|
|
699
|
-
client.get_diagnostics(rel_path, inactivity_timeout=15.0)
|
|
700
|
-
)
|
|
833
|
+
raw_diagnostics = client.get_diagnostics(rel_path, inactivity_timeout=15.0)
|
|
701
834
|
finally:
|
|
702
835
|
if opened_file:
|
|
703
836
|
try:
|
|
704
837
|
client.close_files([rel_path])
|
|
705
|
-
except Exception as exc:
|
|
706
|
-
close_error = str(exc)
|
|
838
|
+
except Exception as exc:
|
|
707
839
|
logger.warning("Failed to close `%s` after run_code: %s", rel_path, exc)
|
|
708
840
|
try:
|
|
709
841
|
os.remove(abs_path)
|
|
710
842
|
except FileNotFoundError:
|
|
711
843
|
pass
|
|
712
844
|
except Exception as e:
|
|
713
|
-
remove_error = str(e)
|
|
714
845
|
logger.warning(
|
|
715
846
|
"Failed to remove temporary Lean snippet `%s`: %s", abs_path, e
|
|
716
847
|
)
|
|
717
848
|
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
if close_error:
|
|
721
|
-
return f"Error closing temporary Lean document `{rel_path}`:\n{close_error}"
|
|
722
|
-
|
|
723
|
-
return (
|
|
724
|
-
diagnostics
|
|
725
|
-
if diagnostics
|
|
726
|
-
else "No diagnostics found for the code snippet (compiled successfully)."
|
|
727
|
-
)
|
|
849
|
+
diagnostics = _to_diagnostic_messages(raw_diagnostics)
|
|
850
|
+
has_errors = any(d.severity == "error" for d in diagnostics)
|
|
728
851
|
|
|
852
|
+
return RunResult(success=not has_errors, diagnostics=diagnostics)
|
|
729
853
|
|
|
730
|
-
@mcp.tool("lean_local_search")
|
|
731
|
-
def local_search(
|
|
732
|
-
ctx: Context, query: str, limit: int = 10, project_root: str | None = None
|
|
733
|
-
) -> List[Dict[str, str]] | str:
|
|
734
|
-
"""Confirm declarations exist in the current workspace to prevent hallucinating APIs.
|
|
735
854
|
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
The index spans theorems, lemmas, defs, classes, instances, structures, inductives, abbrevs, and opaque decls.
|
|
855
|
+
class LocalSearchError(Exception):
|
|
856
|
+
pass
|
|
739
857
|
|
|
740
|
-
Args:
|
|
741
|
-
query (str): Declaration name or prefix.
|
|
742
|
-
limit (int): Max matches to return (default 10).
|
|
743
858
|
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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."""
|
|
747
877
|
if not _RG_AVAILABLE:
|
|
748
|
-
|
|
878
|
+
raise LocalSearchError(_RG_MESSAGE)
|
|
749
879
|
|
|
750
880
|
lifespan = ctx.request_context.lifespan_context
|
|
751
881
|
stored_root = lifespan.lean_project_path
|
|
@@ -753,248 +883,269 @@ def local_search(
|
|
|
753
883
|
if project_root:
|
|
754
884
|
try:
|
|
755
885
|
resolved_root = Path(project_root).expanduser().resolve()
|
|
756
|
-
except OSError as exc:
|
|
757
|
-
|
|
886
|
+
except OSError as exc:
|
|
887
|
+
raise LocalSearchError(f"Invalid project root '{project_root}': {exc}")
|
|
758
888
|
if not resolved_root.exists():
|
|
759
|
-
|
|
889
|
+
raise LocalSearchError(f"Project root '{project_root}' does not exist.")
|
|
760
890
|
lifespan.lean_project_path = resolved_root
|
|
761
891
|
else:
|
|
762
892
|
resolved_root = stored_root
|
|
763
893
|
|
|
764
894
|
if resolved_root is None:
|
|
765
|
-
|
|
895
|
+
raise LocalSearchError(
|
|
896
|
+
"Lean project path not set. Call a file-based tool first."
|
|
897
|
+
)
|
|
766
898
|
|
|
767
899
|
try:
|
|
768
|
-
|
|
900
|
+
raw_results = lean_local_search(
|
|
769
901
|
query=query.strip(), limit=limit, project_root=resolved_root
|
|
770
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)
|
|
771
908
|
except RuntimeError as exc:
|
|
772
|
-
|
|
909
|
+
raise LocalSearchError(f"Search failed: {exc}")
|
|
773
910
|
|
|
774
911
|
|
|
775
|
-
@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
|
+
)
|
|
776
921
|
@rate_limited("leansearch", max_requests=3, per_seconds=30)
|
|
777
|
-
def leansearch(
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
- Concept names: "Cauchy Schwarz"
|
|
784
|
-
- Lean identifiers: "List.sum", "Finset induction"
|
|
785
|
-
- Lean term: "{f : A → B} {g : B → A} (hf : Injective f) (hg : Injective g) : ∃ h, Bijective h"
|
|
786
|
-
|
|
787
|
-
Args:
|
|
788
|
-
query (str): Search query
|
|
789
|
-
num_results (int, optional): Max results. Defaults to 5.
|
|
790
|
-
|
|
791
|
-
Returns:
|
|
792
|
-
List[Dict] | str: Search results or error msg
|
|
793
|
-
"""
|
|
794
|
-
try:
|
|
795
|
-
headers = {"User-Agent": "lean-lsp-mcp/0.1", "Content-Type": "application/json"}
|
|
796
|
-
payload = orjson.dumps({"num_results": str(num_results), "query": [query]})
|
|
797
|
-
|
|
798
|
-
req = urllib.request.Request(
|
|
799
|
-
"https://leansearch.net/search",
|
|
800
|
-
data=payload,
|
|
801
|
-
headers=headers,
|
|
802
|
-
method="POST",
|
|
803
|
-
)
|
|
804
|
-
|
|
805
|
-
with urllib.request.urlopen(req, timeout=20) as response:
|
|
806
|
-
results = orjson.loads(response.read())
|
|
807
|
-
|
|
808
|
-
if not results or not results[0]:
|
|
809
|
-
return "No results found."
|
|
810
|
-
results = results[0][:num_results]
|
|
811
|
-
results = [r["result"] for r in results]
|
|
812
|
-
|
|
813
|
-
for result in results:
|
|
814
|
-
result.pop("docstring")
|
|
815
|
-
result["module_name"] = ".".join(result["module_name"])
|
|
816
|
-
result["name"] = ".".join(result["name"])
|
|
817
|
-
|
|
818
|
-
return results
|
|
819
|
-
except Exception as e:
|
|
820
|
-
return f"leansearch error:\n{str(e)}"
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
@mcp.tool("lean_loogle")
|
|
824
|
-
@rate_limited("loogle", max_requests=3, per_seconds=30)
|
|
825
|
-
def loogle(ctx: Context, query: str, num_results: int = 8) -> List[dict] | str:
|
|
826
|
-
"""Search for definitions and theorems using loogle.
|
|
827
|
-
|
|
828
|
-
Query patterns:
|
|
829
|
-
- By constant: Real.sin # finds lemmas mentioning Real.sin
|
|
830
|
-
- By lemma name: "differ" # finds lemmas with "differ" in the name
|
|
831
|
-
- By subexpression: _ * (_ ^ _) # finds lemmas with a product and power
|
|
832
|
-
- Non-linear: Real.sqrt ?a * Real.sqrt ?a
|
|
833
|
-
- By type shape: (?a -> ?b) -> List ?a -> List ?b
|
|
834
|
-
- By conclusion: |- tsum _ = _ * tsum _
|
|
835
|
-
- By conclusion w/hyps: |- _ < _ → tsum _ < tsum _
|
|
836
|
-
|
|
837
|
-
Args:
|
|
838
|
-
query (str): Search query
|
|
839
|
-
num_results (int, optional): Max results. Defaults to 8.
|
|
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.
|
|
840
928
|
|
|
841
|
-
|
|
842
|
-
|
|
929
|
+
Examples: "sum of two even numbers is even", "Cauchy-Schwarz inequality",
|
|
930
|
+
"{f : A → B} (hf : Injective f) : ∃ g, LeftInverse g f"
|
|
843
931
|
"""
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
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
|
+
)
|
|
850
941
|
|
|
851
|
-
|
|
852
|
-
|
|
942
|
+
with urllib.request.urlopen(req, timeout=20) as response:
|
|
943
|
+
results = orjson.loads(response.read())
|
|
853
944
|
|
|
854
|
-
|
|
855
|
-
|
|
945
|
+
if not results or not results[0]:
|
|
946
|
+
return "[]"
|
|
856
947
|
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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.
|
|
863
978
|
|
|
979
|
+
Examples: `Real.sin`, `"comm"`, `(?a → ?b) → List ?a → List ?b`,
|
|
980
|
+
`_ * (_ ^ _)`, `|- _ < _ → _ + 1 < _ + 1`
|
|
981
|
+
"""
|
|
982
|
+
app_ctx: AppContext = ctx.request_context.lifespan_context
|
|
864
983
|
|
|
865
|
-
|
|
984
|
+
# Try local loogle first if available (no rate limiting)
|
|
985
|
+
if app_ctx.loogle_local_available and app_ctx.loogle_manager:
|
|
986
|
+
try:
|
|
987
|
+
results = await app_ctx.loogle_manager.query(query, num_results)
|
|
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)
|
|
999
|
+
except Exception as e:
|
|
1000
|
+
logger.warning(f"Local loogle failed: {e}, falling back to remote")
|
|
1001
|
+
|
|
1002
|
+
# Fall back to remote (with rate limiting)
|
|
1003
|
+
rate_limit = app_ctx.rate_limit["loogle"]
|
|
1004
|
+
now = int(time.time())
|
|
1005
|
+
rate_limit[:] = [t for t in rate_limit if now - t < 30]
|
|
1006
|
+
if len(rate_limit) >= 3:
|
|
1007
|
+
return "Rate limit exceeded: 3 requests per 30s. Use --loogle-local to avoid limits."
|
|
1008
|
+
rate_limit.append(now)
|
|
1009
|
+
|
|
1010
|
+
result = loogle_remote(query, num_results)
|
|
1011
|
+
if isinstance(result, str):
|
|
1012
|
+
return result # Error message
|
|
1013
|
+
return _to_json_array(result)
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
@mcp.tool(
|
|
1017
|
+
"lean_leanfinder",
|
|
1018
|
+
annotations=ToolAnnotations(
|
|
1019
|
+
title="Lean Finder",
|
|
1020
|
+
readOnlyHint=True,
|
|
1021
|
+
idempotentHint=True,
|
|
1022
|
+
openWorldHint=True,
|
|
1023
|
+
),
|
|
1024
|
+
)
|
|
866
1025
|
@rate_limited("leanfinder", max_requests=10, per_seconds=30)
|
|
867
|
-
def leanfinder(
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
- Proof state. For better results, enter a proof state followed by how you want to transform the proof state.
|
|
874
|
-
- Statement definition: Fragment or the whole statement definition.
|
|
875
|
-
|
|
876
|
-
Tips: Multiple targeted queries beat one complex query.
|
|
877
|
-
|
|
878
|
-
Args:
|
|
879
|
-
query (str): Mathematical concept or proof state
|
|
880
|
-
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.
|
|
881
1032
|
|
|
882
|
-
|
|
883
|
-
|
|
1033
|
+
Examples: "commutativity of addition on natural numbers",
|
|
1034
|
+
"I have h : n < m and need n + 1 < m + 1", proof state text.
|
|
884
1035
|
"""
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
req = urllib.request.Request(
|
|
892
|
-
request_url, data=payload, headers=headers, method="POST"
|
|
893
|
-
)
|
|
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
|
+
)
|
|
894
1042
|
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
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)
|
|
915
1063
|
|
|
916
1064
|
|
|
917
|
-
@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
|
+
)
|
|
918
1074
|
@rate_limited("lean_state_search", max_requests=3, per_seconds=30)
|
|
919
1075
|
def state_search(
|
|
920
|
-
ctx: Context,
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
file_path (str): Abs path to Lean file
|
|
928
|
-
line (int): Line number (1-indexed)
|
|
929
|
-
column (int): Column number (1-indexed)
|
|
930
|
-
num_results (int, optional): Max results. Defaults to 5.
|
|
931
|
-
|
|
932
|
-
Returns:
|
|
933
|
-
List | str: Search results or error msg
|
|
934
|
-
"""
|
|
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."""
|
|
935
1083
|
rel_path = setup_client_for_file(ctx, file_path)
|
|
936
1084
|
if not rel_path:
|
|
937
|
-
|
|
1085
|
+
raise LeanToolError(
|
|
1086
|
+
"Invalid Lean file path: Unable to start LSP server or load file"
|
|
1087
|
+
)
|
|
938
1088
|
|
|
939
1089
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
940
1090
|
client.open_file(rel_path)
|
|
941
|
-
file_contents = client.get_file_content(rel_path)
|
|
942
1091
|
goal = client.get_goal(rel_path, line - 1, column - 1)
|
|
943
1092
|
|
|
944
|
-
f_line = format_line(file_contents, line, column)
|
|
945
1093
|
if not goal or not goal.get("goals"):
|
|
946
|
-
|
|
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
|
+
)
|
|
947
1097
|
|
|
948
|
-
|
|
1098
|
+
goal_str = urllib.parse.quote(goal["goals"][0])
|
|
949
1099
|
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
)
|
|
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
|
+
)
|
|
957
1106
|
|
|
958
|
-
|
|
959
|
-
|
|
1107
|
+
with urllib.request.urlopen(req, timeout=20) as response:
|
|
1108
|
+
results = orjson.loads(response.read())
|
|
960
1109
|
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
# Very dirty type mix
|
|
964
|
-
results.insert(0, f"Results for line:\n{f_line}")
|
|
965
|
-
return results
|
|
966
|
-
except Exception as e:
|
|
967
|
-
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)
|
|
968
1112
|
|
|
969
1113
|
|
|
970
|
-
@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
|
+
)
|
|
971
1123
|
@rate_limited("hammer_premise", max_requests=3, per_seconds=30)
|
|
972
1124
|
def hammer_premise(
|
|
973
|
-
ctx: Context,
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
Returns:
|
|
984
|
-
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.
|
|
985
1134
|
"""
|
|
986
1135
|
rel_path = setup_client_for_file(ctx, file_path)
|
|
987
1136
|
if not rel_path:
|
|
988
|
-
|
|
1137
|
+
raise LeanToolError(
|
|
1138
|
+
"Invalid Lean file path: Unable to start LSP server or load file"
|
|
1139
|
+
)
|
|
989
1140
|
|
|
990
1141
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
991
1142
|
client.open_file(rel_path)
|
|
992
|
-
file_contents = client.get_file_content(rel_path)
|
|
993
1143
|
goal = client.get_goal(rel_path, line - 1, column - 1)
|
|
994
1144
|
|
|
995
|
-
f_line = format_line(file_contents, line, column)
|
|
996
1145
|
if not goal or not goal.get("goals"):
|
|
997
|
-
|
|
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
|
+
)
|
|
998
1149
|
|
|
999
1150
|
data = {
|
|
1000
1151
|
"state": goal["goals"][0],
|
|
@@ -1002,26 +1153,22 @@ def hammer_premise(
|
|
|
1002
1153
|
"k": num_results,
|
|
1003
1154
|
}
|
|
1004
1155
|
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
)
|
|
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
|
+
)
|
|
1016
1166
|
|
|
1017
|
-
|
|
1018
|
-
|
|
1167
|
+
with urllib.request.urlopen(req, timeout=20) as response:
|
|
1168
|
+
results = orjson.loads(response.read())
|
|
1019
1169
|
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
return results
|
|
1023
|
-
except Exception as e:
|
|
1024
|
-
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)
|
|
1025
1172
|
|
|
1026
1173
|
|
|
1027
1174
|
if __name__ == "__main__":
|