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/README.md +19 -0
- leanback/__init__.py +3 -0
- leanback/check.py +365 -0
- leanback/common/__init__.py +0 -0
- leanback/common/env.py +91 -0
- leanback/common/errors.py +517 -0
- leanback/common/path_manager.py +191 -0
- leanback/common/project_context.py +276 -0
- leanback/common/util.py +72 -0
- leanback/goals.py +167 -0
- leanback/mathlib_search.py +190 -0
- leanback/mathlib_search_simple.py +221 -0
- leanback/mcp_server.py +1179 -0
- leanback/mcp_test.py +496 -0
- leanback/prove.py +87 -0
- leanback-0.1.0.dist-info/METADATA +154 -0
- leanback-0.1.0.dist-info/RECORD +20 -0
- leanback-0.1.0.dist-info/WHEEL +4 -0
- leanback-0.1.0.dist-info/entry_points.txt +3 -0
- leanback-0.1.0.dist-info/licenses/LICENSE +191 -0
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()
|