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