lean-lsp-mcp 0.15.0__tar.gz → 0.16.0__tar.gz

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.
Files changed (32) hide show
  1. {lean_lsp_mcp-0.15.0/src/lean_lsp_mcp.egg-info → lean_lsp_mcp-0.16.0}/PKG-INFO +2 -1
  2. {lean_lsp_mcp-0.15.0 → lean_lsp_mcp-0.16.0}/pyproject.toml +2 -2
  3. lean_lsp_mcp-0.16.0/src/lean_lsp_mcp/instructions.py +36 -0
  4. {lean_lsp_mcp-0.15.0 → lean_lsp_mcp-0.16.0}/src/lean_lsp_mcp/loogle.py +61 -8
  5. lean_lsp_mcp-0.16.0/src/lean_lsp_mcp/models.py +120 -0
  6. {lean_lsp_mcp-0.15.0 → lean_lsp_mcp-0.16.0}/src/lean_lsp_mcp/outline_utils.py +93 -0
  7. lean_lsp_mcp-0.16.0/src/lean_lsp_mcp/server.py +1175 -0
  8. {lean_lsp_mcp-0.15.0 → lean_lsp_mcp-0.16.0}/src/lean_lsp_mcp/utils.py +31 -0
  9. {lean_lsp_mcp-0.15.0 → lean_lsp_mcp-0.16.0/src/lean_lsp_mcp.egg-info}/PKG-INFO +2 -1
  10. {lean_lsp_mcp-0.15.0 → lean_lsp_mcp-0.16.0}/src/lean_lsp_mcp.egg-info/SOURCES.txt +1 -0
  11. {lean_lsp_mcp-0.15.0 → lean_lsp_mcp-0.16.0}/src/lean_lsp_mcp.egg-info/requires.txt +1 -0
  12. {lean_lsp_mcp-0.15.0 → lean_lsp_mcp-0.16.0}/tests/test_diagnostic_line_range.py +18 -20
  13. {lean_lsp_mcp-0.15.0 → lean_lsp_mcp-0.16.0}/tests/test_outline.py +43 -23
  14. {lean_lsp_mcp-0.15.0 → lean_lsp_mcp-0.16.0}/tests/test_search_tools.py +19 -4
  15. lean_lsp_mcp-0.15.0/src/lean_lsp_mcp/instructions.py +0 -17
  16. lean_lsp_mcp-0.15.0/src/lean_lsp_mcp/server.py +0 -1054
  17. {lean_lsp_mcp-0.15.0 → lean_lsp_mcp-0.16.0}/LICENSE +0 -0
  18. {lean_lsp_mcp-0.15.0 → lean_lsp_mcp-0.16.0}/README.md +0 -0
  19. {lean_lsp_mcp-0.15.0 → lean_lsp_mcp-0.16.0}/setup.cfg +0 -0
  20. {lean_lsp_mcp-0.15.0 → lean_lsp_mcp-0.16.0}/src/lean_lsp_mcp/__init__.py +0 -0
  21. {lean_lsp_mcp-0.15.0 → lean_lsp_mcp-0.16.0}/src/lean_lsp_mcp/__main__.py +0 -0
  22. {lean_lsp_mcp-0.15.0 → lean_lsp_mcp-0.16.0}/src/lean_lsp_mcp/client_utils.py +0 -0
  23. {lean_lsp_mcp-0.15.0 → lean_lsp_mcp-0.16.0}/src/lean_lsp_mcp/file_utils.py +0 -0
  24. {lean_lsp_mcp-0.15.0 → lean_lsp_mcp-0.16.0}/src/lean_lsp_mcp/search_utils.py +0 -0
  25. {lean_lsp_mcp-0.15.0 → lean_lsp_mcp-0.16.0}/src/lean_lsp_mcp.egg-info/dependency_links.txt +0 -0
  26. {lean_lsp_mcp-0.15.0 → lean_lsp_mcp-0.16.0}/src/lean_lsp_mcp.egg-info/entry_points.txt +0 -0
  27. {lean_lsp_mcp-0.15.0 → lean_lsp_mcp-0.16.0}/src/lean_lsp_mcp.egg-info/top_level.txt +0 -0
  28. {lean_lsp_mcp-0.15.0 → lean_lsp_mcp-0.16.0}/tests/test_editor_tools.py +0 -0
  29. {lean_lsp_mcp-0.15.0 → lean_lsp_mcp-0.16.0}/tests/test_file_caching.py +0 -0
  30. {lean_lsp_mcp-0.15.0 → lean_lsp_mcp-0.16.0}/tests/test_logging.py +0 -0
  31. {lean_lsp_mcp-0.15.0 → lean_lsp_mcp-0.16.0}/tests/test_misc_tools.py +0 -0
  32. {lean_lsp_mcp-0.15.0 → lean_lsp_mcp-0.16.0}/tests/test_project_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lean-lsp-mcp
3
- Version: 0.15.0
3
+ Version: 0.16.0
4
4
  Summary: Lean Theorem Prover MCP
5
5
  Author-email: Oliver Dressler <hey@oli.show>
6
6
  License-Expression: MIT
@@ -10,6 +10,7 @@ Description-Content-Type: text/markdown
10
10
  License-File: LICENSE
11
11
  Requires-Dist: leanclient==0.6.1
12
12
  Requires-Dist: mcp[cli]==1.23.1
13
+ Requires-Dist: mcp[cli]>=1.22.0
13
14
  Requires-Dist: orjson>=3.11.1
14
15
  Provides-Extra: lint
15
16
  Requires-Dist: ruff>=0.2.0; extra == "lint"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lean-lsp-mcp"
3
- version = "0.15.0"
3
+ version = "0.16.0"
4
4
  description = "Lean Theorem Prover MCP"
5
5
  authors = [{name="Oliver Dressler", email="hey@oli.show"}]
6
6
  readme = "README.md"
@@ -8,7 +8,7 @@ requires-python = ">=3.10"
8
8
  license = "MIT"
9
9
  dependencies = [
10
10
  "leanclient==0.6.1",
11
- "mcp[cli]==1.23.1",
11
+ "mcp[cli]==1.23.1", "mcp[cli]>=1.22.0",
12
12
  "orjson>=3.11.1",
13
13
  ]
14
14
 
@@ -0,0 +1,36 @@
1
+ INSTRUCTIONS = """## General Rules
2
+ - All line and column numbers are 1-indexed.
3
+ - This MCP does NOT edit files. Use other tools for editing.
4
+
5
+ ## Key Tools
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 = `[]`.
36
+ """
@@ -15,6 +15,8 @@ from typing import Any
15
15
 
16
16
  import orjson
17
17
 
18
+ from lean_lsp_mcp.models import LoogleResult
19
+
18
20
  logger = logging.getLogger(__name__)
19
21
 
20
22
 
@@ -25,7 +27,7 @@ def get_cache_dir() -> Path:
25
27
  return Path(xdg) / "lean-lsp-mcp" / "loogle"
26
28
 
27
29
 
28
- def loogle_remote(query: str, num_results: int) -> list[dict] | str:
30
+ def loogle_remote(query: str, num_results: int) -> list[LoogleResult] | str:
29
31
  """Query the remote loogle API."""
30
32
  try:
31
33
  req = urllib.request.Request(
@@ -36,10 +38,15 @@ def loogle_remote(query: str, num_results: int) -> list[dict] | str:
36
38
  results = orjson.loads(response.read())
37
39
  if "hits" not in results:
38
40
  return "No results found."
39
- results = results["hits"][:num_results]
40
- for r in results:
41
- r.pop("doc", None)
42
- return results
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
+ ]
43
50
  except Exception as e:
44
51
  return f"loogle error:\n{e}"
45
52
 
@@ -141,6 +148,39 @@ class LoogleManager:
141
148
  pass
142
149
  return "unknown"
143
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
+
144
184
  def _get_index_path(self) -> Path:
145
185
  return self.index_dir / f"mathlib-{self._get_mathlib_version()}.idx"
146
186
 
@@ -190,7 +230,9 @@ class LoogleManager:
190
230
  async def start(self) -> bool:
191
231
  if self.process is not None and self.process.returncode is None:
192
232
  return self._ready
193
- if not self.is_installed:
233
+ ok, err = self.check_environment()
234
+ if not ok:
235
+ logger.error(f"Loogle environment check failed: {err}")
194
236
  return False
195
237
  cmd = [str(self.binary_path), "--json", "--interactive"]
196
238
  if (idx := self._get_index_path()).exists():
@@ -201,14 +243,25 @@ class LoogleManager:
201
243
  *cmd,
202
244
  stdin=asyncio.subprocess.PIPE,
203
245
  stdout=asyncio.subprocess.PIPE,
204
- stderr=asyncio.subprocess.DEVNULL,
246
+ stderr=asyncio.subprocess.PIPE,
205
247
  cwd=self.repo_dir,
206
248
  )
207
249
  line = await asyncio.wait_for(self.process.stdout.readline(), timeout=120)
208
- if self.READY_SIGNAL in line.decode():
250
+ decoded = line.decode()
251
+ if self.READY_SIGNAL in decoded:
209
252
  self._ready = True
210
253
  logger.info("Loogle ready")
211
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()}")
212
265
  return False
213
266
  except asyncio.TimeoutError:
214
267
  logger.error("Loogle startup timeout")
@@ -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")
@@ -3,6 +3,8 @@ from typing import Dict, List, Optional, Tuple
3
3
  from leanclient import LeanLSPClient
4
4
  from leanclient.utils import DocumentContentChange
5
5
 
6
+ from lean_lsp_mcp.models import FileOutline, OutlineEntry
7
+
6
8
 
7
9
  METHOD_KIND = {6, "method"}
8
10
  KIND_TAGS = {"namespace": "Ns"}
@@ -181,6 +183,97 @@ def _format_symbol(sym: Dict, type_sigs: Dict, fields_map: Dict, indent: int) ->
181
183
  return result + "\n"
182
184
 
183
185
 
186
+ def _build_outline_entry(
187
+ sym: Dict, type_sigs: Dict, fields_map: Dict, indent: int
188
+ ) -> Optional[OutlineEntry]:
189
+ """Build a structured outline entry for a symbol."""
190
+ name = sym["name"]
191
+ type_sig = sym.get("_type") or type_sigs.get(name, "")
192
+ fields = fields_map.get(name, [])
193
+
194
+ tag = _detect_tag(
195
+ name, sym.get("kind", ""), type_sig, bool(fields), sym.get("_keyword")
196
+ )
197
+ start = sym["range"]["start"]["line"] + 1
198
+ end = sym["range"]["end"]["line"] + 1
199
+
200
+ # Add fields as children for structs/classes
201
+ children = [
202
+ OutlineEntry(
203
+ name=fname,
204
+ kind="field",
205
+ start_line=start,
206
+ end_line=start,
207
+ type_signature=ftype,
208
+ children=[],
209
+ )
210
+ for fname, ftype in fields
211
+ ]
212
+
213
+ return OutlineEntry(
214
+ name=name,
215
+ kind=tag,
216
+ start_line=start,
217
+ end_line=end,
218
+ type_signature=type_sig if type_sig else None,
219
+ children=children,
220
+ )
221
+
222
+
223
+ def generate_outline_data(client: LeanLSPClient, path: str) -> FileOutline:
224
+ """Generate structured outline data for a Lean file."""
225
+ client.open_file(path)
226
+ content = client.get_file_content(path)
227
+
228
+ # Extract imports
229
+ imports = [
230
+ line.strip()[7:]
231
+ for line in content.splitlines()
232
+ if line.strip().startswith("import ")
233
+ ]
234
+
235
+ symbols = client.get_document_symbols(path)
236
+ if not symbols and not imports:
237
+ return FileOutline(imports=[], declarations=[])
238
+
239
+ # Flatten symbol tree and extract namespace declarations
240
+ all_symbols = _flatten_symbols(symbols, content=content)
241
+
242
+ # Get info trees only for LSP symbols (not extracted declarations)
243
+ lsp_methods = [
244
+ s
245
+ for s, _ in all_symbols
246
+ if s.get("kind") in METHOD_KIND and "_keyword" not in s
247
+ ]
248
+ info_trees = _get_info_trees(client, path, lsp_methods)
249
+
250
+ # Extract type signatures and fields from info trees
251
+ type_sigs = {
252
+ name: sig
253
+ for name, info in info_trees.items()
254
+ if (sig := _extract_type(info, name))
255
+ }
256
+ fields_map = {
257
+ name: fields
258
+ for name, info in info_trees.items()
259
+ if (fields := _extract_fields(info, name))
260
+ }
261
+
262
+ # Build declarations list
263
+ declarations = []
264
+ for sym, indent in all_symbols:
265
+ if (
266
+ sym.get("kind") in METHOD_KIND
267
+ or sym.get("_keyword")
268
+ or sym.get("kind") == "namespace"
269
+ ):
270
+ entry = _build_outline_entry(sym, type_sigs, fields_map, indent)
271
+ if entry:
272
+ declarations.append(entry)
273
+
274
+ return FileOutline(imports=imports, declarations=declarations)
275
+
276
+
184
277
  def generate_outline(client: LeanLSPClient, path: str) -> str:
185
278
  """Generate a concise outline of a Lean file showing structure and signatures."""
186
279
  client.open_file(path)