lean-lsp-mcp 0.15.0__py3-none-any.whl → 0.16.1__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
@@ -2,20 +2,21 @@ import asyncio
2
2
  import os
3
3
  import re
4
4
  import time
5
- from typing import List, Optional, Dict
5
+ from typing import Annotated, List, Optional, Dict
6
6
  from contextlib import asynccontextmanager
7
7
  from collections.abc import AsyncIterator
8
8
  from dataclasses import dataclass
9
9
  import urllib
10
10
  import orjson
11
11
  import functools
12
- import subprocess
13
12
  import uuid
14
13
  from pathlib import Path
15
14
 
15
+ from pydantic import BaseModel, Field
16
16
  from mcp.server.fastmcp import Context, FastMCP
17
17
  from mcp.server.fastmcp.utilities.logging import get_logger, configure_logging
18
18
  from mcp.server.auth.settings import AuthSettings
19
+ from mcp.types import ToolAnnotations
19
20
  from leanclient import LeanLSPClient, DocumentContentChange
20
21
 
21
22
  from lean_lsp_mcp.client_utils import (
@@ -27,20 +28,51 @@ from lean_lsp_mcp.file_utils import get_file_contents
27
28
  from lean_lsp_mcp.instructions import INSTRUCTIONS
28
29
  from lean_lsp_mcp.search_utils import check_ripgrep_status, lean_local_search
29
30
  from lean_lsp_mcp.loogle import LoogleManager, loogle_remote
30
- from lean_lsp_mcp.outline_utils import generate_outline
31
+ from lean_lsp_mcp.outline_utils import generate_outline_data
32
+ from lean_lsp_mcp.models import (
33
+ LocalSearchResult,
34
+ LeanSearchResult,
35
+ LoogleResult,
36
+ LeanFinderResult,
37
+ StateSearchResult,
38
+ PremiseResult,
39
+ DiagnosticMessage,
40
+ GoalState,
41
+ CompletionItem,
42
+ HoverInfo,
43
+ TermGoalState,
44
+ FileOutline,
45
+ AttemptResult,
46
+ BuildResult,
47
+ RunResult,
48
+ DeclarationInfo,
49
+ )
31
50
  from lean_lsp_mcp.utils import (
51
+ COMPLETION_KIND,
32
52
  OutputCapture,
33
53
  deprecated,
34
54
  extract_range,
35
55
  filter_diagnostics_by_position,
36
56
  find_start_position,
37
- format_diagnostics,
38
57
  format_goal,
39
- format_line,
40
58
  get_declaration_range,
41
59
  OptionalTokenVerifier,
42
60
  )
43
61
 
62
+ # LSP Diagnostic severity: 1=error, 2=warning, 3=info, 4=hint
63
+ DIAGNOSTIC_SEVERITY: Dict[int, str] = {1: "error", 2: "warning", 3: "info", 4: "hint"}
64
+
65
+
66
+ class LeanToolError(Exception):
67
+ pass
68
+
69
+
70
+ def _to_json_array(items: List[BaseModel]) -> str:
71
+ """Serialize list of models as JSON array (avoids FastMCP list flattening)."""
72
+ return orjson.dumps(
73
+ [item.model_dump() for item in items], option=orjson.OPT_INDENT_2
74
+ ).decode()
75
+
44
76
 
45
77
  _LOG_LEVEL = os.environ.get("LEAN_LOG_LEVEL", "INFO")
46
78
  configure_logging("CRITICAL" if _LOG_LEVEL == "NONE" else _LOG_LEVEL)
@@ -50,7 +82,6 @@ logger = get_logger(__name__)
50
82
  _RG_AVAILABLE, _RG_MESSAGE = check_ripgrep_status()
51
83
 
52
84
 
53
- # Server and context
54
85
  @dataclass
55
86
  class AppContext:
56
87
  lean_project_path: Path | None
@@ -130,7 +161,6 @@ if auth_token:
130
161
  mcp = FastMCP(**mcp_kwargs)
131
162
 
132
163
 
133
- # Rate limiting: n requests per m seconds
134
164
  def rate_limited(category: str, max_requests: int, per_seconds: int):
135
165
  def decorator(func):
136
166
  @functools.wraps(func)
@@ -160,22 +190,27 @@ def rate_limited(category: str, max_requests: int, per_seconds: int):
160
190
  return decorator
161
191
 
162
192
 
163
- # Project level tools
164
- @mcp.tool("lean_build")
193
+ @mcp.tool(
194
+ "lean_build",
195
+ annotations=ToolAnnotations(
196
+ title="Build Project",
197
+ readOnlyHint=False,
198
+ destructiveHint=True,
199
+ idempotentHint=True,
200
+ openWorldHint=False,
201
+ ),
202
+ )
165
203
  async def lsp_build(
166
- ctx: Context, lean_project_path: str = None, clean: bool = False
167
- ) -> str:
168
- """Build the Lean project and restart the LSP Server.
169
-
170
- Use only if needed (e.g. new imports).
171
-
172
- Args:
173
- lean_project_path (str, optional): Path to the Lean project. If not provided, it will be inferred from previous tool calls.
174
- 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.
175
-
176
- Returns:
177
- str: Build output or error msg
178
- """
204
+ ctx: Context,
205
+ lean_project_path: Annotated[
206
+ Optional[str], Field(description="Path to Lean project")
207
+ ] = None,
208
+ clean: Annotated[bool, Field(description="Run lake clean first (slow)")] = False,
209
+ output_lines: Annotated[
210
+ int, Field(description="Return last N lines of build log (0=none)")
211
+ ] = 20,
212
+ ) -> BuildResult:
213
+ """Build the Lean project and restart LSP. Use only if needed (e.g. new imports)."""
179
214
  if not lean_project_path:
180
215
  lean_project_path_obj = ctx.request_context.lifespan_context.lean_project_path
181
216
  else:
@@ -183,9 +218,13 @@ async def lsp_build(
183
218
  ctx.request_context.lifespan_context.lean_project_path = lean_project_path_obj
184
219
 
185
220
  if lean_project_path_obj is None:
186
- return "Lean project path not known yet. Provide `lean_project_path` explicitly or call a tool that infers it (e.g. `lean_file_contents`) before running `lean_build`."
221
+ raise LeanToolError(
222
+ "Lean project path not known yet. Provide `lean_project_path` explicitly or call another tool first."
223
+ )
224
+
225
+ log_lines: List[str] = []
226
+ errors: List[str] = []
187
227
 
188
- build_output = ""
189
228
  try:
190
229
  client: LeanLSPClient = ctx.request_context.lifespan_context.client
191
230
  if client:
@@ -193,13 +232,21 @@ async def lsp_build(
193
232
  client.close()
194
233
 
195
234
  if clean:
196
- subprocess.run(["lake", "clean"], cwd=lean_project_path_obj, check=False)
197
- logger.info("Ran `lake clean`")
235
+ await ctx.report_progress(
236
+ progress=1, total=16, message="Running `lake clean`"
237
+ )
238
+ clean_proc = await asyncio.create_subprocess_exec(
239
+ "lake", "clean", cwd=lean_project_path_obj
240
+ )
241
+ await clean_proc.wait()
198
242
 
199
- # Fetch cache
200
- subprocess.run(
201
- ["lake", "exe", "cache", "get"], cwd=lean_project_path_obj, check=False
243
+ await ctx.report_progress(
244
+ progress=2, total=16, message="Running `lake exe cache get`"
245
+ )
246
+ cache_proc = await asyncio.create_subprocess_exec(
247
+ "lake", "exe", "cache", "get", cwd=lean_project_path_obj
202
248
  )
249
+ await cache_proc.wait()
203
250
 
204
251
  # Run build with progress reporting
205
252
  process = await asyncio.create_subprocess_exec(
@@ -211,39 +258,35 @@ async def lsp_build(
211
258
  stderr=asyncio.subprocess.STDOUT,
212
259
  )
213
260
 
214
- output_lines = []
215
-
216
- while True:
217
- line = await process.stdout.readline()
218
- if not line:
219
- break
220
-
261
+ while line := await process.stdout.readline():
221
262
  line_str = line.decode("utf-8", errors="replace").rstrip()
222
- output_lines.append(line_str)
223
263
 
224
- # Parse progress: look for pattern like "[2/8]" or "[10/100]"
225
- match = re.search(r"\[(\d+)/(\d+)\]", line_str)
226
- if match:
227
- current_job = int(match.group(1))
228
- total_jobs = int(match.group(2))
264
+ if line_str.startswith("trace:") or "LEAN_PATH=" in line_str:
265
+ continue
229
266
 
230
- # Extract what's being built
231
- # Line format: " [2/8] Built TestLeanBuild.Basic (1.6s)"
232
- desc_match = re.search(
233
- r"\[\d+/\d+\]\s+(.+?)(?:\s+\(\d+\.?\d*[ms]+\))?$", line_str
234
- )
235
- description = desc_match.group(1) if desc_match else "Building"
267
+ log_lines.append(line_str)
268
+ if "error" in line_str.lower():
269
+ errors.append(line_str)
236
270
 
237
- # Report progress using dynamic totals from Lake
271
+ # Parse progress: "[2/8] Building Foo (1.2s)" -> (2, 8, "Building Foo")
272
+ if m := re.search(
273
+ r"\[(\d+)/(\d+)\]\s*(.+?)(?:\s+\(\d+\.?\d*[ms]+\))?$", line_str
274
+ ):
238
275
  await ctx.report_progress(
239
- progress=current_job, total=total_jobs, message=description
276
+ progress=int(m.group(1)),
277
+ total=int(m.group(2)),
278
+ message=m.group(3) or "Building",
240
279
  )
241
280
 
242
281
  await process.wait()
243
282
 
244
283
  if process.returncode != 0:
245
- build_output = "\n".join(output_lines)
246
- raise Exception(f"Build failed with return code {process.returncode}")
284
+ return BuildResult(
285
+ success=False,
286
+ output="\n".join(log_lines[-output_lines:]) if output_lines else "",
287
+ errors=errors
288
+ or [f"Build failed with return code {process.returncode}"],
289
+ )
247
290
 
248
291
  # Start LSP client (without initial build since we just did it)
249
292
  with OutputCapture():
@@ -252,29 +295,38 @@ async def lsp_build(
252
295
  )
253
296
 
254
297
  logger.info("Built project and re-started LSP client")
255
-
256
298
  ctx.request_context.lifespan_context.client = client
257
- build_output = "\n".join(output_lines)
258
- return build_output
259
- except Exception as e:
260
- return f"Error during build:\n{str(e)}\n{build_output}"
261
-
262
299
 
263
- # File level tools
264
- @mcp.tool("lean_file_contents")
265
- @deprecated
266
- def file_contents(ctx: Context, file_path: str, annotate_lines: bool = True) -> str:
267
- """Get the text contents of a Lean file, optionally with line numbers.
300
+ return BuildResult(
301
+ success=True,
302
+ output="\n".join(log_lines[-output_lines:]) if output_lines else "",
303
+ errors=[],
304
+ )
268
305
 
269
- Use sparingly (bloats context). Mainly when unsure about line numbers.
306
+ except Exception as e:
307
+ return BuildResult(
308
+ success=False,
309
+ output="\n".join(log_lines[-output_lines:]) if output_lines else "",
310
+ errors=[str(e)],
311
+ )
270
312
 
271
- Args:
272
- file_path (str): Abs path to Lean file
273
- annotate_lines (bool, optional): Annotate lines with line numbers. Defaults to True.
274
313
 
275
- Returns:
276
- str: File content or error msg
277
- """
314
+ @mcp.tool(
315
+ "lean_file_contents",
316
+ annotations=ToolAnnotations(
317
+ title="File Contents (Deprecated)",
318
+ readOnlyHint=True,
319
+ idempotentHint=True,
320
+ openWorldHint=False,
321
+ ),
322
+ )
323
+ @deprecated
324
+ def file_contents(
325
+ ctx: Context,
326
+ file_path: Annotated[str, Field(description="Absolute path to Lean file")],
327
+ annotate_lines: Annotated[bool, Field(description="Add line numbers")] = True,
328
+ ) -> str:
329
+ """DEPRECATED. Get file contents with optional line numbers."""
278
330
  # Infer project path but do not start a client
279
331
  if file_path.endswith(".lean"):
280
332
  infer_project_path(ctx, file_path) # Silently fails for non-project files
@@ -297,53 +349,78 @@ def file_contents(ctx: Context, file_path: str, annotate_lines: bool = True) ->
297
349
  return data
298
350
 
299
351
 
300
- @mcp.tool("lean_file_outline")
301
- def file_outline(ctx: Context, file_path: str) -> str:
302
- """Get a concise outline showing imports and declarations with type signatures (theorems, defs, classes, structures).
303
-
304
- Highly useful and token-efficient. Slow-ish.
305
-
306
- Args:
307
- file_path (str): Abs path to Lean file
308
-
309
- Returns:
310
- str: Markdown formatted outline or error msg
311
- """
352
+ @mcp.tool(
353
+ "lean_file_outline",
354
+ annotations=ToolAnnotations(
355
+ title="File Outline",
356
+ readOnlyHint=True,
357
+ idempotentHint=True,
358
+ openWorldHint=False,
359
+ ),
360
+ )
361
+ def file_outline(
362
+ ctx: Context,
363
+ file_path: Annotated[str, Field(description="Absolute path to Lean file")],
364
+ ) -> FileOutline:
365
+ """Get imports and declarations with type signatures. Token-efficient."""
312
366
  rel_path = setup_client_for_file(ctx, file_path)
313
367
  if not rel_path:
314
- return "Invalid Lean file path: Unable to start LSP server or load file"
368
+ raise LeanToolError(
369
+ "Invalid Lean file path: Unable to start LSP server or load file"
370
+ )
315
371
 
316
372
  client: LeanLSPClient = ctx.request_context.lifespan_context.client
317
- return generate_outline(client, rel_path)
373
+ return generate_outline_data(client, rel_path)
374
+
375
+
376
+ def _to_diagnostic_messages(diagnostics: List[Dict]) -> List[DiagnosticMessage]:
377
+ result = []
378
+ for diag in diagnostics:
379
+ r = diag.get("fullRange", diag.get("range"))
380
+ if r is None:
381
+ continue
382
+ severity_int = diag.get("severity", 1)
383
+ result.append(
384
+ DiagnosticMessage(
385
+ severity=DIAGNOSTIC_SEVERITY.get(
386
+ severity_int, f"unknown({severity_int})"
387
+ ),
388
+ message=diag.get("message", ""),
389
+ line=r["start"]["line"] + 1,
390
+ column=r["start"]["character"] + 1,
391
+ )
392
+ )
393
+ return result
318
394
 
319
395
 
320
- @mcp.tool("lean_diagnostic_messages")
396
+ @mcp.tool(
397
+ "lean_diagnostic_messages",
398
+ annotations=ToolAnnotations(
399
+ title="Diagnostics",
400
+ readOnlyHint=True,
401
+ idempotentHint=True,
402
+ openWorldHint=False,
403
+ ),
404
+ )
321
405
  def diagnostic_messages(
322
406
  ctx: Context,
323
- file_path: str,
324
- start_line: Optional[int] = None,
325
- end_line: Optional[int] = None,
326
- declaration_name: Optional[str] = None,
327
- ) -> List[str] | str:
328
- """Get all diagnostic msgs (errors, warnings, infos) for a Lean file.
329
-
330
- "no goals to be solved" means code may need removal.
331
-
332
- Args:
333
- file_path (str): Abs path to Lean file
334
- start_line (int, optional): Start line (1-indexed). Filters from this line.
335
- end_line (int, optional): End line (1-indexed). Filters to this line.
336
- declaration_name (str, optional): Name of a specific theorem/lemma/definition.
337
- If provided, only returns diagnostics within that declaration.
338
- Takes precedence over start_line/end_line.
339
- Slow, requires waiting for full file analysis.
340
-
341
- Returns:
342
- List[str] | str: Diagnostic msgs or error msg
343
- """
407
+ file_path: Annotated[str, Field(description="Absolute path to Lean file")],
408
+ start_line: Annotated[
409
+ Optional[int], Field(description="Filter from line", ge=1)
410
+ ] = None,
411
+ end_line: Annotated[
412
+ Optional[int], Field(description="Filter to line", ge=1)
413
+ ] = None,
414
+ declaration_name: Annotated[
415
+ Optional[str], Field(description="Filter to declaration (slow)")
416
+ ] = None,
417
+ ) -> str:
418
+ """Get compiler diagnostics (errors, warnings, infos) for a Lean file."""
344
419
  rel_path = setup_client_for_file(ctx, file_path)
345
420
  if not rel_path:
346
- return "Invalid Lean file path: Unable to start LSP server or load file"
421
+ raise LeanToolError(
422
+ "Invalid Lean file path: Unable to start LSP server or load file"
423
+ )
347
424
 
348
425
  client: LeanLSPClient = ctx.request_context.lifespan_context.client
349
426
  client.open_file(rel_path)
@@ -352,7 +429,7 @@ def diagnostic_messages(
352
429
  if declaration_name:
353
430
  decl_range = get_declaration_range(client, rel_path, declaration_name)
354
431
  if decl_range is None:
355
- return f"Declaration '{declaration_name}' not found in file. Check the name (case-sensitive) and try again."
432
+ raise LeanToolError(f"Declaration '{declaration_name}' not found in file.")
356
433
  start_line, end_line = decl_range
357
434
 
358
435
  # Convert 1-indexed to 0-indexed for leanclient
@@ -366,123 +443,144 @@ def diagnostic_messages(
366
443
  inactivity_timeout=15.0,
367
444
  )
368
445
 
369
- return format_diagnostics(diagnostics)
370
-
446
+ return _to_json_array(_to_diagnostic_messages(diagnostics))
371
447
 
372
- @mcp.tool("lean_goal")
373
- def goal(ctx: Context, file_path: str, line: int, column: Optional[int] = None) -> str:
374
- """Get the proof goals (proof state) at a specific location in a Lean file.
375
448
 
376
- VERY USEFUL! Main tool to understand the proof state and its evolution!
377
- Returns "no goals" if solved.
378
- To see the goal at sorry, use the cursor before the "s".
379
- Avoid giving a column if unsure-default behavior works well.
380
-
381
- Args:
382
- file_path (str): Abs path to Lean file
383
- line (int): Line number (1-indexed)
384
- column (int, optional): Column number (1-indexed). Defaults to None => Both before and after the line.
385
-
386
- Returns:
387
- str: Goal(s) or error msg
449
+ @mcp.tool(
450
+ "lean_goal",
451
+ annotations=ToolAnnotations(
452
+ title="Proof Goals",
453
+ readOnlyHint=True,
454
+ idempotentHint=True,
455
+ openWorldHint=False,
456
+ ),
457
+ )
458
+ def goal(
459
+ ctx: Context,
460
+ file_path: Annotated[str, Field(description="Absolute path to Lean file")],
461
+ line: Annotated[int, Field(description="Line number (1-indexed)", ge=1)],
462
+ column: Annotated[
463
+ Optional[int],
464
+ Field(description="Column (1-indexed). Omit for before/after", ge=1),
465
+ ] = None,
466
+ ) -> GoalState:
467
+ """Get proof goals at a position. MOST IMPORTANT tool - use often!
468
+
469
+ Omit column to see goals_before (line start) and goals_after (line end),
470
+ showing how the tactic transforms the state. "no goals" = proof complete.
388
471
  """
389
472
  rel_path = setup_client_for_file(ctx, file_path)
390
473
  if not rel_path:
391
- return "Invalid Lean file path: Unable to start LSP server or load file"
474
+ raise LeanToolError(
475
+ "Invalid Lean file path: Unable to start LSP server or load file"
476
+ )
392
477
 
393
478
  client: LeanLSPClient = ctx.request_context.lifespan_context.client
394
479
  client.open_file(rel_path)
395
480
  content = client.get_file_content(rel_path)
481
+ lines = content.splitlines()
482
+
483
+ if line < 1 or line > len(lines):
484
+ raise LeanToolError(f"Line {line} out of range (file has {len(lines)} lines)")
485
+
486
+ line_context = lines[line - 1]
396
487
 
397
488
  if column is None:
398
- lines = content.splitlines()
399
- if line < 1 or line > len(lines):
400
- return "Line number out of range. Try elsewhere?"
401
- column_end = len(lines[line - 1])
489
+ column_end = len(line_context)
402
490
  column_start = next(
403
- (i for i, c in enumerate(lines[line - 1]) if not c.isspace()), 0
491
+ (i for i, c in enumerate(line_context) if not c.isspace()), 0
404
492
  )
405
493
  goal_start = client.get_goal(rel_path, line - 1, column_start)
406
494
  goal_end = client.get_goal(rel_path, line - 1, column_end)
407
-
408
- if goal_start is None and goal_end is None:
409
- return f"No goals on line:\n{lines[line - 1]}\nTry another line?"
410
-
411
- start_text = format_goal(goal_start, "No goals at line start.")
412
- end_text = format_goal(goal_end, "No goals at line end.")
413
- return f"Goals on line:\n{lines[line - 1]}\nBefore:\n{start_text}\nAfter:\n{end_text}"
414
-
495
+ before = format_goal(goal_start, None)
496
+ after = format_goal(goal_end, None)
497
+ goals = f"{before} {after}" if before != after else after
498
+ return GoalState(line_context=line_context, goals=goals)
415
499
  else:
416
- goal = client.get_goal(rel_path, line - 1, column - 1)
417
- f_goal = format_goal(goal, "Not a valid goal position. Try elsewhere?")
418
- f_line = format_line(content, line, column)
419
- return f"Goals at:\n{f_line}\n{f_goal}"
500
+ goal_result = client.get_goal(rel_path, line - 1, column - 1)
501
+ return GoalState(
502
+ line_context=line_context, goals=format_goal(goal_result, None)
503
+ )
420
504
 
421
505
 
422
- @mcp.tool("lean_term_goal")
506
+ @mcp.tool(
507
+ "lean_term_goal",
508
+ annotations=ToolAnnotations(
509
+ title="Term Goal",
510
+ readOnlyHint=True,
511
+ idempotentHint=True,
512
+ openWorldHint=False,
513
+ ),
514
+ )
423
515
  def term_goal(
424
- ctx: Context, file_path: str, line: int, column: Optional[int] = None
425
- ) -> str:
426
- """Get the expected type (term goal) at a specific location in a Lean file.
427
-
428
- Args:
429
- file_path (str): Abs path to Lean file
430
- line (int): Line number (1-indexed)
431
- column (int, optional): Column number (1-indexed). Defaults to None => end of line.
432
-
433
- Returns:
434
- str: Expected type or error msg
435
- """
516
+ ctx: Context,
517
+ file_path: Annotated[str, Field(description="Absolute path to Lean file")],
518
+ line: Annotated[int, Field(description="Line number (1-indexed)", ge=1)],
519
+ column: Annotated[
520
+ Optional[int], Field(description="Column (defaults to end of line)", ge=1)
521
+ ] = None,
522
+ ) -> TermGoalState:
523
+ """Get the expected type at a position."""
436
524
  rel_path = setup_client_for_file(ctx, file_path)
437
525
  if not rel_path:
438
- return "Invalid Lean file path: Unable to start LSP server or load file"
526
+ raise LeanToolError(
527
+ "Invalid Lean file path: Unable to start LSP server or load file"
528
+ )
439
529
 
440
530
  client: LeanLSPClient = ctx.request_context.lifespan_context.client
441
531
  client.open_file(rel_path)
442
532
  content = client.get_file_content(rel_path)
533
+ lines = content.splitlines()
534
+
535
+ if line < 1 or line > len(lines):
536
+ raise LeanToolError(f"Line {line} out of range (file has {len(lines)} lines)")
537
+
538
+ line_context = lines[line - 1]
443
539
  if column is None:
444
- lines = content.splitlines()
445
- if line < 1 or line > len(lines):
446
- return "Line number out of range. Try elsewhere?"
447
- column = len(content.splitlines()[line - 1])
448
-
449
- term_goal = client.get_term_goal(rel_path, line - 1, column - 1)
450
- f_line = format_line(content, line, column)
451
- if term_goal is None:
452
- return f"Not a valid term goal position:\n{f_line}\nTry elsewhere?"
453
- rendered = term_goal.get("goal", None)
454
- if rendered is not None:
455
- rendered = rendered.replace("```lean\n", "").replace("\n```", "")
456
- return f"Term goal at:\n{f_line}\n{rendered or 'No term goal found.'}"
457
-
458
-
459
- @mcp.tool("lean_hover_info")
460
- def hover(ctx: Context, file_path: str, line: int, column: int) -> str:
461
- """Get hover info (docs for syntax, variables, functions, etc.) at a specific location in a Lean file.
462
-
463
- Args:
464
- file_path (str): Abs path to Lean file
465
- line (int): Line number (1-indexed)
466
- column (int): Column number (1-indexed). Make sure to use the start or within the term, not the end.
467
-
468
- Returns:
469
- str: Hover info or error msg
470
- """
540
+ column = len(line_context)
541
+
542
+ term_goal_result = client.get_term_goal(rel_path, line - 1, column - 1)
543
+ expected_type = None
544
+ if term_goal_result is not None:
545
+ rendered = term_goal_result.get("goal")
546
+ if rendered:
547
+ expected_type = rendered.replace("```lean\n", "").replace("\n```", "")
548
+
549
+ return TermGoalState(line_context=line_context, expected_type=expected_type)
550
+
551
+
552
+ @mcp.tool(
553
+ "lean_hover_info",
554
+ annotations=ToolAnnotations(
555
+ title="Hover Info",
556
+ readOnlyHint=True,
557
+ idempotentHint=True,
558
+ openWorldHint=False,
559
+ ),
560
+ )
561
+ def hover(
562
+ ctx: Context,
563
+ file_path: Annotated[str, Field(description="Absolute path to Lean file")],
564
+ line: Annotated[int, Field(description="Line number (1-indexed)", ge=1)],
565
+ column: Annotated[int, Field(description="Column at START of identifier", ge=1)],
566
+ ) -> HoverInfo:
567
+ """Get type signature and docs for a symbol. Essential for understanding APIs."""
471
568
  rel_path = setup_client_for_file(ctx, file_path)
472
569
  if not rel_path:
473
- return "Invalid Lean file path: Unable to start LSP server or load file"
570
+ raise LeanToolError(
571
+ "Invalid Lean file path: Unable to start LSP server or load file"
572
+ )
474
573
 
475
574
  client: LeanLSPClient = ctx.request_context.lifespan_context.client
476
575
  client.open_file(rel_path)
477
576
  file_content = client.get_file_content(rel_path)
478
577
  hover_info = client.get_hover(rel_path, line - 1, column - 1)
479
578
  if hover_info is None:
480
- f_line = format_line(file_content, line, column)
481
- return f"No hover information at position:\n{f_line}\nTry elsewhere?"
579
+ raise LeanToolError(f"No hover information at line {line}, column {column}")
482
580
 
483
581
  # Get the symbol and the hover information
484
582
  h_range = hover_info.get("range")
485
- symbol = extract_range(file_content, h_range)
583
+ symbol = extract_range(file_content, h_range) or ""
486
584
  info = hover_info["contents"].get("value", "No hover information available.")
487
585
  info = info.replace("```lean\n", "").replace("\n```", "").strip()
488
586
 
@@ -490,45 +588,58 @@ def hover(ctx: Context, file_path: str, line: int, column: int) -> str:
490
588
  diagnostics = client.get_diagnostics(rel_path)
491
589
  filtered = filter_diagnostics_by_position(diagnostics, line - 1, column - 1)
492
590
 
493
- msg = f"Hover info `{symbol}`:\n{info}"
494
- if filtered:
495
- msg += "\n\nDiagnostics\n" + "\n".join(format_diagnostics(filtered))
496
- return msg
591
+ return HoverInfo(
592
+ symbol=symbol,
593
+ info=info,
594
+ diagnostics=_to_diagnostic_messages(filtered),
595
+ )
497
596
 
498
597
 
499
- @mcp.tool("lean_completions")
598
+ @mcp.tool(
599
+ "lean_completions",
600
+ annotations=ToolAnnotations(
601
+ title="Completions",
602
+ readOnlyHint=True,
603
+ idempotentHint=True,
604
+ openWorldHint=False,
605
+ ),
606
+ )
500
607
  def completions(
501
- ctx: Context, file_path: str, line: int, column: int, max_completions: int = 32
608
+ ctx: Context,
609
+ file_path: Annotated[str, Field(description="Absolute path to Lean file")],
610
+ line: Annotated[int, Field(description="Line number (1-indexed)", ge=1)],
611
+ column: Annotated[int, Field(description="Column number (1-indexed)", ge=1)],
612
+ max_completions: Annotated[int, Field(description="Max completions", ge=1)] = 32,
502
613
  ) -> str:
503
- """Get code completions at a location in a Lean file.
504
-
505
- Only use this on INCOMPLETE lines/statements to check available identifiers and imports:
506
- - Dot Completion: Displays relevant identifiers after a dot (e.g., `Nat.`, `x.`, or `Nat.ad`).
507
- - Identifier Completion: Suggests matching identifiers after part of a name.
508
- - Import Completion: Lists importable files after `import` at the beginning of a file.
509
-
510
- Args:
511
- file_path (str): Abs path to Lean file
512
- line (int): Line number (1-indexed)
513
- column (int): Column number (1-indexed)
514
- max_completions (int, optional): Maximum number of completions to return. Defaults to 32
515
-
516
- Returns:
517
- str: List of possible completions or error msg
518
- """
614
+ """Get IDE autocompletions. Use on INCOMPLETE code (after `.` or partial name)."""
519
615
  rel_path = setup_client_for_file(ctx, file_path)
520
616
  if not rel_path:
521
- return "Invalid Lean file path: Unable to start LSP server or load file"
617
+ raise LeanToolError(
618
+ "Invalid Lean file path: Unable to start LSP server or load file"
619
+ )
522
620
 
523
621
  client: LeanLSPClient = ctx.request_context.lifespan_context.client
524
622
  client.open_file(rel_path)
525
623
  content = client.get_file_content(rel_path)
526
- completions = client.get_completions(rel_path, line - 1, column - 1)
527
- formatted = [c["label"] for c in completions if "label" in c]
528
- f_line = format_line(content, line, column)
624
+ raw_completions = client.get_completions(rel_path, line - 1, column - 1)
625
+
626
+ # Convert to CompletionItem models
627
+ items: List[CompletionItem] = []
628
+ for c in raw_completions:
629
+ if "label" not in c:
630
+ continue
631
+ kind_int = c.get("kind")
632
+ kind_str = COMPLETION_KIND.get(kind_int) if kind_int else None
633
+ items.append(
634
+ CompletionItem(
635
+ label=c["label"],
636
+ kind=kind_str,
637
+ detail=c.get("detail"),
638
+ )
639
+ )
529
640
 
530
- if not formatted:
531
- return f"No completions at position:\n{f_line}\nTry elsewhere?"
641
+ if not items:
642
+ return "[]"
532
643
 
533
644
  # Find the sort term: The last word/identifier before the cursor
534
645
  lines = content.splitlines()
@@ -541,104 +652,102 @@ def completions(
541
652
  # Sort completions: prefix matches first, then contains, then alphabetical
542
653
  if prefix:
543
654
 
544
- def sort_key(item):
545
- item_lower = item.lower()
546
- if item_lower.startswith(prefix):
547
- return (0, item_lower)
548
- elif prefix in item_lower:
549
- return (1, item_lower)
655
+ def sort_key(item: CompletionItem):
656
+ label_lower = item.label.lower()
657
+ if label_lower.startswith(prefix):
658
+ return (0, label_lower)
659
+ elif prefix in label_lower:
660
+ return (1, label_lower)
550
661
  else:
551
- return (2, item_lower)
662
+ return (2, label_lower)
552
663
 
553
- formatted.sort(key=sort_key)
664
+ items.sort(key=sort_key)
554
665
  else:
555
- formatted.sort(key=str.lower)
666
+ items.sort(key=lambda x: x.label.lower())
556
667
 
557
668
  # Truncate if too many results
558
- if len(formatted) > max_completions:
559
- remaining = len(formatted) - max_completions
560
- formatted = formatted[:max_completions] + [
561
- f"{remaining} more, keep typing to filter further"
562
- ]
563
- completions_text = "\n".join(formatted)
564
- return f"Completions at:\n{f_line}\n{completions_text}"
669
+ return _to_json_array(items[:max_completions])
565
670
 
566
671
 
567
- @mcp.tool("lean_declaration_file")
568
- def declaration_file(ctx: Context, file_path: str, symbol: str) -> str:
569
- """Get the file contents where a symbol/lemma/class/structure is declared.
570
-
571
- Note:
572
- Symbol must be present in the file! Add if necessary!
573
- Lean files can be large, use `lean_hover_info` before this tool.
574
-
575
- Args:
576
- file_path (str): Abs path to Lean file
577
- symbol (str): Symbol to look up the declaration for. Case sensitive!
578
-
579
- Returns:
580
- str: File contents or error msg
581
- """
672
+ @mcp.tool(
673
+ "lean_declaration_file",
674
+ annotations=ToolAnnotations(
675
+ title="Declaration Source",
676
+ readOnlyHint=True,
677
+ idempotentHint=True,
678
+ openWorldHint=False,
679
+ ),
680
+ )
681
+ def declaration_file(
682
+ ctx: Context,
683
+ file_path: Annotated[str, Field(description="Absolute path to Lean file")],
684
+ symbol: Annotated[
685
+ str, Field(description="Symbol (case sensitive, must be in file)")
686
+ ],
687
+ ) -> DeclarationInfo:
688
+ """Get file where a symbol is declared. Symbol must be present in file first."""
582
689
  rel_path = setup_client_for_file(ctx, file_path)
583
690
  if not rel_path:
584
- return "Invalid Lean file path: Unable to start LSP server or load file"
691
+ raise LeanToolError(
692
+ "Invalid Lean file path: Unable to start LSP server or load file"
693
+ )
585
694
 
586
695
  client: LeanLSPClient = ctx.request_context.lifespan_context.client
587
696
  client.open_file(rel_path)
588
697
  orig_file_content = client.get_file_content(rel_path)
589
698
 
590
- # Find the first occurence of the symbol (line and column) in the file,
699
+ # Find the first occurence of the symbol (line and column) in the file
591
700
  position = find_start_position(orig_file_content, symbol)
592
701
  if not position:
593
- return f"Symbol `{symbol}` (case sensitive) not found in file `{rel_path}`. Add it first, then try again."
702
+ raise LeanToolError(
703
+ f"Symbol `{symbol}` (case sensitive) not found in file. Add it first."
704
+ )
594
705
 
595
706
  declaration = client.get_declarations(
596
707
  rel_path, position["line"], position["column"]
597
708
  )
598
709
 
599
710
  if len(declaration) == 0:
600
- return f"No declaration available for `{symbol}`."
711
+ raise LeanToolError(f"No declaration available for `{symbol}`.")
601
712
 
602
713
  # Load the declaration file
603
- declaration = declaration[0]
604
- uri = declaration.get("targetUri")
605
- if not uri:
606
- uri = declaration.get("uri")
714
+ decl = declaration[0]
715
+ uri = decl.get("targetUri") or decl.get("uri")
607
716
 
608
717
  abs_path = client._uri_to_abs(uri)
609
718
  if not os.path.exists(abs_path):
610
- return f"Could not open declaration file `{abs_path}` for `{symbol}`."
719
+ raise LeanToolError(
720
+ f"Could not open declaration file `{abs_path}` for `{symbol}`."
721
+ )
611
722
 
612
723
  file_content = get_file_contents(abs_path)
613
724
 
614
- return f"Declaration of `{symbol}`:\n{file_content}"
725
+ return DeclarationInfo(file_path=str(abs_path), content=file_content)
615
726
 
616
727
 
617
- @mcp.tool("lean_multi_attempt")
728
+ @mcp.tool(
729
+ "lean_multi_attempt",
730
+ annotations=ToolAnnotations(
731
+ title="Multi-Attempt",
732
+ readOnlyHint=True,
733
+ idempotentHint=True,
734
+ openWorldHint=False,
735
+ ),
736
+ )
618
737
  def multi_attempt(
619
- ctx: Context, file_path: str, line: int, snippets: List[str]
620
- ) -> List[str] | str:
621
- """Try multiple Lean code snippets at a line and get the goal state and diagnostics for each.
622
-
623
- Use to compare tactics or approaches.
624
- Use rarely-prefer direct file edits to keep users involved.
625
- For a single snippet, edit the file and run `lean_diagnostic_messages` instead.
626
-
627
- Note:
628
- Only single-line, fully-indented snippets are supported.
629
- Avoid comments for best results.
630
-
631
- Args:
632
- file_path (str): Abs path to Lean file
633
- line (int): Line number (1-indexed)
634
- snippets (List[str]): List of snippets (3+ are recommended)
635
-
636
- Returns:
637
- List[str] | str: Diagnostics and goal states or error msg
638
- """
738
+ ctx: Context,
739
+ file_path: Annotated[str, Field(description="Absolute path to Lean file")],
740
+ line: Annotated[int, Field(description="Line number (1-indexed)", ge=1)],
741
+ snippets: Annotated[
742
+ List[str], Field(description="Tactics to try (3+ recommended)")
743
+ ],
744
+ ) -> str:
745
+ """Try multiple tactics without modifying file. Returns goal state for each."""
639
746
  rel_path = setup_client_for_file(ctx, file_path)
640
747
  if not rel_path:
641
- return "Invalid Lean file path: Unable to start LSP server or load file"
748
+ raise LeanToolError(
749
+ "Invalid Lean file path: Unable to start LSP server or load file"
750
+ )
642
751
 
643
752
  client: LeanLSPClient = ctx.request_context.lifespan_context.client
644
753
  client.open_file(rel_path)
@@ -646,7 +755,7 @@ def multi_attempt(
646
755
  try:
647
756
  client.open_file(rel_path)
648
757
 
649
- results = []
758
+ results: List[AttemptResult] = []
650
759
  # Avoid mutating caller-provided snippets; normalize locally per attempt
651
760
  for snippet in snippets:
652
761
  snippet_str = snippet.rstrip("\n")
@@ -660,13 +769,19 @@ def multi_attempt(
660
769
  # Apply the change to the file, capture diagnostics and goal state
661
770
  client.update_file(rel_path, [change])
662
771
  diag = client.get_diagnostics(rel_path)
663
- formatted_diag = "\n".join(format_diagnostics(diag, select_line=line - 1))
772
+ filtered_diag = filter_diagnostics_by_position(diag, line - 1, None)
664
773
  # Use the snippet text length without any trailing newline for the column
665
- goal = client.get_goal(rel_path, line - 1, len(snippet_str))
666
- formatted_goal = format_goal(goal, "Missing goal")
667
- results.append(f"{snippet_str}:\n {formatted_goal}\n\n{formatted_diag}")
774
+ goal_result = client.get_goal(rel_path, line - 1, len(snippet_str))
775
+ goal_state = format_goal(goal_result, None)
776
+ results.append(
777
+ AttemptResult(
778
+ snippet=snippet_str,
779
+ goal_state=goal_state,
780
+ diagnostics=_to_diagnostic_messages(filtered_diag),
781
+ )
782
+ )
668
783
 
669
- return results
784
+ return _to_json_array(results)
670
785
  finally:
671
786
  try:
672
787
  client.close_files([rel_path])
@@ -676,23 +791,26 @@ def multi_attempt(
676
791
  )
677
792
 
678
793
 
679
- @mcp.tool("lean_run_code")
680
- def run_code(ctx: Context, code: str) -> List[str] | str:
681
- """Run a complete, self-contained code snippet and return diagnostics.
682
-
683
- Has to include all imports and definitions!
684
- Only use for testing outside open files! Keep the user in the loop by editing files instead.
685
-
686
- Args:
687
- code (str): Code snippet
688
-
689
- Returns:
690
- List[str] | str: Diagnostics msgs or error msg
691
- """
794
+ @mcp.tool(
795
+ "lean_run_code",
796
+ annotations=ToolAnnotations(
797
+ title="Run Code",
798
+ readOnlyHint=True,
799
+ idempotentHint=True,
800
+ openWorldHint=False,
801
+ ),
802
+ )
803
+ def run_code(
804
+ ctx: Context,
805
+ code: Annotated[str, Field(description="Self-contained Lean code with imports")],
806
+ ) -> RunResult:
807
+ """Run a code snippet and return diagnostics. Must include all imports."""
692
808
  lifespan_context = ctx.request_context.lifespan_context
693
809
  lean_project_path = lifespan_context.lean_project_path
694
810
  if lean_project_path is None:
695
- return "No valid Lean project path found. Run another tool (e.g. `lean_file_contents`) first to set it up."
811
+ raise LeanToolError(
812
+ "No valid Lean project path found. Run another tool first to set it up."
813
+ )
696
814
 
697
815
  # Use a unique snippet filename to avoid collisions under concurrency
698
816
  rel_path = f"_mcp_snippet_{uuid.uuid4().hex}.lean"
@@ -702,12 +820,10 @@ def run_code(ctx: Context, code: str) -> List[str] | str:
702
820
  with open(abs_path, "w", encoding="utf-8") as f:
703
821
  f.write(code)
704
822
  except Exception as e:
705
- return f"Error writing code snippet to file `{abs_path}`:\n{str(e)}"
823
+ raise LeanToolError(f"Error writing code snippet: {e}")
706
824
 
707
825
  client: LeanLSPClient | None = lifespan_context.client
708
- diagnostics: List[str] | str = []
709
- close_error: str | None = None
710
- remove_error: str | None = None
826
+ raw_diagnostics: List[Dict] = []
711
827
  opened_file = False
712
828
 
713
829
  try:
@@ -715,62 +831,57 @@ def run_code(ctx: Context, code: str) -> List[str] | str:
715
831
  startup_client(ctx)
716
832
  client = lifespan_context.client
717
833
  if client is None:
718
- return "Failed to initialize Lean client for run_code."
834
+ raise LeanToolError("Failed to initialize Lean client for run_code.")
719
835
 
720
- assert client is not None # startup_client guarantees an initialized client
836
+ assert client is not None
721
837
  client.open_file(rel_path)
722
838
  opened_file = True
723
- diagnostics = format_diagnostics(
724
- client.get_diagnostics(rel_path, inactivity_timeout=15.0)
725
- )
839
+ raw_diagnostics = client.get_diagnostics(rel_path, inactivity_timeout=15.0)
726
840
  finally:
727
841
  if opened_file:
728
842
  try:
729
843
  client.close_files([rel_path])
730
- except Exception as exc: # pragma: no cover - close failures only logged
731
- close_error = str(exc)
844
+ except Exception as exc:
732
845
  logger.warning("Failed to close `%s` after run_code: %s", rel_path, exc)
733
846
  try:
734
847
  os.remove(abs_path)
735
848
  except FileNotFoundError:
736
849
  pass
737
850
  except Exception as e:
738
- remove_error = str(e)
739
851
  logger.warning(
740
852
  "Failed to remove temporary Lean snippet `%s`: %s", abs_path, e
741
853
  )
742
854
 
743
- if remove_error:
744
- return f"Error removing temporary file `{abs_path}`:\n{remove_error}"
745
- if close_error:
746
- return f"Error closing temporary Lean document `{rel_path}`:\n{close_error}"
747
-
748
- return (
749
- diagnostics
750
- if diagnostics
751
- else "No diagnostics found for the code snippet (compiled successfully)."
752
- )
855
+ diagnostics = _to_diagnostic_messages(raw_diagnostics)
856
+ has_errors = any(d.severity == "error" for d in diagnostics)
753
857
 
858
+ return RunResult(success=not has_errors, diagnostics=diagnostics)
754
859
 
755
- @mcp.tool("lean_local_search")
756
- def local_search(
757
- ctx: Context, query: str, limit: int = 10, project_root: str | None = None
758
- ) -> List[Dict[str, str]] | str:
759
- """Confirm declarations exist in the current workspace to prevent hallucinating APIs.
760
860
 
761
- VERY USEFUL AND FAST!
762
- Pass a short prefix (e.g. ``map_mul``); the metadata shows the declaration kind and file.
763
- The index spans theorems, lemmas, defs, classes, instances, structures, inductives, abbrevs, and opaque decls.
861
+ class LocalSearchError(Exception):
862
+ pass
764
863
 
765
- Args:
766
- query (str): Declaration name or prefix.
767
- limit (int): Max matches to return (default 10).
768
864
 
769
- Returns:
770
- List[Dict[str, str]] | str: Matches as ``{"name", "kind", "file"}`` or error message.
771
- """
865
+ @mcp.tool(
866
+ "lean_local_search",
867
+ annotations=ToolAnnotations(
868
+ title="Local Search",
869
+ readOnlyHint=True,
870
+ idempotentHint=True,
871
+ openWorldHint=False,
872
+ ),
873
+ )
874
+ def local_search(
875
+ ctx: Context,
876
+ query: Annotated[str, Field(description="Declaration name or prefix")],
877
+ limit: Annotated[int, Field(description="Max matches", ge=1)] = 10,
878
+ project_root: Annotated[
879
+ Optional[str], Field(description="Project root (inferred if omitted)")
880
+ ] = None,
881
+ ) -> str:
882
+ """Fast local search to verify declarations exist. Use BEFORE trying a lemma name."""
772
883
  if not _RG_AVAILABLE:
773
- return _RG_MESSAGE
884
+ raise LocalSearchError(_RG_MESSAGE)
774
885
 
775
886
  lifespan = ctx.request_context.lifespan_context
776
887
  stored_root = lifespan.lean_project_path
@@ -778,92 +889,101 @@ def local_search(
778
889
  if project_root:
779
890
  try:
780
891
  resolved_root = Path(project_root).expanduser().resolve()
781
- except OSError as exc: # pragma: no cover - defensive path handling
782
- return f"Invalid project root '{project_root}': {exc}"
892
+ except OSError as exc:
893
+ raise LocalSearchError(f"Invalid project root '{project_root}': {exc}")
783
894
  if not resolved_root.exists():
784
- return f"Project root '{project_root}' does not exist."
895
+ raise LocalSearchError(f"Project root '{project_root}' does not exist.")
785
896
  lifespan.lean_project_path = resolved_root
786
897
  else:
787
898
  resolved_root = stored_root
788
899
 
789
900
  if resolved_root is None:
790
- return "Lean project path not set. Call a file-based tool (like lean_file_contents) first to set the project path."
901
+ raise LocalSearchError(
902
+ "Lean project path not set. Call a file-based tool first."
903
+ )
791
904
 
792
905
  try:
793
- return lean_local_search(
906
+ raw_results = lean_local_search(
794
907
  query=query.strip(), limit=limit, project_root=resolved_root
795
908
  )
909
+ results = [
910
+ LocalSearchResult(name=r["name"], kind=r["kind"], file=r["file"])
911
+ for r in raw_results
912
+ ]
913
+ return _to_json_array(results)
796
914
  except RuntimeError as exc:
797
- return f"lean_local_search error:\n{exc}"
915
+ raise LocalSearchError(f"Search failed: {exc}")
798
916
 
799
917
 
800
- @mcp.tool("lean_leansearch")
918
+ @mcp.tool(
919
+ "lean_leansearch",
920
+ annotations=ToolAnnotations(
921
+ title="LeanSearch",
922
+ readOnlyHint=True,
923
+ idempotentHint=True,
924
+ openWorldHint=True,
925
+ ),
926
+ )
801
927
  @rate_limited("leansearch", max_requests=3, per_seconds=30)
802
- def leansearch(ctx: Context, query: str, num_results: int = 5) -> List[Dict] | str:
803
- """Search for Lean theorems, definitions, and tactics using leansearch.net.
804
-
805
- Query patterns:
806
- - 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."
807
- - Mixed natural/Lean: "natural numbers. from: n < m, to: n + 1 < m + 1", "n + 1 <= m if n < m"
808
- - Concept names: "Cauchy Schwarz"
809
- - Lean identifiers: "List.sum", "Finset induction"
810
- - Lean term: "{f : A → B} {g : B → A} (hf : Injective f) (hg : Injective g) : ∃ h, Bijective h"
811
-
812
- Args:
813
- query (str): Search query
814
- num_results (int, optional): Max results. Defaults to 5.
815
-
816
- Returns:
817
- List[Dict] | str: Search results or error msg
818
- """
819
- try:
820
- headers = {"User-Agent": "lean-lsp-mcp/0.1", "Content-Type": "application/json"}
821
- payload = orjson.dumps({"num_results": str(num_results), "query": [query]})
822
-
823
- req = urllib.request.Request(
824
- "https://leansearch.net/search",
825
- data=payload,
826
- headers=headers,
827
- method="POST",
828
- )
829
-
830
- with urllib.request.urlopen(req, timeout=20) as response:
831
- results = orjson.loads(response.read())
832
-
833
- if not results or not results[0]:
834
- return "No results found."
835
- results = results[0][:num_results]
836
- results = [r["result"] for r in results]
837
-
838
- for result in results:
839
- result.pop("docstring")
840
- result["module_name"] = ".".join(result["module_name"])
841
- result["name"] = ".".join(result["name"])
842
-
843
- return results
844
- except Exception as e:
845
- return f"leansearch error:\n{str(e)}"
928
+ def leansearch(
929
+ ctx: Context,
930
+ query: Annotated[str, Field(description="Natural language or Lean term query")],
931
+ num_results: Annotated[int, Field(description="Max results", ge=1)] = 5,
932
+ ) -> str:
933
+ """Search Mathlib via leansearch.net using natural language.
846
934
 
935
+ Examples: "sum of two even numbers is even", "Cauchy-Schwarz inequality",
936
+ "{f : A → B} (hf : Injective f) : ∃ g, LeftInverse g f"
937
+ """
938
+ headers = {"User-Agent": "lean-lsp-mcp/0.1", "Content-Type": "application/json"}
939
+ payload = orjson.dumps({"num_results": str(num_results), "query": [query]})
940
+
941
+ req = urllib.request.Request(
942
+ "https://leansearch.net/search",
943
+ data=payload,
944
+ headers=headers,
945
+ method="POST",
946
+ )
847
947
 
848
- @mcp.tool("lean_loogle")
849
- async def loogle(ctx: Context, query: str, num_results: int = 8) -> List[dict] | str:
850
- """Search for definitions and theorems using loogle.
948
+ with urllib.request.urlopen(req, timeout=20) as response:
949
+ results = orjson.loads(response.read())
851
950
 
852
- Query patterns:
853
- - By constant: Real.sin # finds lemmas mentioning Real.sin
854
- - By lemma name: "differ" # finds lemmas with "differ" in the name
855
- - By subexpression: _ * (_ ^ _) # finds lemmas with a product and power
856
- - Non-linear: Real.sqrt ?a * Real.sqrt ?a
857
- - By type shape: (?a -> ?b) -> List ?a -> List ?b
858
- - By conclusion: |- tsum _ = _ * tsum _
859
- - By conclusion w/hyps: |- _ < _ → tsum _ < tsum _
951
+ if not results or not results[0]:
952
+ return "[]"
860
953
 
861
- Args:
862
- query (str): Search query
863
- num_results (int, optional): Max results. Defaults to 8.
954
+ raw_results = [r["result"] for r in results[0][:num_results]]
955
+ items = [
956
+ LeanSearchResult(
957
+ name=".".join(r["name"]),
958
+ module_name=".".join(r["module_name"]),
959
+ kind=r.get("kind"),
960
+ type=r.get("type"),
961
+ )
962
+ for r in raw_results
963
+ ]
964
+ return _to_json_array(items)
965
+
966
+
967
+ @mcp.tool(
968
+ "lean_loogle",
969
+ annotations=ToolAnnotations(
970
+ title="Loogle",
971
+ readOnlyHint=True,
972
+ idempotentHint=True,
973
+ openWorldHint=True,
974
+ ),
975
+ )
976
+ async def loogle(
977
+ ctx: Context,
978
+ query: Annotated[
979
+ str, Field(description="Type pattern, constant, or name substring")
980
+ ],
981
+ num_results: Annotated[int, Field(description="Max results", ge=1)] = 8,
982
+ ) -> str:
983
+ """Search Mathlib by type signature via loogle.lean-lang.org.
864
984
 
865
- Returns:
866
- List[dict] | str: Search results or error msg
985
+ Examples: `Real.sin`, `"comm"`, `(?a → ?b) → List ?a → List ?b`,
986
+ `_ * (_ ^ _)`, `|- _ < _ → _ + 1 < _ + 1`
867
987
  """
868
988
  app_ctx: AppContext = ctx.request_context.lifespan_context
869
989
 
@@ -871,9 +991,17 @@ async def loogle(ctx: Context, query: str, num_results: int = 8) -> List[dict] |
871
991
  if app_ctx.loogle_local_available and app_ctx.loogle_manager:
872
992
  try:
873
993
  results = await app_ctx.loogle_manager.query(query, num_results)
874
- for result in results:
875
- result.pop("doc", None)
876
- return results if results else "No results found."
994
+ if not results:
995
+ return "No results found."
996
+ items = [
997
+ LoogleResult(
998
+ name=r.get("name", ""),
999
+ type=r.get("type", ""),
1000
+ module=r.get("module", ""),
1001
+ )
1002
+ for r in results
1003
+ ]
1004
+ return _to_json_array(items)
877
1005
  except Exception as e:
878
1006
  logger.warning(f"Local loogle failed: {e}, falling back to remote")
879
1007
 
@@ -885,142 +1013,145 @@ async def loogle(ctx: Context, query: str, num_results: int = 8) -> List[dict] |
885
1013
  return "Rate limit exceeded: 3 requests per 30s. Use --loogle-local to avoid limits."
886
1014
  rate_limit.append(now)
887
1015
 
888
- return loogle_remote(query, num_results)
1016
+ result = loogle_remote(query, num_results)
1017
+ if isinstance(result, str):
1018
+ return result # Error message
1019
+ return _to_json_array(result)
889
1020
 
890
1021
 
891
- @mcp.tool("lean_leanfinder")
1022
+ @mcp.tool(
1023
+ "lean_leanfinder",
1024
+ annotations=ToolAnnotations(
1025
+ title="Lean Finder",
1026
+ readOnlyHint=True,
1027
+ idempotentHint=True,
1028
+ openWorldHint=True,
1029
+ ),
1030
+ )
892
1031
  @rate_limited("leanfinder", max_requests=10, per_seconds=30)
893
- def leanfinder(ctx: Context, query: str, num_results: int = 5) -> List[Dict] | str:
894
- """Search Mathlib theorems/definitions semantically by mathematical concept or proof state using Lean Finder.
895
-
896
- Effective query types:
897
- - Natural language mathematical statement: "For any natural numbers n and m, the sum n+m is equal to m+n."
898
- - 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?"
899
- - Proof state. For better results, enter a proof state followed by how you want to transform the proof state.
900
- - Statement definition: Fragment or the whole statement definition.
901
-
902
- Tips: Multiple targeted queries beat one complex query.
903
-
904
- Args:
905
- query (str): Mathematical concept or proof state
906
- num_results (int, optional): Max results. Defaults to 5.
1032
+ def leanfinder(
1033
+ ctx: Context,
1034
+ query: Annotated[str, Field(description="Mathematical concept or proof state")],
1035
+ num_results: Annotated[int, Field(description="Max results", ge=1)] = 5,
1036
+ ) -> str:
1037
+ """Semantic search by mathematical meaning via Lean Finder.
907
1038
 
908
- Returns:
909
- List[Dict] | str: List of Lean statement objects (full name, formal statement, informal statement) or error msg
1039
+ Examples: "commutativity of addition on natural numbers",
1040
+ "I have h : n < m and need n + 1 < m + 1", proof state text.
910
1041
  """
911
- try:
912
- headers = {"User-Agent": "lean-lsp-mcp/0.1", "Content-Type": "application/json"}
913
- request_url = (
914
- "https://bxrituxuhpc70w8w.us-east-1.aws.endpoints.huggingface.cloud"
915
- )
916
- payload = orjson.dumps({"inputs": query, "top_k": int(num_results)})
917
- req = urllib.request.Request(
918
- request_url, data=payload, headers=headers, method="POST"
919
- )
1042
+ headers = {"User-Agent": "lean-lsp-mcp/0.1", "Content-Type": "application/json"}
1043
+ request_url = "https://bxrituxuhpc70w8w.us-east-1.aws.endpoints.huggingface.cloud"
1044
+ payload = orjson.dumps({"inputs": query, "top_k": int(num_results)})
1045
+ req = urllib.request.Request(
1046
+ request_url, data=payload, headers=headers, method="POST"
1047
+ )
920
1048
 
921
- results = []
922
- with urllib.request.urlopen(req, timeout=30) as response:
923
- data = orjson.loads(response.read())
924
- for result in data["results"]:
925
- if (
926
- "https://leanprover-community.github.io/mathlib4_docs"
927
- not in result["url"]
928
- ): # Do not include results from other sources other than mathlib4, since users might not have imported them
929
- continue
930
- full_name = re.search(r"pattern=(.*?)#doc", result["url"]).group(1)
931
- obj = {
932
- "full_name": full_name,
933
- "formal_statement": result["formal_statement"],
934
- "informal_statement": result["informal_statement"],
935
- }
936
- results.append(obj)
937
-
938
- return results if results else "Lean Finder: No results parsed"
939
- except Exception as e:
940
- return f"Lean Finder Error:\n{str(e)}"
1049
+ results: List[LeanFinderResult] = []
1050
+ with urllib.request.urlopen(req, timeout=30) as response:
1051
+ data = orjson.loads(response.read())
1052
+ for result in data["results"]:
1053
+ if (
1054
+ "https://leanprover-community.github.io/mathlib4_docs"
1055
+ not in result["url"]
1056
+ ): # Only include mathlib4 results
1057
+ continue
1058
+ match = re.search(r"pattern=(.*?)#doc", result["url"])
1059
+ if match:
1060
+ results.append(
1061
+ LeanFinderResult(
1062
+ full_name=match.group(1),
1063
+ formal_statement=result["formal_statement"],
1064
+ informal_statement=result["informal_statement"],
1065
+ )
1066
+ )
1067
+
1068
+ return _to_json_array(results)
941
1069
 
942
1070
 
943
- @mcp.tool("lean_state_search")
1071
+ @mcp.tool(
1072
+ "lean_state_search",
1073
+ annotations=ToolAnnotations(
1074
+ title="State Search",
1075
+ readOnlyHint=True,
1076
+ idempotentHint=True,
1077
+ openWorldHint=True,
1078
+ ),
1079
+ )
944
1080
  @rate_limited("lean_state_search", max_requests=3, per_seconds=30)
945
1081
  def state_search(
946
- ctx: Context, file_path: str, line: int, column: int, num_results: int = 5
947
- ) -> List | str:
948
- """Search for theorems based on proof state using premise-search.com.
949
-
950
- Only uses first goal if multiple.
951
-
952
- Args:
953
- file_path (str): Abs path to Lean file
954
- line (int): Line number (1-indexed)
955
- column (int): Column number (1-indexed)
956
- num_results (int, optional): Max results. Defaults to 5.
957
-
958
- Returns:
959
- List | str: Search results or error msg
960
- """
1082
+ ctx: Context,
1083
+ file_path: Annotated[str, Field(description="Absolute path to Lean file")],
1084
+ line: Annotated[int, Field(description="Line number (1-indexed)", ge=1)],
1085
+ column: Annotated[int, Field(description="Column number (1-indexed)", ge=1)],
1086
+ num_results: Annotated[int, Field(description="Max results", ge=1)] = 5,
1087
+ ) -> str:
1088
+ """Find lemmas to close the goal at a position. Searches premise-search.com."""
961
1089
  rel_path = setup_client_for_file(ctx, file_path)
962
1090
  if not rel_path:
963
- return "Invalid Lean file path: Unable to start LSP server or load file"
1091
+ raise LeanToolError(
1092
+ "Invalid Lean file path: Unable to start LSP server or load file"
1093
+ )
964
1094
 
965
1095
  client: LeanLSPClient = ctx.request_context.lifespan_context.client
966
1096
  client.open_file(rel_path)
967
- file_contents = client.get_file_content(rel_path)
968
1097
  goal = client.get_goal(rel_path, line - 1, column - 1)
969
1098
 
970
- f_line = format_line(file_contents, line, column)
971
1099
  if not goal or not goal.get("goals"):
972
- return f"No goals found:\n{f_line}\nTry elsewhere?"
1100
+ raise LeanToolError(
1101
+ f"No goals found at line {line}, column {column}. Try a different position or check if the proof is complete."
1102
+ )
973
1103
 
974
- goal = urllib.parse.quote(goal["goals"][0])
1104
+ goal_str = urllib.parse.quote(goal["goals"][0])
975
1105
 
976
- try:
977
- url = os.getenv("LEAN_STATE_SEARCH_URL", "https://premise-search.com")
978
- req = urllib.request.Request(
979
- f"{url}/api/search?query={goal}&results={num_results}&rev=v4.22.0",
980
- headers={"User-Agent": "lean-lsp-mcp/0.1"},
981
- method="GET",
982
- )
1106
+ url = os.getenv("LEAN_STATE_SEARCH_URL", "https://premise-search.com")
1107
+ req = urllib.request.Request(
1108
+ f"{url}/api/search?query={goal_str}&results={num_results}&rev=v4.22.0",
1109
+ headers={"User-Agent": "lean-lsp-mcp/0.1"},
1110
+ method="GET",
1111
+ )
983
1112
 
984
- with urllib.request.urlopen(req, timeout=20) as response:
985
- results = orjson.loads(response.read())
1113
+ with urllib.request.urlopen(req, timeout=20) as response:
1114
+ results = orjson.loads(response.read())
986
1115
 
987
- for result in results:
988
- result.pop("rev")
989
- # Very dirty type mix
990
- results.insert(0, f"Results for line:\n{f_line}")
991
- return results
992
- except Exception as e:
993
- return f"lean state search error:\n{str(e)}"
1116
+ items = [StateSearchResult(name=r["name"]) for r in results]
1117
+ return _to_json_array(items)
994
1118
 
995
1119
 
996
- @mcp.tool("lean_hammer_premise")
1120
+ @mcp.tool(
1121
+ "lean_hammer_premise",
1122
+ annotations=ToolAnnotations(
1123
+ title="Hammer Premises",
1124
+ readOnlyHint=True,
1125
+ idempotentHint=True,
1126
+ openWorldHint=True,
1127
+ ),
1128
+ )
997
1129
  @rate_limited("hammer_premise", max_requests=3, per_seconds=30)
998
1130
  def hammer_premise(
999
- ctx: Context, file_path: str, line: int, column: int, num_results: int = 32
1000
- ) -> List[str] | str:
1001
- """Search for premises based on proof state using the lean hammer premise search.
1002
-
1003
- Args:
1004
- file_path (str): Abs path to Lean file
1005
- line (int): Line number (1-indexed)
1006
- column (int): Column number (1-indexed)
1007
- num_results (int, optional): Max results. Defaults to 32.
1008
-
1009
- Returns:
1010
- List[str] | str: List of relevant premises or error message
1131
+ ctx: Context,
1132
+ file_path: Annotated[str, Field(description="Absolute path to Lean file")],
1133
+ line: Annotated[int, Field(description="Line number (1-indexed)", ge=1)],
1134
+ column: Annotated[int, Field(description="Column number (1-indexed)", ge=1)],
1135
+ num_results: Annotated[int, Field(description="Max results", ge=1)] = 32,
1136
+ ) -> str:
1137
+ """Get premise suggestions for automation tactics at a goal position.
1138
+
1139
+ Returns lemma names to try with `simp only [...]`, `aesop`, or as hints.
1011
1140
  """
1012
1141
  rel_path = setup_client_for_file(ctx, file_path)
1013
1142
  if not rel_path:
1014
- return "Invalid Lean file path: Unable to start LSP server or load file"
1143
+ raise LeanToolError(
1144
+ "Invalid Lean file path: Unable to start LSP server or load file"
1145
+ )
1015
1146
 
1016
1147
  client: LeanLSPClient = ctx.request_context.lifespan_context.client
1017
1148
  client.open_file(rel_path)
1018
- file_contents = client.get_file_content(rel_path)
1019
1149
  goal = client.get_goal(rel_path, line - 1, column - 1)
1020
1150
 
1021
- f_line = format_line(file_contents, line, column)
1022
1151
  if not goal or not goal.get("goals"):
1023
- return f"No goals found:\n{f_line}\nTry elsewhere?"
1152
+ raise LeanToolError(
1153
+ f"No goals found at line {line}, column {column}. Try a different position or check if the proof is complete."
1154
+ )
1024
1155
 
1025
1156
  data = {
1026
1157
  "state": goal["goals"][0],
@@ -1028,26 +1159,22 @@ def hammer_premise(
1028
1159
  "k": num_results,
1029
1160
  }
1030
1161
 
1031
- try:
1032
- url = os.getenv("LEAN_HAMMER_URL", "http://leanpremise.net")
1033
- req = urllib.request.Request(
1034
- url + "/retrieve",
1035
- headers={
1036
- "User-Agent": "lean-lsp-mcp/0.1",
1037
- "Content-Type": "application/json",
1038
- },
1039
- method="POST",
1040
- data=orjson.dumps(data),
1041
- )
1162
+ url = os.getenv("LEAN_HAMMER_URL", "http://leanpremise.net")
1163
+ req = urllib.request.Request(
1164
+ url + "/retrieve",
1165
+ headers={
1166
+ "User-Agent": "lean-lsp-mcp/0.1",
1167
+ "Content-Type": "application/json",
1168
+ },
1169
+ method="POST",
1170
+ data=orjson.dumps(data),
1171
+ )
1042
1172
 
1043
- with urllib.request.urlopen(req, timeout=20) as response:
1044
- results = orjson.loads(response.read())
1173
+ with urllib.request.urlopen(req, timeout=20) as response:
1174
+ results = orjson.loads(response.read())
1045
1175
 
1046
- results = [result["name"] for result in results]
1047
- results.insert(0, f"Results for line:\n{f_line}")
1048
- return results
1049
- except Exception as e:
1050
- return f"lean hammer premise error:\n{str(e)}"
1176
+ items = [PremiseResult(name=r["name"]) for r in results]
1177
+ return _to_json_array(items)
1051
1178
 
1052
1179
 
1053
1180
  if __name__ == "__main__":