lean-lsp-mcp 0.14.1__py3-none-any.whl → 0.16.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lean_lsp_mcp/__init__.py CHANGED
@@ -1,4 +1,5 @@
1
1
  import argparse
2
+ import os
2
3
 
3
4
  from lean_lsp_mcp.server import mcp
4
5
 
@@ -24,7 +25,25 @@ def main():
24
25
  default=8000,
25
26
  help="Host port for transport",
26
27
  )
28
+ parser.add_argument(
29
+ "--loogle-local",
30
+ action="store_true",
31
+ help="Enable local loogle (auto-installs on first run, ~5-10 min). "
32
+ "Avoids rate limits and network dependencies.",
33
+ )
34
+ parser.add_argument(
35
+ "--loogle-cache-dir",
36
+ type=str,
37
+ help="Override loogle cache location (default: ~/.cache/lean-lsp-mcp/loogle)",
38
+ )
27
39
  args = parser.parse_args()
40
+
41
+ # Set env vars from CLI args (CLI takes precedence over env vars)
42
+ if args.loogle_local:
43
+ os.environ["LEAN_LOOGLE_LOCAL"] = "true"
44
+ if args.loogle_cache_dir:
45
+ os.environ["LEAN_LOOGLE_CACHE_DIR"] = args.loogle_cache_dir
46
+
28
47
  mcp.settings.host = args.host
29
48
  mcp.settings.port = args.port
30
49
  mcp.run(transport=args.transport)
@@ -1,17 +1,36 @@
1
1
  INSTRUCTIONS = """## General Rules
2
2
  - All line and column numbers are 1-indexed.
3
- - Always analyze/search context before each file edit.
4
- - This MCP does NOT make permanent file changes. Use other tools for editing.
5
- - Work iteratively: Small steps, intermediate sorries, frequent checks.
3
+ - This MCP does NOT edit files. Use other tools for editing.
6
4
 
7
5
  ## Key Tools
8
- - lean_file_outline: Concise skeleton of a file (imports, docstrings, declarations). Token efficient.
9
- - lean_local_search: Confirm declarations (theorems/lemmas/defs/etc.) exist. VERY USEFUL AND FAST!
10
- - lean_goal: Check proof state. USE OFTEN!
11
- - lean_diagnostic_messages: Understand current proof situation.
12
- - lean_hover_info: Documentation about terms and lean syntax.
13
- - lean_leansearch: Search theorems using natural language or Lean terms.
14
- - lean_loogle: Search definitions and theorems by name, type, or subexpression.
15
- - lean_leanfinder: Semantic search for theorems using Lean Finder.
16
- - lean_state_search: Search theorems using goal-based search.
6
+ - **lean_goal**: Proof state at position. Omit `column` for before/after. "no goals" = done!
7
+ - **lean_diagnostic_messages**: Compiler errors/warnings. "no goals to be solved" = remove tactics.
8
+ - **lean_hover_info**: Type signature + docs. Column at START of identifier.
9
+ - **lean_completions**: IDE autocomplete on incomplete code.
10
+ - **lean_local_search**: Fast local declaration search. Use BEFORE trying a lemma name.
11
+ - **lean_file_outline**: Token-efficient file skeleton (slow-ish).
12
+ - **lean_multi_attempt**: Test tactics without editing: `["simp", "ring", "omega"]`
13
+ - **lean_declaration_file**: Get declaration source. Use sparingly (large output).
14
+ - **lean_run_code**: Run standalone snippet. Use rarely.
15
+ - **lean_build**: Rebuild + restart LSP. Only if needed (new imports). SLOW!
16
+
17
+ ## Search Tools (rate limited)
18
+ - **lean_leansearch** (3/30s): Natural language → mathlib
19
+ - **lean_loogle** (3/30s): Type pattern → mathlib
20
+ - **lean_leanfinder** (10/30s): Semantic/conceptual search
21
+ - **lean_state_search** (3/30s): Goal → closing lemmas
22
+ - **lean_hammer_premise** (3/30s): Goal → premises for simp/aesop
23
+
24
+ ## Search Decision Tree
25
+ 1. "Does X exist locally?" → lean_local_search
26
+ 2. "I need a lemma that says X" → lean_leansearch
27
+ 3. "Find lemma with type pattern" → lean_loogle
28
+ 4. "What's the Lean name for concept X?" → lean_leanfinder
29
+ 5. "What closes this goal?" → lean_state_search
30
+ 6. "What to feed simp?" → lean_hammer_premise
31
+
32
+ After finding a name: lean_local_search to verify, lean_hover_info for signature.
33
+
34
+ ## Return Formats
35
+ List tools return JSON arrays. Empty = `[]`.
17
36
  """
lean_lsp_mcp/loogle.py ADDED
@@ -0,0 +1,329 @@
1
+ """Loogle search - local subprocess and remote API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import os
9
+ import shutil
10
+ import subprocess
11
+ import urllib.parse
12
+ import urllib.request
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import orjson
17
+
18
+ from lean_lsp_mcp.models import LoogleResult
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def get_cache_dir() -> Path:
24
+ if d := os.environ.get("LEAN_LOOGLE_CACHE_DIR"):
25
+ return Path(d)
26
+ xdg = os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")
27
+ return Path(xdg) / "lean-lsp-mcp" / "loogle"
28
+
29
+
30
+ def loogle_remote(query: str, num_results: int) -> list[LoogleResult] | str:
31
+ """Query the remote loogle API."""
32
+ try:
33
+ req = urllib.request.Request(
34
+ f"https://loogle.lean-lang.org/json?q={urllib.parse.quote(query)}",
35
+ headers={"User-Agent": "lean-lsp-mcp/0.1"},
36
+ )
37
+ with urllib.request.urlopen(req, timeout=20) as response:
38
+ results = orjson.loads(response.read())
39
+ if "hits" not in results:
40
+ return "No results found."
41
+ hits = results["hits"][:num_results]
42
+ return [
43
+ LoogleResult(
44
+ name=r.get("name", ""),
45
+ type=r.get("type", ""),
46
+ module=r.get("module", ""),
47
+ )
48
+ for r in hits
49
+ ]
50
+ except Exception as e:
51
+ return f"loogle error:\n{e}"
52
+
53
+
54
+ class LoogleManager:
55
+ """Manages local loogle installation and async subprocess."""
56
+
57
+ REPO_URL = "https://github.com/nomeata/loogle.git"
58
+ READY_SIGNAL = "Loogle is ready."
59
+
60
+ def __init__(self, cache_dir: Path | None = None):
61
+ self.cache_dir = cache_dir or get_cache_dir()
62
+ self.repo_dir = self.cache_dir / "repo"
63
+ self.index_dir = self.cache_dir / "index"
64
+ self.process: asyncio.subprocess.Process | None = None
65
+ self._ready = False
66
+ self._lock = asyncio.Lock()
67
+
68
+ @property
69
+ def binary_path(self) -> Path:
70
+ return self.repo_dir / ".lake" / "build" / "bin" / "loogle"
71
+
72
+ @property
73
+ def is_installed(self) -> bool:
74
+ return self.binary_path.exists()
75
+
76
+ @property
77
+ def is_running(self) -> bool:
78
+ return (
79
+ self._ready and self.process is not None and self.process.returncode is None
80
+ )
81
+
82
+ def _check_prerequisites(self) -> tuple[bool, str]:
83
+ if not shutil.which("git"):
84
+ return False, "git not found in PATH"
85
+ if not shutil.which("lake"):
86
+ return (
87
+ False,
88
+ "lake not found (install elan: https://github.com/leanprover/elan)",
89
+ )
90
+ return True, ""
91
+
92
+ def _run(
93
+ self, cmd: list[str], timeout: int = 300, cwd: Path | None = None
94
+ ) -> subprocess.CompletedProcess:
95
+ env = os.environ.copy()
96
+ env["LAKE_ARTIFACT_CACHE"] = "false"
97
+ return subprocess.run(
98
+ cmd,
99
+ capture_output=True,
100
+ text=True,
101
+ timeout=timeout,
102
+ cwd=cwd or self.repo_dir,
103
+ env=env,
104
+ )
105
+
106
+ def _clone_repo(self) -> bool:
107
+ if self.repo_dir.exists():
108
+ return True
109
+ logger.info(f"Cloning loogle to {self.repo_dir}...")
110
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
111
+ try:
112
+ r = self._run(
113
+ ["git", "clone", "--depth", "1", self.REPO_URL, str(self.repo_dir)],
114
+ cwd=self.cache_dir,
115
+ )
116
+ if r.returncode != 0:
117
+ logger.error(f"Clone failed: {r.stderr}")
118
+ return False
119
+ return True
120
+ except Exception as e:
121
+ logger.error(f"Clone error: {e}")
122
+ return False
123
+
124
+ def _build_loogle(self) -> bool:
125
+ if self.is_installed:
126
+ return True
127
+ if not self.repo_dir.exists():
128
+ return False
129
+ logger.info("Downloading mathlib cache...")
130
+ try:
131
+ self._run(["lake", "exe", "cache", "get"], timeout=600)
132
+ except Exception as e:
133
+ logger.warning(f"Cache download: {e}")
134
+ logger.info("Building loogle...")
135
+ try:
136
+ return self._run(["lake", "build"], timeout=900).returncode == 0
137
+ except Exception as e:
138
+ logger.error(f"Build error: {e}")
139
+ return False
140
+
141
+ def _get_mathlib_version(self) -> str:
142
+ try:
143
+ manifest = json.loads((self.repo_dir / "lake-manifest.json").read_text())
144
+ for pkg in manifest.get("packages", []):
145
+ if pkg.get("name") == "mathlib":
146
+ return pkg.get("rev", "unknown")[:12]
147
+ except Exception:
148
+ pass
149
+ return "unknown"
150
+
151
+ def _get_toolchain_version(self) -> str | None:
152
+ """Get the Lean toolchain version from lean-toolchain file."""
153
+ try:
154
+ return (self.repo_dir / "lean-toolchain").read_text().strip()
155
+ except Exception:
156
+ return None
157
+
158
+ def _check_toolchain_installed(self) -> tuple[bool, str]:
159
+ """Check if the required Lean toolchain is installed."""
160
+ tc = self._get_toolchain_version()
161
+ if not tc:
162
+ return True, "" # Can't check without lean-toolchain file
163
+ # Convert lean-toolchain format to elan directory name
164
+ # e.g., "leanprover/lean4:v4.25.0-rc1" -> "leanprover--lean4---v4.25.0-rc1"
165
+ tc_dir_name = tc.replace("/", "--").replace(":", "---")
166
+ elan_home = Path(os.environ.get("ELAN_HOME", Path.home() / ".elan"))
167
+ tc_path = elan_home / "toolchains" / tc_dir_name
168
+ if not tc_path.exists():
169
+ return False, (
170
+ f"Toolchain '{tc}' not installed. "
171
+ f"Run: cd {self.repo_dir} && lake update"
172
+ )
173
+ return True, ""
174
+
175
+ def check_environment(self) -> tuple[bool, str]:
176
+ """Check if the loogle environment is valid. Returns (ok, error_msg)."""
177
+ if not self.is_installed:
178
+ return False, "Loogle binary not found"
179
+ ok, err = self._check_toolchain_installed()
180
+ if not ok:
181
+ return False, err
182
+ return True, ""
183
+
184
+ def _get_index_path(self) -> Path:
185
+ return self.index_dir / f"mathlib-{self._get_mathlib_version()}.idx"
186
+
187
+ def _cleanup_old_indices(self) -> None:
188
+ """Remove old index files from previous mathlib versions."""
189
+ if not self.index_dir.exists():
190
+ return
191
+ current = self._get_index_path()
192
+ for idx in self.index_dir.glob("*.idx"):
193
+ if idx != current:
194
+ try:
195
+ idx.unlink()
196
+ logger.info(f"Removed old index: {idx.name}")
197
+ except Exception:
198
+ pass
199
+
200
+ def _build_index(self) -> Path | None:
201
+ index_path = self._get_index_path()
202
+ if index_path.exists():
203
+ return index_path
204
+ if not self.is_installed:
205
+ return None
206
+ self.index_dir.mkdir(parents=True, exist_ok=True)
207
+ self._cleanup_old_indices()
208
+ logger.info("Building search index...")
209
+ try:
210
+ self._run(
211
+ [str(self.binary_path), "--write-index", str(index_path), "--json", ""],
212
+ timeout=600,
213
+ )
214
+ return index_path if index_path.exists() else None
215
+ except Exception as e:
216
+ logger.error(f"Index build error: {e}")
217
+ return None
218
+
219
+ def ensure_installed(self) -> bool:
220
+ ok, err = self._check_prerequisites()
221
+ if not ok:
222
+ logger.warning(f"Prerequisites: {err}")
223
+ return False
224
+ if not self._clone_repo() or not self._build_loogle():
225
+ return False
226
+ if not self._build_index():
227
+ logger.warning("Index build failed, loogle will build on startup")
228
+ return self.is_installed
229
+
230
+ async def start(self) -> bool:
231
+ if self.process is not None and self.process.returncode is None:
232
+ return self._ready
233
+ ok, err = self.check_environment()
234
+ if not ok:
235
+ logger.error(f"Loogle environment check failed: {err}")
236
+ return False
237
+ cmd = [str(self.binary_path), "--json", "--interactive"]
238
+ if (idx := self._get_index_path()).exists():
239
+ cmd.extend(["--read-index", str(idx)])
240
+ logger.info("Starting loogle subprocess...")
241
+ try:
242
+ self.process = await asyncio.create_subprocess_exec(
243
+ *cmd,
244
+ stdin=asyncio.subprocess.PIPE,
245
+ stdout=asyncio.subprocess.PIPE,
246
+ stderr=asyncio.subprocess.PIPE,
247
+ cwd=self.repo_dir,
248
+ )
249
+ line = await asyncio.wait_for(self.process.stdout.readline(), timeout=120)
250
+ decoded = line.decode()
251
+ if self.READY_SIGNAL in decoded:
252
+ self._ready = True
253
+ logger.info("Loogle ready")
254
+ return True
255
+ # Check stderr for error messages
256
+ try:
257
+ stderr_data = await asyncio.wait_for(
258
+ self.process.stderr.read(), timeout=1
259
+ )
260
+ if stderr_data:
261
+ logger.error(f"Loogle stderr: {stderr_data.decode().strip()}")
262
+ except asyncio.TimeoutError:
263
+ pass
264
+ logger.error(f"Loogle failed to start. stdout: {decoded.strip()}")
265
+ return False
266
+ except asyncio.TimeoutError:
267
+ logger.error("Loogle startup timeout")
268
+ return False
269
+ except Exception as e:
270
+ logger.error(f"Start failed: {e}")
271
+ return False
272
+
273
+ async def query(self, q: str, num_results: int = 8) -> list[dict[str, Any]]:
274
+ async with self._lock:
275
+ # Try up to 2 attempts (initial + one restart)
276
+ for attempt in range(2):
277
+ if (
278
+ not self._ready
279
+ or self.process is None
280
+ or self.process.returncode is not None
281
+ ):
282
+ if attempt > 0:
283
+ raise RuntimeError("Loogle subprocess not ready")
284
+ self._ready = False
285
+ if not await self.start():
286
+ raise RuntimeError("Failed to start loogle")
287
+ continue
288
+
289
+ try:
290
+ self.process.stdin.write(f"{q}\n".encode())
291
+ await self.process.stdin.drain()
292
+ line = await asyncio.wait_for(
293
+ self.process.stdout.readline(), timeout=30
294
+ )
295
+ response = json.loads(line.decode())
296
+ if err := response.get("error"):
297
+ logger.warning(f"Query error: {err}")
298
+ return []
299
+ return [
300
+ {
301
+ "name": h.get("name", ""),
302
+ "type": h.get("type", ""),
303
+ "module": h.get("module", ""),
304
+ "doc": h.get("doc"),
305
+ }
306
+ for h in response.get("hits", [])[:num_results]
307
+ ]
308
+ except asyncio.TimeoutError:
309
+ raise RuntimeError("Query timeout") from None
310
+ except json.JSONDecodeError as e:
311
+ raise RuntimeError(f"Invalid response: {e}") from e
312
+
313
+ raise RuntimeError("Loogle subprocess not ready")
314
+
315
+ async def stop(self) -> None:
316
+ if self.process:
317
+ try:
318
+ self.process.terminate()
319
+ await asyncio.wait_for(self.process.wait(), timeout=5)
320
+ except asyncio.TimeoutError:
321
+ self.process.kill()
322
+ try:
323
+ await asyncio.wait_for(self.process.wait(), timeout=2)
324
+ except asyncio.TimeoutError:
325
+ pass
326
+ except Exception:
327
+ pass
328
+ self.process = None
329
+ self._ready = False
lean_lsp_mcp/models.py ADDED
@@ -0,0 +1,120 @@
1
+ """Pydantic models for MCP tool structured outputs."""
2
+
3
+ from typing import List, Optional
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ class LocalSearchResult(BaseModel):
8
+ name: str = Field(description="Declaration name")
9
+ kind: str = Field(description="Declaration kind (theorem, def, class, etc.)")
10
+ file: str = Field(description="Relative file path")
11
+
12
+
13
+ class LeanSearchResult(BaseModel):
14
+ name: str = Field(description="Full qualified name")
15
+ module_name: str = Field(description="Module where declared")
16
+ kind: Optional[str] = Field(None, description="Declaration kind")
17
+ type: Optional[str] = Field(None, description="Type signature")
18
+
19
+
20
+ class LoogleResult(BaseModel):
21
+ name: str = Field(description="Declaration name")
22
+ type: str = Field(description="Type signature")
23
+ module: str = Field(description="Module where declared")
24
+
25
+
26
+ class LeanFinderResult(BaseModel):
27
+ full_name: str = Field(description="Full qualified name")
28
+ formal_statement: str = Field(description="Lean type signature")
29
+ informal_statement: str = Field(description="Natural language description")
30
+
31
+
32
+ class StateSearchResult(BaseModel):
33
+ name: str = Field(description="Theorem/lemma name")
34
+
35
+
36
+ class PremiseResult(BaseModel):
37
+ name: str = Field(description="Premise name for simp/omega/aesop")
38
+
39
+
40
+ class DiagnosticMessage(BaseModel):
41
+ severity: str = Field(description="error, warning, info, or hint")
42
+ message: str = Field(description="Diagnostic message text")
43
+ line: int = Field(description="Line (1-indexed)")
44
+ column: int = Field(description="Column (1-indexed)")
45
+
46
+
47
+ class GoalState(BaseModel):
48
+ line_context: str = Field(description="Source line where goals were queried")
49
+ goals: str = Field(description="Goal state (before→after if column omitted)")
50
+
51
+
52
+ class CompletionItem(BaseModel):
53
+ label: str = Field(description="Completion text to insert")
54
+ kind: Optional[str] = Field(
55
+ None, description="Completion kind (function, variable, etc.)"
56
+ )
57
+ detail: Optional[str] = Field(None, description="Additional detail")
58
+
59
+
60
+ class HoverInfo(BaseModel):
61
+ symbol: str = Field(description="The symbol being hovered")
62
+ info: str = Field(description="Type signature and documentation")
63
+ diagnostics: List[DiagnosticMessage] = Field(
64
+ default_factory=list, description="Diagnostics at this position"
65
+ )
66
+
67
+
68
+ class TermGoalState(BaseModel):
69
+ line_context: str = Field(description="Source line where term goal was queried")
70
+ expected_type: Optional[str] = Field(
71
+ None, description="Expected type at this position"
72
+ )
73
+
74
+
75
+ class OutlineEntry(BaseModel):
76
+ name: str = Field(description="Declaration name")
77
+ kind: str = Field(description="Declaration kind (Thm, Def, Class, Struct, Ns, Ex)")
78
+ start_line: int = Field(description="Start line (1-indexed)")
79
+ end_line: int = Field(description="End line (1-indexed)")
80
+ type_signature: Optional[str] = Field(
81
+ None, description="Type signature if available"
82
+ )
83
+ children: List["OutlineEntry"] = Field(
84
+ default_factory=list, description="Nested declarations"
85
+ )
86
+
87
+
88
+ class FileOutline(BaseModel):
89
+ imports: List[str] = Field(default_factory=list, description="Import statements")
90
+ declarations: List[OutlineEntry] = Field(
91
+ default_factory=list, description="Top-level declarations"
92
+ )
93
+
94
+
95
+ class AttemptResult(BaseModel):
96
+ snippet: str = Field(description="Code snippet that was tried")
97
+ goal_state: Optional[str] = Field(
98
+ None, description="Goal state after applying snippet"
99
+ )
100
+ diagnostics: List[DiagnosticMessage] = Field(
101
+ default_factory=list, description="Diagnostics for this attempt"
102
+ )
103
+
104
+
105
+ class BuildResult(BaseModel):
106
+ success: bool = Field(description="Whether build succeeded")
107
+ output: str = Field(description="Build output")
108
+ errors: List[str] = Field(default_factory=list, description="Build errors if any")
109
+
110
+
111
+ class RunResult(BaseModel):
112
+ success: bool = Field(description="Whether code compiled successfully")
113
+ diagnostics: List[DiagnosticMessage] = Field(
114
+ default_factory=list, description="Compiler diagnostics"
115
+ )
116
+
117
+
118
+ class DeclarationInfo(BaseModel):
119
+ file_path: str = Field(description="Path to declaration file")
120
+ content: str = Field(description="File content")