lean-lsp-mcp 0.16.0__tar.gz → 0.16.2__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.16.0/src/lean_lsp_mcp.egg-info → lean_lsp_mcp-0.16.2}/PKG-INFO +3 -4
  2. {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/pyproject.toml +3 -3
  3. {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/models.py +77 -0
  4. lean_lsp_mcp-0.16.2/src/lean_lsp_mcp/search_utils.py +236 -0
  5. {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/server.py +74 -65
  6. {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2/src/lean_lsp_mcp.egg-info}/PKG-INFO +3 -4
  7. {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp.egg-info/SOURCES.txt +2 -1
  8. {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp.egg-info/requires.txt +2 -3
  9. {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/tests/test_diagnostic_line_range.py +55 -41
  10. {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/tests/test_search_tools.py +20 -10
  11. lean_lsp_mcp-0.16.2/tests/test_structured_output.py +124 -0
  12. lean_lsp_mcp-0.16.0/src/lean_lsp_mcp/search_utils.py +0 -142
  13. {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/LICENSE +0 -0
  14. {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/README.md +0 -0
  15. {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/setup.cfg +0 -0
  16. {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/__init__.py +0 -0
  17. {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/__main__.py +0 -0
  18. {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/client_utils.py +0 -0
  19. {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/file_utils.py +0 -0
  20. {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/instructions.py +0 -0
  21. {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/loogle.py +0 -0
  22. {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/outline_utils.py +0 -0
  23. {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/utils.py +0 -0
  24. {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp.egg-info/dependency_links.txt +0 -0
  25. {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp.egg-info/entry_points.txt +0 -0
  26. {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp.egg-info/top_level.txt +0 -0
  27. {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/tests/test_editor_tools.py +0 -0
  28. {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/tests/test_file_caching.py +0 -0
  29. {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/tests/test_logging.py +0 -0
  30. {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/tests/test_misc_tools.py +0 -0
  31. {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/tests/test_outline.py +0 -0
  32. {lean_lsp_mcp-0.16.0 → lean_lsp_mcp-0.16.2}/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.16.0
3
+ Version: 0.16.2
4
4
  Summary: Lean Theorem Prover MCP
5
5
  Author-email: Oliver Dressler <hey@oli.show>
6
6
  License-Expression: MIT
@@ -8,9 +8,8 @@ Project-URL: Repository, https://github.com/oOo0oOo/lean-lsp-mcp
8
8
  Requires-Python: >=3.10
9
9
  Description-Content-Type: text/markdown
10
10
  License-File: LICENSE
11
- Requires-Dist: leanclient==0.6.1
12
- Requires-Dist: mcp[cli]==1.23.1
13
- Requires-Dist: mcp[cli]>=1.22.0
11
+ Requires-Dist: leanclient==0.6.2
12
+ Requires-Dist: mcp[cli]==1.24.0
14
13
  Requires-Dist: orjson>=3.11.1
15
14
  Provides-Extra: lint
16
15
  Requires-Dist: ruff>=0.2.0; extra == "lint"
@@ -1,14 +1,14 @@
1
1
  [project]
2
2
  name = "lean-lsp-mcp"
3
- version = "0.16.0"
3
+ version = "0.16.2"
4
4
  description = "Lean Theorem Prover MCP"
5
5
  authors = [{name="Oliver Dressler", email="hey@oli.show"}]
6
6
  readme = "README.md"
7
7
  requires-python = ">=3.10"
8
8
  license = "MIT"
9
9
  dependencies = [
10
- "leanclient==0.6.1",
11
- "mcp[cli]==1.23.1", "mcp[cli]>=1.22.0",
10
+ "leanclient==0.6.2",
11
+ "mcp[cli]==1.24.0",
12
12
  "orjson>=3.11.1",
13
13
  ]
14
14
 
@@ -118,3 +118,80 @@ class RunResult(BaseModel):
118
118
  class DeclarationInfo(BaseModel):
119
119
  file_path: str = Field(description="Path to declaration file")
120
120
  content: str = Field(description="File content")
121
+
122
+
123
+ # Wrapper models for list-returning tools
124
+ # FastMCP flattens bare lists into separate TextContent blocks, causing serialization issues.
125
+ # Wrapping in a model ensures proper JSON serialization.
126
+
127
+
128
+ class DiagnosticsResult(BaseModel):
129
+ """Wrapper for diagnostic messages list."""
130
+
131
+ items: List[DiagnosticMessage] = Field(
132
+ default_factory=list, description="List of diagnostic messages"
133
+ )
134
+
135
+
136
+ class CompletionsResult(BaseModel):
137
+ """Wrapper for completions list."""
138
+
139
+ items: List[CompletionItem] = Field(
140
+ default_factory=list, description="List of completion items"
141
+ )
142
+
143
+
144
+ class MultiAttemptResult(BaseModel):
145
+ """Wrapper for multi-attempt results list."""
146
+
147
+ items: List[AttemptResult] = Field(
148
+ default_factory=list, description="List of attempt results"
149
+ )
150
+
151
+
152
+ class LocalSearchResults(BaseModel):
153
+ """Wrapper for local search results list."""
154
+
155
+ items: List[LocalSearchResult] = Field(
156
+ default_factory=list, description="List of local search results"
157
+ )
158
+
159
+
160
+ class LeanSearchResults(BaseModel):
161
+ """Wrapper for LeanSearch results list."""
162
+
163
+ items: List[LeanSearchResult] = Field(
164
+ default_factory=list, description="List of LeanSearch results"
165
+ )
166
+
167
+
168
+ class LoogleResults(BaseModel):
169
+ """Wrapper for Loogle results list."""
170
+
171
+ items: List[LoogleResult] = Field(
172
+ default_factory=list, description="List of Loogle results"
173
+ )
174
+
175
+
176
+ class LeanFinderResults(BaseModel):
177
+ """Wrapper for Lean Finder results list."""
178
+
179
+ items: List[LeanFinderResult] = Field(
180
+ default_factory=list, description="List of Lean Finder results"
181
+ )
182
+
183
+
184
+ class StateSearchResults(BaseModel):
185
+ """Wrapper for state search results list."""
186
+
187
+ items: List[StateSearchResult] = Field(
188
+ default_factory=list, description="List of state search results"
189
+ )
190
+
191
+
192
+ class PremiseResults(BaseModel):
193
+ """Wrapper for premise results list."""
194
+
195
+ items: List[PremiseResult] = Field(
196
+ default_factory=list, description="List of premise results"
197
+ )
@@ -0,0 +1,236 @@
1
+ """Utilities for Lean search tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from functools import lru_cache
7
+ import platform
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ import threading
12
+ from orjson import loads as _json_loads
13
+ from pathlib import Path
14
+
15
+
16
+ INSTALL_URL = "https://github.com/BurntSushi/ripgrep#installation"
17
+
18
+ _PLATFORM_INSTRUCTIONS: dict[str, Iterable[str]] = {
19
+ "Windows": (
20
+ "winget install BurntSushi.ripgrep.MSVC",
21
+ "choco install ripgrep",
22
+ ),
23
+ "Darwin": ("brew install ripgrep",),
24
+ "Linux": (
25
+ "sudo apt-get install ripgrep",
26
+ "sudo dnf install ripgrep",
27
+ ),
28
+ }
29
+
30
+
31
+ def _create_ripgrep_process(command: list[str], *, cwd: str) -> subprocess.Popen[str]:
32
+ """Spawn ripgrep and return a process with line-streaming stdout.
33
+
34
+ Separated for test monkeypatching and to allow early termination once we
35
+ have enough matches.
36
+ """
37
+ return subprocess.Popen(
38
+ command,
39
+ stdout=subprocess.PIPE,
40
+ stderr=subprocess.PIPE,
41
+ text=True,
42
+ cwd=cwd,
43
+ )
44
+
45
+
46
+ def check_ripgrep_status() -> tuple[bool, str]:
47
+ """Check whether ``rg`` is available on PATH and return status + message."""
48
+
49
+ if shutil.which("rg"):
50
+ return True, ""
51
+
52
+ system = platform.system()
53
+ platform_instructions = _PLATFORM_INSTRUCTIONS.get(
54
+ system, ("Check alternative installation methods.",)
55
+ )
56
+
57
+ lines = [
58
+ "ripgrep (rg) was not found on your PATH. The lean_local_search tool uses ripgrep for fast declaration search.",
59
+ "",
60
+ "Installation options:",
61
+ *(f" - {item}" for item in platform_instructions),
62
+ f"More installation options: {INSTALL_URL}",
63
+ ]
64
+
65
+ return False, "\n".join(lines)
66
+
67
+
68
+ def lean_local_search(
69
+ query: str,
70
+ limit: int = 32,
71
+ project_root: Path | None = None,
72
+ ) -> list[dict[str, str]]:
73
+ """Search Lean declarations matching ``query`` using ripgrep; results include theorems, lemmas, defs, classes, instances, structures, inductives, abbrevs, and opaque decls."""
74
+ root = (project_root or Path.cwd()).resolve()
75
+
76
+ pattern = (
77
+ rf"^\s*(?:theorem|lemma|def|axiom|class|instance|structure|inductive|abbrev|opaque)\s+"
78
+ rf"(?:[A-Za-z0-9_'.]+\.)*{re.escape(query)}[A-Za-z0-9_'.]*(?:\s|:)"
79
+ )
80
+
81
+ command = [
82
+ "rg",
83
+ "--json",
84
+ "--no-ignore",
85
+ "--smart-case",
86
+ "--hidden",
87
+ "--color",
88
+ "never",
89
+ "--no-messages",
90
+ "-g",
91
+ "*.lean",
92
+ "-g",
93
+ "!.git/**",
94
+ "-g",
95
+ "!.lake/build/**",
96
+ pattern,
97
+ str(root),
98
+ ]
99
+
100
+ if lean_src := _get_lean_src_search_path():
101
+ command.append(lean_src)
102
+
103
+ process = _create_ripgrep_process(command, cwd=str(root))
104
+
105
+ matches: list[dict[str, str]] = []
106
+ stderr_text = ""
107
+ terminated_early = False
108
+ stderr_chunks: list[str] = []
109
+ stderr_chars = 0
110
+ stderr_truncated = False
111
+ max_stderr_chars = 100_000
112
+
113
+ def _drain_stderr(pipe) -> None:
114
+ nonlocal stderr_chars, stderr_truncated
115
+ try:
116
+ for err_line in pipe:
117
+ if stderr_chars < max_stderr_chars:
118
+ stderr_chunks.append(err_line)
119
+ stderr_chars += len(err_line)
120
+ else:
121
+ stderr_truncated = True
122
+ except Exception:
123
+ return
124
+
125
+ stderr_thread: threading.Thread | None = None
126
+ if process.stderr is not None:
127
+ stderr_thread = threading.Thread(
128
+ target=_drain_stderr,
129
+ args=(process.stderr,),
130
+ name="lean-local-search-rg-stderr",
131
+ daemon=True,
132
+ )
133
+ stderr_thread.start()
134
+
135
+ try:
136
+ stdout = process.stdout
137
+ if stdout is None:
138
+ raise RuntimeError("ripgrep did not provide stdout pipe")
139
+
140
+ for line in stdout:
141
+ if not line or (event := _json_loads(line)).get("type") != "match":
142
+ continue
143
+
144
+ data = event["data"]
145
+ parts = data["lines"]["text"].lstrip().split(maxsplit=2)
146
+ if len(parts) < 2:
147
+ continue
148
+
149
+ decl_kind, decl_name = parts[0], parts[1].rstrip(":")
150
+ file_path = Path(data["path"]["text"])
151
+ abs_path = (
152
+ file_path if file_path.is_absolute() else (root / file_path).resolve()
153
+ )
154
+
155
+ try:
156
+ display_path = str(abs_path.relative_to(root))
157
+ except ValueError:
158
+ display_path = str(file_path)
159
+
160
+ matches.append({"name": decl_name, "kind": decl_kind, "file": display_path})
161
+
162
+ if len(matches) >= limit:
163
+ terminated_early = True
164
+ try:
165
+ process.terminate()
166
+ except Exception:
167
+ pass
168
+ break
169
+
170
+ try:
171
+ if terminated_early:
172
+ process.wait(timeout=5)
173
+ else:
174
+ process.wait()
175
+ except subprocess.TimeoutExpired:
176
+ process.kill()
177
+ process.wait()
178
+ finally:
179
+ if process.returncode is None:
180
+ try:
181
+ process.terminate()
182
+ except Exception:
183
+ pass
184
+ try:
185
+ process.wait(timeout=5)
186
+ except Exception:
187
+ try:
188
+ process.kill()
189
+ except Exception:
190
+ pass
191
+ try:
192
+ process.wait(timeout=5)
193
+ except Exception:
194
+ pass
195
+ if stderr_thread is not None:
196
+ stderr_thread.join(timeout=1)
197
+ if process.stdout is not None:
198
+ process.stdout.close()
199
+ if process.stderr is not None:
200
+ process.stderr.close()
201
+
202
+ if stderr_chunks:
203
+ stderr_text = "".join(stderr_chunks)
204
+ if stderr_truncated:
205
+ stderr_text += "\n[stderr truncated]"
206
+
207
+ returncode = process.returncode if process.returncode is not None else 0
208
+
209
+ if returncode not in (0, 1) and not matches:
210
+ error_msg = f"ripgrep exited with code {returncode}"
211
+ if stderr_text:
212
+ error_msg += f"\n{stderr_text}"
213
+ raise RuntimeError(error_msg)
214
+
215
+ return matches
216
+
217
+
218
+ @lru_cache(maxsize=1)
219
+ def _get_lean_src_search_path() -> str | None:
220
+ """Return the Lean stdlib directory, if available (cache once)."""
221
+ try:
222
+ completed = subprocess.run(
223
+ ["lean", "--print-prefix"], capture_output=True, text=True
224
+ )
225
+ except (FileNotFoundError, subprocess.CalledProcessError):
226
+ return None
227
+
228
+ prefix = completed.stdout.strip()
229
+ if not prefix:
230
+ return None
231
+
232
+ candidate = Path(prefix).expanduser().resolve() / "src"
233
+ if candidate.exists():
234
+ return str(candidate)
235
+
236
+ return None