leanback 0.1.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.
leanback/mcp_server.py ADDED
@@ -0,0 +1,1179 @@
1
+ #!/usr/bin/env python3
2
+ """MCP server that exposes LeanBack tools for Lean 4 theorem proving."""
3
+
4
+ import argparse
5
+ import functools
6
+ import inspect
7
+ import json
8
+ import logging
9
+ import re
10
+ import shlex
11
+ import sys
12
+ import time
13
+ from pathlib import Path
14
+
15
+ from mcp.server.fastmcp import FastMCP
16
+
17
+
18
+ def _json(obj: object) -> str:
19
+ """Serialize to JSON with Unicode symbols rendered (not escaped)."""
20
+ return json.dumps(obj, ensure_ascii=False)
21
+
22
+
23
+ def _with_elapsed(func):
24
+ """Decorator that adds elapsed_ms to every tool response."""
25
+
26
+ @functools.wraps(func)
27
+ def wrapper(*args, **kwargs):
28
+ start = time.time()
29
+ result_json = func(*args, **kwargs)
30
+ elapsed = round((time.time() - start) * 1000)
31
+ try:
32
+ obj = json.loads(result_json)
33
+ if isinstance(obj, dict):
34
+ obj["elapsed_ms"] = elapsed
35
+ return json.dumps(obj, ensure_ascii=False)
36
+ except (json.JSONDecodeError, TypeError):
37
+ return result_json
38
+
39
+ wrapper.__signature__ = inspect.signature(func)
40
+ return wrapper
41
+
42
+
43
+ # Configure logging (MCP servers use stderr for logging)
44
+ logging.basicConfig(
45
+ level=logging.INFO,
46
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
47
+ stream=sys.stderr,
48
+ )
49
+ logger = logging.getLogger(__name__)
50
+
51
+ # Create the MCP server
52
+ mcp = FastMCP(
53
+ "LeanBack",
54
+ instructions="""\
55
+ LeanBack provides tools for Lean 4 theorem proving. All tools are read-only — they never \
56
+ modify files. Use your own file tools to create/edit .lean files, then use LeanBack to verify them.
57
+
58
+ ## Getting Started
59
+ 1. Call project_info(project="/path/to/project") to check if a project is ready
60
+ 2. If no project exists, create one (requires shell access):
61
+ lake +leanprover-community/mathlib4:lean-toolchain new <name> math && cd <name> && lake exe cache get
62
+ 3. If you have no shell access, ask the user to create a project
63
+
64
+ ## Tool Selection
65
+ - Check project status or get setup help → project_info
66
+ - Want to know if code compiles? → check
67
+ - Want to see the goal state during proof construction? → goals
68
+ - Want to verify a completed proof? → prove
69
+ - Want to run #eval, #check, or test definitions? → eval
70
+ - Looking for a Mathlib lemma or definition? → search
71
+
72
+ ## Proof Development Workflow
73
+ 1. Search for relevant lemmas: search(project=..., pattern="add_comm")
74
+ 2. See the initial goal: goals(project=..., theorem="...")
75
+ 3. Try tactics and inspect remaining goals: goals(project=..., theorem="...", tactics=["intro n"])
76
+ 4. If a tactic fails at index i, inspect state before it: goals(project=..., theorem="...", tactics=original[:i])
77
+ 5. When proof is complete, verify: prove(project=..., theorem="...", tactics=["intro n", "simp"])
78
+ 6. Once working, write to a .lean file and verify: prove(project=..., file="MyProof.lean")
79
+
80
+ ## Important Details
81
+ - All tools require 'project' as an absolute path (e.g., "/home/user/lean/myproject")
82
+ - check has NO mathlib flag — use imports=["Mathlib.Tactic"] instead
83
+ - goals, prove, and eval with mathlib=True all import Mathlib.Tactic (simp, ring, omega, linarith, norm_num, decide, aesop)
84
+ - Tool calls typically take 5-30 seconds. The first call may be slower (Lean server startup).
85
+ """,
86
+ )
87
+
88
+
89
+ def _resolve_project(project: str) -> Path | None:
90
+ """Resolve and validate a project path. Returns Path or None."""
91
+ p = Path(project)
92
+ if not p.is_absolute():
93
+ return None
94
+ if not p.exists() or not p.is_dir():
95
+ return None
96
+ # Check for lakefile
97
+ if not (p / "lakefile.toml").exists() and not (p / "lakefile.lean").exists():
98
+ return None
99
+ return p
100
+
101
+
102
+ def _project_error(project: str) -> str:
103
+ """Return a JSON error for invalid project path."""
104
+ p = Path(project)
105
+ if not p.is_absolute():
106
+ return _json(
107
+ {
108
+ "success": False,
109
+ "error_code": "RELATIVE_PATH",
110
+ "message": f"Project path must be absolute, got: {project}",
111
+ "hint": "Use an absolute path like /home/user/lean/myproject",
112
+ }
113
+ )
114
+ if not p.exists():
115
+ return _json(
116
+ {
117
+ "success": False,
118
+ "error_code": "PATH_NOT_FOUND",
119
+ "message": f"Path does not exist: {project}",
120
+ "hint": "Use project_info() to diagnose, or create a project with: "
121
+ "lake +leanprover-community/mathlib4:lean-toolchain new <name> math",
122
+ }
123
+ )
124
+ if not p.is_dir():
125
+ return _json(
126
+ {
127
+ "success": False,
128
+ "error_code": "NOT_A_DIRECTORY",
129
+ "message": f"Path is not a directory: {project}",
130
+ }
131
+ )
132
+ return _json(
133
+ {
134
+ "success": False,
135
+ "error_code": "NOT_A_LEAN_PROJECT",
136
+ "message": f"No lakefile.toml or lakefile.lean found at: {project}",
137
+ "hint": "Use project_info() for setup instructions",
138
+ }
139
+ )
140
+
141
+
142
+ def _sanitize_file_path(file_name: str) -> str:
143
+ """Replace temp file paths with a descriptive label."""
144
+ import tempfile
145
+
146
+ temp_dir = tempfile.gettempdir()
147
+ if file_name.startswith(temp_dir) or file_name.startswith("/var/folders/"):
148
+ return "<inline>"
149
+ return file_name
150
+
151
+
152
+ def _clean_lean_message(message: str) -> str:
153
+ """Clean up verbose Lean error messages for AI-friendly output."""
154
+ # Wrap .olean/.lake path errors with a friendlier message
155
+ if ".olean" in message and "does not exist" in message:
156
+ match = re.search(r"of module\s+(\S+)", message)
157
+ module = match.group(1) if match else "the required module"
158
+ return (
159
+ f"Module {module} is not built. "
160
+ f"Run `lake build` in the project directory to compile dependencies.\n"
161
+ f"Original: {message}"
162
+ )
163
+
164
+ # Truncate verbose search path listings from unknown module errors
165
+ if "in the search path entries:" in message:
166
+ truncated = message.split("in the search path entries:")[0].rstrip()
167
+ return truncated
168
+
169
+ return message
170
+
171
+
172
+ def _serialize_errors(errors: list, sanitize_paths: bool = False) -> list[dict]:
173
+ """Serialize LeanError objects to dicts for JSON output."""
174
+ return [
175
+ {
176
+ "file": _sanitize_file_path(e.file_name) if sanitize_paths else e.file_name,
177
+ "line": e.line,
178
+ "column": e.column,
179
+ "end_line": e.end_line,
180
+ "end_column": e.end_column,
181
+ "severity": e.severity,
182
+ "message": _clean_lean_message(e.message),
183
+ "suggestion": e.suggestion,
184
+ }
185
+ for e in errors
186
+ ]
187
+
188
+
189
+ def _has_mathlib(project_path: Path) -> bool:
190
+ """Check if a project has Mathlib as a dependency."""
191
+ lakefile_lean = project_path / "lakefile.lean"
192
+ lakefile_toml = project_path / "lakefile.toml"
193
+ if lakefile_lean.exists():
194
+ try:
195
+ content = lakefile_lean.read_text()
196
+ return (
197
+ '"leanprover-community" / "mathlib"' in content
198
+ or "require mathlib" in content
199
+ or 'require "mathlib"' in content
200
+ )
201
+ except (OSError, UnicodeDecodeError):
202
+ return False
203
+ elif lakefile_toml.exists():
204
+ try:
205
+ content = lakefile_toml.read_text()
206
+ return "[dependencies.mathlib]" in content or (
207
+ "[[require]]" in content and "mathlib" in content
208
+ )
209
+ except (OSError, UnicodeDecodeError):
210
+ return False
211
+ return False
212
+
213
+
214
+ def _get_mathlib_version(project_path: Path) -> str | None:
215
+ """Read Mathlib version from lake-manifest.json."""
216
+ manifest = project_path / "lake-manifest.json"
217
+ if not manifest.exists():
218
+ return None
219
+ try:
220
+ data = json.loads(manifest.read_text())
221
+ for pkg in data.get("packages", []):
222
+ if pkg.get("name") == "mathlib":
223
+ rev = pkg.get("rev", "")
224
+ return rev[:12] if rev else None
225
+ return None
226
+ except (OSError, json.JSONDecodeError, KeyError):
227
+ return None
228
+
229
+
230
+ @mcp.tool()
231
+ @_with_elapsed
232
+ def project_info(project: str) -> str:
233
+ """Check the status of a Lean project and get setup instructions.
234
+
235
+ Call this tool to diagnose whether a project is ready to use, or to get
236
+ exact shell commands for setting one up. Use before other tools to verify
237
+ the project is properly configured.
238
+
239
+ Parameters:
240
+ - project: Absolute path to the Lean project directory (e.g., "/home/user/lean/myproject")
241
+
242
+ Returns JSON with project status. Possible scenarios:
243
+
244
+ 1. Valid project with Mathlib:
245
+ {"valid": true, "lean_version": "...", "has_mathlib": true, "built": true}
246
+
247
+ 2. Path does not exist (includes setup commands):
248
+ {"valid": false, "reason": "path_not_found", "setup_commands": [...]}
249
+
250
+ 3. Directory exists but not a Lean project:
251
+ {"valid": false, "reason": "not_a_lean_project", "setup_commands": [...]}
252
+
253
+ 4. Valid project without Mathlib:
254
+ {"valid": true, "has_mathlib": false, "hint": "search and mathlib=True require Mathlib"}
255
+
256
+ 5. Project not yet built:
257
+ {"valid": true, "built": false, "setup_commands": ["lake build"]}
258
+
259
+ Examples:
260
+ - Check existing project: project_info(project="/home/user/lean/myproject")
261
+ - Check before starting: project_info(project="/home/user/lean/newproject")
262
+ """
263
+ try:
264
+ p = Path(project)
265
+
266
+ if not p.is_absolute():
267
+ return _json(
268
+ {
269
+ "valid": False,
270
+ "reason": "relative_path",
271
+ "message": f"Project path must be absolute, got: {project}",
272
+ "hint": "Use an absolute path like /home/user/lean/myproject",
273
+ }
274
+ )
275
+
276
+ parent = p.parent
277
+ name = p.name
278
+
279
+ # Path does not exist at all
280
+ if not p.exists():
281
+ result = {
282
+ "valid": False,
283
+ "reason": "path_not_found",
284
+ "message": f"No directory found at: {project}",
285
+ }
286
+ # If parent exists, give setup commands
287
+ if parent.exists():
288
+ result["setup_commands"] = [
289
+ f"cd {shlex.quote(str(parent))}",
290
+ f"lake +leanprover-community/mathlib4:lean-toolchain new {shlex.quote(name)} math",
291
+ f"cd {shlex.quote(name)} && lake exe cache get",
292
+ ]
293
+ result["note"] = (
294
+ "These commands create a Lean project with Mathlib. "
295
+ "The cache download takes a few minutes."
296
+ )
297
+ else:
298
+ result["hint"] = f"Parent directory {parent} does not exist either"
299
+ return _json(result)
300
+
301
+ if not p.is_dir():
302
+ return _json(
303
+ {
304
+ "valid": False,
305
+ "reason": "not_a_directory",
306
+ "message": f"Path is not a directory: {project}",
307
+ }
308
+ )
309
+
310
+ # Directory exists but not a Lean project
311
+ has_lakefile = (p / "lakefile.toml").exists() or (p / "lakefile.lean").exists()
312
+ if not has_lakefile:
313
+ return _json(
314
+ {
315
+ "valid": False,
316
+ "reason": "not_a_lean_project",
317
+ "message": f"No lakefile.toml or lakefile.lean found at: {project}",
318
+ "setup_commands": [
319
+ f"cd {shlex.quote(str(p))}",
320
+ "lake +leanprover-community/mathlib4:lean-toolchain init math",
321
+ "lake exe cache get",
322
+ ],
323
+ "note": (
324
+ "These commands initialize a Lean project with Mathlib in the existing directory. "
325
+ "The cache download takes a few minutes."
326
+ ),
327
+ }
328
+ )
329
+
330
+ # Valid Lean project — gather diagnostics
331
+ result = {"valid": True}
332
+
333
+ # Lean version
334
+ toolchain_file = p / "lean-toolchain"
335
+ if toolchain_file.exists():
336
+ try:
337
+ result["lean_version"] = toolchain_file.read_text().strip()
338
+ except OSError:
339
+ result["lean_version"] = "unknown"
340
+ else:
341
+ result["lean_version"] = "unknown (no lean-toolchain file)"
342
+
343
+ # Mathlib status
344
+ result["has_mathlib"] = _has_mathlib(p)
345
+
346
+ if result["has_mathlib"]:
347
+ mathlib_rev = _get_mathlib_version(p)
348
+ if mathlib_rev:
349
+ result["mathlib_revision"] = mathlib_rev
350
+
351
+ # Built status
352
+ build_dir = p / ".lake" / "build"
353
+ result["built"] = build_dir.exists()
354
+
355
+ if not result["built"]:
356
+ result["setup_commands"] = ["lake build"]
357
+ result["note"] = "Project exists but has not been built yet"
358
+
359
+ if not result["has_mathlib"]:
360
+ result["hint"] = (
361
+ "search and mathlib=True require Mathlib. "
362
+ "To create a project with Mathlib: "
363
+ "lake +leanprover-community/mathlib4:lean-toolchain new <name> math"
364
+ )
365
+
366
+ # Update hint for Mathlib cache
367
+ if result["has_mathlib"] and not result["built"]:
368
+ result["setup_commands"] = ["lake exe cache get", "lake build"]
369
+ result["note"] = (
370
+ "Project has Mathlib but is not built. "
371
+ "Run cache get first to download precompiled files (faster)."
372
+ )
373
+
374
+ return _json(result)
375
+
376
+ except Exception as e:
377
+ return _json(
378
+ {
379
+ "valid": False,
380
+ "reason": "error",
381
+ "message": str(e),
382
+ }
383
+ )
384
+
385
+
386
+ @mcp.tool()
387
+ @_with_elapsed
388
+ def check(
389
+ project: str,
390
+ file: str | None = None,
391
+ check_all: bool = False,
392
+ expr: str | None = None,
393
+ imports: list[str] | None = None,
394
+ ) -> str:
395
+ """Verify Lean code correctness in a project.
396
+
397
+ Use this tool to check if Lean code compiles without errors. It can verify:
398
+ - Entire projects (when only 'project' is specified)
399
+ - Specific files within a project (using 'file' parameter)
400
+ - Individual Lean expressions (using 'expr' parameter)
401
+ - All files individually for detailed errors (using 'check_all' parameter)
402
+
403
+ Choose one mode: check entire project (default), check a specific file, check an expression, or check all files individually.
404
+ Use only one of 'file', 'expr', or 'check_all' in a single call.
405
+
406
+ Parameters:
407
+ - project: Absolute path to the Lean project directory (e.g., "/home/user/lean/myproject")
408
+ - file: Path to a .lean file within the project (e.g., "Basic.lean", "src/Logic.lean")
409
+ - check_all: Check each file individually for detailed error reporting (slower but more thorough)
410
+ - expr: A Lean expression or command to check (e.g., "2 + 2 = 4", "Nat.add", "#eval 1 + 1"). Bare expressions are auto-wrapped in #check.
411
+ - imports: List of modules to import when checking expressions (e.g., ["Mathlib.Tactic"])
412
+
413
+ Returns JSON with 'success' boolean. The 'messages' array contains objects with:
414
+ - file: Path to the file (or "<inline>" for expressions)
415
+ - line/column: Location of the issue
416
+ - severity: "error", "warning", or "info" (e.g., output from #check)
417
+ - message: The error or output message
418
+ - suggestion: Helpful hint to fix the issue (when available)
419
+
420
+ Examples:
421
+ - Verify project compiles: check(project="/home/user/lean/myproject")
422
+ - Check specific file: check(project="/home/user/lean/myproject", file="MyProof.lean")
423
+ - Test expression: check(project="/home/user/lean/myproject", expr="List.map")
424
+ - Check proposition: check(project="/home/user/lean/myproject", expr="2 + 2 = 4")
425
+ - Check with imports: check(project="/home/user/lean/myproject", expr="#check group", imports=["Mathlib.Algebra.Group.Defs"])
426
+ - Get detailed errors: check(project="/home/user/lean/myproject", check_all=True)
427
+ """
428
+ try:
429
+ from rich.console import Console
430
+
431
+ from leanback.check import check_file, check_lean_expr, check_project
432
+ from leanback.common.project_context import ProjectContext
433
+
434
+ project_path = _resolve_project(project)
435
+ if not project_path:
436
+ return _project_error(project)
437
+
438
+ console = Console(stderr=True)
439
+
440
+ # Case 1: Check an expression
441
+ if expr is not None:
442
+ expr_stripped = expr.strip()
443
+ if not expr_stripped:
444
+ return _json(
445
+ {
446
+ "success": False,
447
+ "error_code": "NO_EXPRESSION",
448
+ "message": "Empty expression. Provide a Lean expression or command to check.",
449
+ }
450
+ )
451
+
452
+ # Auto-wrap bare expressions that aren't valid Lean commands
453
+ lean_commands = (
454
+ "#",
455
+ "def ",
456
+ "theorem ",
457
+ "lemma ",
458
+ "example ",
459
+ "instance ",
460
+ "structure ",
461
+ "class ",
462
+ "inductive ",
463
+ "abbrev ",
464
+ "axiom ",
465
+ "import ",
466
+ "open ",
467
+ "namespace ",
468
+ "section ",
469
+ "variable ",
470
+ "set_option ",
471
+ "attribute ",
472
+ "noncomputable ",
473
+ "private ",
474
+ "protected ",
475
+ "mutual ",
476
+ )
477
+ if expr_stripped and not any(
478
+ expr_stripped.startswith(cmd) for cmd in lean_commands
479
+ ):
480
+ expr = f"#check ({expr_stripped})"
481
+
482
+ success, errors = check_lean_expr(
483
+ expr=expr,
484
+ imports=imports or [],
485
+ project_path=project_path,
486
+ console=console,
487
+ )
488
+
489
+ error_count = sum(1 for e in errors if e.severity == "error")
490
+ warning_count = sum(1 for e in errors if e.severity == "warning")
491
+ info_count = sum(1 for e in errors if e.severity == "info")
492
+
493
+ result = {
494
+ "success": success,
495
+ "type": "expression_check",
496
+ "expression": expr,
497
+ "imports": imports or [],
498
+ "messages": _serialize_errors(errors, sanitize_paths=True),
499
+ "statistics": {
500
+ "total_errors": error_count,
501
+ "total_warnings": warning_count,
502
+ "total_info": info_count,
503
+ },
504
+ }
505
+ return _json(result)
506
+
507
+ # Case 2: Check a specific file
508
+ if file:
509
+ # Guard against path traversal
510
+ resolved_file = (project_path / file).resolve()
511
+ if not resolved_file.is_relative_to(project_path.resolve()):
512
+ return _json(
513
+ {
514
+ "success": False,
515
+ "error_code": "PATH_TRAVERSAL",
516
+ "message": f"File path '{file}' resolves outside the project directory",
517
+ }
518
+ )
519
+
520
+ project_context = ProjectContext(project_path, console=console)
521
+ file_path = project_context.resolve_file_path(file)
522
+
523
+ if not file_path:
524
+ suggestions = project_context.suggest_file_path(file)
525
+ result = {
526
+ "success": False,
527
+ "error_code": "FILE_NOT_FOUND",
528
+ "message": f"File '{file}' not found in project",
529
+ "suggestions": suggestions if suggestions else None,
530
+ "hint": f"Did you mean one of: {', '.join(suggestions[:3])}?"
531
+ if suggestions
532
+ else "Check the file path and try again.",
533
+ }
534
+ return _json(result)
535
+
536
+ success, errors = check_file(
537
+ file_path=file_path,
538
+ project_path=project_path,
539
+ console=console,
540
+ )
541
+
542
+ error_count = sum(1 for e in errors if e.severity == "error")
543
+ warning_count = sum(1 for e in errors if e.severity == "warning")
544
+ info_count = sum(1 for e in errors if e.severity == "info")
545
+
546
+ try:
547
+ display_path = file_path.relative_to(project_path)
548
+ except ValueError:
549
+ display_path = file
550
+
551
+ result = {
552
+ "success": success,
553
+ "type": "file_check",
554
+ "file_path": str(display_path),
555
+ "messages": _serialize_errors(errors),
556
+ "statistics": {
557
+ "total_errors": error_count,
558
+ "total_warnings": warning_count,
559
+ "total_info": info_count,
560
+ "files_checked": 1,
561
+ },
562
+ }
563
+ return _json(result)
564
+
565
+ # Case 3: Check entire project
566
+ success, errors = check_project(
567
+ project_path=project_path,
568
+ check_all_files=check_all,
569
+ console=console,
570
+ )
571
+
572
+ error_count = sum(1 for e in errors if e.severity == "error")
573
+ warning_count = sum(1 for e in errors if e.severity == "warning")
574
+ info_count = sum(1 for e in errors if e.severity == "info")
575
+
576
+ files_with_errors = set(e.file_name for e in errors if e.file_name != "unknown")
577
+
578
+ result = {
579
+ "success": success,
580
+ "type": "project_check",
581
+ "check_all_files": check_all,
582
+ "messages": _serialize_errors(errors),
583
+ "statistics": {
584
+ "total_errors": error_count,
585
+ "total_warnings": warning_count,
586
+ "total_info": info_count,
587
+ "files_with_errors": len(files_with_errors),
588
+ },
589
+ }
590
+ return _json(result)
591
+
592
+ except Exception as e:
593
+ result = {"success": False, "error_code": "EXECUTION_ERROR", "message": str(e)}
594
+ return _json(result)
595
+
596
+
597
+ @mcp.tool()
598
+ @_with_elapsed
599
+ def prove(
600
+ project: str,
601
+ theorem: str | None = None,
602
+ tactics: list[str] | None = None,
603
+ file: str | None = None,
604
+ imports: list[str] | None = None,
605
+ mathlib: bool = False,
606
+ ) -> str:
607
+ """Verify theorem proofs in Lean.
608
+
609
+ Use this tool to verify that a theorem statement can be proved with given tactics,
610
+ or to check that a proof file is valid. This tool verifies proofs non-interactively.
611
+
612
+ Parameters:
613
+ - project: Absolute path to the Lean project directory (e.g., "/home/user/lean/myproject")
614
+ - theorem: Complete theorem statement to prove (e.g., "2 + 2 = 4")
615
+ - tactics: List of proof tactics to apply in order (e.g., ["intro n", "rfl"])
616
+ - file: Path to a .lean file containing complete proofs to verify
617
+ - imports: Additional modules to import (e.g., ["Mathlib.Data.Nat.Basic"])
618
+ - mathlib: Import Mathlib.Tactic for all standard tactics (simp, ring, omega, linarith, norm_num, decide, aesop, etc.)
619
+
620
+ Use either (theorem + tactics) OR (file), not both. When using theorem + tactics,
621
+ the tool constructs and verifies the proof. When using file, it verifies existing proofs.
622
+
623
+ Returns JSON with 'success' boolean. If proof fails, 'messages' array contains objects with:
624
+ - file: Path to the file (or "<inline>" for inline proofs)
625
+ - line/column: Location of the issue
626
+ - severity: "error" or "warning"
627
+ - message: Description of why the proof failed
628
+ - suggestion: Hint about what tactic might work (when available)
629
+
630
+ Examples:
631
+ - Simple equality: prove(project="/home/user/lean/myproject", theorem="2 + 2 = 4", tactics=["rfl"])
632
+ - Basic proof: prove(project="/home/user/lean/myproject", theorem="n + 0 = n", tactics=["omega"])
633
+ - With mathlib: prove(project="/home/user/lean/myproject", theorem="x > 0", tactics=["use 1", "simp"], mathlib=True)
634
+ - Check proof file: prove(project="/home/user/lean/myproject", file="MyTheorems.lean")
635
+ """
636
+ try:
637
+ from rich.console import Console
638
+
639
+ from leanback.prove import verify_proof
640
+
641
+ project_path = _resolve_project(project)
642
+ if not project_path:
643
+ return _project_error(project)
644
+
645
+ console = Console(stderr=True)
646
+
647
+ # Determine imports
648
+ resolved_imports = imports or []
649
+ if mathlib:
650
+ resolved_imports = ["Mathlib.Tactic", *resolved_imports]
651
+
652
+ # Reject conflicting inputs
653
+ if file and theorem:
654
+ return _json(
655
+ {
656
+ "success": False,
657
+ "error_code": "CONFLICTING_INPUTS",
658
+ "message": "Cannot specify both file and theorem. Use one or the other.",
659
+ }
660
+ )
661
+
662
+ if file and (imports or mathlib):
663
+ return _json(
664
+ {
665
+ "success": False,
666
+ "error_code": "CONFLICTING_INPUTS",
667
+ "message": "Cannot use imports or mathlib with file — the file must contain its own imports.",
668
+ }
669
+ )
670
+
671
+ # Mode 1: Verify a proof file
672
+ if file:
673
+ proof_file = (project_path / file).resolve()
674
+ if not proof_file.is_relative_to(project_path.resolve()):
675
+ return _json(
676
+ {
677
+ "success": False,
678
+ "error_code": "PATH_TRAVERSAL",
679
+ "message": f"File path '{file}' resolves outside the project directory",
680
+ }
681
+ )
682
+ if not proof_file.exists():
683
+ return _json(
684
+ {
685
+ "success": False,
686
+ "error_code": "FILE_NOT_FOUND",
687
+ "message": f"Proof file '{file}' not found in project",
688
+ }
689
+ )
690
+
691
+ success, errors = verify_proof(
692
+ theorem="",
693
+ proof_file=str(proof_file),
694
+ imports=resolved_imports if resolved_imports else None,
695
+ project_path=project_path,
696
+ console=console,
697
+ )
698
+
699
+ return _json(
700
+ {
701
+ "success": success,
702
+ "type": "file_verification",
703
+ "proof_file": file,
704
+ "messages": _serialize_errors(errors) if errors else [],
705
+ }
706
+ )
707
+
708
+ # Mode 2: Verify theorem + tactics
709
+ elif theorem:
710
+ if tactics is None:
711
+ return _json(
712
+ {
713
+ "success": False,
714
+ "error_code": "MISSING_TACTICS",
715
+ "message": 'No tactics provided. Use tactics=["rfl"], tactics=["simp"], etc.',
716
+ }
717
+ )
718
+
719
+ if len(tactics) == 0:
720
+ return _json(
721
+ {
722
+ "success": False,
723
+ "error_code": "EMPTY_TACTICS",
724
+ "message": 'Empty tactics list. Provide at least one tactic (e.g., ["rfl"], ["simp"], ["sorry"])',
725
+ }
726
+ )
727
+
728
+ success, errors = verify_proof(
729
+ theorem=theorem,
730
+ tactics=tactics,
731
+ imports=resolved_imports,
732
+ project_path=project_path,
733
+ console=console,
734
+ )
735
+
736
+ # Detect sorry usage — sorry is not a real proof
737
+ sorry_used = any(
738
+ "declaration uses 'sorry'" in e.message
739
+ for e in errors
740
+ if e.severity == "warning"
741
+ )
742
+ if sorry_used:
743
+ success = False
744
+
745
+ result = {
746
+ "success": success,
747
+ "type": "theorem_verification",
748
+ "theorem": theorem,
749
+ "tactics": tactics or [],
750
+ "imports": resolved_imports,
751
+ "messages": _serialize_errors(errors, sanitize_paths=True)
752
+ if errors
753
+ else [],
754
+ }
755
+ if sorry_used:
756
+ result["sorry_used"] = True
757
+ result["warning"] = (
758
+ "Proof uses 'sorry' — this is a placeholder, not a valid proof"
759
+ )
760
+ return _json(result)
761
+
762
+ else:
763
+ return _json(
764
+ {
765
+ "success": False,
766
+ "error_code": "NO_THEOREM_OR_FILE",
767
+ "message": "Must provide either theorem (with optional tactics) or file parameter",
768
+ }
769
+ )
770
+
771
+ except Exception as e:
772
+ return _json(
773
+ {"success": False, "error_code": "EXECUTION_ERROR", "message": str(e)}
774
+ )
775
+
776
+
777
+ @mcp.tool()
778
+ @_with_elapsed
779
+ def goals(
780
+ project: str,
781
+ theorem: str,
782
+ tactics: list[str] | None = None,
783
+ imports: list[str] | None = None,
784
+ mathlib: bool = False,
785
+ ) -> str:
786
+ """Inspect the proof state during proof construction.
787
+
788
+ Use this tool to see what goals remain after applying tactics to a theorem.
789
+ This is for proof exploration — to see the current state, not to verify completeness.
790
+
791
+ Parameters:
792
+ - project: Absolute path to the Lean project directory (e.g., "/home/user/lean/myproject")
793
+ - theorem: Theorem statement to prove (e.g., "2 + 2 = 4", "∀ n : Nat, n + 0 = n")
794
+ - tactics: List of tactics applied so far (e.g., ["intro n"]). Omit or pass [] to see the initial goal.
795
+ - imports: Additional modules to import (e.g., ["Mathlib.Data.Nat.Basic"])
796
+ - mathlib: Import Mathlib.Tactic for standard tactics (simp, ring, omega, linarith, norm_num, decide, aesop, etc.)
797
+
798
+ Returns JSON with:
799
+ - success: Whether the tool ran without internal errors
800
+ - status: "incomplete" (goals remain), "complete" (proof done), or "error" (tactic/setup failure)
801
+ - tactics_applied: Number of successfully applied tactics (0 when showing initial goal)
802
+ - goals: List of remaining goal strings (each contains hypotheses and ⊢ conclusion)
803
+
804
+ When a tactic fails, the response includes:
805
+ - failed_tactic: {"index": 0-based, "tactic": "...", "error": "..."}
806
+ - goals: May contain remaining goals if Lean provides them
807
+ - messages: Raw error details
808
+
809
+ To see the goal state before a failed tactic, call goals() again with tactics[:index].
810
+
811
+ Workflow:
812
+ 1. See initial goal: goals(project=..., theorem="∀ n, n + 0 = n")
813
+ 2. Apply tactics: goals(project=..., theorem="∀ n, n + 0 = n", tactics=["intro n"])
814
+ 3. If tactic fails at index i, inspect: goals(project=..., theorem=..., tactics=original[:i])
815
+ 4. When complete, verify with prove()
816
+
817
+ Examples:
818
+ - Initial goal: goals(project="/home/user/lean/myproject", theorem="∀ n : Nat, n + 0 = n")
819
+ - After intro: goals(project="/home/user/lean/myproject", theorem="∀ n : Nat, n + 0 = n", tactics=["intro n"])
820
+ - Multiple tactics: goals(project="/home/user/lean/myproject", theorem="True ∧ True", tactics=["constructor"], mathlib=True)
821
+ """
822
+ try:
823
+ from rich.console import Console
824
+
825
+ from leanback.goals import extract_goals
826
+
827
+ project_path = _resolve_project(project)
828
+ if not project_path:
829
+ return _project_error(project)
830
+
831
+ if not theorem or not theorem.strip():
832
+ return _json(
833
+ {
834
+ "success": False,
835
+ "error_code": "NO_THEOREM",
836
+ "message": "Theorem statement is required.",
837
+ }
838
+ )
839
+
840
+ console = Console(stderr=True)
841
+
842
+ # Resolve imports
843
+ resolved_imports = imports or []
844
+ if mathlib:
845
+ resolved_imports = ["Mathlib.Tactic", *resolved_imports]
846
+
847
+ result = extract_goals(
848
+ theorem=theorem,
849
+ tactics=tactics,
850
+ imports=resolved_imports,
851
+ project_path=project_path,
852
+ console=console,
853
+ )
854
+
855
+ return _json(result)
856
+
857
+ except Exception as e:
858
+ return _json(
859
+ {"success": False, "error_code": "EXECUTION_ERROR", "message": str(e)}
860
+ )
861
+
862
+
863
+ @mcp.tool()
864
+ @_with_elapsed
865
+ def eval(
866
+ project: str,
867
+ code: str | None = None,
868
+ file: str | None = None,
869
+ imports: list[str] | None = None,
870
+ mathlib: bool = False,
871
+ ) -> str:
872
+ """Execute Lean expressions and see their results.
873
+
874
+ Use this tool to evaluate Lean code, check types, test functions, or run examples.
875
+ The code is executed in the context of the specified project.
876
+
877
+ Parameters:
878
+ - project: Absolute path to the Lean project directory (e.g., "/home/user/lean/myproject")
879
+ - code: Lean code to execute (e.g., "#eval 2 + 2", "#check Nat.add", "def f := 5")
880
+ - file: Path to a .lean file to execute instead of inline code
881
+ - imports: Additional modules to import (e.g., ["Mathlib.Data.Finset.Basic"])
882
+ - mathlib: Import Mathlib.Tactic for all standard tactics (simp, ring, omega, linarith, norm_num, decide, aesop, etc.)
883
+
884
+ Use either 'code' OR 'file', not both. The tool can handle multi-line code.
885
+
886
+ Returns JSON with 'success' boolean and 'messages' array containing:
887
+ - Actual errors (severity: "error")
888
+ - Warnings (severity: "warning")
889
+ - Output from #eval and #check (severity: "info")
890
+ Each entry has file, line, column, severity, and message fields.
891
+
892
+ Examples:
893
+ - Evaluate arithmetic: eval(project="/home/user/lean/myproject", code="#eval 2 + 2")
894
+ - Check type: eval(project="/home/user/lean/myproject", code="#check List.map")
895
+ - Define and test: eval(project="/home/user/lean/myproject", code="def double (n : Nat) := n * 2\\n#eval double 21")
896
+ - With mathlib: eval(project="/home/user/lean/myproject", code="#eval Nat.gcd 12 18", mathlib=True)
897
+ - With imports: eval(project="/home/user/lean/myproject", code="#check @Finset.sum", imports=["Mathlib.Algebra.BigOperators.Group.Finset"])
898
+ - Execute from file: eval(project="/home/user/lean/myproject", file="examples.lean")
899
+ """
900
+ try:
901
+ from rich.console import Console
902
+
903
+ from leanback.check import check_lean_expr
904
+
905
+ # Validate parameters
906
+ if (code is None or (isinstance(code, str) and not code.strip())) and not file:
907
+ return _json(
908
+ {
909
+ "success": False,
910
+ "error_code": "NO_CODE_PROVIDED",
911
+ "message": "No code provided. Use 'code' parameter or 'file' parameter",
912
+ }
913
+ )
914
+
915
+ if code is not None and file:
916
+ return _json(
917
+ {
918
+ "success": False,
919
+ "error_code": "CONFLICTING_INPUTS",
920
+ "message": "Cannot specify both code and file",
921
+ }
922
+ )
923
+
924
+ project_path = _resolve_project(project)
925
+ if not project_path:
926
+ return _project_error(project)
927
+
928
+ console = Console(stderr=True)
929
+
930
+ # Get the code to execute
931
+ if file:
932
+ file_path = (project_path / file).resolve()
933
+ if not file_path.is_relative_to(project_path.resolve()):
934
+ return _json(
935
+ {
936
+ "success": False,
937
+ "error_code": "PATH_TRAVERSAL",
938
+ "message": f"File path '{file}' resolves outside the project directory",
939
+ }
940
+ )
941
+ if not file_path.exists():
942
+ return _json(
943
+ {
944
+ "success": False,
945
+ "error_code": "FILE_NOT_FOUND",
946
+ "message": f"File '{file}' not found in project",
947
+ }
948
+ )
949
+ try:
950
+ with open(file_path) as f:
951
+ code_to_execute = f.read()
952
+ except Exception as e:
953
+ return _json(
954
+ {
955
+ "success": False,
956
+ "error_code": "FILE_READ_ERROR",
957
+ "message": f"Failed to read file '{file}': {e!s}",
958
+ }
959
+ )
960
+ else:
961
+ code_to_execute = code
962
+
963
+ # Set up imports — merge mathlib defaults with user-provided imports
964
+ resolved_imports = list(imports or [])
965
+ if mathlib:
966
+ if "Mathlib.Tactic" not in resolved_imports:
967
+ resolved_imports.insert(0, "Mathlib.Tactic")
968
+
969
+ success, errors = check_lean_expr(
970
+ expr=code_to_execute,
971
+ imports=resolved_imports,
972
+ project_path=project_path,
973
+ console=console,
974
+ )
975
+
976
+ return _json(
977
+ {
978
+ "success": success,
979
+ "type": "code_execution",
980
+ "code": code_to_execute,
981
+ "imports": resolved_imports,
982
+ "messages": _serialize_errors(errors, sanitize_paths=True),
983
+ }
984
+ )
985
+
986
+ except Exception as e:
987
+ return _json(
988
+ {"success": False, "error_code": "EXECUTION_ERROR", "message": str(e)}
989
+ )
990
+
991
+
992
+ @mcp.tool()
993
+ @_with_elapsed
994
+ def search(
995
+ project: str,
996
+ pattern: str | None = None,
997
+ batch: str | None = None,
998
+ max_results: int = 15,
999
+ filter_namespace: str | None = None,
1000
+ include_internal: bool = False,
1001
+ ) -> str:
1002
+ """Find Lean declarations in Mathlib by pattern matching.
1003
+
1004
+ Use this tool to search for theorems, lemmas, definitions, and other declarations
1005
+ in the project's Mathlib. Searches use Python regex patterns on declaration names.
1006
+
1007
+ Parameters:
1008
+ - project: Absolute path to the Lean project directory (must have Mathlib)
1009
+ - pattern: Single search pattern (regex or text) to find matching declarations
1010
+ - batch: Comma-separated patterns for multiple searches (e.g., "add_comm,mul_comm")
1011
+ - max_results: Maximum results to return per pattern (default: 15)
1012
+ - filter_namespace: Only show results from this namespace (e.g., "List", "Nat")
1013
+ - include_internal: Include internal/private declarations (default: false)
1014
+
1015
+ Use either 'pattern' OR 'batch', not both. Patterns can be:
1016
+ - Exact names: "Nat.add_comm"
1017
+ - Substrings: "comm" (finds anything containing 'comm')
1018
+ - Regex: "theorem.*add.*comm"
1019
+
1020
+ Returns JSON with results. Each result has: name, type_signature, module, suggested_import.
1021
+
1022
+ Examples:
1023
+ - Find theorem: search(project="/home/user/lean/myproject", pattern="Nat.add_comm")
1024
+ - Find by substring: search(project="/home/user/lean/myproject", pattern="comm")
1025
+ - Filtered: search(project="/home/user/lean/myproject", pattern="reverse", filter_namespace="List")
1026
+ - Batch: search(project="/home/user/lean/myproject", batch="add_assoc,mul_assoc,add_comm")
1027
+ """
1028
+ try:
1029
+ from leanback.mathlib_search import (
1030
+ SearchError,
1031
+ batch_search_declarations,
1032
+ search_mathlib_declarations,
1033
+ )
1034
+
1035
+ # Validate parameters
1036
+ if pattern and batch:
1037
+ return _json(
1038
+ {
1039
+ "success": False,
1040
+ "error_code": "CONFLICTING_INPUTS",
1041
+ "message": "Use either 'pattern' or 'batch', not both",
1042
+ }
1043
+ )
1044
+ if not pattern and not batch:
1045
+ return _json(
1046
+ {
1047
+ "success": False,
1048
+ "error_code": "MISSING_PATTERN",
1049
+ "message": "Either 'pattern' or 'batch' is required",
1050
+ }
1051
+ )
1052
+
1053
+ project_path = _resolve_project(project)
1054
+ if not project_path:
1055
+ return _project_error(project)
1056
+
1057
+ search_params = {
1058
+ "timeout": 30,
1059
+ "filter_namespace": filter_namespace,
1060
+ "exclude_internal": not include_internal,
1061
+ "max_results": max_results,
1062
+ "use_minimal_import": False,
1063
+ "backend": "auto",
1064
+ }
1065
+
1066
+ if batch is not None:
1067
+ if not batch.strip():
1068
+ return _json(
1069
+ {
1070
+ "success": False,
1071
+ "error_code": "INVALID_PATTERN",
1072
+ "message": "Empty batch pattern list",
1073
+ }
1074
+ )
1075
+
1076
+ patterns = [p.strip() for p in batch.split(",") if p.strip()]
1077
+ if not patterns:
1078
+ return _json(
1079
+ {
1080
+ "success": False,
1081
+ "error_code": "INVALID_PATTERN",
1082
+ "message": "No valid patterns in batch list",
1083
+ }
1084
+ )
1085
+
1086
+ results = batch_search_declarations(patterns, project_path, **search_params)
1087
+ return _json(results)
1088
+ else:
1089
+ try:
1090
+ search_results, total_found = search_mathlib_declarations(
1091
+ pattern, project_path, **search_params
1092
+ )
1093
+
1094
+ serializable_results = []
1095
+ for r in search_results:
1096
+ if hasattr(r, "name"):
1097
+ serializable_results.append(
1098
+ {
1099
+ "name": r.name,
1100
+ "type_signature": r.type_signature,
1101
+ "module": r.module,
1102
+ "suggested_import": r.suggested_import,
1103
+ }
1104
+ )
1105
+ else:
1106
+ serializable_results.append(r)
1107
+
1108
+ metadata = {
1109
+ "total_found": len(serializable_results),
1110
+ "project_path": str(project_path),
1111
+ }
1112
+ if total_found > len(serializable_results):
1113
+ metadata["total_matched"] = total_found
1114
+ result = {
1115
+ "success": True,
1116
+ "pattern": pattern,
1117
+ "results": serializable_results,
1118
+ "metadata": metadata,
1119
+ }
1120
+ if len(serializable_results) == 0:
1121
+ if filter_namespace and total_found > 0:
1122
+ result["note"] = (
1123
+ f"No matches in namespace '{filter_namespace}'. "
1124
+ f"{total_found} matches exist without the namespace filter. "
1125
+ f"Try searching without filter_namespace."
1126
+ )
1127
+ else:
1128
+ result["note"] = (
1129
+ "No matches found. This search covers Mathlib declarations only, "
1130
+ "not core Lean (e.g., Nat.add_comm, List.map). "
1131
+ "Try a broader pattern or check the declaration name."
1132
+ )
1133
+ return _json(result)
1134
+ except SearchError as e:
1135
+ return _json({"success": False, "pattern": pattern, **e.to_dict()})
1136
+
1137
+ except SearchError as e:
1138
+ return _json({"success": False, **e.to_dict()})
1139
+ except Exception as e:
1140
+ return _json(
1141
+ {
1142
+ "success": False,
1143
+ "message": str(e),
1144
+ "error_code": "UNEXPECTED_ERROR",
1145
+ }
1146
+ )
1147
+
1148
+
1149
+ def main():
1150
+ """Main entry point for the MCP server."""
1151
+ parser = argparse.ArgumentParser(description="LeanBack MCP Server")
1152
+ parser.add_argument(
1153
+ "--transport",
1154
+ type=str,
1155
+ choices=["stdio", "sse", "streamable-http"],
1156
+ default="stdio",
1157
+ help="Transport type to use (default: stdio)",
1158
+ )
1159
+ parser.add_argument(
1160
+ "--port",
1161
+ type=int,
1162
+ default=3000,
1163
+ help="Port to listen on (for sse/streamable-http transport)",
1164
+ )
1165
+
1166
+ args = parser.parse_args()
1167
+
1168
+ logger.info(f"Starting LeanBack MCP server with {args.transport} transport")
1169
+
1170
+ if args.transport == "stdio":
1171
+ mcp.run()
1172
+ elif args.transport == "sse":
1173
+ mcp.run(transport="sse", port=args.port)
1174
+ elif args.transport == "streamable-http":
1175
+ mcp.run(transport="streamable-http", port=args.port)
1176
+
1177
+
1178
+ if __name__ == "__main__":
1179
+ main()