lean-lsp-mcp 0.1.7__py3-none-any.whl → 0.11.2__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 +26 -1
- lean_lsp_mcp/client_utils.py +122 -0
- lean_lsp_mcp/file_utils.py +100 -0
- lean_lsp_mcp/instructions.py +16 -0
- lean_lsp_mcp/search_utils.py +142 -0
- lean_lsp_mcp/server.py +723 -272
- lean_lsp_mcp/utils.py +228 -10
- lean_lsp_mcp-0.11.2.dist-info/METADATA +569 -0
- lean_lsp_mcp-0.11.2.dist-info/RECORD +14 -0
- {lean_lsp_mcp-0.1.7.dist-info → lean_lsp_mcp-0.11.2.dist-info}/WHEEL +1 -1
- lean_lsp_mcp/prompts.py +0 -42
- lean_lsp_mcp-0.1.7.dist-info/METADATA +0 -191
- lean_lsp_mcp-0.1.7.dist-info/RECORD +0 -11
- {lean_lsp_mcp-0.1.7.dist-info → lean_lsp_mcp-0.11.2.dist-info}/entry_points.txt +0 -0
- {lean_lsp_mcp-0.1.7.dist-info → lean_lsp_mcp-0.11.2.dist-info}/licenses/LICENSE +0 -0
- {lean_lsp_mcp-0.1.7.dist-info → lean_lsp_mcp-0.11.2.dist-info}/top_level.txt +0 -0
lean_lsp_mcp/server.py
CHANGED
|
@@ -1,216 +1,258 @@
|
|
|
1
|
+
import asyncio
|
|
1
2
|
import os
|
|
2
|
-
import
|
|
3
|
-
import
|
|
3
|
+
import re
|
|
4
|
+
import time
|
|
4
5
|
from typing import List, Optional, Dict
|
|
5
6
|
from contextlib import asynccontextmanager
|
|
6
7
|
from collections.abc import AsyncIterator
|
|
7
8
|
from dataclasses import dataclass
|
|
8
9
|
import urllib
|
|
9
|
-
import
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
import orjson
|
|
11
|
+
import functools
|
|
12
|
+
import subprocess
|
|
13
|
+
import uuid
|
|
14
|
+
from pathlib import Path
|
|
12
15
|
|
|
13
16
|
from mcp.server.fastmcp import Context, FastMCP
|
|
14
|
-
|
|
15
|
-
from
|
|
16
|
-
from
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
17
|
+
from mcp.server.fastmcp.utilities.logging import get_logger, configure_logging
|
|
18
|
+
from mcp.server.auth.settings import AuthSettings
|
|
19
|
+
from leanclient import LeanLSPClient, DocumentContentChange
|
|
20
|
+
|
|
21
|
+
from lean_lsp_mcp.client_utils import setup_client_for_file, startup_client
|
|
22
|
+
from lean_lsp_mcp.file_utils import get_file_contents, update_file
|
|
23
|
+
from lean_lsp_mcp.instructions import INSTRUCTIONS
|
|
24
|
+
from lean_lsp_mcp.search_utils import check_ripgrep_status, lean_local_search
|
|
25
|
+
from lean_lsp_mcp.utils import (
|
|
26
|
+
OutputCapture,
|
|
27
|
+
extract_range,
|
|
28
|
+
filter_diagnostics_by_position,
|
|
29
|
+
find_start_position,
|
|
30
|
+
format_diagnostics,
|
|
31
|
+
format_goal,
|
|
32
|
+
format_line,
|
|
33
|
+
OptionalTokenVerifier,
|
|
24
34
|
)
|
|
25
35
|
|
|
26
|
-
logger = logging.getLogger("lean-lsp-mcp")
|
|
27
36
|
|
|
37
|
+
_LOG_LEVEL = os.environ.get("LEAN_LOG_LEVEL", "INFO")
|
|
38
|
+
configure_logging("CRITICAL" if _LOG_LEVEL == "NONE" else _LOG_LEVEL)
|
|
39
|
+
logger = get_logger(__name__)
|
|
28
40
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
cwd = os.getcwd().strip() # Strip necessary?
|
|
32
|
-
if not LEAN_PROJECT_PATH:
|
|
33
|
-
logger.error("Please set the LEAN_PROJECT_PATH environment variable")
|
|
34
|
-
sys.exit(1)
|
|
41
|
+
|
|
42
|
+
_RG_AVAILABLE, _RG_MESSAGE = check_ripgrep_status()
|
|
35
43
|
|
|
36
44
|
|
|
37
45
|
# Server and context
|
|
38
46
|
@dataclass
|
|
39
47
|
class AppContext:
|
|
40
|
-
|
|
48
|
+
lean_project_path: Path | None
|
|
49
|
+
client: LeanLSPClient | None
|
|
41
50
|
file_content_hashes: Dict[str, str]
|
|
51
|
+
rate_limit: Dict[str, List[int]]
|
|
52
|
+
lean_search_available: bool
|
|
42
53
|
|
|
43
54
|
|
|
44
55
|
@asynccontextmanager
|
|
45
56
|
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
|
|
46
|
-
with StdoutToStderr():
|
|
47
|
-
try:
|
|
48
|
-
client = LeanLSPClient(LEAN_PROJECT_PATH)
|
|
49
|
-
logger.info(f"Connected to Lean project at {LEAN_PROJECT_PATH}")
|
|
50
|
-
except Exception as e:
|
|
51
|
-
client = LeanLSPClient(LEAN_PROJECT_PATH, initial_build=False)
|
|
52
|
-
logger.error(f"Could not do initial build, error: {e}")
|
|
53
|
-
|
|
54
57
|
try:
|
|
55
|
-
|
|
58
|
+
lean_project_path_str = os.environ.get("LEAN_PROJECT_PATH", "").strip()
|
|
59
|
+
if not lean_project_path_str:
|
|
60
|
+
lean_project_path = None
|
|
61
|
+
else:
|
|
62
|
+
lean_project_path = Path(lean_project_path_str).resolve()
|
|
63
|
+
|
|
64
|
+
context = AppContext(
|
|
65
|
+
lean_project_path=lean_project_path,
|
|
66
|
+
client=None,
|
|
67
|
+
file_content_hashes={},
|
|
68
|
+
rate_limit={
|
|
69
|
+
"leansearch": [],
|
|
70
|
+
"loogle": [],
|
|
71
|
+
"leanfinder": [],
|
|
72
|
+
"lean_state_search": [],
|
|
73
|
+
"hammer_premise": [],
|
|
74
|
+
},
|
|
75
|
+
lean_search_available=_RG_AVAILABLE,
|
|
76
|
+
)
|
|
56
77
|
yield context
|
|
57
78
|
finally:
|
|
58
79
|
logger.info("Closing Lean LSP client")
|
|
59
|
-
|
|
80
|
+
|
|
81
|
+
if context.client:
|
|
82
|
+
context.client.close()
|
|
60
83
|
|
|
61
84
|
|
|
62
|
-
|
|
63
|
-
"Lean LSP",
|
|
64
|
-
|
|
85
|
+
mcp_kwargs = dict(
|
|
86
|
+
name="Lean LSP",
|
|
87
|
+
instructions=INSTRUCTIONS,
|
|
65
88
|
dependencies=["leanclient"],
|
|
66
89
|
lifespan=app_lifespan,
|
|
67
|
-
env_vars={
|
|
68
|
-
"LEAN_PROJECT_PATH": {
|
|
69
|
-
"description": "Path to the Lean project root",
|
|
70
|
-
"required": True,
|
|
71
|
-
}
|
|
72
|
-
},
|
|
73
90
|
)
|
|
74
91
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
92
|
+
auth_token = os.environ.get("LEAN_LSP_MCP_TOKEN")
|
|
93
|
+
if auth_token:
|
|
94
|
+
mcp_kwargs["auth"] = AuthSettings(
|
|
95
|
+
type="optional",
|
|
96
|
+
issuer_url="http://localhost/dummy-issuer",
|
|
97
|
+
resource_server_url="http://localhost/dummy-resource",
|
|
98
|
+
)
|
|
99
|
+
mcp_kwargs["token_verifier"] = OptionalTokenVerifier(auth_token)
|
|
100
|
+
|
|
101
|
+
mcp = FastMCP(**mcp_kwargs)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# Rate limiting: n requests per m seconds
|
|
105
|
+
def rate_limited(category: str, max_requests: int, per_seconds: int):
|
|
106
|
+
def decorator(func):
|
|
107
|
+
@functools.wraps(func)
|
|
108
|
+
def wrapper(*args, **kwargs):
|
|
109
|
+
ctx = kwargs.get("ctx")
|
|
110
|
+
if ctx is None:
|
|
111
|
+
if not args:
|
|
112
|
+
raise KeyError(
|
|
113
|
+
"rate_limited wrapper requires ctx as a keyword argument or the first positional argument"
|
|
114
|
+
)
|
|
115
|
+
ctx = args[0]
|
|
116
|
+
rate_limit = ctx.request_context.lifespan_context.rate_limit
|
|
117
|
+
current_time = int(time.time())
|
|
118
|
+
rate_limit[category] = [
|
|
119
|
+
timestamp
|
|
120
|
+
for timestamp in rate_limit[category]
|
|
121
|
+
if timestamp > current_time - per_seconds
|
|
122
|
+
]
|
|
123
|
+
if len(rate_limit[category]) >= max_requests:
|
|
124
|
+
return f"Tool limit exceeded: {max_requests} requests per {per_seconds} s. Try again later."
|
|
125
|
+
rate_limit[category].append(current_time)
|
|
126
|
+
return func(*args, **kwargs)
|
|
127
|
+
|
|
128
|
+
wrapper.__doc__ = f"Limit: {max_requests}req/{per_seconds}s. " + wrapper.__doc__
|
|
129
|
+
return wrapper
|
|
130
|
+
|
|
131
|
+
return decorator
|
|
101
132
|
|
|
102
133
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
134
|
+
# Project level tools
|
|
135
|
+
@mcp.tool("lean_build")
|
|
136
|
+
async def lsp_build(
|
|
137
|
+
ctx: Context, lean_project_path: str = None, clean: bool = False
|
|
138
|
+
) -> str:
|
|
139
|
+
"""Build the Lean project and restart the LSP Server.
|
|
107
140
|
|
|
141
|
+
Use only if needed (e.g. new imports).
|
|
108
142
|
|
|
109
|
-
def update_file(ctx: Context, rel_path: str) -> str:
|
|
110
|
-
"""Update the file contents in the context.
|
|
111
143
|
Args:
|
|
112
|
-
|
|
113
|
-
|
|
144
|
+
lean_project_path (str, optional): Path to the Lean project. If not provided, it will be inferred from previous tool calls.
|
|
145
|
+
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.
|
|
114
146
|
|
|
115
147
|
Returns:
|
|
116
|
-
str:
|
|
148
|
+
str: Build output or error msg
|
|
117
149
|
"""
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
file_content_hashes: Dict[str, str] = (
|
|
124
|
-
ctx.request_context.lifespan_context.file_content_hashes
|
|
125
|
-
)
|
|
126
|
-
if rel_path not in file_content_hashes:
|
|
127
|
-
file_content_hashes[rel_path] = hashed_file
|
|
128
|
-
return file_content
|
|
129
|
-
|
|
130
|
-
elif hashed_file == file_content_hashes[rel_path]:
|
|
131
|
-
return file_content
|
|
150
|
+
if not lean_project_path:
|
|
151
|
+
lean_project_path_obj = ctx.request_context.lifespan_context.lean_project_path
|
|
152
|
+
else:
|
|
153
|
+
lean_project_path_obj = Path(lean_project_path).resolve()
|
|
154
|
+
ctx.request_context.lifespan_context.lean_project_path = lean_project_path_obj
|
|
132
155
|
|
|
133
|
-
|
|
134
|
-
|
|
156
|
+
if lean_project_path_obj is None:
|
|
157
|
+
return (
|
|
158
|
+
"Lean project path not known yet. Provide `lean_project_path` explicitly or call a "
|
|
159
|
+
"tool that infers it (e.g. `lean_goal`) before running `lean_build`."
|
|
160
|
+
)
|
|
135
161
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
162
|
+
build_output = ""
|
|
163
|
+
try:
|
|
164
|
+
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
165
|
+
if client:
|
|
166
|
+
ctx.request_context.lifespan_context.client = None
|
|
167
|
+
client.close()
|
|
168
|
+
ctx.request_context.lifespan_context.file_content_hashes.clear()
|
|
169
|
+
|
|
170
|
+
if clean:
|
|
171
|
+
subprocess.run(["lake", "clean"], cwd=lean_project_path_obj, check=False)
|
|
172
|
+
logger.info("Ran `lake clean`")
|
|
173
|
+
|
|
174
|
+
# Fetch cache
|
|
175
|
+
subprocess.run(
|
|
176
|
+
["lake", "exe", "cache", "get"], cwd=lean_project_path_obj, check=False
|
|
177
|
+
)
|
|
140
178
|
|
|
179
|
+
# Run build with progress reporting
|
|
180
|
+
process = await asyncio.create_subprocess_exec(
|
|
181
|
+
"lake",
|
|
182
|
+
"build",
|
|
183
|
+
"--verbose",
|
|
184
|
+
cwd=lean_project_path_obj,
|
|
185
|
+
stdout=asyncio.subprocess.PIPE,
|
|
186
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
187
|
+
)
|
|
141
188
|
|
|
142
|
-
|
|
143
|
-
@mcp.tool("lean_auto_proof_instructions")
|
|
144
|
-
def auto_proof() -> str:
|
|
145
|
-
"""Get the description of the Lean LSP MCP and how to use it to automatically prove theorems.
|
|
189
|
+
output_lines = []
|
|
146
190
|
|
|
147
|
-
|
|
191
|
+
while True:
|
|
192
|
+
line = await process.stdout.readline()
|
|
193
|
+
if not line:
|
|
194
|
+
break
|
|
148
195
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
"""
|
|
152
|
-
try:
|
|
153
|
-
toolchain = get_file_contents("lean-toolchain")
|
|
154
|
-
lean_version = toolchain.split(":")[1].strip()
|
|
155
|
-
except Exception:
|
|
156
|
-
lean_version = "v4"
|
|
157
|
-
return PROMPT_AUTOMATIC_PROOF.format(lean_version=lean_version)
|
|
196
|
+
line_str = line.decode("utf-8", errors="replace").rstrip()
|
|
197
|
+
output_lines.append(line_str)
|
|
158
198
|
|
|
199
|
+
# Parse progress: look for pattern like "[2/8]" or "[10/100]"
|
|
200
|
+
match = re.search(r"\[(\d+)/(\d+)\]", line_str)
|
|
201
|
+
if match:
|
|
202
|
+
current_job = int(match.group(1))
|
|
203
|
+
total_jobs = int(match.group(2))
|
|
159
204
|
|
|
160
|
-
#
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
205
|
+
# Extract what's being built
|
|
206
|
+
# Line format: "ℹ [2/8] Built TestLeanBuild.Basic (1.6s)"
|
|
207
|
+
desc_match = re.search(
|
|
208
|
+
r"\[\d+/\d+\]\s+(.+?)(?:\s+\(\d+\.?\d*[ms]+\))?$", line_str
|
|
209
|
+
)
|
|
210
|
+
description = desc_match.group(1) if desc_match else "Building"
|
|
164
211
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
212
|
+
# Report progress using dynamic totals from Lake
|
|
213
|
+
await ctx.report_progress(
|
|
214
|
+
progress=current_job, total=total_jobs, message=description
|
|
215
|
+
)
|
|
169
216
|
|
|
217
|
+
await process.wait()
|
|
170
218
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
219
|
+
if process.returncode != 0:
|
|
220
|
+
build_output = "\n".join(output_lines)
|
|
221
|
+
raise Exception(f"Build failed with return code {process.returncode}")
|
|
174
222
|
|
|
175
|
-
|
|
223
|
+
# Start LSP client (without initial build since we just did it)
|
|
224
|
+
with OutputCapture():
|
|
225
|
+
client = LeanLSPClient(
|
|
226
|
+
lean_project_path_obj, initial_build=False, prevent_cache_get=True
|
|
227
|
+
)
|
|
176
228
|
|
|
177
|
-
|
|
178
|
-
rebuild (bool, optional): Rebuild the Lean project. Defaults to True.
|
|
229
|
+
logger.info("Built project and re-started LSP client")
|
|
179
230
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
client.close()
|
|
186
|
-
ctx.request_context.lifespan_context.client = LeanLSPClient(
|
|
187
|
-
os.environ["LEAN_PROJECT_PATH"], initial_build=rebuild
|
|
188
|
-
)
|
|
189
|
-
except Exception:
|
|
190
|
-
return False
|
|
191
|
-
return True
|
|
231
|
+
ctx.request_context.lifespan_context.client = client
|
|
232
|
+
build_output = "\n".join(output_lines)
|
|
233
|
+
return build_output
|
|
234
|
+
except Exception as e:
|
|
235
|
+
return f"Error during build:\n{str(e)}\n{build_output}"
|
|
192
236
|
|
|
193
237
|
|
|
194
238
|
# File level tools
|
|
195
239
|
@mcp.tool("lean_file_contents")
|
|
196
240
|
def file_contents(ctx: Context, file_path: str, annotate_lines: bool = True) -> str:
|
|
197
|
-
"""Get the text contents of a Lean file.
|
|
198
|
-
|
|
199
|
-
IMPORTANT! Look up the file_contents for the currently open file including line number annotations.
|
|
200
|
-
Use this during the proof process to keep updated on the line numbers and the current state of the file.
|
|
241
|
+
"""Get the text contents of a Lean file, optionally with line numbers.
|
|
201
242
|
|
|
202
243
|
Args:
|
|
203
|
-
file_path (str):
|
|
204
|
-
annotate_lines (bool, optional): Annotate lines with line numbers. Defaults to
|
|
244
|
+
file_path (str): Abs path to Lean file
|
|
245
|
+
annotate_lines (bool, optional): Annotate lines with line numbers. Defaults to True.
|
|
205
246
|
|
|
206
247
|
Returns:
|
|
207
|
-
str:
|
|
248
|
+
str: File content or error msg
|
|
208
249
|
"""
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
250
|
+
try:
|
|
251
|
+
data = get_file_contents(file_path)
|
|
252
|
+
except FileNotFoundError:
|
|
253
|
+
return (
|
|
254
|
+
f"File `{file_path}` does not exist. Please check the path and try again."
|
|
255
|
+
)
|
|
214
256
|
|
|
215
257
|
if annotate_lines:
|
|
216
258
|
data = data.split("\n")
|
|
@@ -225,20 +267,19 @@ def file_contents(ctx: Context, file_path: str, annotate_lines: bool = True) ->
|
|
|
225
267
|
|
|
226
268
|
@mcp.tool("lean_diagnostic_messages")
|
|
227
269
|
def diagnostic_messages(ctx: Context, file_path: str) -> List[str] | str:
|
|
228
|
-
"""Get all diagnostic
|
|
270
|
+
"""Get all diagnostic msgs (errors, warnings, infos) for a Lean file.
|
|
229
271
|
|
|
230
|
-
|
|
231
|
-
"no goals to be solved" indicates some code needs to be removed. Keep going!
|
|
272
|
+
"no goals to be solved" means code may need removal.
|
|
232
273
|
|
|
233
274
|
Args:
|
|
234
|
-
file_path (str):
|
|
275
|
+
file_path (str): Abs path to Lean file
|
|
235
276
|
|
|
236
277
|
Returns:
|
|
237
|
-
List[str] | str: Diagnostic
|
|
278
|
+
List[str] | str: Diagnostic msgs or error msg
|
|
238
279
|
"""
|
|
239
|
-
rel_path =
|
|
280
|
+
rel_path = setup_client_for_file(ctx, file_path)
|
|
240
281
|
if not rel_path:
|
|
241
|
-
return "
|
|
282
|
+
return "Invalid Lean file path: Unable to start LSP server or load file"
|
|
242
283
|
|
|
243
284
|
update_file(ctx, rel_path)
|
|
244
285
|
|
|
@@ -249,213 +290,427 @@ def diagnostic_messages(ctx: Context, file_path: str) -> List[str] | str:
|
|
|
249
290
|
|
|
250
291
|
@mcp.tool("lean_goal")
|
|
251
292
|
def goal(ctx: Context, file_path: str, line: int, column: Optional[int] = None) -> str:
|
|
252
|
-
"""Get the proof goals at a specific location
|
|
293
|
+
"""Get the proof goals (proof state) at a specific location in a Lean file.
|
|
253
294
|
|
|
254
|
-
VERY USEFUL
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
295
|
+
VERY USEFUL! Main tool to understand the proof state and its evolution!
|
|
296
|
+
Returns "no goals" if solved.
|
|
297
|
+
To see the goal at sorry, use the cursor before the "s".
|
|
298
|
+
Avoid giving a column if unsure-default behavior works well.
|
|
258
299
|
|
|
259
300
|
Args:
|
|
260
|
-
file_path (str):
|
|
301
|
+
file_path (str): Abs path to Lean file
|
|
261
302
|
line (int): Line number (1-indexed)
|
|
262
303
|
column (int, optional): Column number (1-indexed). Defaults to None => Both before and after the line.
|
|
263
304
|
|
|
264
305
|
Returns:
|
|
265
|
-
str: Goal
|
|
306
|
+
str: Goal(s) or error msg
|
|
266
307
|
"""
|
|
267
|
-
rel_path =
|
|
308
|
+
rel_path = setup_client_for_file(ctx, file_path)
|
|
268
309
|
if not rel_path:
|
|
269
|
-
return "
|
|
310
|
+
return "Invalid Lean file path: Unable to start LSP server or load file"
|
|
270
311
|
|
|
271
312
|
content = update_file(ctx, rel_path)
|
|
272
313
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
273
314
|
|
|
274
|
-
def format_goal(goal, default_msg):
|
|
275
|
-
if goal is None:
|
|
276
|
-
return default_msg
|
|
277
|
-
rendered = goal.get("rendered")
|
|
278
|
-
return (
|
|
279
|
-
rendered.replace("```lean\n", "").replace("\n```", "") if rendered else None
|
|
280
|
-
)
|
|
281
|
-
|
|
282
315
|
if column is None:
|
|
283
316
|
lines = content.splitlines()
|
|
284
317
|
if line < 1 or line > len(lines):
|
|
285
|
-
return "Line number out of range. Try
|
|
286
|
-
column_end = len(lines[line - 1])
|
|
287
|
-
|
|
318
|
+
return "Line number out of range. Try elsewhere?"
|
|
319
|
+
column_end = len(lines[line - 1])
|
|
320
|
+
column_start = next(
|
|
321
|
+
(i for i, c in enumerate(lines[line - 1]) if not c.isspace()), 0
|
|
322
|
+
)
|
|
323
|
+
goal_start = client.get_goal(rel_path, line - 1, column_start)
|
|
288
324
|
goal_end = client.get_goal(rel_path, line - 1, column_end)
|
|
289
325
|
|
|
290
326
|
if goal_start is None and goal_end is None:
|
|
291
|
-
return "No goals
|
|
327
|
+
return f"No goals on line:\n{lines[line - 1]}\nTry another line?"
|
|
292
328
|
|
|
293
|
-
start_text = format_goal(goal_start, "No
|
|
294
|
-
end_text = format_goal(goal_end, "No
|
|
295
|
-
|
|
296
|
-
return start_text
|
|
297
|
-
return f"Before:\n{start_text}\nAfter:\n{end_text}"
|
|
329
|
+
start_text = format_goal(goal_start, "No goals at line start.")
|
|
330
|
+
end_text = format_goal(goal_end, "No goals at line end.")
|
|
331
|
+
return f"Goals on line:\n{lines[line - 1]}\nBefore:\n{start_text}\nAfter:\n{end_text}"
|
|
298
332
|
|
|
299
333
|
else:
|
|
300
334
|
goal = client.get_goal(rel_path, line - 1, column - 1)
|
|
301
|
-
|
|
335
|
+
f_goal = format_goal(goal, "Not a valid goal position. Try elsewhere?")
|
|
336
|
+
f_line = format_line(content, line, column)
|
|
337
|
+
return f"Goals at:\n{f_line}\n{f_goal}"
|
|
302
338
|
|
|
303
339
|
|
|
304
340
|
@mcp.tool("lean_term_goal")
|
|
305
341
|
def term_goal(
|
|
306
342
|
ctx: Context, file_path: str, line: int, column: Optional[int] = None
|
|
307
343
|
) -> str:
|
|
308
|
-
"""Get the term goal at a specific location in a Lean file.
|
|
309
|
-
|
|
310
|
-
Use this to get a better understanding of the proof state.
|
|
344
|
+
"""Get the expected type (term goal) at a specific location in a Lean file.
|
|
311
345
|
|
|
312
346
|
Args:
|
|
313
|
-
file_path (str):
|
|
347
|
+
file_path (str): Abs path to Lean file
|
|
314
348
|
line (int): Line number (1-indexed)
|
|
315
349
|
column (int, optional): Column number (1-indexed). Defaults to None => end of line.
|
|
316
350
|
|
|
317
351
|
Returns:
|
|
318
|
-
str:
|
|
352
|
+
str: Expected type or error msg
|
|
319
353
|
"""
|
|
320
|
-
rel_path =
|
|
354
|
+
rel_path = setup_client_for_file(ctx, file_path)
|
|
321
355
|
if not rel_path:
|
|
322
|
-
return "
|
|
356
|
+
return "Invalid Lean file path: Unable to start LSP server or load file"
|
|
323
357
|
|
|
324
358
|
content = update_file(ctx, rel_path)
|
|
325
359
|
if column is None:
|
|
360
|
+
lines = content.splitlines()
|
|
361
|
+
if line < 1 or line > len(lines):
|
|
362
|
+
return "Line number out of range. Try elsewhere?"
|
|
326
363
|
column = len(content.splitlines()[line - 1])
|
|
327
364
|
|
|
328
365
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
329
366
|
term_goal = client.get_term_goal(rel_path, line - 1, column - 1)
|
|
367
|
+
f_line = format_line(content, line, column)
|
|
330
368
|
if term_goal is None:
|
|
331
|
-
return "Not a valid term goal position
|
|
369
|
+
return f"Not a valid term goal position:\n{f_line}\nTry elsewhere?"
|
|
332
370
|
rendered = term_goal.get("goal", None)
|
|
333
371
|
if rendered is not None:
|
|
334
372
|
rendered = rendered.replace("```lean\n", "").replace("\n```", "")
|
|
335
|
-
return rendered
|
|
373
|
+
return f"Term goal at:\n{f_line}\n{rendered or 'No term goal found.'}"
|
|
336
374
|
|
|
337
375
|
|
|
338
376
|
@mcp.tool("lean_hover_info")
|
|
339
377
|
def hover(ctx: Context, file_path: str, line: int, column: int) -> str:
|
|
340
|
-
"""Get
|
|
341
|
-
|
|
342
|
-
Hover information provides documentation about any lean syntax, variables, functions, etc. in your code.
|
|
378
|
+
"""Get hover info (docs for syntax, variables, functions, etc.) at a specific location in a Lean file.
|
|
343
379
|
|
|
344
380
|
Args:
|
|
345
|
-
file_path (str):
|
|
381
|
+
file_path (str): Abs path to Lean file
|
|
346
382
|
line (int): Line number (1-indexed)
|
|
347
|
-
column (int): Column number (1-indexed).
|
|
383
|
+
column (int): Column number (1-indexed). Make sure to use the start or within the term, not the end.
|
|
348
384
|
|
|
349
385
|
Returns:
|
|
350
|
-
str: Hover
|
|
386
|
+
str: Hover info or error msg
|
|
351
387
|
"""
|
|
352
|
-
rel_path =
|
|
388
|
+
rel_path = setup_client_for_file(ctx, file_path)
|
|
353
389
|
if not rel_path:
|
|
354
|
-
return "
|
|
355
|
-
|
|
356
|
-
update_file(ctx, rel_path)
|
|
390
|
+
return "Invalid Lean file path: Unable to start LSP server or load file"
|
|
357
391
|
|
|
392
|
+
file_content = update_file(ctx, rel_path)
|
|
358
393
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
359
394
|
hover_info = client.get_hover(rel_path, line - 1, column - 1)
|
|
360
395
|
if hover_info is None:
|
|
361
|
-
|
|
396
|
+
f_line = format_line(file_content, line, column)
|
|
397
|
+
return f"No hover information at position:\n{f_line}\nTry elsewhere?"
|
|
398
|
+
|
|
399
|
+
# Get the symbol and the hover information
|
|
400
|
+
h_range = hover_info.get("range")
|
|
401
|
+
symbol = extract_range(file_content, h_range)
|
|
402
|
+
info = hover_info["contents"].get("value", "No hover information available.")
|
|
403
|
+
info = info.replace("```lean\n", "").replace("\n```", "").strip()
|
|
404
|
+
|
|
405
|
+
# Add diagnostics if available
|
|
406
|
+
diagnostics = client.get_diagnostics(rel_path)
|
|
407
|
+
filtered = filter_diagnostics_by_position(diagnostics, line - 1, column - 1)
|
|
362
408
|
|
|
363
|
-
|
|
364
|
-
if
|
|
365
|
-
|
|
366
|
-
|
|
409
|
+
msg = f"Hover info `{symbol}`:\n{info}"
|
|
410
|
+
if filtered:
|
|
411
|
+
msg += "\n\nDiagnostics\n" + "\n".join(format_diagnostics(filtered))
|
|
412
|
+
return msg
|
|
367
413
|
|
|
368
414
|
|
|
369
|
-
@mcp.tool("
|
|
370
|
-
def
|
|
371
|
-
|
|
415
|
+
@mcp.tool("lean_completions")
|
|
416
|
+
def completions(
|
|
417
|
+
ctx: Context, file_path: str, line: int, column: int, max_completions: int = 32
|
|
418
|
+
) -> str:
|
|
419
|
+
"""Get code completions at a location in a Lean file.
|
|
372
420
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
421
|
+
Only use this on INCOMPLETE lines/statements to check available identifiers and imports:
|
|
422
|
+
- Dot Completion: Displays relevant identifiers after a dot (e.g., `Nat.`, `x.`, or `Nat.ad`).
|
|
423
|
+
- Identifier Completion: Suggests matching identifiers after part of a name.
|
|
424
|
+
- Import Completion: Lists importable files after `import` at the beginning of a file.
|
|
377
425
|
|
|
378
426
|
Args:
|
|
379
|
-
file_path (str):
|
|
427
|
+
file_path (str): Abs path to Lean file
|
|
428
|
+
line (int): Line number (1-indexed)
|
|
429
|
+
column (int): Column number (1-indexed)
|
|
430
|
+
max_completions (int, optional): Maximum number of completions to return. Defaults to 32
|
|
380
431
|
|
|
381
432
|
Returns:
|
|
382
|
-
str:
|
|
433
|
+
str: List of possible completions or error msg
|
|
383
434
|
"""
|
|
384
|
-
rel_path =
|
|
435
|
+
rel_path = setup_client_for_file(ctx, file_path)
|
|
385
436
|
if not rel_path:
|
|
386
|
-
return "
|
|
437
|
+
return "Invalid Lean file path: Unable to start LSP server or load file"
|
|
438
|
+
content = update_file(ctx, rel_path)
|
|
387
439
|
|
|
388
|
-
|
|
440
|
+
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
441
|
+
completions = client.get_completions(rel_path, line - 1, column - 1)
|
|
442
|
+
formatted = [c["label"] for c in completions if "label" in c]
|
|
443
|
+
f_line = format_line(content, line, column)
|
|
444
|
+
|
|
445
|
+
if not formatted:
|
|
446
|
+
return f"No completions at position:\n{f_line}\nTry elsewhere?"
|
|
447
|
+
|
|
448
|
+
# Find the sort term: The last word/identifier before the cursor
|
|
449
|
+
lines = content.splitlines()
|
|
450
|
+
prefix = ""
|
|
451
|
+
if 0 < line <= len(lines):
|
|
452
|
+
text_before_cursor = lines[line - 1][: column - 1] if column > 0 else ""
|
|
453
|
+
if not text_before_cursor.endswith("."):
|
|
454
|
+
prefix = re.split(r"[\s()\[\]{},:;.]+", text_before_cursor)[-1].lower()
|
|
455
|
+
|
|
456
|
+
# Sort completions: prefix matches first, then contains, then alphabetical
|
|
457
|
+
if prefix:
|
|
458
|
+
|
|
459
|
+
def sort_key(item):
|
|
460
|
+
item_lower = item.lower()
|
|
461
|
+
if item_lower.startswith(prefix):
|
|
462
|
+
return (0, item_lower)
|
|
463
|
+
elif prefix in item_lower:
|
|
464
|
+
return (1, item_lower)
|
|
465
|
+
else:
|
|
466
|
+
return (2, item_lower)
|
|
467
|
+
|
|
468
|
+
formatted.sort(key=sort_key)
|
|
469
|
+
else:
|
|
470
|
+
formatted.sort(key=str.lower)
|
|
471
|
+
|
|
472
|
+
# Truncate if too many results
|
|
473
|
+
if len(formatted) > max_completions:
|
|
474
|
+
remaining = len(formatted) - max_completions
|
|
475
|
+
formatted = formatted[:max_completions] + [
|
|
476
|
+
f"{remaining} more, keep typing to filter further"
|
|
477
|
+
]
|
|
478
|
+
completions_text = "\n".join(formatted)
|
|
479
|
+
return f"Completions at:\n{f_line}\n{completions_text}"
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
@mcp.tool("lean_declaration_file")
|
|
483
|
+
def declaration_file(ctx: Context, file_path: str, symbol: str) -> str:
|
|
484
|
+
"""Get the file contents where a symbol/lemma/class/structure is declared.
|
|
485
|
+
|
|
486
|
+
Note:
|
|
487
|
+
Symbol must be present in the file! Add if necessary!
|
|
488
|
+
Lean files can be large, use `lean_hover_info` before this tool.
|
|
489
|
+
|
|
490
|
+
Args:
|
|
491
|
+
file_path (str): Abs path to Lean file
|
|
492
|
+
symbol (str): Symbol to look up the declaration for. Case sensitive!
|
|
493
|
+
|
|
494
|
+
Returns:
|
|
495
|
+
str: File contents or error msg
|
|
496
|
+
"""
|
|
497
|
+
rel_path = setup_client_for_file(ctx, file_path)
|
|
498
|
+
if not rel_path:
|
|
499
|
+
return "Invalid Lean file path: Unable to start LSP server or load file"
|
|
500
|
+
orig_file_content = update_file(ctx, rel_path)
|
|
501
|
+
|
|
502
|
+
# Find the first occurence of the symbol (line and column) in the file,
|
|
503
|
+
position = find_start_position(orig_file_content, symbol)
|
|
504
|
+
if not position:
|
|
505
|
+
return f"Symbol `{symbol}` (case sensitive) not found in file `{rel_path}`. Add it first, then try again."
|
|
389
506
|
|
|
390
507
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
391
|
-
|
|
508
|
+
declaration = client.get_declarations(
|
|
509
|
+
rel_path, position["line"], position["column"]
|
|
510
|
+
)
|
|
392
511
|
|
|
393
|
-
if
|
|
394
|
-
return "
|
|
512
|
+
if len(declaration) == 0:
|
|
513
|
+
return f"No declaration available for `{symbol}`."
|
|
395
514
|
|
|
396
|
-
|
|
515
|
+
# Load the declaration file
|
|
516
|
+
declaration = declaration[0]
|
|
517
|
+
uri = declaration.get("targetUri")
|
|
518
|
+
if not uri:
|
|
519
|
+
uri = declaration.get("uri")
|
|
397
520
|
|
|
521
|
+
abs_path = client._uri_to_abs(uri)
|
|
522
|
+
if not os.path.exists(abs_path):
|
|
523
|
+
return f"Could not open declaration file `{abs_path}` for `{symbol}`."
|
|
398
524
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
525
|
+
file_content = get_file_contents(abs_path)
|
|
526
|
+
|
|
527
|
+
return f"Declaration of `{symbol}`:\n{file_content}"
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
@mcp.tool("lean_multi_attempt")
|
|
531
|
+
def multi_attempt(
|
|
532
|
+
ctx: Context, file_path: str, line: int, snippets: List[str]
|
|
402
533
|
) -> List[str] | str:
|
|
403
|
-
"""
|
|
534
|
+
"""Try multiple Lean code snippets at a line and get the goal state and diagnostics for each.
|
|
535
|
+
|
|
536
|
+
Use to compare tactics or approaches.
|
|
537
|
+
Use rarely-prefer direct file edits to keep users involved.
|
|
538
|
+
For a single snippet, edit the file and run `lean_diagnostic_messages` instead.
|
|
404
539
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
- Import Completion: Lists importable files after typing import at the beginning of a file.
|
|
540
|
+
Note:
|
|
541
|
+
Only single-line, fully-indented snippets are supported.
|
|
542
|
+
Avoid comments for best results.
|
|
409
543
|
|
|
410
544
|
Args:
|
|
411
|
-
file_path (str):
|
|
545
|
+
file_path (str): Abs path to Lean file
|
|
412
546
|
line (int): Line number (1-indexed)
|
|
413
|
-
|
|
414
|
-
max_completions (int, optional): Maximum number of completions to return. Defaults to 100.
|
|
547
|
+
snippets (List[str]): List of snippets (3+ are recommended)
|
|
415
548
|
|
|
416
549
|
Returns:
|
|
417
|
-
List[str] | str:
|
|
550
|
+
List[str] | str: Diagnostics and goal states or error msg
|
|
418
551
|
"""
|
|
419
|
-
rel_path =
|
|
552
|
+
rel_path = setup_client_for_file(ctx, file_path)
|
|
420
553
|
if not rel_path:
|
|
421
|
-
return "
|
|
554
|
+
return "Invalid Lean file path: Unable to start LSP server or load file"
|
|
422
555
|
update_file(ctx, rel_path)
|
|
423
|
-
|
|
424
556
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
425
|
-
completions = client.get_completions(rel_path, line - 1, column - 1)
|
|
426
557
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
558
|
+
try:
|
|
559
|
+
client.open_file(rel_path)
|
|
560
|
+
|
|
561
|
+
results = []
|
|
562
|
+
# Avoid mutating caller-provided snippets; normalize locally per attempt
|
|
563
|
+
for snippet in snippets:
|
|
564
|
+
snippet_str = snippet.rstrip("\n")
|
|
565
|
+
payload = f"{snippet_str}\n"
|
|
566
|
+
# Create a DocumentContentChange for the snippet
|
|
567
|
+
change = DocumentContentChange(
|
|
568
|
+
payload,
|
|
569
|
+
[line - 1, 0],
|
|
570
|
+
[line, 0],
|
|
571
|
+
)
|
|
572
|
+
# Apply the change to the file, capture diagnostics and goal state
|
|
573
|
+
client.update_file(rel_path, [change])
|
|
574
|
+
diag = client.get_diagnostics(rel_path)
|
|
575
|
+
formatted_diag = "\n".join(format_diagnostics(diag, select_line=line - 1))
|
|
576
|
+
# Use the snippet text length without any trailing newline for the column
|
|
577
|
+
goal = client.get_goal(rel_path, line - 1, len(snippet_str))
|
|
578
|
+
formatted_goal = format_goal(goal, "Missing goal")
|
|
579
|
+
results.append(f"{snippet_str}:\n {formatted_goal}\n\n{formatted_diag}")
|
|
580
|
+
|
|
581
|
+
return results
|
|
582
|
+
finally:
|
|
583
|
+
try:
|
|
584
|
+
client.close_files([rel_path])
|
|
585
|
+
except Exception as exc: # pragma: no cover - close failures only logged
|
|
586
|
+
logger.warning(
|
|
587
|
+
"Failed to close `%s` after multi_attempt: %s", rel_path, exc
|
|
588
|
+
)
|
|
589
|
+
|
|
432
590
|
|
|
433
|
-
|
|
434
|
-
|
|
591
|
+
@mcp.tool("lean_run_code")
|
|
592
|
+
def run_code(ctx: Context, code: str) -> List[str] | str:
|
|
593
|
+
"""Run a complete, self-contained code snippet and return diagnostics.
|
|
435
594
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
595
|
+
Has to include all imports and definitions!
|
|
596
|
+
Only use for testing outside open files! Keep the user in the loop by editing files instead.
|
|
597
|
+
|
|
598
|
+
Args:
|
|
599
|
+
code (str): Code snippet
|
|
600
|
+
|
|
601
|
+
Returns:
|
|
602
|
+
List[str] | str: Diagnostics msgs or error msg
|
|
603
|
+
"""
|
|
604
|
+
lifespan_context = ctx.request_context.lifespan_context
|
|
605
|
+
lean_project_path = lifespan_context.lean_project_path
|
|
606
|
+
if lean_project_path is None:
|
|
607
|
+
return "No valid Lean project path found. Run another tool (e.g. `lean_diagnostic_messages`) first to set it up or set the LEAN_PROJECT_PATH environment variable."
|
|
608
|
+
|
|
609
|
+
# Use a unique snippet filename to avoid collisions under concurrency
|
|
610
|
+
rel_path = f"_mcp_snippet_{uuid.uuid4().hex}.lean"
|
|
611
|
+
abs_path = lean_project_path / rel_path
|
|
612
|
+
|
|
613
|
+
try:
|
|
614
|
+
with open(abs_path, "w", encoding="utf-8") as f:
|
|
615
|
+
f.write(code)
|
|
616
|
+
except Exception as e:
|
|
617
|
+
return f"Error writing code snippet to file `{abs_path}`:\n{str(e)}"
|
|
618
|
+
|
|
619
|
+
client: LeanLSPClient | None = lifespan_context.client
|
|
620
|
+
diagnostics: List[str] | str = []
|
|
621
|
+
close_error: str | None = None
|
|
622
|
+
remove_error: str | None = None
|
|
623
|
+
opened_file = False
|
|
624
|
+
|
|
625
|
+
try:
|
|
626
|
+
if client is None:
|
|
627
|
+
startup_client(ctx)
|
|
628
|
+
client = lifespan_context.client
|
|
629
|
+
if client is None:
|
|
630
|
+
return "Failed to initialize Lean client for run_code."
|
|
631
|
+
|
|
632
|
+
assert client is not None # startup_client guarantees an initialized client
|
|
633
|
+
client.open_file(rel_path)
|
|
634
|
+
opened_file = True
|
|
635
|
+
diagnostics = format_diagnostics(client.get_diagnostics(rel_path))
|
|
636
|
+
finally:
|
|
637
|
+
if opened_file:
|
|
638
|
+
try:
|
|
639
|
+
client.close_files([rel_path])
|
|
640
|
+
except Exception as exc: # pragma: no cover - close failures only logged
|
|
641
|
+
close_error = str(exc)
|
|
642
|
+
logger.warning("Failed to close `%s` after run_code: %s", rel_path, exc)
|
|
643
|
+
try:
|
|
644
|
+
os.remove(abs_path)
|
|
645
|
+
except FileNotFoundError:
|
|
646
|
+
pass
|
|
647
|
+
except Exception as e:
|
|
648
|
+
remove_error = str(e)
|
|
649
|
+
logger.warning(
|
|
650
|
+
"Failed to remove temporary Lean snippet `%s`: %s", abs_path, e
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
if remove_error:
|
|
654
|
+
return f"Error removing temporary file `{abs_path}`:\n{remove_error}"
|
|
655
|
+
if close_error:
|
|
656
|
+
return f"Error closing temporary Lean document `{rel_path}`:\n{close_error}"
|
|
657
|
+
|
|
658
|
+
return (
|
|
659
|
+
diagnostics
|
|
660
|
+
if diagnostics
|
|
661
|
+
else "No diagnostics found for the code snippet (compiled successfully)."
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
@mcp.tool("lean_local_search")
|
|
666
|
+
def local_search(
|
|
667
|
+
ctx: Context, query: str, limit: int = 10
|
|
668
|
+
) -> List[Dict[str, str]] | str:
|
|
669
|
+
"""Confirm declarations exist in the current workspace to prevent hallucinating APIs.
|
|
670
|
+
|
|
671
|
+
VERY USEFUL AND FAST!
|
|
672
|
+
Pass a short prefix (e.g. ``map_mul``); the metadata shows the declaration kind and file.
|
|
673
|
+
The index spans theorems, lemmas, defs, classes, instances, structures, inductives, abbrevs, and opaque decls.
|
|
674
|
+
|
|
675
|
+
Args:
|
|
676
|
+
query (str): Declaration name or prefix.
|
|
677
|
+
limit (int): Max matches to return (default 10).
|
|
678
|
+
|
|
679
|
+
Returns:
|
|
680
|
+
List[Dict[str, str]] | str: Matches as ``{"name", "kind", "file"}`` or error message.
|
|
681
|
+
"""
|
|
682
|
+
if not _RG_AVAILABLE:
|
|
683
|
+
return _RG_MESSAGE
|
|
684
|
+
|
|
685
|
+
stored_root = ctx.request_context.lifespan_context.lean_project_path
|
|
686
|
+
if stored_root is None:
|
|
687
|
+
return "Lean project path not set. Call a file-based tool (like lean_goal) first to set the project path."
|
|
688
|
+
|
|
689
|
+
return lean_local_search(query=query.strip(), limit=limit, project_root=stored_root)
|
|
441
690
|
|
|
442
691
|
|
|
443
692
|
@mcp.tool("lean_leansearch")
|
|
444
|
-
|
|
445
|
-
|
|
693
|
+
@rate_limited("leansearch", max_requests=3, per_seconds=30)
|
|
694
|
+
def leansearch(ctx: Context, query: str, num_results: int = 5) -> List[Dict] | str:
|
|
695
|
+
"""Search for Lean theorems, definitions, and tactics using leansearch.net.
|
|
696
|
+
|
|
697
|
+
Query patterns:
|
|
698
|
+
- Natural language: "If there exist injective maps of sets from A to B and from B to A, then there exists a bijective map between A and B."
|
|
699
|
+
- Mixed natural/Lean: "natural numbers. from: n < m, to: n + 1 < m + 1", "n + 1 <= m if n < m"
|
|
700
|
+
- Concept names: "Cauchy Schwarz"
|
|
701
|
+
- Lean identifiers: "List.sum", "Finset induction"
|
|
702
|
+
- Lean term: "{f : A → B} {g : B → A} (hf : Injective f) (hg : Injective g) : ∃ h, Bijective h"
|
|
446
703
|
|
|
447
704
|
Args:
|
|
448
|
-
query (str):
|
|
449
|
-
|
|
705
|
+
query (str): Search query
|
|
706
|
+
num_results (int, optional): Max results. Defaults to 5.
|
|
450
707
|
|
|
451
708
|
Returns:
|
|
452
|
-
List[Dict] | str:
|
|
709
|
+
List[Dict] | str: Search results or error msg
|
|
453
710
|
"""
|
|
454
711
|
try:
|
|
455
712
|
headers = {"User-Agent": "lean-lsp-mcp/0.1", "Content-Type": "application/json"}
|
|
456
|
-
payload =
|
|
457
|
-
{"num_results": str(max_results), "query": [query]}
|
|
458
|
-
).encode("utf-8")
|
|
713
|
+
payload = orjson.dumps({"num_results": str(num_results), "query": [query]})
|
|
459
714
|
|
|
460
715
|
req = urllib.request.Request(
|
|
461
716
|
"https://leansearch.net/search",
|
|
@@ -464,28 +719,224 @@ def leansearch(ctx: Context, query: str, max_results: int = 5) -> List[Dict] | s
|
|
|
464
719
|
method="POST",
|
|
465
720
|
)
|
|
466
721
|
|
|
467
|
-
with urllib.request.urlopen(req, timeout=
|
|
468
|
-
results =
|
|
722
|
+
with urllib.request.urlopen(req, timeout=20) as response:
|
|
723
|
+
results = orjson.loads(response.read())
|
|
469
724
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
725
|
+
if not results or not results[0]:
|
|
726
|
+
return "No results found."
|
|
727
|
+
results = results[0][:num_results]
|
|
728
|
+
results = [r["result"] for r in results]
|
|
729
|
+
|
|
730
|
+
for result in results:
|
|
731
|
+
result.pop("docstring")
|
|
732
|
+
result["module_name"] = ".".join(result["module_name"])
|
|
733
|
+
result["name"] = ".".join(result["name"])
|
|
734
|
+
|
|
735
|
+
return results
|
|
736
|
+
except Exception as e:
|
|
737
|
+
return f"leansearch error:\n{str(e)}"
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
@mcp.tool("lean_loogle")
|
|
741
|
+
@rate_limited("loogle", max_requests=3, per_seconds=30)
|
|
742
|
+
def loogle(ctx: Context, query: str, num_results: int = 8) -> List[dict] | str:
|
|
743
|
+
"""Search for definitions and theorems using loogle.
|
|
744
|
+
|
|
745
|
+
Query patterns:
|
|
746
|
+
- By constant: Real.sin # finds lemmas mentioning Real.sin
|
|
747
|
+
- By lemma name: "differ" # finds lemmas with "differ" in the name
|
|
748
|
+
- By subexpression: _ * (_ ^ _) # finds lemmas with a product and power
|
|
749
|
+
- Non-linear: Real.sqrt ?a * Real.sqrt ?a
|
|
750
|
+
- By type shape: (?a -> ?b) -> List ?a -> List ?b
|
|
751
|
+
- By conclusion: |- tsum _ = _ * tsum _
|
|
752
|
+
- By conclusion w/hyps: |- _ < _ → tsum _ < tsum _
|
|
753
|
+
|
|
754
|
+
Args:
|
|
755
|
+
query (str): Search query
|
|
756
|
+
num_results (int, optional): Max results. Defaults to 8.
|
|
757
|
+
|
|
758
|
+
Returns:
|
|
759
|
+
List[dict] | str: Search results or error msg
|
|
760
|
+
"""
|
|
761
|
+
try:
|
|
762
|
+
req = urllib.request.Request(
|
|
763
|
+
f"https://loogle.lean-lang.org/json?q={urllib.parse.quote(query)}",
|
|
764
|
+
headers={"User-Agent": "lean-lsp-mcp/0.1"},
|
|
765
|
+
method="GET",
|
|
474
766
|
)
|
|
475
767
|
|
|
768
|
+
with urllib.request.urlopen(req, timeout=20) as response:
|
|
769
|
+
results = orjson.loads(response.read())
|
|
770
|
+
|
|
771
|
+
if "hits" not in results:
|
|
772
|
+
return "No results found."
|
|
773
|
+
|
|
774
|
+
results = results["hits"][:num_results]
|
|
775
|
+
for result in results:
|
|
776
|
+
result.pop("doc", None)
|
|
777
|
+
return results
|
|
476
778
|
except Exception as e:
|
|
477
|
-
return f"
|
|
779
|
+
return f"loogle error:\n{str(e)}"
|
|
478
780
|
|
|
479
781
|
|
|
480
|
-
|
|
481
|
-
@
|
|
482
|
-
def
|
|
483
|
-
"""
|
|
782
|
+
@mcp.tool("lean_leanfinder")
|
|
783
|
+
@rate_limited("leanfinder", max_requests=10, per_seconds=30)
|
|
784
|
+
def leanfinder(ctx: Context, query: str, num_results: int = 5) -> List[Dict] | str:
|
|
785
|
+
"""Search Mathlib theorems/definitions semantically by mathematical concept or proof state using Lean Finder.
|
|
786
|
+
|
|
787
|
+
Effective query types:
|
|
788
|
+
- Natural language mathematical statement: "For any natural numbers n and m, the sum n+m is equal to m+n."
|
|
789
|
+
- Natural language questions: "I'm working with algebraic elements over a field extension … Does this imply that the minimal polynomials of x and y are equal?"
|
|
790
|
+
- Proof state. For better results, enter a proof state followed by how you want to transform the proof state.
|
|
791
|
+
- Statement definition: Fragment or the whole statement definition.
|
|
792
|
+
|
|
793
|
+
Tips: Multiple targeted queries beat one complex query.
|
|
794
|
+
|
|
795
|
+
Args:
|
|
796
|
+
query (str): Mathematical concept or proof state
|
|
797
|
+
num_results (int, optional): Max results. Defaults to 5.
|
|
798
|
+
|
|
799
|
+
Returns:
|
|
800
|
+
List[Dict] | str: List of Lean statement objects (full name, formal statement, informal statement) or error msg
|
|
801
|
+
"""
|
|
802
|
+
try:
|
|
803
|
+
headers = {"User-Agent": "lean-lsp-mcp/0.1", "Content-Type": "application/json"}
|
|
804
|
+
request_url = (
|
|
805
|
+
"https://bxrituxuhpc70w8w.us-east-1.aws.endpoints.huggingface.cloud"
|
|
806
|
+
)
|
|
807
|
+
payload = orjson.dumps({"inputs": query, "top_k": int(num_results)})
|
|
808
|
+
req = urllib.request.Request(
|
|
809
|
+
request_url, data=payload, headers=headers, method="POST"
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
results = []
|
|
813
|
+
with urllib.request.urlopen(req, timeout=30) as response:
|
|
814
|
+
data = orjson.loads(response.read())
|
|
815
|
+
for result in data["results"]:
|
|
816
|
+
if (
|
|
817
|
+
"https://leanprover-community.github.io/mathlib4_docs"
|
|
818
|
+
not in result["url"]
|
|
819
|
+
): # Do not include results from other sources other than mathlib4, since users might not have imported them
|
|
820
|
+
continue
|
|
821
|
+
full_name = re.search(r"pattern=(.*?)#doc", result["url"]).group(1)
|
|
822
|
+
obj = {
|
|
823
|
+
"full_name": full_name,
|
|
824
|
+
"formal_statement": result["formal_statement"],
|
|
825
|
+
"informal_statement": result["informal_statement"],
|
|
826
|
+
}
|
|
827
|
+
results.append(obj)
|
|
828
|
+
|
|
829
|
+
return results if results else "Lean Finder: No results parsed"
|
|
830
|
+
except Exception as e:
|
|
831
|
+
return f"Lean Finder Error:\n{str(e)}"
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
@mcp.tool("lean_state_search")
|
|
835
|
+
@rate_limited("lean_state_search", max_requests=3, per_seconds=30)
|
|
836
|
+
def state_search(
|
|
837
|
+
ctx: Context, file_path: str, line: int, column: int, num_results: int = 5
|
|
838
|
+
) -> List | str:
|
|
839
|
+
"""Search for theorems based on proof state using premise-search.com.
|
|
840
|
+
|
|
841
|
+
Only uses first goal if multiple.
|
|
842
|
+
|
|
843
|
+
Args:
|
|
844
|
+
file_path (str): Abs path to Lean file
|
|
845
|
+
line (int): Line number (1-indexed)
|
|
846
|
+
column (int): Column number (1-indexed)
|
|
847
|
+
num_results (int, optional): Max results. Defaults to 5.
|
|
484
848
|
|
|
485
849
|
Returns:
|
|
486
|
-
str:
|
|
850
|
+
List | str: Search results or error msg
|
|
487
851
|
"""
|
|
488
|
-
|
|
852
|
+
rel_path = setup_client_for_file(ctx, file_path)
|
|
853
|
+
if not rel_path:
|
|
854
|
+
return "Invalid Lean file path: Unable to start LSP server or load file"
|
|
855
|
+
|
|
856
|
+
file_contents = update_file(ctx, rel_path)
|
|
857
|
+
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
858
|
+
goal = client.get_goal(rel_path, line - 1, column - 1)
|
|
859
|
+
|
|
860
|
+
f_line = format_line(file_contents, line, column)
|
|
861
|
+
if not goal or not goal.get("goals"):
|
|
862
|
+
return f"No goals found:\n{f_line}\nTry elsewhere?"
|
|
863
|
+
|
|
864
|
+
goal = urllib.parse.quote(goal["goals"][0])
|
|
865
|
+
|
|
866
|
+
try:
|
|
867
|
+
url = os.getenv("LEAN_STATE_SEARCH_URL", "https://premise-search.com")
|
|
868
|
+
req = urllib.request.Request(
|
|
869
|
+
f"{url}/api/search?query={goal}&results={num_results}&rev=v4.22.0",
|
|
870
|
+
headers={"User-Agent": "lean-lsp-mcp/0.1"},
|
|
871
|
+
method="GET",
|
|
872
|
+
)
|
|
873
|
+
|
|
874
|
+
with urllib.request.urlopen(req, timeout=20) as response:
|
|
875
|
+
results = orjson.loads(response.read())
|
|
876
|
+
|
|
877
|
+
for result in results:
|
|
878
|
+
result.pop("rev")
|
|
879
|
+
# Very dirty type mix
|
|
880
|
+
results.insert(0, f"Results for line:\n{f_line}")
|
|
881
|
+
return results
|
|
882
|
+
except Exception as e:
|
|
883
|
+
return f"lean state search error:\n{str(e)}"
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
@mcp.tool("lean_hammer_premise")
|
|
887
|
+
@rate_limited("hammer_premise", max_requests=3, per_seconds=30)
|
|
888
|
+
def hammer_premise(
|
|
889
|
+
ctx: Context, file_path: str, line: int, column: int, num_results: int = 32
|
|
890
|
+
) -> List[str] | str:
|
|
891
|
+
"""Search for premises based on proof state using the lean hammer premise search.
|
|
892
|
+
|
|
893
|
+
Args:
|
|
894
|
+
file_path (str): Abs path to Lean file
|
|
895
|
+
line (int): Line number (1-indexed)
|
|
896
|
+
column (int): Column number (1-indexed)
|
|
897
|
+
num_results (int, optional): Max results. Defaults to 32.
|
|
898
|
+
|
|
899
|
+
Returns:
|
|
900
|
+
List[str] | str: List of relevant premises or error message
|
|
901
|
+
"""
|
|
902
|
+
rel_path = setup_client_for_file(ctx, file_path)
|
|
903
|
+
if not rel_path:
|
|
904
|
+
return "Invalid Lean file path: Unable to start LSP server or load file"
|
|
905
|
+
|
|
906
|
+
file_contents = update_file(ctx, rel_path)
|
|
907
|
+
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
908
|
+
goal = client.get_goal(rel_path, line - 1, column - 1)
|
|
909
|
+
|
|
910
|
+
f_line = format_line(file_contents, line, column)
|
|
911
|
+
if not goal or not goal.get("goals"):
|
|
912
|
+
return f"No goals found:\n{f_line}\nTry elsewhere?"
|
|
913
|
+
|
|
914
|
+
data = {
|
|
915
|
+
"state": goal["goals"][0],
|
|
916
|
+
"new_premises": [],
|
|
917
|
+
"k": num_results,
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
try:
|
|
921
|
+
url = os.getenv("LEAN_HAMMER_URL", "http://leanpremise.net")
|
|
922
|
+
req = urllib.request.Request(
|
|
923
|
+
url + "/retrieve",
|
|
924
|
+
headers={
|
|
925
|
+
"User-Agent": "lean-lsp-mcp/0.1",
|
|
926
|
+
"Content-Type": "application/json",
|
|
927
|
+
},
|
|
928
|
+
method="POST",
|
|
929
|
+
data=orjson.dumps(data),
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
with urllib.request.urlopen(req, timeout=20) as response:
|
|
933
|
+
results = orjson.loads(response.read())
|
|
934
|
+
|
|
935
|
+
results = [result["name"] for result in results]
|
|
936
|
+
results.insert(0, f"Results for line:\n{f_line}")
|
|
937
|
+
return results
|
|
938
|
+
except Exception as e:
|
|
939
|
+
return f"lean hammer premise error:\n{str(e)}"
|
|
489
940
|
|
|
490
941
|
|
|
491
942
|
if __name__ == "__main__":
|