lean-lsp-mcp 0.1.1__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 +27 -1
- lean_lsp_mcp/__main__.py +1 -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 +781 -249
- lean_lsp_mcp/utils.py +261 -0
- 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.1.dist-info → lean_lsp_mcp-0.11.2.dist-info}/WHEEL +1 -1
- lean_lsp_mcp-0.11.2.dist-info/licenses/LICENSE +21 -0
- lean_lsp_mcp/prompts.py +0 -33
- lean_lsp_mcp-0.1.1.dist-info/METADATA +0 -17
- lean_lsp_mcp-0.1.1.dist-info/RECORD +0 -9
- {lean_lsp_mcp-0.1.1.dist-info → lean_lsp_mcp-0.11.2.dist-info}/entry_points.txt +0 -0
- {lean_lsp_mcp-0.1.1.dist-info → lean_lsp_mcp-0.11.2.dist-info}/top_level.txt +0 -0
lean_lsp_mcp/server.py
CHANGED
|
@@ -1,239 +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 leanclient import LeanLSPClient
|
|
7
|
-
|
|
8
6
|
from contextlib import asynccontextmanager
|
|
9
7
|
from collections.abc import AsyncIterator
|
|
10
8
|
from dataclasses import dataclass
|
|
9
|
+
import urllib
|
|
10
|
+
import orjson
|
|
11
|
+
import functools
|
|
12
|
+
import subprocess
|
|
13
|
+
import uuid
|
|
14
|
+
from pathlib import Path
|
|
11
15
|
|
|
12
16
|
from mcp.server.fastmcp import Context, FastMCP
|
|
13
|
-
|
|
14
|
-
from
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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,
|
|
21
34
|
)
|
|
22
35
|
|
|
23
|
-
logger = logging.getLogger("lean-lsp-mcp")
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
class StdoutToStderr:
|
|
27
|
-
"""Redirects stdout to stderr at the file descriptor level bc lake build logging"""
|
|
28
|
-
|
|
29
|
-
def __init__(self):
|
|
30
|
-
self.original_stdout_fd = None
|
|
31
|
-
|
|
32
|
-
def __enter__(self):
|
|
33
|
-
self.original_stdout_fd = os.dup(sys.stdout.fileno())
|
|
34
|
-
stderr_fd = sys.stderr.fileno()
|
|
35
|
-
os.dup2(stderr_fd, sys.stdout.fileno())
|
|
36
|
-
return self
|
|
37
|
-
|
|
38
|
-
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
39
|
-
if self.original_stdout_fd is not None:
|
|
40
|
-
os.dup2(self.original_stdout_fd, sys.stdout.fileno())
|
|
41
|
-
os.close(self.original_stdout_fd)
|
|
42
|
-
self.original_stdout_fd = None
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
# Lean project path management
|
|
46
|
-
LEAN_PROJECT_PATH = os.environ.get("LEAN_PROJECT_PATH", "").strip()
|
|
47
|
-
cwd = os.getcwd().strip() # Strip necessary?
|
|
48
|
-
if not LEAN_PROJECT_PATH:
|
|
49
|
-
logger.error("Please set the LEAN_PROJECT_PATH environment variable")
|
|
50
|
-
sys.exit(1)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
# File operations
|
|
54
|
-
def get_relative_file_path(file_path: str) -> Optional[str]:
|
|
55
|
-
"""Convert path relative to project path.
|
|
56
|
-
|
|
57
|
-
Args:
|
|
58
|
-
file_path (str): File path.
|
|
59
|
-
|
|
60
|
-
Returns:
|
|
61
|
-
str: Relative file path.
|
|
62
|
-
"""
|
|
63
|
-
# Check if absolute path
|
|
64
|
-
if os.path.exists(file_path):
|
|
65
|
-
return os.path.relpath(file_path, LEAN_PROJECT_PATH)
|
|
66
|
-
|
|
67
|
-
# Check if relative to project path
|
|
68
|
-
path = os.path.join(LEAN_PROJECT_PATH, file_path)
|
|
69
|
-
if os.path.exists(path):
|
|
70
|
-
return os.path.relpath(path, LEAN_PROJECT_PATH)
|
|
71
|
-
|
|
72
|
-
# Check if relative to CWD
|
|
73
|
-
path = os.path.join(cwd, file_path)
|
|
74
|
-
if os.path.exists(path):
|
|
75
|
-
return os.path.relpath(path, LEAN_PROJECT_PATH)
|
|
76
|
-
|
|
77
|
-
return None
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def get_file_contents(rel_path: str) -> Optional[str]:
|
|
81
|
-
with open(os.path.join(LEAN_PROJECT_PATH, rel_path), "r") as f:
|
|
82
|
-
data = f.read()
|
|
83
|
-
return data
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def update_file(ctx: Context, rel_path: str) -> str:
|
|
87
|
-
"""Update the file contents in the context.
|
|
88
|
-
Args:
|
|
89
|
-
ctx (Context): Context object.
|
|
90
|
-
rel_path (str): Relative file path.
|
|
91
|
-
|
|
92
|
-
Returns:
|
|
93
|
-
str: Updated file contents.
|
|
94
|
-
"""
|
|
95
|
-
# Get file contents
|
|
96
|
-
data = get_file_contents(rel_path)
|
|
97
|
-
|
|
98
|
-
# Check if file_contents have changed
|
|
99
|
-
file_contents: Dict[str, str] = ctx.request_context.lifespan_context.file_contents
|
|
100
|
-
if rel_path not in file_contents:
|
|
101
|
-
file_contents[rel_path] = data
|
|
102
|
-
return data
|
|
103
36
|
|
|
104
|
-
|
|
105
|
-
|
|
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__)
|
|
106
40
|
|
|
107
|
-
# Update file_contents
|
|
108
|
-
file_contents[rel_path] = data
|
|
109
41
|
|
|
110
|
-
|
|
111
|
-
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
112
|
-
client.close_files([rel_path])
|
|
113
|
-
return data
|
|
42
|
+
_RG_AVAILABLE, _RG_MESSAGE = check_ripgrep_status()
|
|
114
43
|
|
|
115
44
|
|
|
116
45
|
# Server and context
|
|
117
46
|
@dataclass
|
|
118
47
|
class AppContext:
|
|
119
|
-
|
|
120
|
-
|
|
48
|
+
lean_project_path: Path | None
|
|
49
|
+
client: LeanLSPClient | None
|
|
50
|
+
file_content_hashes: Dict[str, str]
|
|
51
|
+
rate_limit: Dict[str, List[int]]
|
|
52
|
+
lean_search_available: bool
|
|
121
53
|
|
|
122
54
|
|
|
123
55
|
@asynccontextmanager
|
|
124
56
|
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
|
|
125
|
-
with StdoutToStderr():
|
|
126
|
-
try:
|
|
127
|
-
client = LeanLSPClient(LEAN_PROJECT_PATH)
|
|
128
|
-
logger.info(f"Connected to Lean project at {LEAN_PROJECT_PATH}")
|
|
129
|
-
except Exception as e:
|
|
130
|
-
client = LeanLSPClient(LEAN_PROJECT_PATH, initial_build=False)
|
|
131
|
-
logger.error(f"Could not do initial build, error: {e}")
|
|
132
|
-
|
|
133
57
|
try:
|
|
134
|
-
|
|
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
|
+
)
|
|
135
77
|
yield context
|
|
136
78
|
finally:
|
|
137
79
|
logger.info("Closing Lean LSP client")
|
|
138
|
-
|
|
80
|
+
|
|
81
|
+
if context.client:
|
|
82
|
+
context.client.close()
|
|
139
83
|
|
|
140
84
|
|
|
141
|
-
|
|
142
|
-
"Lean LSP",
|
|
143
|
-
|
|
85
|
+
mcp_kwargs = dict(
|
|
86
|
+
name="Lean LSP",
|
|
87
|
+
instructions=INSTRUCTIONS,
|
|
144
88
|
dependencies=["leanclient"],
|
|
145
89
|
lifespan=app_lifespan,
|
|
146
|
-
env_vars={
|
|
147
|
-
"LEAN_PROJECT_PATH": {
|
|
148
|
-
"description": "Path to the Lean project root",
|
|
149
|
-
"required": True,
|
|
150
|
-
}
|
|
151
|
-
},
|
|
152
90
|
)
|
|
153
91
|
|
|
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
|
|
154
132
|
|
|
155
|
-
# Meta level tools
|
|
156
|
-
@mcp.tool("lean_auto_proof_instructions")
|
|
157
|
-
def auto_proof() -> str:
|
|
158
|
-
"""Get the description of the Lean LSP MCP and how to use it to automatically prove theorems.
|
|
159
133
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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.
|
|
166
140
|
|
|
141
|
+
Use only if needed (e.g. new imports).
|
|
167
142
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
"""Get the path to the Lean project root.
|
|
143
|
+
Args:
|
|
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.
|
|
172
146
|
|
|
173
147
|
Returns:
|
|
174
|
-
str:
|
|
148
|
+
str: Build output or error msg
|
|
175
149
|
"""
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
178
155
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
+
)
|
|
182
161
|
|
|
183
|
-
|
|
184
|
-
bool: True if the Lean project is functional, False otherwise.
|
|
185
|
-
"""
|
|
162
|
+
build_output = ""
|
|
186
163
|
try:
|
|
187
164
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
188
|
-
client
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
+
)
|
|
192
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
|
+
)
|
|
193
188
|
|
|
194
|
-
|
|
195
|
-
def lsp_restart(ctx: Context, rebuild: bool = True) -> bool:
|
|
196
|
-
"""Restart the LSP server. Can also rebuild the lean project.
|
|
189
|
+
output_lines = []
|
|
197
190
|
|
|
198
|
-
|
|
191
|
+
while True:
|
|
192
|
+
line = await process.stdout.readline()
|
|
193
|
+
if not line:
|
|
194
|
+
break
|
|
199
195
|
|
|
200
|
-
|
|
201
|
-
|
|
196
|
+
line_str = line.decode("utf-8", errors="replace").rstrip()
|
|
197
|
+
output_lines.append(line_str)
|
|
202
198
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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))
|
|
204
|
+
|
|
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"
|
|
211
|
+
|
|
212
|
+
# Report progress using dynamic totals from Lake
|
|
213
|
+
await ctx.report_progress(
|
|
214
|
+
progress=current_job, total=total_jobs, message=description
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
await process.wait()
|
|
218
|
+
|
|
219
|
+
if process.returncode != 0:
|
|
220
|
+
build_output = "\n".join(output_lines)
|
|
221
|
+
raise Exception(f"Build failed with return code {process.returncode}")
|
|
222
|
+
|
|
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
|
+
)
|
|
228
|
+
|
|
229
|
+
logger.info("Built project and re-started LSP client")
|
|
230
|
+
|
|
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}"
|
|
215
236
|
|
|
216
237
|
|
|
217
238
|
# File level tools
|
|
218
239
|
@mcp.tool("lean_file_contents")
|
|
219
240
|
def file_contents(ctx: Context, file_path: str, annotate_lines: bool = True) -> str:
|
|
220
|
-
"""Get the text contents of a Lean file.
|
|
221
|
-
|
|
222
|
-
IMPORTANT! Look up the file_contents for the currently open file including line number annotations.
|
|
223
|
-
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.
|
|
224
242
|
|
|
225
243
|
Args:
|
|
226
|
-
file_path (str):
|
|
227
|
-
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.
|
|
228
246
|
|
|
229
247
|
Returns:
|
|
230
|
-
|
|
248
|
+
str: File content or error msg
|
|
231
249
|
"""
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
+
)
|
|
237
256
|
|
|
238
257
|
if annotate_lines:
|
|
239
258
|
data = data.split("\n")
|
|
@@ -248,163 +267,676 @@ def file_contents(ctx: Context, file_path: str, annotate_lines: bool = True) ->
|
|
|
248
267
|
|
|
249
268
|
@mcp.tool("lean_diagnostic_messages")
|
|
250
269
|
def diagnostic_messages(ctx: Context, file_path: str) -> List[str] | str:
|
|
251
|
-
"""Get all diagnostic
|
|
270
|
+
"""Get all diagnostic msgs (errors, warnings, infos) for a Lean file.
|
|
252
271
|
|
|
253
|
-
|
|
272
|
+
"no goals to be solved" means code may need removal.
|
|
254
273
|
|
|
255
274
|
Args:
|
|
256
|
-
file_path (str):
|
|
275
|
+
file_path (str): Abs path to Lean file
|
|
257
276
|
|
|
258
277
|
Returns:
|
|
259
|
-
List[str] | str: Diagnostic
|
|
278
|
+
List[str] | str: Diagnostic msgs or error msg
|
|
260
279
|
"""
|
|
261
|
-
rel_path =
|
|
280
|
+
rel_path = setup_client_for_file(ctx, file_path)
|
|
262
281
|
if not rel_path:
|
|
263
|
-
return "
|
|
282
|
+
return "Invalid Lean file path: Unable to start LSP server or load file"
|
|
264
283
|
|
|
265
284
|
update_file(ctx, rel_path)
|
|
266
285
|
|
|
267
286
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
268
287
|
diagnostics = client.get_diagnostics(rel_path)
|
|
269
|
-
|
|
270
|
-
# Format more compact
|
|
271
|
-
for diag in diagnostics:
|
|
272
|
-
r = diag.get("fullRange", diag.get("range", None))
|
|
273
|
-
if r is None:
|
|
274
|
-
r_text = "No range"
|
|
275
|
-
else:
|
|
276
|
-
r_text = f"l{r['start']['line'] + 1}c{r['start']['character'] + 1} - l{r['end']['line'] + 1}c{r['end']['character'] + 1}"
|
|
277
|
-
msgs.append(f"{r_text}, severity: {diag['severity']}\n{diag['message']}")
|
|
278
|
-
return msgs
|
|
288
|
+
return format_diagnostics(diagnostics)
|
|
279
289
|
|
|
280
290
|
|
|
281
291
|
@mcp.tool("lean_goal")
|
|
282
292
|
def goal(ctx: Context, file_path: str, line: int, column: Optional[int] = None) -> str:
|
|
283
|
-
"""Get the proof
|
|
284
|
-
|
|
285
|
-
VERY USEFUL AND CHEAP! This is your main tool to understand the proof state and its evolution!!
|
|
286
|
-
Use this multiple times after every edit to the file!
|
|
293
|
+
"""Get the proof goals (proof state) at a specific location in a Lean file.
|
|
287
294
|
|
|
288
|
-
|
|
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.
|
|
289
299
|
|
|
290
300
|
Args:
|
|
291
|
-
file_path (str):
|
|
301
|
+
file_path (str): Abs path to Lean file
|
|
292
302
|
line (int): Line number (1-indexed)
|
|
293
|
-
column (int, optional): Column number (1-indexed). Defaults to None =>
|
|
303
|
+
column (int, optional): Column number (1-indexed). Defaults to None => Both before and after the line.
|
|
294
304
|
|
|
295
305
|
Returns:
|
|
296
|
-
str: Goal
|
|
306
|
+
str: Goal(s) or error msg
|
|
297
307
|
"""
|
|
298
|
-
rel_path =
|
|
308
|
+
rel_path = setup_client_for_file(ctx, file_path)
|
|
299
309
|
if not rel_path:
|
|
300
|
-
return "
|
|
310
|
+
return "Invalid Lean file path: Unable to start LSP server or load file"
|
|
301
311
|
|
|
302
312
|
content = update_file(ctx, rel_path)
|
|
313
|
+
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
303
314
|
|
|
304
315
|
if column is None:
|
|
305
|
-
|
|
316
|
+
lines = content.splitlines()
|
|
317
|
+
if line < 1 or line > len(lines):
|
|
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)
|
|
324
|
+
goal_end = client.get_goal(rel_path, line - 1, column_end)
|
|
306
325
|
|
|
307
|
-
|
|
326
|
+
if goal_start is None and goal_end is None:
|
|
327
|
+
return f"No goals on line:\n{lines[line - 1]}\nTry another line?"
|
|
308
328
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
return "
|
|
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}"
|
|
312
332
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
333
|
+
else:
|
|
334
|
+
goal = client.get_goal(rel_path, line - 1, column - 1)
|
|
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}"
|
|
317
338
|
|
|
318
339
|
|
|
319
340
|
@mcp.tool("lean_term_goal")
|
|
320
341
|
def term_goal(
|
|
321
342
|
ctx: Context, file_path: str, line: int, column: Optional[int] = None
|
|
322
343
|
) -> str:
|
|
323
|
-
"""Get the term goal at a specific location in a Lean file.
|
|
324
|
-
|
|
325
|
-
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.
|
|
326
345
|
|
|
327
346
|
Args:
|
|
328
|
-
file_path (str):
|
|
347
|
+
file_path (str): Abs path to Lean file
|
|
329
348
|
line (int): Line number (1-indexed)
|
|
330
349
|
column (int, optional): Column number (1-indexed). Defaults to None => end of line.
|
|
331
350
|
|
|
332
351
|
Returns:
|
|
333
|
-
str:
|
|
352
|
+
str: Expected type or error msg
|
|
334
353
|
"""
|
|
335
|
-
rel_path =
|
|
354
|
+
rel_path = setup_client_for_file(ctx, file_path)
|
|
336
355
|
if not rel_path:
|
|
337
|
-
return "
|
|
356
|
+
return "Invalid Lean file path: Unable to start LSP server or load file"
|
|
338
357
|
|
|
339
358
|
content = update_file(ctx, rel_path)
|
|
340
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?"
|
|
341
363
|
column = len(content.splitlines()[line - 1])
|
|
342
364
|
|
|
343
365
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
344
366
|
term_goal = client.get_term_goal(rel_path, line - 1, column - 1)
|
|
367
|
+
f_line = format_line(content, line, column)
|
|
345
368
|
if term_goal is None:
|
|
346
|
-
return "Not a valid term goal position
|
|
369
|
+
return f"Not a valid term goal position:\n{f_line}\nTry elsewhere?"
|
|
347
370
|
rendered = term_goal.get("goal", None)
|
|
348
371
|
if rendered is not None:
|
|
349
372
|
rendered = rendered.replace("```lean\n", "").replace("\n```", "")
|
|
350
|
-
return rendered
|
|
373
|
+
return f"Term goal at:\n{f_line}\n{rendered or 'No term goal found.'}"
|
|
351
374
|
|
|
352
375
|
|
|
353
376
|
@mcp.tool("lean_hover_info")
|
|
354
377
|
def hover(ctx: Context, file_path: str, line: int, column: int) -> str:
|
|
355
|
-
"""Get
|
|
356
|
-
|
|
357
|
-
Use this information to look up information about lean syntax, variables, functions, etc.
|
|
378
|
+
"""Get hover info (docs for syntax, variables, functions, etc.) at a specific location in a Lean file.
|
|
358
379
|
|
|
359
380
|
Args:
|
|
360
|
-
file_path (str):
|
|
381
|
+
file_path (str): Abs path to Lean file
|
|
361
382
|
line (int): Line number (1-indexed)
|
|
362
|
-
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.
|
|
363
384
|
|
|
364
385
|
Returns:
|
|
365
|
-
str: Hover
|
|
386
|
+
str: Hover info or error msg
|
|
366
387
|
"""
|
|
367
|
-
rel_path =
|
|
388
|
+
rel_path = setup_client_for_file(ctx, file_path)
|
|
368
389
|
if not rel_path:
|
|
369
|
-
return "
|
|
370
|
-
|
|
371
|
-
update_file(ctx, rel_path)
|
|
390
|
+
return "Invalid Lean file path: Unable to start LSP server or load file"
|
|
372
391
|
|
|
392
|
+
file_content = update_file(ctx, rel_path)
|
|
373
393
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
374
394
|
hover_info = client.get_hover(rel_path, line - 1, column - 1)
|
|
375
395
|
if hover_info is None:
|
|
376
|
-
|
|
396
|
+
f_line = format_line(file_content, line, column)
|
|
397
|
+
return f"No hover information at position:\n{f_line}\nTry elsewhere?"
|
|
377
398
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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()
|
|
381
404
|
|
|
405
|
+
# Add diagnostics if available
|
|
406
|
+
diagnostics = client.get_diagnostics(rel_path)
|
|
407
|
+
filtered = filter_diagnostics_by_position(diagnostics, line - 1, column - 1)
|
|
382
408
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
409
|
+
msg = f"Hover info `{symbol}`:\n{info}"
|
|
410
|
+
if filtered:
|
|
411
|
+
msg += "\n\nDiagnostics\n" + "\n".join(format_diagnostics(filtered))
|
|
412
|
+
return msg
|
|
386
413
|
|
|
387
|
-
|
|
414
|
+
|
|
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.
|
|
420
|
+
|
|
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.
|
|
425
|
+
|
|
426
|
+
Args:
|
|
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
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
str: List of possible completions or error msg
|
|
434
|
+
"""
|
|
435
|
+
rel_path = setup_client_for_file(ctx, file_path)
|
|
436
|
+
if not rel_path:
|
|
437
|
+
return "Invalid Lean file path: Unable to start LSP server or load file"
|
|
438
|
+
content = update_file(ctx, rel_path)
|
|
439
|
+
|
|
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.
|
|
388
489
|
|
|
389
490
|
Args:
|
|
390
|
-
file_path (str):
|
|
491
|
+
file_path (str): Abs path to Lean file
|
|
492
|
+
symbol (str): Symbol to look up the declaration for. Case sensitive!
|
|
391
493
|
|
|
392
494
|
Returns:
|
|
393
|
-
str:
|
|
495
|
+
str: File contents or error msg
|
|
394
496
|
"""
|
|
395
|
-
rel_path =
|
|
497
|
+
rel_path = setup_client_for_file(ctx, file_path)
|
|
396
498
|
if not rel_path:
|
|
397
|
-
return "
|
|
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."
|
|
506
|
+
|
|
507
|
+
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
508
|
+
declaration = client.get_declarations(
|
|
509
|
+
rel_path, position["line"], position["column"]
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
if len(declaration) == 0:
|
|
513
|
+
return f"No declaration available for `{symbol}`."
|
|
514
|
+
|
|
515
|
+
# Load the declaration file
|
|
516
|
+
declaration = declaration[0]
|
|
517
|
+
uri = declaration.get("targetUri")
|
|
518
|
+
if not uri:
|
|
519
|
+
uri = declaration.get("uri")
|
|
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}`."
|
|
524
|
+
|
|
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]
|
|
533
|
+
) -> List[str] | str:
|
|
534
|
+
"""Try multiple Lean code snippets at a line and get the goal state and diagnostics for each.
|
|
398
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.
|
|
539
|
+
|
|
540
|
+
Note:
|
|
541
|
+
Only single-line, fully-indented snippets are supported.
|
|
542
|
+
Avoid comments for best results.
|
|
543
|
+
|
|
544
|
+
Args:
|
|
545
|
+
file_path (str): Abs path to Lean file
|
|
546
|
+
line (int): Line number (1-indexed)
|
|
547
|
+
snippets (List[str]): List of snippets (3+ are recommended)
|
|
548
|
+
|
|
549
|
+
Returns:
|
|
550
|
+
List[str] | str: Diagnostics and goal states or error msg
|
|
551
|
+
"""
|
|
552
|
+
rel_path = setup_client_for_file(ctx, file_path)
|
|
553
|
+
if not rel_path:
|
|
554
|
+
return "Invalid Lean file path: Unable to start LSP server or load file"
|
|
399
555
|
update_file(ctx, rel_path)
|
|
556
|
+
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
557
|
+
|
|
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
|
+
|
|
590
|
+
|
|
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.
|
|
594
|
+
|
|
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)
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
@mcp.tool("lean_leansearch")
|
|
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"
|
|
703
|
+
|
|
704
|
+
Args:
|
|
705
|
+
query (str): Search query
|
|
706
|
+
num_results (int, optional): Max results. Defaults to 5.
|
|
707
|
+
|
|
708
|
+
Returns:
|
|
709
|
+
List[Dict] | str: Search results or error msg
|
|
710
|
+
"""
|
|
711
|
+
try:
|
|
712
|
+
headers = {"User-Agent": "lean-lsp-mcp/0.1", "Content-Type": "application/json"}
|
|
713
|
+
payload = orjson.dumps({"num_results": str(num_results), "query": [query]})
|
|
714
|
+
|
|
715
|
+
req = urllib.request.Request(
|
|
716
|
+
"https://leansearch.net/search",
|
|
717
|
+
data=payload,
|
|
718
|
+
headers=headers,
|
|
719
|
+
method="POST",
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
with urllib.request.urlopen(req, timeout=20) as response:
|
|
723
|
+
results = orjson.loads(response.read())
|
|
724
|
+
|
|
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.
|
|
400
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",
|
|
766
|
+
)
|
|
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
|
|
778
|
+
except Exception as e:
|
|
779
|
+
return f"loogle error:\n{str(e)}"
|
|
780
|
+
|
|
781
|
+
|
|
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.
|
|
848
|
+
|
|
849
|
+
Returns:
|
|
850
|
+
List | str: Search results or error msg
|
|
851
|
+
"""
|
|
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)
|
|
401
857
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
402
|
-
|
|
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
|
+
)
|
|
403
931
|
|
|
404
|
-
|
|
405
|
-
|
|
932
|
+
with urllib.request.urlopen(req, timeout=20) as response:
|
|
933
|
+
results = orjson.loads(response.read())
|
|
406
934
|
|
|
407
|
-
|
|
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)}"
|
|
408
940
|
|
|
409
941
|
|
|
410
942
|
if __name__ == "__main__":
|