lean-lsp-mcp 0.19.1__py3-none-any.whl → 0.20.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/__init__.py CHANGED
@@ -36,6 +36,16 @@ def main():
36
36
  type=str,
37
37
  help="Override loogle cache location (default: ~/.cache/lean-lsp-mcp/loogle)",
38
38
  )
39
+ parser.add_argument(
40
+ "--repl",
41
+ action="store_true",
42
+ help="Enable fast REPL-based multi-attempt (~5x faster). Requires Lean REPL.",
43
+ )
44
+ parser.add_argument(
45
+ "--repl-timeout",
46
+ type=int,
47
+ help="REPL command timeout in seconds (default: 60)",
48
+ )
39
49
  args = parser.parse_args()
40
50
 
41
51
  # Set env vars from CLI args (CLI takes precedence over env vars)
@@ -43,6 +53,10 @@ def main():
43
53
  os.environ["LEAN_LOOGLE_LOCAL"] = "true"
44
54
  if args.loogle_cache_dir:
45
55
  os.environ["LEAN_LOOGLE_CACHE_DIR"] = args.loogle_cache_dir
56
+ if args.repl:
57
+ os.environ["LEAN_REPL"] = "true"
58
+ if args.repl_timeout:
59
+ os.environ["LEAN_REPL_TIMEOUT"] = str(args.repl_timeout)
46
60
 
47
61
  mcp.settings.host = args.host
48
62
  mcp.settings.port = args.port
@@ -16,19 +16,19 @@ INSTRUCTIONS = """## General Rules
16
16
  - **lean_profile_proof**: Profile a theorem for performance. Shows tactic hotspots. SLOW!
17
17
 
18
18
  ## Search Tools (rate limited)
19
- - **lean_leansearch** (3/30s): Natural language mathlib
20
- - **lean_loogle** (3/30s): Type pattern mathlib
19
+ - **lean_leansearch** (3/30s): Natural language -> mathlib
20
+ - **lean_loogle** (3/30s): Type pattern -> mathlib
21
21
  - **lean_leanfinder** (10/30s): Semantic/conceptual search
22
- - **lean_state_search** (3/30s): Goal closing lemmas
23
- - **lean_hammer_premise** (3/30s): Goal premises for simp/aesop
22
+ - **lean_state_search** (3/30s): Goal -> closing lemmas
23
+ - **lean_hammer_premise** (3/30s): Goal -> premises for simp/aesop
24
24
 
25
25
  ## Search Decision Tree
26
- 1. "Does X exist locally?" lean_local_search
27
- 2. "I need a lemma that says X" lean_leansearch
28
- 3. "Find lemma with type pattern" lean_loogle
29
- 4. "What's the Lean name for concept X?" lean_leanfinder
30
- 5. "What closes this goal?" lean_state_search
31
- 6. "What to feed simp?" lean_hammer_premise
26
+ 1. "Does X exist locally?" -> lean_local_search
27
+ 2. "I need a lemma that says X" -> lean_leansearch
28
+ 3. "Find lemma with type pattern" -> lean_loogle
29
+ 4. "What's the Lean name for concept X?" -> lean_leanfinder
30
+ 5. "What closes this goal?" -> lean_state_search
31
+ 6. "What to feed simp?" -> lean_hammer_premise
32
32
 
33
33
  After finding a name: lean_local_search to verify, lean_hover_info for signature.
34
34
 
@@ -37,4 +37,5 @@ List tools return JSON arrays. Empty = `[]`.
37
37
 
38
38
  ## Error Handling
39
39
  Check `isError` in responses: `true` means failure (timeout/LSP error), while `[]` with `isError: false` means no results found.
40
+
40
41
  """
lean_lsp_mcp/loogle.py CHANGED
@@ -8,12 +8,14 @@ import json
8
8
  import logging
9
9
  import os
10
10
  import shutil
11
+ import ssl
11
12
  import subprocess
12
13
  import urllib.parse
13
14
  import urllib.request
14
15
  from pathlib import Path
15
16
  from typing import Any
16
17
 
18
+ import certifi
17
19
  import orjson
18
20
 
19
21
  from lean_lsp_mcp.models import LoogleResult
@@ -35,7 +37,8 @@ def loogle_remote(query: str, num_results: int) -> list[LoogleResult] | str:
35
37
  f"https://loogle.lean-lang.org/json?q={urllib.parse.quote(query)}",
36
38
  headers={"User-Agent": "lean-lsp-mcp/0.1"},
37
39
  )
38
- with urllib.request.urlopen(req, timeout=10) as response:
40
+ ssl_ctx = ssl.create_default_context(cafile=certifi.where())
41
+ with urllib.request.urlopen(req, timeout=10, context=ssl_ctx) as response:
39
42
  results = orjson.loads(response.read())
40
43
  if "hits" not in results:
41
44
  return "No results found."
@@ -139,9 +142,12 @@ class LoogleManager:
139
142
  self._run(["lake", "exe", "cache", "get"], timeout=600)
140
143
  except Exception as e:
141
144
  logger.warning(f"Cache download: {e}")
142
- logger.info("Building loogle...")
145
+ logger.info("Building loogle (this may a few minutes)...")
143
146
  try:
144
- return self._run(["lake", "build"], timeout=900).returncode == 0
147
+ result = self._run(["lake", "build"], timeout=900)
148
+ if result.returncode != 0:
149
+ logger.error(f"Build failed: {result.stderr[:1000]}")
150
+ return result.returncode == 0
145
151
  except Exception as e:
146
152
  logger.error(f"Build error: {e}")
147
153
  return False
lean_lsp_mcp/repl.py ADDED
@@ -0,0 +1,260 @@
1
+ """Lean REPL for fast multi-attempt tactic execution using tactic mode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ import platform
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+ if platform.system() != "Windows":
13
+ import resource
14
+
15
+
16
+ class ReplError(Exception):
17
+ pass
18
+
19
+
20
+ @dataclass
21
+ class SnippetResult:
22
+ goals: list[str] = field(default_factory=list)
23
+ messages: list[dict[str, Any]] = field(default_factory=list)
24
+ proof_status: str | None = None
25
+ error: str | None = None
26
+
27
+
28
+ def repl_enabled() -> bool:
29
+ return os.environ.get("LEAN_REPL", "").lower() in ("1", "true", "yes")
30
+
31
+
32
+ def find_repl_binary(project_dir: str | None = None) -> str | None:
33
+ """Find REPL binary: env var > .lake/packages > PATH."""
34
+ import shutil
35
+ from pathlib import Path
36
+
37
+ # 1. Explicit env var
38
+ if path := os.environ.get("LEAN_REPL_PATH"):
39
+ return path if Path(path).exists() or shutil.which(path) else None
40
+
41
+ # 2. Auto-detect from .lake/packages (common location after `lake build`)
42
+ if project_dir:
43
+ candidates = [
44
+ Path(project_dir)
45
+ / ".lake"
46
+ / "packages"
47
+ / "repl"
48
+ / ".lake"
49
+ / "build"
50
+ / "bin"
51
+ / "repl",
52
+ Path(project_dir) / ".lake" / "build" / "bin" / "repl",
53
+ ]
54
+ for p in candidates:
55
+ if p.exists():
56
+ return str(p)
57
+
58
+ # 3. Fall back to PATH
59
+ if found := shutil.which("repl"):
60
+ return found
61
+
62
+ return None
63
+
64
+
65
+ def _split_imports(code: str) -> tuple[str, str]:
66
+ """Split code into (header with imports, body)."""
67
+ lines = code.splitlines()
68
+ i = 0
69
+ while i < len(lines) and (not lines[i].strip() or lines[i].startswith("import ")):
70
+ i += 1
71
+
72
+ # Deduplicate imports while preserving order
73
+ imports = [ln.strip() for ln in lines[:i] if ln.startswith("import ")]
74
+ header = "\n".join(dict.fromkeys(imports))
75
+ return header, "\n".join(lines[i:])
76
+
77
+
78
+ class Repl:
79
+ """Lean REPL using tactic mode for fast multi-attempt."""
80
+
81
+ def __init__(self, project_dir: str, repl_path: str | None = None):
82
+ self.project_dir = project_dir
83
+ self.repl_path = repl_path or find_repl_binary(project_dir) or "repl"
84
+ self.timeout = int(os.environ.get("LEAN_REPL_TIMEOUT", "60"))
85
+ self.mem_mb = int(os.environ.get("LEAN_REPL_MEM_MB", "8192"))
86
+ self._proc: asyncio.subprocess.Process | None = None
87
+ self._header: str | None = None
88
+ self._header_env: int | None = None
89
+ self._lock = asyncio.Lock()
90
+
91
+ async def _start(self) -> None:
92
+ """Start REPL subprocess."""
93
+ kwargs: dict[str, Any] = {
94
+ "cwd": self.project_dir,
95
+ "stdin": asyncio.subprocess.PIPE,
96
+ "stdout": asyncio.subprocess.PIPE,
97
+ "stderr": asyncio.subprocess.PIPE,
98
+ }
99
+ if platform.system() != "Windows":
100
+ kwargs["start_new_session"] = True
101
+ # Memory limit on Unix systems
102
+ if platform.system() == "Linux":
103
+ limit = resource.RLIMIT_AS # Virtual memory
104
+ else: # macOS
105
+ limit = resource.RLIMIT_RSS # Resident set size
106
+ mem = self.mem_mb * 1024 * 1024
107
+ kwargs["preexec_fn"] = lambda: resource.setrlimit(limit, (mem, mem))
108
+ self._proc = await asyncio.create_subprocess_exec(
109
+ "lake", "env", self.repl_path, **kwargs
110
+ )
111
+
112
+ async def _send(self, cmd: dict[str, Any]) -> dict[str, Any]:
113
+ """Send command and return response."""
114
+ if not self._proc or not self._proc.stdin or not self._proc.stdout:
115
+ raise ReplError("REPL not running")
116
+
117
+ self._proc.stdin.write((json.dumps(cmd) + "\n\n").encode())
118
+ await self._proc.stdin.drain()
119
+
120
+ lines = []
121
+ while True:
122
+ line = await self._proc.stdout.readline()
123
+ if not line or not line.strip():
124
+ break
125
+ lines.append(line)
126
+
127
+ if not lines:
128
+ raise ReplError("No response from REPL")
129
+ return json.loads(b"".join(lines))
130
+
131
+ async def _send_cmd(self, code: str, env: int | None = None) -> dict[str, Any]:
132
+ """Send a command (code) to the REPL."""
133
+ cmd: dict[str, Any] = {"cmd": code}
134
+ if env is not None:
135
+ cmd["env"] = env
136
+ return await self._send(cmd)
137
+
138
+ async def _send_tactic(self, tactic: str, proof_state: int) -> dict[str, Any]:
139
+ """Send a tactic to run in a proof state."""
140
+ return await self._send({"tactic": tactic, "proofState": proof_state})
141
+
142
+ async def _ensure_header(self, header: str) -> int | None:
143
+ """Ensure REPL is running with given header, return header env."""
144
+ if self._header != header:
145
+ await self.close()
146
+ self._header = header
147
+ self._header_env = None
148
+
149
+ if not self._proc or self._proc.returncode is not None:
150
+ await self._start()
151
+ if header:
152
+ resp = await self._send_cmd(header, env=None)
153
+ if "error" in resp:
154
+ raise ReplError(f"Failed to load imports: {resp['error']}")
155
+ self._header_env = resp.get("env")
156
+
157
+ return self._header_env
158
+
159
+ async def run_snippets(
160
+ self, base_code: str, snippets: list[str]
161
+ ) -> list[SnippetResult]:
162
+ """Run multiple tactic snippets using tactic mode.
163
+
164
+ 1. Load header (imports) - cached across calls
165
+ 2. Send body + sorry to get proofState
166
+ 3. Run each tactic via tactic mode (very fast)
167
+ """
168
+ header, body = _split_imports(base_code)
169
+
170
+ async with self._lock:
171
+ try:
172
+ # Load imports (cached)
173
+ header_env = await asyncio.wait_for(
174
+ self._ensure_header(header), timeout=self.timeout
175
+ )
176
+
177
+ # Send body with sorry to get proof state
178
+ if not body.strip():
179
+ return [SnippetResult(error="No proof body") for _ in snippets]
180
+
181
+ # Ensure proper whitespace before sorry
182
+ body_with_sorry = body.rstrip() + "\n sorry"
183
+ resp = await asyncio.wait_for(
184
+ self._send_cmd(body_with_sorry, env=header_env),
185
+ timeout=self.timeout,
186
+ )
187
+
188
+ if "error" in resp:
189
+ return [SnippetResult(error=resp["error"]) for _ in snippets]
190
+
191
+ # Get proof state from the sorry
192
+ sorries = resp.get("sorries", [])
193
+ if not sorries:
194
+ # No sorry = no proof goal, check messages for errors
195
+ msgs = resp.get("messages", [])
196
+ err = "; ".join(
197
+ m.get("data", "") for m in msgs if m.get("severity") == "error"
198
+ )
199
+ return [
200
+ SnippetResult(error=err or "No proof goal found")
201
+ for _ in snippets
202
+ ]
203
+
204
+ proof_state = sorries[0].get("proofState")
205
+ if proof_state is None:
206
+ return [
207
+ SnippetResult(error="No proofState returned") for _ in snippets
208
+ ]
209
+
210
+ # Run each tactic in tactic mode
211
+ results = []
212
+ for snippet in snippets:
213
+ try:
214
+ resp = await asyncio.wait_for(
215
+ self._send_tactic(snippet.strip(), proof_state),
216
+ timeout=self.timeout,
217
+ )
218
+
219
+ if "error" in resp:
220
+ # Lean error (tactic failed)
221
+ results.append(SnippetResult(error=resp["error"]))
222
+ else:
223
+ goals = resp.get("goals", [])
224
+ messages = resp.get("messages", [])
225
+ proof_status = resp.get("proofStatus")
226
+ results.append(
227
+ SnippetResult(
228
+ goals=goals,
229
+ messages=messages,
230
+ proof_status=proof_status,
231
+ )
232
+ )
233
+ except Exception as e:
234
+ results.append(SnippetResult(error=str(e)))
235
+
236
+ return results
237
+
238
+ except Exception as e:
239
+ await self.close()
240
+ raise ReplError(str(e)) from e
241
+
242
+ async def close(self) -> None:
243
+ if not self._proc:
244
+ return
245
+ proc, self._proc = self._proc, None
246
+ self._header = None
247
+ self._header_env = None
248
+ try:
249
+ if platform.system() != "Windows":
250
+ os.killpg(os.getpgid(proc.pid), 9)
251
+ else:
252
+ proc.kill()
253
+ except (ProcessLookupError, OSError):
254
+ pass
255
+ try:
256
+ await asyncio.wait_for(proc.wait(), timeout=1.0)
257
+ except asyncio.TimeoutError:
258
+ pass
259
+ if hasattr(proc, "_transport") and proc._transport:
260
+ proc._transport.close()
lean_lsp_mcp/server.py CHANGED
@@ -1,7 +1,9 @@
1
1
  import asyncio
2
2
  import functools
3
+ import logging.config
3
4
  import os
4
5
  import re
6
+ import ssl
5
7
  import time
6
8
  import urllib
7
9
  import uuid
@@ -11,6 +13,7 @@ from dataclasses import dataclass
11
13
  from pathlib import Path
12
14
  from typing import Annotated, Dict, List, Optional
13
15
 
16
+ import certifi
14
17
  import orjson
15
18
  from leanclient import DocumentContentChange, LeanLSPClient
16
19
  from mcp.server.auth.settings import AuthSettings
@@ -27,6 +30,7 @@ from lean_lsp_mcp.client_utils import (
27
30
  from lean_lsp_mcp.file_utils import get_file_contents
28
31
  from lean_lsp_mcp.instructions import INSTRUCTIONS
29
32
  from lean_lsp_mcp.loogle import LoogleManager, loogle_remote
33
+ from lean_lsp_mcp.repl import Repl, repl_enabled
30
34
  from lean_lsp_mcp.models import (
31
35
  AttemptResult,
32
36
  BuildResult,
@@ -56,6 +60,9 @@ from lean_lsp_mcp.models import (
56
60
  StateSearchResults,
57
61
  TermGoalState,
58
62
  )
63
+
64
+ # REPL models not imported - low-level REPL tools not exposed to keep API simple.
65
+ # The model uses lean_multi_attempt which handles REPL internally.
59
66
  from lean_lsp_mcp.outline_utils import generate_outline_data
60
67
  from lean_lsp_mcp.search_utils import check_ripgrep_status, lean_local_search
61
68
  from lean_lsp_mcp.utils import (
@@ -80,9 +87,10 @@ DIAGNOSTIC_SEVERITY: Dict[int, str] = {1: "error", 2: "warning", 3: "info", 4: "
80
87
 
81
88
  async def _urlopen_json(req: urllib.request.Request, timeout: float):
82
89
  """Run urllib.request.urlopen in a worker thread to avoid blocking the event loop."""
90
+ ssl_ctx = ssl.create_default_context(cafile=certifi.where())
83
91
 
84
92
  def _do_request():
85
- with urllib.request.urlopen(req, timeout=timeout) as response:
93
+ with urllib.request.urlopen(req, timeout=timeout, context=ssl_ctx) as response:
86
94
  return orjson.loads(response.read())
87
95
 
88
96
  return await asyncio.to_thread(_do_request)
@@ -97,8 +105,36 @@ async def _safe_report_progress(
97
105
  return
98
106
 
99
107
 
108
+ _LOG_FILE_CONFIG = os.environ.get("LEAN_LOG_FILE_CONFIG", None)
100
109
  _LOG_LEVEL = os.environ.get("LEAN_LOG_LEVEL", "INFO")
101
- configure_logging("CRITICAL" if _LOG_LEVEL == "NONE" else _LOG_LEVEL)
110
+ if _LOG_FILE_CONFIG:
111
+ try:
112
+ if _LOG_FILE_CONFIG.endswith((".yaml", ".yml")):
113
+ import yaml
114
+
115
+ with open(_LOG_FILE_CONFIG, "r", encoding="utf-8") as f:
116
+ cfg = yaml.safe_load(f)
117
+ logging.config.dictConfig(cfg)
118
+ elif _LOG_FILE_CONFIG.endswith(".json"):
119
+ with open(_LOG_FILE_CONFIG, "r", encoding="utf-8") as f:
120
+ cfg = orjson.loads(f.read())
121
+ logging.config.dictConfig(cfg)
122
+ else:
123
+ # .ini / fileConfig
124
+ logging.config.fileConfig(_LOG_FILE_CONFIG, disable_existing_loggers=False)
125
+ except Exception as e:
126
+ # fallback to LEAN_LOG_LEVEL so server still runs
127
+ # use the existing configure_logging helper to set level
128
+ configure_logging("CRITICAL" if _LOG_LEVEL == "NONE" else _LOG_LEVEL)
129
+ logger = get_logger(__name__) # temporary to emit the warning
130
+ logger.warning(
131
+ "Failed to load logging config %s: %s. Falling back to LEAN_LOG_LEVEL.",
132
+ _LOG_FILE_CONFIG,
133
+ e,
134
+ )
135
+ else:
136
+ configure_logging("CRITICAL" if _LOG_LEVEL == "NONE" else _LOG_LEVEL)
137
+
102
138
  logger = get_logger(__name__)
103
139
 
104
140
 
@@ -113,12 +149,17 @@ class AppContext:
113
149
  lean_search_available: bool
114
150
  loogle_manager: LoogleManager | None = None
115
151
  loogle_local_available: bool = False
152
+ # REPL for efficient multi-attempt execution
153
+ repl: Repl | None = None
154
+ repl_enabled: bool = False
116
155
 
117
156
 
118
157
  @asynccontextmanager
119
158
  async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
120
159
  loogle_manager: LoogleManager | None = None
121
160
  loogle_local_available = False
161
+ repl: Repl | None = None
162
+ repl_on = False
122
163
 
123
164
  try:
124
165
  lean_project_path_str = os.environ.get("LEAN_PROJECT_PATH", "").strip()
@@ -140,6 +181,26 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
140
181
  else:
141
182
  logger.warning("Local loogle installation failed, will use remote API")
142
183
 
184
+ # Initialize REPL if enabled
185
+ if repl_enabled():
186
+ if lean_project_path:
187
+ from lean_lsp_mcp.repl import find_repl_binary
188
+
189
+ repl_bin = find_repl_binary(str(lean_project_path))
190
+ if repl_bin:
191
+ logger.info("REPL enabled, using: %s", repl_bin)
192
+ repl = Repl(project_dir=str(lean_project_path), repl_path=repl_bin)
193
+ repl_on = True
194
+ logger.info("REPL initialized: timeout=%ds", repl.timeout)
195
+ else:
196
+ logger.warning(
197
+ "REPL enabled but binary not found. "
198
+ 'Add `require repl from git "https://github.com/leanprover-community/repl"` '
199
+ "to lakefile and run `lake build repl`. Falling back to LSP."
200
+ )
201
+ else:
202
+ logger.warning("REPL requires LEAN_PROJECT_PATH to be set")
203
+
143
204
  context = AppContext(
144
205
  lean_project_path=lean_project_path,
145
206
  client=None,
@@ -153,6 +214,8 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
153
214
  lean_search_available=_RG_AVAILABLE,
154
215
  loogle_manager=loogle_manager,
155
216
  loogle_local_available=loogle_local_available,
217
+ repl=repl,
218
+ repl_enabled=repl_on,
156
219
  )
157
220
  yield context
158
221
  finally:
@@ -164,6 +227,9 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
164
227
  if loogle_manager:
165
228
  await loogle_manager.stop()
166
229
 
230
+ if repl:
231
+ await repl.close()
232
+
167
233
 
168
234
  mcp_kwargs = dict(
169
235
  name="Lean LSP",
@@ -823,24 +889,65 @@ def declaration_file(
823
889
  return DeclarationInfo(file_path=str(abs_path), content=file_content)
824
890
 
825
891
 
826
- @mcp.tool(
827
- "lean_multi_attempt",
828
- annotations=ToolAnnotations(
829
- title="Multi-Attempt",
830
- readOnlyHint=True,
831
- idempotentHint=True,
832
- openWorldHint=False,
833
- ),
834
- )
835
- def multi_attempt(
892
+ async def _multi_attempt_repl(
836
893
  ctx: Context,
837
- file_path: Annotated[str, Field(description="Absolute path to Lean file")],
838
- line: Annotated[int, Field(description="Line number (1-indexed)", ge=1)],
839
- snippets: Annotated[
840
- List[str], Field(description="Tactics to try (3+ recommended)")
841
- ],
894
+ file_path: str,
895
+ line: int,
896
+ snippets: List[str],
897
+ ) -> MultiAttemptResult | None:
898
+ """Try tactics using REPL (fast path)."""
899
+ app_ctx: AppContext = ctx.request_context.lifespan_context
900
+ if not app_ctx.repl_enabled or not app_ctx.repl:
901
+ return None
902
+
903
+ try:
904
+ content = get_file_contents(file_path)
905
+ if content is None:
906
+ return None
907
+ lines = content.splitlines()
908
+ if line > len(lines):
909
+ return None
910
+
911
+ base_code = "\n".join(lines[: line - 1])
912
+ repl_results = await app_ctx.repl.run_snippets(base_code, snippets)
913
+
914
+ results = []
915
+ for snippet, pr in zip(snippets, repl_results):
916
+ diagnostics = [
917
+ DiagnosticMessage(
918
+ severity=m.get("severity", "info"),
919
+ message=m.get("data", ""),
920
+ line=m.get("pos", {}).get("line", 0),
921
+ column=m.get("pos", {}).get("column", 0),
922
+ )
923
+ for m in (pr.messages or [])
924
+ ]
925
+ if pr.error:
926
+ diagnostics.append(
927
+ DiagnosticMessage(
928
+ severity="error", message=pr.error, line=0, column=0
929
+ )
930
+ )
931
+ results.append(
932
+ AttemptResult(
933
+ snippet=snippet.rstrip("\n"),
934
+ goals=pr.goals or [],
935
+ diagnostics=diagnostics,
936
+ )
937
+ )
938
+ return MultiAttemptResult(items=results)
939
+ except Exception as e:
940
+ logger.debug(f"REPL multi_attempt failed: {e}")
941
+ return None
942
+
943
+
944
+ def _multi_attempt_lsp(
945
+ ctx: Context,
946
+ file_path: str,
947
+ line: int,
948
+ snippets: List[str],
842
949
  ) -> MultiAttemptResult:
843
- """Try multiple tactics without modifying file. Returns goal state for each."""
950
+ """Try tactics using LSP file modifications (fallback)."""
844
951
  rel_path = setup_client_for_file(ctx, file_path)
845
952
  if not rel_path:
846
953
  raise LeanToolError(
@@ -849,25 +956,22 @@ def multi_attempt(
849
956
 
850
957
  client: LeanLSPClient = ctx.request_context.lifespan_context.client
851
958
  client.open_file(rel_path)
959
+ original_content = get_file_contents(file_path)
852
960
 
853
961
  try:
854
962
  results: List[AttemptResult] = []
855
- # Avoid mutating caller-provided snippets; normalize locally per attempt
856
963
  for snippet in snippets:
857
964
  snippet_str = snippet.rstrip("\n")
858
965
  payload = f"{snippet_str}\n"
859
- # Create a DocumentContentChange for the snippet
860
966
  change = DocumentContentChange(
861
967
  payload,
862
968
  [line - 1, 0],
863
969
  [line, 0],
864
970
  )
865
- # Apply the change to the file, capture diagnostics and goal state
866
971
  client.update_file(rel_path, [change])
867
972
  diag = client.get_diagnostics(rel_path)
868
973
  check_lsp_response(diag, "get_diagnostics")
869
974
  filtered_diag = filter_diagnostics_by_position(diag, line - 1, None)
870
- # Use the snippet text length without any trailing newline for the column
871
975
  goal_result = client.get_goal(rel_path, line - 1, len(snippet_str))
872
976
  goals = extract_goals_list(goal_result)
873
977
  results.append(
@@ -880,12 +984,41 @@ def multi_attempt(
880
984
 
881
985
  return MultiAttemptResult(items=results)
882
986
  finally:
883
- try:
884
- client.close_files([rel_path])
885
- except Exception as exc: # pragma: no cover - close failures only logged
886
- logger.warning(
887
- "Failed to close `%s` after multi_attempt: %s", rel_path, exc
888
- )
987
+ if original_content is not None:
988
+ try:
989
+ client.update_file_content(rel_path, original_content)
990
+ except Exception as exc:
991
+ logger.warning(
992
+ "Failed to restore `%s` after multi_attempt: %s", rel_path, exc
993
+ )
994
+
995
+
996
+ @mcp.tool(
997
+ "lean_multi_attempt",
998
+ annotations=ToolAnnotations(
999
+ title="Multi-Attempt",
1000
+ readOnlyHint=True,
1001
+ idempotentHint=True,
1002
+ openWorldHint=False,
1003
+ ),
1004
+ )
1005
+ async def multi_attempt(
1006
+ ctx: Context,
1007
+ file_path: Annotated[str, Field(description="Absolute path to Lean file")],
1008
+ line: Annotated[int, Field(description="Line number (1-indexed)", ge=1)],
1009
+ snippets: Annotated[
1010
+ List[str],
1011
+ Field(description="Tactics to try (3+ recommended)"),
1012
+ ],
1013
+ ) -> MultiAttemptResult:
1014
+ """Try multiple tactics without modifying file. Returns goal state for each."""
1015
+ # Priority 1: REPL
1016
+ result = await _multi_attempt_repl(ctx, file_path, line, snippets)
1017
+ if result is not None:
1018
+ return result
1019
+
1020
+ # Priority 2: LSP approach (fallback)
1021
+ return _multi_attempt_lsp(ctx, file_path, line, snippets)
889
1022
 
890
1023
 
891
1024
  @mcp.tool(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lean-lsp-mcp
3
- Version: 0.19.1
3
+ Version: 0.20.0
4
4
  Summary: Lean Theorem Prover MCP
5
5
  Author-email: Oliver Dressler <hey@oli.show>
6
6
  License-Expression: MIT
@@ -11,6 +11,9 @@ License-File: LICENSE
11
11
  Requires-Dist: leanclient==0.9.2
12
12
  Requires-Dist: mcp[cli]==1.25.0
13
13
  Requires-Dist: orjson>=3.11.1
14
+ Requires-Dist: certifi>=2024.0.0
15
+ Provides-Extra: yaml
16
+ Requires-Dist: PyYAML>=6.0; extra == "yaml"
14
17
  Provides-Extra: lint
15
18
  Requires-Dist: ruff>=0.2.0; extra == "lint"
16
19
  Provides-Extra: dev
@@ -266,8 +269,10 @@ l1c1-l1c6, severity: 3
266
269
 
267
270
  #### lean_multi_attempt
268
271
 
269
- Attempt multiple lean code snippets on a line and return goal state and diagnostics for each snippet.
270
- This tool is useful to screen different proof attempts before using the most promising one.
272
+ Attempt multiple tactics on a line and return goal state and diagnostics for each.
273
+ Useful to screen different proof attempts before committing to one.
274
+
275
+ When `LEAN_REPL=true`, uses the REPL tactic mode for up to 5x faster execution (see [Environment Variables](#environment-variables)).
271
276
 
272
277
  <details>
273
278
  <summary>Example output (attempting `rw [Nat.pow_sub (Fintype.card_pos_of_nonempty S)]` and `by_contra h_neq`)</summary>
@@ -483,7 +488,12 @@ This MCP server works out-of-the-box without any configuration. However, a few o
483
488
  ### Environment Variables
484
489
 
485
490
  - `LEAN_LOG_LEVEL`: Log level for the server. Options are "INFO", "WARNING", "ERROR", "NONE". Defaults to "INFO".
491
+ - `LEAN_LOG_FILE_CONFIG`: Config file path for logging, with priority over `LEAN_LOG_LEVEL`. If not set, logs are printed to stdout.
486
492
  - `LEAN_PROJECT_PATH`: Path to your Lean project root. Set this if the server cannot automatically detect your project.
493
+ - `LEAN_REPL`: Set to `true`, `1`, or `yes` to enable fast REPL-based `lean_multi_attempt` (~5x faster, see [REPL Setup](#repl-setup)).
494
+ - `LEAN_REPL_PATH`: Path to the `repl` binary. Auto-detected from `.lake/packages/repl/` if not set.
495
+ - `LEAN_REPL_TIMEOUT`: Per-command timeout in seconds (default: 60).
496
+ - `LEAN_REPL_MEM_MB`: Max memory per REPL in MB (default: 8192). Only enforced on Linux/macOS.
487
497
  - `LEAN_LSP_MCP_TOKEN`: Secret token for bearer authentication when using `streamable-http` or `sse` transport.
488
498
  - `LEAN_STATE_SEARCH_URL`: URL for a self-hosted [premise-search.com](https://premise-search.com) instance.
489
499
  - `LEAN_HAMMER_URL`: URL for a self-hosted [Lean Hammer Premise Search](https://github.com/hanwenzhu/lean-premise-server) instance.
@@ -544,6 +554,36 @@ uvx lean-lsp-mcp --transport streamable-http
544
554
 
545
555
  Clients should then include the token in the `Authorization` header.
546
556
 
557
+ ### REPL Setup
558
+
559
+ Enable fast REPL-based `lean_multi_attempt` (~5x faster). Uses [leanprover-community/repl](https://github.com/leanprover-community/repl) tactic mode.
560
+
561
+ **1. Add REPL to your Lean project's `lakefile.toml`:**
562
+
563
+ ```toml
564
+ [[require]]
565
+ name = "repl"
566
+ git = "https://github.com/leanprover-community/repl"
567
+ rev = "v4.25.0" # Match your Lean version
568
+ ```
569
+
570
+ **2. Build it:**
571
+
572
+ ```bash
573
+ lake build repl
574
+ ```
575
+
576
+ **3. Enable via CLI or environment variable:**
577
+
578
+ ```bash
579
+ uvx lean-lsp-mcp --repl
580
+
581
+ # Or via environment variable
582
+ export LEAN_REPL=true
583
+ ```
584
+
585
+ The REPL binary is auto-detected from `.lake/packages/repl/`. Falls back to LSP if not found.
586
+
547
587
  ### Local Loogle
548
588
 
549
589
  Run loogle locally to avoid the remote API's rate limit (3 req/30s). First run takes ~5-10 minutes to build; subsequent runs start in seconds.
@@ -590,9 +630,15 @@ uv sync --all-extras
590
630
  uv run pytest tests
591
631
  ```
592
632
 
593
- ## Publications using lean-lsp-mcp
633
+ ## Publications and Formalization Projects using lean-lsp-mcp
594
634
 
595
635
  - Ax-Prover: A Deep Reasoning Agentic Framework for Theorem Proving in Mathematics and Quantum Physics [arxiv](https://arxiv.org/abs/2510.12787)
636
+ - Numina-Lean-Agent: An Open and General Agentic Reasoning System for Formal Mathematics [arxiv](https://arxiv.org/abs/2601.14027) [github](https://github.com/project-numina/numina-lean-agent)
637
+ - A Group-Theoretic Approach to Shannon Capacity of Graphs and a Limit Theorem from Lattice Packings [github](https://github.com/jzuiddam/GroupTheoreticShannonCapacity/)
638
+
639
+ ## Talks
640
+
641
+ lean-lsp-mcp: Tools for agentic interaction with Lean (Lean Together 2026) [youtube](https://www.youtube.com/watch?v=uttbYaTaF-E)
596
642
 
597
643
  ## Related Projects
598
644
 
@@ -0,0 +1,19 @@
1
+ lean_lsp_mcp/__init__.py,sha256=0D0XJqzMsM_4EFOrfXQsRhn_F5n3z_Dzc7_5urTwehs,1855
2
+ lean_lsp_mcp/__main__.py,sha256=XnpTzfJc0T-j9tHtdkA8ovTr1c139ffTewcJGhxYDaM,49
3
+ lean_lsp_mcp/client_utils.py,sha256=HgPuB35rMitn2Xm8SCAErsFLq15trB6VMz3FDFgmPd8,4897
4
+ lean_lsp_mcp/file_utils.py,sha256=kCTYQSfmV-R2cm_NCi_L8W5Dcsm0_rTOPpTtpyAin78,1365
5
+ lean_lsp_mcp/instructions.py,sha256=pAmgg-Lr3-t0f377SQP4ahtfsHAmAbfZ7dXfcQQv-Zg,1985
6
+ lean_lsp_mcp/loogle.py,sha256=PJ_z9InIk-3yJsWmkhVctPOYIbovD7Z9A3vrGuC1Em0,15774
7
+ lean_lsp_mcp/models.py,sha256=dyjM6m36PscEtBiLjGxDxeM5b0ELpRTw1SUayYDeOwA,7549
8
+ lean_lsp_mcp/outline_utils.py,sha256=i7xL27UO2rTT48IdKXkoMq5FVJNxyA3tPuQREOBf_gU,11105
9
+ lean_lsp_mcp/profile_utils.py,sha256=LwtBmgwrywPiNhWS6xK-_QF-FTy0uWkW6NCK5l5rCQI,8144
10
+ lean_lsp_mcp/repl.py,sha256=5a3JrfwU6lvZaPfsCzdCqTm2LrbGoXOdh9ov6RVkf84,9259
11
+ lean_lsp_mcp/search_utils.py,sha256=MLqKGe4bhEvyfFLIBCmiDxkbcH4O5J3vl9mWnRSb_v0,6801
12
+ lean_lsp_mcp/server.py,sha256=kXzKrmfuoaLwZy8SHQoILhaXbf2e2OoyEJi1G5lswzM,50051
13
+ lean_lsp_mcp/utils.py,sha256=dGv84a4E-szOkQVYtSE-q9GbawpiVk47qvrkTN-Clts,13478
14
+ lean_lsp_mcp-0.20.0.dist-info/licenses/LICENSE,sha256=CQlxnf0tQyoVrBE93JYvAUYxv6Z5Yg6sX0pwogOkFvo,1071
15
+ lean_lsp_mcp-0.20.0.dist-info/METADATA,sha256=apVisDrqIP-TAGv0bJKnssn4gikRt9la2Pcq_-A7Tzg,23168
16
+ lean_lsp_mcp-0.20.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
17
+ lean_lsp_mcp-0.20.0.dist-info/entry_points.txt,sha256=nQbvwctWkWD7I-2f4VrdVQBZYGUw8CnUnFC6QjXxOSE,51
18
+ lean_lsp_mcp-0.20.0.dist-info/top_level.txt,sha256=LGEK0lgMSNPIQ6mG8EO-adaZEGPi_0daDs004epOTF0,13
19
+ lean_lsp_mcp-0.20.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,18 +0,0 @@
1
- lean_lsp_mcp/__init__.py,sha256=MN_bNFyb5-p33JWWGbrlUYBd1UUMQKtZYGC9KCh2mtM,1403
2
- lean_lsp_mcp/__main__.py,sha256=XnpTzfJc0T-j9tHtdkA8ovTr1c139ffTewcJGhxYDaM,49
3
- lean_lsp_mcp/client_utils.py,sha256=HgPuB35rMitn2Xm8SCAErsFLq15trB6VMz3FDFgmPd8,4897
4
- lean_lsp_mcp/file_utils.py,sha256=kCTYQSfmV-R2cm_NCi_L8W5Dcsm0_rTOPpTtpyAin78,1365
5
- lean_lsp_mcp/instructions.py,sha256=HgENbIe1nwIzKXqf_yYC44f7YQ8j47zJgKBYmmaD_UI,1994
6
- lean_lsp_mcp/loogle.py,sha256=zUgnDWoTIqa4G6GXStAIxxJUR545YbU8Z-8KMjddKV0,15500
7
- lean_lsp_mcp/models.py,sha256=dyjM6m36PscEtBiLjGxDxeM5b0ELpRTw1SUayYDeOwA,7549
8
- lean_lsp_mcp/outline_utils.py,sha256=i7xL27UO2rTT48IdKXkoMq5FVJNxyA3tPuQREOBf_gU,11105
9
- lean_lsp_mcp/profile_utils.py,sha256=LwtBmgwrywPiNhWS6xK-_QF-FTy0uWkW6NCK5l5rCQI,8144
10
- lean_lsp_mcp/search_utils.py,sha256=MLqKGe4bhEvyfFLIBCmiDxkbcH4O5J3vl9mWnRSb_v0,6801
11
- lean_lsp_mcp/server.py,sha256=VFdyRxHJsBM0sehHyvEkdG17HQl78MYgBgH8usBkpUU,45430
12
- lean_lsp_mcp/utils.py,sha256=dGv84a4E-szOkQVYtSE-q9GbawpiVk47qvrkTN-Clts,13478
13
- lean_lsp_mcp-0.19.1.dist-info/licenses/LICENSE,sha256=CQlxnf0tQyoVrBE93JYvAUYxv6Z5Yg6sX0pwogOkFvo,1071
14
- lean_lsp_mcp-0.19.1.dist-info/METADATA,sha256=D2CgfgVE_jKV0Lnz3TDlbCgOdywCk48WXP1Bp9xLZNs,21274
15
- lean_lsp_mcp-0.19.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
16
- lean_lsp_mcp-0.19.1.dist-info/entry_points.txt,sha256=nQbvwctWkWD7I-2f4VrdVQBZYGUw8CnUnFC6QjXxOSE,51
17
- lean_lsp_mcp-0.19.1.dist-info/top_level.txt,sha256=LGEK0lgMSNPIQ6mG8EO-adaZEGPi_0daDs004epOTF0,13
18
- lean_lsp_mcp-0.19.1.dist-info/RECORD,,