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/server.py CHANGED
@@ -1,239 +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 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 lean_lsp_mcp.prompts import PROMPT_AUTOMATIC_PROOF
15
-
16
- # Configure logging to stderr instead of stdout to avoid interfering with LSP JSON communication
17
- logging.basicConfig(
18
- level=logging.INFO,
19
- format="%(message)s",
20
- 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,
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
- elif data == file_contents[rel_path]:
105
- return data
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
- # Reload file in LSP
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
- client: LeanLSPClient
120
- file_contents: Dict[str, str]
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
- context = AppContext(client=client, file_contents={})
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
- context.client.close()
80
+
81
+ if context.client:
82
+ context.client.close()
139
83
 
140
84
 
141
- mcp = FastMCP(
142
- "Lean LSP",
143
- description="Interact with the Lean prover via the LSP",
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
- VERY IMPORTANT! Call this at the start of every proof and whenever you are unsure about the proof process.
161
-
162
- Returns:
163
- str: Description of the Lean LSP MCP.
164
- """
165
- return PROMPT_AUTOMATIC_PROOF
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
- # Project level tools
169
- @mcp.tool("lean_project_path")
170
- def project_path() -> str:
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: Path to the Lean project.
148
+ str: Build output or error msg
175
149
  """
176
- return os.environ["LEAN_PROJECT_PATH"]
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
- @mcp.tool("lean_project_functional")
180
- def project_functional(ctx: Context) -> bool:
181
- """Check if the Lean project and the LSP are functional.
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
- Returns:
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.get_env(return_dict=False)
189
- return True
190
- except Exception:
191
- return False
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
- @mcp.tool("lean_lsp_restart")
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
- SLOW! Use only when necessary (e.g. imports) and in emergencies.
191
+ while True:
192
+ line = await process.stdout.readline()
193
+ if not line:
194
+ break
199
195
 
200
- Args:
201
- rebuild (bool, optional): Rebuild the Lean project. Defaults to True.
196
+ line_str = line.decode("utf-8", errors="replace").rstrip()
197
+ output_lines.append(line_str)
202
198
 
203
- Returns:
204
- bool: True if the Lean LSP server was restarted, False otherwise.
205
- """
206
- try:
207
- client: LeanLSPClient = ctx.request_context.lifespan_context.client
208
- client.close()
209
- ctx.request_context.lifespan_context.client = LeanLSPClient(
210
- os.environ["LEAN_PROJECT_PATH"], initial_build=rebuild
211
- )
212
- except Exception:
213
- return False
214
- return True
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): Absolute path to the Lean file.
227
- 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.
228
246
 
229
247
  Returns:
230
- Optional[str]: Text contents of the Lean file or None if file does not exist.
248
+ str: File content or error msg
231
249
  """
232
- rel_path = get_relative_file_path(file_path)
233
- if not rel_path:
234
- return "No valid lean file found."
235
-
236
- 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
+ )
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 messages for a Lean file.
270
+ """Get all diagnostic msgs (errors, warnings, infos) for a Lean file.
252
271
 
253
- Attention! "no goals to be solved" indicates a mistake. Keep going!
272
+ "no goals to be solved" means code may need removal.
254
273
 
255
274
  Args:
256
- file_path (str): Absolute path to the Lean file.
275
+ file_path (str): Abs path to Lean file
257
276
 
258
277
  Returns:
259
- List[str] | str: Diagnostic messages or error message.
278
+ List[str] | str: Diagnostic msgs or error msg
260
279
  """
261
- rel_path = get_relative_file_path(file_path)
280
+ rel_path = setup_client_for_file(ctx, file_path)
262
281
  if not rel_path:
263
- return "No valid lean file found."
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
- msgs = []
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 goal at a specific location in a Lean file.
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
- 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.
289
299
 
290
300
  Args:
291
- file_path (str): Absolute path to the Lean file.
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 => end of line.
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 at the specified location or error message.
306
+ str: Goal(s) or error msg
297
307
  """
298
- rel_path = get_relative_file_path(file_path)
308
+ rel_path = setup_client_for_file(ctx, file_path)
299
309
  if not rel_path:
300
- return "No valid lean file found."
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
- column = len(content.splitlines()[line - 1])
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
- client: LeanLSPClient = ctx.request_context.lifespan_context.client
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
- goal = client.get_goal(rel_path, line - 1, column - 1)
310
- if goal is None:
311
- return "Not a valid goal position. Try again?"
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
- rendered = goal.get("rendered", None)
314
- if rendered is not None:
315
- rendered = rendered.replace("```lean\n", "").replace("\n```", "")
316
- return rendered
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): Absolute path to the Lean file.
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: Term goal at the specified location or error message.
352
+ str: Expected type or error msg
334
353
  """
335
- rel_path = get_relative_file_path(file_path)
354
+ rel_path = setup_client_for_file(ctx, file_path)
336
355
  if not rel_path:
337
- return "No valid lean file found."
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. Try again?"
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 the hover information at a specific location in a Lean file.
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): Absolute path to the Lean file.
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 information at the specified location or error message.
386
+ str: Hover info or error msg
366
387
  """
367
- rel_path = get_relative_file_path(file_path)
388
+ rel_path = setup_client_for_file(ctx, file_path)
368
389
  if not rel_path:
369
- return "No valid lean file found."
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
- 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?"
377
398
 
378
- info = hover_info.get("contents", None)
379
- if info is not None:
380
- return info.get("value", "No hover information available.")
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
- @mcp.tool("lean_proofs_complete")
384
- def proofs_complete(ctx: Context, file_path: str) -> str:
385
- """Always check if all proofs in the file are complete in the end.
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
- Attention! "no goals to be solved" indicates a mistake. Keep going!
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): Absolute path to the Lean file.
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: Message indicating if the proofs are complete or not.
495
+ str: File contents or error msg
394
496
  """
395
- rel_path = get_relative_file_path(file_path)
497
+ rel_path = setup_client_for_file(ctx, file_path)
396
498
  if not rel_path:
397
- return "No valid lean file found."
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
- diagnostics = client.get_diagnostics(rel_path)
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
- if diagnostics is None or len(diagnostics) > 0:
405
- return "Proof not complete! " + str(diagnostics)
932
+ with urllib.request.urlopen(req, timeout=20) as response:
933
+ results = orjson.loads(response.read())
406
934
 
407
- return "All proofs are complete!"
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__":