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/server.py CHANGED
@@ -1,216 +1,258 @@
1
+ import asyncio
1
2
  import os
2
- import sys
3
- import logging
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 json
10
-
11
- from leanclient import LeanLSPClient
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 lean_lsp_mcp.prompts import PROMPT_AUTOMATIC_PROOF
16
- from lean_lsp_mcp.utils import StdoutToStderr, format_diagnostics
17
-
18
-
19
- # Configure logging to stderr instead of stdout to avoid interfering with LSP JSON communication
20
- logging.basicConfig(
21
- level=logging.INFO,
22
- format="%(message)s",
23
- handlers=[logging.StreamHandler(sys.stderr)],
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
- # Lean project path management
30
- LEAN_PROJECT_PATH = os.environ.get("LEAN_PROJECT_PATH", "").strip()
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
- client: LeanLSPClient
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
- context = AppContext(client=client, file_content_hashes={})
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
- context.client.close()
80
+
81
+ if context.client:
82
+ context.client.close()
60
83
 
61
84
 
62
- mcp = FastMCP(
63
- "Lean LSP",
64
- description="Interact with the Lean prover via the LSP",
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
- # File operations
77
- def get_relative_file_path(file_path: str) -> Optional[str]:
78
- """Convert path relative to project path.
79
-
80
- Args:
81
- file_path (str): File path.
82
-
83
- Returns:
84
- str: Relative file path.
85
- """
86
- # Check if absolute path
87
- if os.path.exists(file_path):
88
- return os.path.relpath(file_path, LEAN_PROJECT_PATH)
89
-
90
- # Check if relative to project path
91
- path = os.path.join(LEAN_PROJECT_PATH, file_path)
92
- if os.path.exists(path):
93
- return os.path.relpath(path, LEAN_PROJECT_PATH)
94
-
95
- # Check if relative to CWD
96
- path = os.path.join(cwd, file_path)
97
- if os.path.exists(path):
98
- return os.path.relpath(path, LEAN_PROJECT_PATH)
99
-
100
- return None
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
- def get_file_contents(rel_path: str) -> str:
104
- with open(os.path.join(LEAN_PROJECT_PATH, rel_path), "r") as f:
105
- data = f.read()
106
- return data
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
- ctx (Context): Context object.
113
- rel_path (str): Relative file path.
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: Updated file contents.
148
+ str: Build output or error msg
117
149
  """
118
- # Get file contents and hash
119
- file_content = get_file_contents(rel_path)
120
- hashed_file = hash(file_content)
121
-
122
- # Check if file_contents have changed
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
- # Update file_contents
134
- file_content_hashes[rel_path] = hashed_file
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
- # Reload file in LSP
137
- client: LeanLSPClient = ctx.request_context.lifespan_context.client
138
- client.close_files([rel_path])
139
- return file_content
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
- # Meta level tools
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
- VERY IMPORTANT! Call this at the start of every proof and whenever you are unsure about the proof process.
191
+ while True:
192
+ line = await process.stdout.readline()
193
+ if not line:
194
+ break
148
195
 
149
- Returns:
150
- str: Description of the Lean LSP MCP.
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
- # Project level tools
161
- @mcp.tool("lean_project_path")
162
- def project_path() -> str:
163
- """Get the path to the Lean project root.
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
- Returns:
166
- str: Path to the Lean project.
167
- """
168
- return os.environ["LEAN_PROJECT_PATH"]
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
- @mcp.tool("lean_lsp_restart")
172
- def lsp_restart(ctx: Context, rebuild: bool = True) -> bool:
173
- """Restart the LSP server. Can also rebuild the lean project.
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
- SLOW! Use only when necessary (e.g. imports) and in emergencies.
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
- Args:
178
- rebuild (bool, optional): Rebuild the Lean project. Defaults to True.
229
+ logger.info("Built project and re-started LSP client")
179
230
 
180
- Returns:
181
- bool: True if the Lean LSP server was restarted, False otherwise.
182
- """
183
- try:
184
- client: LeanLSPClient = ctx.request_context.lifespan_context.client
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): Absolute path to the Lean file.
204
- annotate_lines (bool, optional): Annotate lines with line numbers. Defaults to False.
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: Text contents of the Lean file or None if file does not exist.
248
+ str: File content or error msg
208
249
  """
209
- rel_path = get_relative_file_path(file_path)
210
- if not rel_path:
211
- return "No valid lean file found."
212
-
213
- data = get_file_contents(rel_path)
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 messages for a Lean file.
270
+ """Get all diagnostic msgs (errors, warnings, infos) for a Lean file.
229
271
 
230
- Attention:
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): Absolute path to the Lean file.
275
+ file_path (str): Abs path to Lean file
235
276
 
236
277
  Returns:
237
- List[str] | str: Diagnostic messages or error message.
278
+ List[str] | str: Diagnostic msgs or error msg
238
279
  """
239
- rel_path = get_relative_file_path(file_path)
280
+ rel_path = setup_client_for_file(ctx, file_path)
240
281
  if not rel_path:
241
- return "No valid lean file found."
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 or line in a Lean file.
293
+ """Get the proof goals (proof state) at a specific location in a Lean file.
253
294
 
254
- VERY USEFUL AND CHEAP! This is your main tool to understand the proof state and its evolution!!
255
- Use this multiple times after every edit to the file!
256
-
257
- Solved proof state returns "no goals".
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): Absolute path to the Lean file.
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 at the specified location or error message.
306
+ str: Goal(s) or error msg
266
307
  """
267
- rel_path = get_relative_file_path(file_path)
308
+ rel_path = setup_client_for_file(ctx, file_path)
268
309
  if not rel_path:
269
- return "No valid lean file found."
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 again?"
286
- column_end = len(lines[line - 1]) - 1
287
- goal_start = client.get_goal(rel_path, line - 1, 0)
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 found on line. Try another position?"
327
+ return f"No goals on line:\n{lines[line - 1]}\nTry another line?"
292
328
 
293
- start_text = format_goal(goal_start, "No goal found at the start of the line.")
294
- end_text = format_goal(goal_end, "No goal found at the end of the line.")
295
- if start_text == end_text:
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
- return format_goal(goal, "Not a valid goal position. Try again?")
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): Absolute path to the Lean file.
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: Term goal at the specified location or error message.
352
+ str: Expected type or error msg
319
353
  """
320
- rel_path = get_relative_file_path(file_path)
354
+ rel_path = setup_client_for_file(ctx, file_path)
321
355
  if not rel_path:
322
- return "No valid lean file found."
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. Try again?"
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 the hover information at a specific location in a Lean file.
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): Absolute path to the Lean file.
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 information at the specified location or error message.
386
+ str: Hover info or error msg
351
387
  """
352
- rel_path = get_relative_file_path(file_path)
388
+ rel_path = setup_client_for_file(ctx, file_path)
353
389
  if not rel_path:
354
- return "No valid lean file found."
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
- return "No hover information available. Try another position?"
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
- info = hover_info.get("contents", None)
364
- if info is not None:
365
- info = info.get("value", "No hover information available.")
366
- return info.replace("```lean\n", "").replace("\n```", "")
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("lean_proofs_complete")
370
- def proofs_complete(ctx: Context, file_path: str) -> str:
371
- """Always check if all proofs in the file are complete in the end.
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
- Attention:
374
- "no goals to be solved" indicates code needs to be removed.
375
- Warnings (e.g. linter) indicate an unfinished proof.
376
- Keep going!
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): Absolute path to the Lean file.
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: Message indicating if the proofs are complete or not.
433
+ str: List of possible completions or error msg
383
434
  """
384
- rel_path = get_relative_file_path(file_path)
435
+ rel_path = setup_client_for_file(ctx, file_path)
385
436
  if not rel_path:
386
- return "No valid lean file found."
437
+ return "Invalid Lean file path: Unable to start LSP server or load file"
438
+ content = update_file(ctx, rel_path)
387
439
 
388
- update_file(ctx, rel_path)
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
- diagnostics = client.get_diagnostics(rel_path)
508
+ declaration = client.get_declarations(
509
+ rel_path, position["line"], position["column"]
510
+ )
392
511
 
393
- if diagnostics is None or len(diagnostics) > 0:
394
- return "Proof not complete!\n" + "\n".join(format_diagnostics(diagnostics))
512
+ if len(declaration) == 0:
513
+ return f"No declaration available for `{symbol}`."
395
514
 
396
- return "All proofs are complete!"
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
- @mcp.tool("lean_completions")
400
- def completions(
401
- ctx: Context, file_path: str, line: int, column: int, max_completions: int = 100
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
- """Find possible code completions at a location in a Lean file.
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
- Check available identifiers and imports:
406
- - Dot Completion: Displays relevant identifiers after typing a dot (e.g., `Nat.`, `x.`, or `.`).
407
- - Identifier Completion: Suggests matching identifiers after typing part of a name.
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): Absolute path to the Lean file.
545
+ file_path (str): Abs path to Lean file
412
546
  line (int): Line number (1-indexed)
413
- column (int): Column number (1-indexed).
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: List of possible completions or error message.
550
+ List[str] | str: Diagnostics and goal states or error msg
418
551
  """
419
- rel_path = get_relative_file_path(file_path)
552
+ rel_path = setup_client_for_file(ctx, file_path)
420
553
  if not rel_path:
421
- return "No valid lean file found."
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
- formatted = []
428
- for completion in completions:
429
- label = completion.get("label", None)
430
- if label is not None:
431
- formatted.append(label)
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
- if not formatted:
434
- return "No completions available. Try another position?"
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
- if len(formatted) > max_completions:
437
- formatted = formatted[:max_completions] + [
438
- f"{len(formatted) - max_completions} more, start typing and check again..."
439
- ]
440
- return formatted
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
- def leansearch(ctx: Context, query: str, max_results: int = 5) -> List[Dict] | str:
445
- """Search for Lean theorems, definitions, and tactics using leansearch.net API.
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): Natural language search query
449
- max_results (int, optional): Max results. Defaults to 5.
705
+ query (str): Search query
706
+ num_results (int, optional): Max results. Defaults to 5.
450
707
 
451
708
  Returns:
452
- List[Dict] | str: List of search results or error message
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 = json.dumps(
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=10) as response:
468
- results = json.loads(response.read().decode("utf-8"))
722
+ with urllib.request.urlopen(req, timeout=20) as response:
723
+ results = orjson.loads(response.read())
469
724
 
470
- return (
471
- [r["result"] for r in results[0]]
472
- if results and results[0]
473
- else "No results found."
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"Error: {str(e)}"
779
+ return f"loogle error:\n{str(e)}"
478
780
 
479
781
 
480
- # Prompts
481
- @mcp.prompt()
482
- def auto_proof_instructions() -> str:
483
- """Get the description of the Lean LSP MCP and how to use it to automatically prove theorems.
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: Description of the Lean LSP MCP.
850
+ List | str: Search results or error msg
487
851
  """
488
- return auto_proof()
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__":