lean-lsp-mcp 0.16.1__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.
- {lean_lsp_mcp-0.16.1/src/lean_lsp_mcp.egg-info → lean_lsp_mcp-0.16.2}/PKG-INFO +3 -4
- {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/pyproject.toml +3 -3
- {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/models.py +77 -0
- lean_lsp_mcp-0.16.2/src/lean_lsp_mcp/search_utils.py +236 -0
- {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/server.py +37 -34
- {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2/src/lean_lsp_mcp.egg-info}/PKG-INFO +3 -4
- {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp.egg-info/SOURCES.txt +2 -1
- {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp.egg-info/requires.txt +2 -3
- {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/tests/test_diagnostic_line_range.py +55 -41
- {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/tests/test_search_tools.py +20 -10
- lean_lsp_mcp-0.16.2/tests/test_structured_output.py +124 -0
- lean_lsp_mcp-0.16.1/src/lean_lsp_mcp/search_utils.py +0 -142
- {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/LICENSE +0 -0
- {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/README.md +0 -0
- {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/setup.cfg +0 -0
- {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/__init__.py +0 -0
- {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/__main__.py +0 -0
- {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/client_utils.py +0 -0
- {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/file_utils.py +0 -0
- {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/instructions.py +0 -0
- {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/loogle.py +0 -0
- {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/outline_utils.py +0 -0
- {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/utils.py +0 -0
- {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp.egg-info/dependency_links.txt +0 -0
- {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp.egg-info/entry_points.txt +0 -0
- {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp.egg-info/top_level.txt +0 -0
- {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/tests/test_editor_tools.py +0 -0
- {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/tests/test_file_caching.py +0 -0
- {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/tests/test_logging.py +0 -0
- {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/tests/test_misc_tools.py +0 -0
- {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/tests/test_outline.py +0 -0
- {lean_lsp_mcp-0.16.1 → 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.
|
|
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.
|
|
12
|
-
Requires-Dist: mcp[cli]==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.
|
|
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.
|
|
11
|
-
"mcp[cli]==1.
|
|
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
|
|
@@ -12,7 +12,7 @@ import functools
|
|
|
12
12
|
import uuid
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
|
|
15
|
-
from pydantic import
|
|
15
|
+
from pydantic import Field
|
|
16
16
|
from mcp.server.fastmcp import Context, FastMCP
|
|
17
17
|
from mcp.server.fastmcp.utilities.logging import get_logger, configure_logging
|
|
18
18
|
from mcp.server.auth.settings import AuthSettings
|
|
@@ -46,6 +46,16 @@ from lean_lsp_mcp.models import (
|
|
|
46
46
|
BuildResult,
|
|
47
47
|
RunResult,
|
|
48
48
|
DeclarationInfo,
|
|
49
|
+
# Wrapper models for list-returning tools
|
|
50
|
+
DiagnosticsResult,
|
|
51
|
+
CompletionsResult,
|
|
52
|
+
MultiAttemptResult,
|
|
53
|
+
LocalSearchResults,
|
|
54
|
+
LeanSearchResults,
|
|
55
|
+
LoogleResults,
|
|
56
|
+
LeanFinderResults,
|
|
57
|
+
StateSearchResults,
|
|
58
|
+
PremiseResults,
|
|
49
59
|
)
|
|
50
60
|
from lean_lsp_mcp.utils import (
|
|
51
61
|
COMPLETION_KIND,
|
|
@@ -67,13 +77,6 @@ class LeanToolError(Exception):
|
|
|
67
77
|
pass
|
|
68
78
|
|
|
69
79
|
|
|
70
|
-
def _to_json_array(items: List[BaseModel]) -> str:
|
|
71
|
-
"""Serialize list of models as JSON array (avoids FastMCP list flattening)."""
|
|
72
|
-
return orjson.dumps(
|
|
73
|
-
[item.model_dump() for item in items], option=orjson.OPT_INDENT_2
|
|
74
|
-
).decode()
|
|
75
|
-
|
|
76
|
-
|
|
77
80
|
_LOG_LEVEL = os.environ.get("LEAN_LOG_LEVEL", "INFO")
|
|
78
81
|
configure_logging("CRITICAL" if _LOG_LEVEL == "NONE" else _LOG_LEVEL)
|
|
79
82
|
logger = get_logger(__name__)
|
|
@@ -414,7 +417,7 @@ def diagnostic_messages(
|
|
|
414
417
|
declaration_name: Annotated[
|
|
415
418
|
Optional[str], Field(description="Filter to declaration (slow)")
|
|
416
419
|
] = None,
|
|
417
|
-
) ->
|
|
420
|
+
) -> DiagnosticsResult:
|
|
418
421
|
"""Get compiler diagnostics (errors, warnings, infos) for a Lean file."""
|
|
419
422
|
rel_path = setup_client_for_file(ctx, file_path)
|
|
420
423
|
if not rel_path:
|
|
@@ -443,7 +446,7 @@ def diagnostic_messages(
|
|
|
443
446
|
inactivity_timeout=15.0,
|
|
444
447
|
)
|
|
445
448
|
|
|
446
|
-
return
|
|
449
|
+
return DiagnosticsResult(items=_to_diagnostic_messages(diagnostics))
|
|
447
450
|
|
|
448
451
|
|
|
449
452
|
@mcp.tool(
|
|
@@ -610,7 +613,7 @@ def completions(
|
|
|
610
613
|
line: Annotated[int, Field(description="Line number (1-indexed)", ge=1)],
|
|
611
614
|
column: Annotated[int, Field(description="Column number (1-indexed)", ge=1)],
|
|
612
615
|
max_completions: Annotated[int, Field(description="Max completions", ge=1)] = 32,
|
|
613
|
-
) ->
|
|
616
|
+
) -> CompletionsResult:
|
|
614
617
|
"""Get IDE autocompletions. Use on INCOMPLETE code (after `.` or partial name)."""
|
|
615
618
|
rel_path = setup_client_for_file(ctx, file_path)
|
|
616
619
|
if not rel_path:
|
|
@@ -639,7 +642,7 @@ def completions(
|
|
|
639
642
|
)
|
|
640
643
|
|
|
641
644
|
if not items:
|
|
642
|
-
return
|
|
645
|
+
return CompletionsResult(items=[])
|
|
643
646
|
|
|
644
647
|
# Find the sort term: The last word/identifier before the cursor
|
|
645
648
|
lines = content.splitlines()
|
|
@@ -666,7 +669,7 @@ def completions(
|
|
|
666
669
|
items.sort(key=lambda x: x.label.lower())
|
|
667
670
|
|
|
668
671
|
# Truncate if too many results
|
|
669
|
-
return
|
|
672
|
+
return CompletionsResult(items=items[:max_completions])
|
|
670
673
|
|
|
671
674
|
|
|
672
675
|
@mcp.tool(
|
|
@@ -741,7 +744,7 @@ def multi_attempt(
|
|
|
741
744
|
snippets: Annotated[
|
|
742
745
|
List[str], Field(description="Tactics to try (3+ recommended)")
|
|
743
746
|
],
|
|
744
|
-
) ->
|
|
747
|
+
) -> MultiAttemptResult:
|
|
745
748
|
"""Try multiple tactics without modifying file. Returns goal state for each."""
|
|
746
749
|
rel_path = setup_client_for_file(ctx, file_path)
|
|
747
750
|
if not rel_path:
|
|
@@ -753,8 +756,6 @@ def multi_attempt(
|
|
|
753
756
|
client.open_file(rel_path)
|
|
754
757
|
|
|
755
758
|
try:
|
|
756
|
-
client.open_file(rel_path)
|
|
757
|
-
|
|
758
759
|
results: List[AttemptResult] = []
|
|
759
760
|
# Avoid mutating caller-provided snippets; normalize locally per attempt
|
|
760
761
|
for snippet in snippets:
|
|
@@ -781,7 +782,7 @@ def multi_attempt(
|
|
|
781
782
|
)
|
|
782
783
|
)
|
|
783
784
|
|
|
784
|
-
return
|
|
785
|
+
return MultiAttemptResult(items=results)
|
|
785
786
|
finally:
|
|
786
787
|
try:
|
|
787
788
|
client.close_files([rel_path])
|
|
@@ -878,7 +879,7 @@ def local_search(
|
|
|
878
879
|
project_root: Annotated[
|
|
879
880
|
Optional[str], Field(description="Project root (inferred if omitted)")
|
|
880
881
|
] = None,
|
|
881
|
-
) ->
|
|
882
|
+
) -> LocalSearchResults:
|
|
882
883
|
"""Fast local search to verify declarations exist. Use BEFORE trying a lemma name."""
|
|
883
884
|
if not _RG_AVAILABLE:
|
|
884
885
|
raise LocalSearchError(_RG_MESSAGE)
|
|
@@ -910,7 +911,7 @@ def local_search(
|
|
|
910
911
|
LocalSearchResult(name=r["name"], kind=r["kind"], file=r["file"])
|
|
911
912
|
for r in raw_results
|
|
912
913
|
]
|
|
913
|
-
return
|
|
914
|
+
return LocalSearchResults(items=results)
|
|
914
915
|
except RuntimeError as exc:
|
|
915
916
|
raise LocalSearchError(f"Search failed: {exc}")
|
|
916
917
|
|
|
@@ -929,7 +930,7 @@ def leansearch(
|
|
|
929
930
|
ctx: Context,
|
|
930
931
|
query: Annotated[str, Field(description="Natural language or Lean term query")],
|
|
931
932
|
num_results: Annotated[int, Field(description="Max results", ge=1)] = 5,
|
|
932
|
-
) ->
|
|
933
|
+
) -> LeanSearchResults:
|
|
933
934
|
"""Search Mathlib via leansearch.net using natural language.
|
|
934
935
|
|
|
935
936
|
Examples: "sum of two even numbers is even", "Cauchy-Schwarz inequality",
|
|
@@ -949,7 +950,7 @@ def leansearch(
|
|
|
949
950
|
results = orjson.loads(response.read())
|
|
950
951
|
|
|
951
952
|
if not results or not results[0]:
|
|
952
|
-
return
|
|
953
|
+
return LeanSearchResults(items=[])
|
|
953
954
|
|
|
954
955
|
raw_results = [r["result"] for r in results[0][:num_results]]
|
|
955
956
|
items = [
|
|
@@ -961,7 +962,7 @@ def leansearch(
|
|
|
961
962
|
)
|
|
962
963
|
for r in raw_results
|
|
963
964
|
]
|
|
964
|
-
return
|
|
965
|
+
return LeanSearchResults(items=items)
|
|
965
966
|
|
|
966
967
|
|
|
967
968
|
@mcp.tool(
|
|
@@ -979,7 +980,7 @@ async def loogle(
|
|
|
979
980
|
str, Field(description="Type pattern, constant, or name substring")
|
|
980
981
|
],
|
|
981
982
|
num_results: Annotated[int, Field(description="Max results", ge=1)] = 8,
|
|
982
|
-
) ->
|
|
983
|
+
) -> LoogleResults:
|
|
983
984
|
"""Search Mathlib by type signature via loogle.lean-lang.org.
|
|
984
985
|
|
|
985
986
|
Examples: `Real.sin`, `"comm"`, `(?a → ?b) → List ?a → List ?b`,
|
|
@@ -992,7 +993,7 @@ async def loogle(
|
|
|
992
993
|
try:
|
|
993
994
|
results = await app_ctx.loogle_manager.query(query, num_results)
|
|
994
995
|
if not results:
|
|
995
|
-
return
|
|
996
|
+
return LoogleResults(items=[])
|
|
996
997
|
items = [
|
|
997
998
|
LoogleResult(
|
|
998
999
|
name=r.get("name", ""),
|
|
@@ -1001,7 +1002,7 @@ async def loogle(
|
|
|
1001
1002
|
)
|
|
1002
1003
|
for r in results
|
|
1003
1004
|
]
|
|
1004
|
-
return
|
|
1005
|
+
return LoogleResults(items=items)
|
|
1005
1006
|
except Exception as e:
|
|
1006
1007
|
logger.warning(f"Local loogle failed: {e}, falling back to remote")
|
|
1007
1008
|
|
|
@@ -1010,13 +1011,15 @@ async def loogle(
|
|
|
1010
1011
|
now = int(time.time())
|
|
1011
1012
|
rate_limit[:] = [t for t in rate_limit if now - t < 30]
|
|
1012
1013
|
if len(rate_limit) >= 3:
|
|
1013
|
-
|
|
1014
|
+
raise LeanToolError(
|
|
1015
|
+
"Rate limit exceeded: 3 requests per 30s. Use --loogle-local to avoid limits."
|
|
1016
|
+
)
|
|
1014
1017
|
rate_limit.append(now)
|
|
1015
1018
|
|
|
1016
1019
|
result = loogle_remote(query, num_results)
|
|
1017
1020
|
if isinstance(result, str):
|
|
1018
|
-
|
|
1019
|
-
return
|
|
1021
|
+
raise LeanToolError(result) # Error message from remote
|
|
1022
|
+
return LoogleResults(items=result)
|
|
1020
1023
|
|
|
1021
1024
|
|
|
1022
1025
|
@mcp.tool(
|
|
@@ -1033,7 +1036,7 @@ def leanfinder(
|
|
|
1033
1036
|
ctx: Context,
|
|
1034
1037
|
query: Annotated[str, Field(description="Mathematical concept or proof state")],
|
|
1035
1038
|
num_results: Annotated[int, Field(description="Max results", ge=1)] = 5,
|
|
1036
|
-
) ->
|
|
1039
|
+
) -> LeanFinderResults:
|
|
1037
1040
|
"""Semantic search by mathematical meaning via Lean Finder.
|
|
1038
1041
|
|
|
1039
1042
|
Examples: "commutativity of addition on natural numbers",
|
|
@@ -1065,7 +1068,7 @@ def leanfinder(
|
|
|
1065
1068
|
)
|
|
1066
1069
|
)
|
|
1067
1070
|
|
|
1068
|
-
return
|
|
1071
|
+
return LeanFinderResults(items=results)
|
|
1069
1072
|
|
|
1070
1073
|
|
|
1071
1074
|
@mcp.tool(
|
|
@@ -1084,7 +1087,7 @@ def state_search(
|
|
|
1084
1087
|
line: Annotated[int, Field(description="Line number (1-indexed)", ge=1)],
|
|
1085
1088
|
column: Annotated[int, Field(description="Column number (1-indexed)", ge=1)],
|
|
1086
1089
|
num_results: Annotated[int, Field(description="Max results", ge=1)] = 5,
|
|
1087
|
-
) ->
|
|
1090
|
+
) -> StateSearchResults:
|
|
1088
1091
|
"""Find lemmas to close the goal at a position. Searches premise-search.com."""
|
|
1089
1092
|
rel_path = setup_client_for_file(ctx, file_path)
|
|
1090
1093
|
if not rel_path:
|
|
@@ -1114,7 +1117,7 @@ def state_search(
|
|
|
1114
1117
|
results = orjson.loads(response.read())
|
|
1115
1118
|
|
|
1116
1119
|
items = [StateSearchResult(name=r["name"]) for r in results]
|
|
1117
|
-
return
|
|
1120
|
+
return StateSearchResults(items=items)
|
|
1118
1121
|
|
|
1119
1122
|
|
|
1120
1123
|
@mcp.tool(
|
|
@@ -1133,7 +1136,7 @@ def hammer_premise(
|
|
|
1133
1136
|
line: Annotated[int, Field(description="Line number (1-indexed)", ge=1)],
|
|
1134
1137
|
column: Annotated[int, Field(description="Column number (1-indexed)", ge=1)],
|
|
1135
1138
|
num_results: Annotated[int, Field(description="Max results", ge=1)] = 32,
|
|
1136
|
-
) ->
|
|
1139
|
+
) -> PremiseResults:
|
|
1137
1140
|
"""Get premise suggestions for automation tactics at a goal position.
|
|
1138
1141
|
|
|
1139
1142
|
Returns lemma names to try with `simp only [...]`, `aesop`, or as hints.
|
|
@@ -1174,7 +1177,7 @@ def hammer_premise(
|
|
|
1174
1177
|
results = orjson.loads(response.read())
|
|
1175
1178
|
|
|
1176
1179
|
items = [PremiseResult(name=r["name"]) for r in results]
|
|
1177
|
-
return
|
|
1180
|
+
return PremiseResults(items=items)
|
|
1178
1181
|
|
|
1179
1182
|
|
|
1180
1183
|
if __name__ == "__main__":
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lean-lsp-mcp
|
|
3
|
-
Version: 0.16.
|
|
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.
|
|
12
|
-
Requires-Dist: mcp[cli]==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,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
import textwrap
|
|
4
5
|
from collections.abc import Callable
|
|
5
6
|
from pathlib import Path
|
|
@@ -10,6 +11,21 @@ import pytest
|
|
|
10
11
|
from tests.helpers.mcp_client import MCPClient, MCPToolError, result_text
|
|
11
12
|
|
|
12
13
|
|
|
14
|
+
def parse_diagnostics_result(result) -> list[dict]:
|
|
15
|
+
"""Parse diagnostics result, handling both structured and text formats."""
|
|
16
|
+
if result.structuredContent is not None:
|
|
17
|
+
return result.structuredContent.get("items", [])
|
|
18
|
+
# Fallback to parsing text output
|
|
19
|
+
text = result_text(result).strip()
|
|
20
|
+
if not text or text == "[]":
|
|
21
|
+
return []
|
|
22
|
+
try:
|
|
23
|
+
parsed = json.loads(text)
|
|
24
|
+
return parsed.get("items", parsed) if isinstance(parsed, dict) else parsed
|
|
25
|
+
except json.JSONDecodeError:
|
|
26
|
+
return []
|
|
27
|
+
|
|
28
|
+
|
|
13
29
|
@pytest.fixture(scope="module")
|
|
14
30
|
def diagnostic_file(test_project_path: Path) -> Path:
|
|
15
31
|
path = test_project_path / "DiagnosticTest.lean"
|
|
@@ -51,12 +67,16 @@ async def test_diagnostic_messages_line_filtering(
|
|
|
51
67
|
"lean_diagnostic_messages",
|
|
52
68
|
{"file_path": str(diagnostic_file)},
|
|
53
69
|
)
|
|
54
|
-
|
|
55
|
-
# Should contain
|
|
56
|
-
assert
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
70
|
+
all_items = parse_diagnostics_result(diagnostics)
|
|
71
|
+
# Should contain at least 2 errors
|
|
72
|
+
assert len(all_items) >= 2, (
|
|
73
|
+
f"Expected at least 2 diagnostics, got {len(all_items)}"
|
|
74
|
+
)
|
|
75
|
+
# Verify items have expected structure
|
|
76
|
+
for item in all_items:
|
|
77
|
+
assert "severity" in item
|
|
78
|
+
assert "message" in item
|
|
79
|
+
assert "line" in item
|
|
60
80
|
|
|
61
81
|
# Test 2: Get diagnostics starting from line 10
|
|
62
82
|
diagnostics = await client.call_tool(
|
|
@@ -66,18 +86,13 @@ async def test_diagnostic_messages_line_filtering(
|
|
|
66
86
|
"start_line": 10,
|
|
67
87
|
},
|
|
68
88
|
)
|
|
69
|
-
|
|
70
|
-
# Should
|
|
71
|
-
assert
|
|
72
|
-
assert len(diag_text) < len(all_diag_text)
|
|
89
|
+
filtered_items = parse_diagnostics_result(diagnostics)
|
|
90
|
+
# Should have fewer diagnostics than unfiltered
|
|
91
|
+
assert len(filtered_items) < len(all_items)
|
|
73
92
|
|
|
74
93
|
# Test 3: Get diagnostics for specific line range
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
# Extract start_line from JSON format (e.g., "start_line": 7)
|
|
78
|
-
line_matches = re.findall(r'"start_line":\s*(\d+)', all_diag_text)
|
|
79
|
-
if line_matches:
|
|
80
|
-
first_error_line = int(line_matches[0])
|
|
94
|
+
if all_items:
|
|
95
|
+
first_error_line = all_items[0]["line"]
|
|
81
96
|
diagnostics = await client.call_tool(
|
|
82
97
|
"lean_diagnostic_messages",
|
|
83
98
|
{
|
|
@@ -86,9 +101,9 @@ async def test_diagnostic_messages_line_filtering(
|
|
|
86
101
|
"end_line": first_error_line,
|
|
87
102
|
},
|
|
88
103
|
)
|
|
89
|
-
|
|
90
|
-
assert
|
|
91
|
-
assert len(
|
|
104
|
+
range_items = parse_diagnostics_result(diagnostics)
|
|
105
|
+
assert len(range_items) >= 1
|
|
106
|
+
assert len(range_items) < len(all_items)
|
|
92
107
|
|
|
93
108
|
# Test 4: Get diagnostics for range with no errors (lines 14-17)
|
|
94
109
|
diagnostics = await client.call_tool(
|
|
@@ -99,9 +114,9 @@ async def test_diagnostic_messages_line_filtering(
|
|
|
99
114
|
"end_line": 17,
|
|
100
115
|
},
|
|
101
116
|
)
|
|
102
|
-
|
|
103
|
-
#
|
|
104
|
-
assert
|
|
117
|
+
empty_items = parse_diagnostics_result(diagnostics)
|
|
118
|
+
# Should be empty
|
|
119
|
+
assert len(empty_items) == 0, f"Expected no diagnostics, got {len(empty_items)}"
|
|
105
120
|
|
|
106
121
|
|
|
107
122
|
@pytest.fixture(scope="module")
|
|
@@ -142,9 +157,8 @@ async def test_diagnostic_messages_declaration_filtering(
|
|
|
142
157
|
"lean_diagnostic_messages",
|
|
143
158
|
{"file_path": str(declaration_diagnostic_file)},
|
|
144
159
|
)
|
|
145
|
-
|
|
146
|
-
assert len(
|
|
147
|
-
assert "string" in all_diag_text.lower() or "type" in all_diag_text.lower()
|
|
160
|
+
all_items = parse_diagnostics_result(all_diagnostics)
|
|
161
|
+
assert len(all_items) > 0, "Expected diagnostics in file with errors"
|
|
148
162
|
|
|
149
163
|
# Test 2: Get diagnostics for firstTheorem only
|
|
150
164
|
diagnostics = await client.call_tool(
|
|
@@ -154,9 +168,9 @@ async def test_diagnostic_messages_declaration_filtering(
|
|
|
154
168
|
"declaration_name": "firstTheorem",
|
|
155
169
|
},
|
|
156
170
|
)
|
|
157
|
-
|
|
158
|
-
assert len(
|
|
159
|
-
assert len(
|
|
171
|
+
first_items = parse_diagnostics_result(diagnostics)
|
|
172
|
+
assert len(first_items) > 0
|
|
173
|
+
assert len(first_items) <= len(all_items)
|
|
160
174
|
|
|
161
175
|
# Test 3: Get diagnostics for secondTheorem (has type error in statement)
|
|
162
176
|
diagnostics = await client.call_tool(
|
|
@@ -166,9 +180,8 @@ async def test_diagnostic_messages_declaration_filtering(
|
|
|
166
180
|
"declaration_name": "secondTheorem",
|
|
167
181
|
},
|
|
168
182
|
)
|
|
169
|
-
|
|
170
|
-
assert len(
|
|
171
|
-
assert isinstance(diag_text, str)
|
|
183
|
+
second_items = parse_diagnostics_result(diagnostics)
|
|
184
|
+
assert len(second_items) > 0
|
|
172
185
|
|
|
173
186
|
# Test 4: Get diagnostics for validFunction (no errors)
|
|
174
187
|
diagnostics = await client.call_tool(
|
|
@@ -178,11 +191,9 @@ async def test_diagnostic_messages_declaration_filtering(
|
|
|
178
191
|
"declaration_name": "validFunction",
|
|
179
192
|
},
|
|
180
193
|
)
|
|
181
|
-
|
|
182
|
-
assert (
|
|
183
|
-
"no
|
|
184
|
-
or len(diag_text.strip()) == 0
|
|
185
|
-
or diag_text == "[]"
|
|
194
|
+
valid_items = parse_diagnostics_result(diagnostics)
|
|
195
|
+
assert len(valid_items) == 0, (
|
|
196
|
+
f"Expected no diagnostics for valid function, got {len(valid_items)}"
|
|
186
197
|
)
|
|
187
198
|
|
|
188
199
|
|
|
@@ -250,9 +261,12 @@ async def test_diagnostic_messages_detects_kernel_errors(
|
|
|
250
261
|
"lean_diagnostic_messages",
|
|
251
262
|
{"file_path": str(kernel_error_file)},
|
|
252
263
|
)
|
|
253
|
-
|
|
264
|
+
items = parse_diagnostics_result(diagnostics)
|
|
265
|
+
|
|
266
|
+
# Should have at least 2 diagnostics
|
|
267
|
+
assert len(items) >= 2, f"Expected at least 2 diagnostics, got {len(items)}"
|
|
254
268
|
|
|
255
|
-
#
|
|
256
|
-
|
|
257
|
-
assert "
|
|
258
|
-
assert
|
|
269
|
+
# Check for kernel error and regular error in message content
|
|
270
|
+
all_messages = " ".join(item.get("message", "").lower() for item in items)
|
|
271
|
+
assert "kernel" in all_messages or "unsafe" in all_messages
|
|
272
|
+
assert "rfl" in all_messages or "failed" in all_messages
|
|
@@ -10,18 +10,28 @@ import pytest
|
|
|
10
10
|
from tests.helpers.mcp_client import MCPClient, result_text
|
|
11
11
|
|
|
12
12
|
|
|
13
|
-
def
|
|
14
|
-
"""Extract the first
|
|
13
|
+
def _first_result_item(result) -> dict[str, str] | None:
|
|
14
|
+
"""Extract the first item from a result that returns a list wrapped in {"items": [...]}.
|
|
15
15
|
|
|
16
|
-
Handles both
|
|
16
|
+
Handles both structured content and text content formats.
|
|
17
17
|
"""
|
|
18
|
+
# Try structured content first (new format)
|
|
19
|
+
if result.structuredContent is not None:
|
|
20
|
+
items = result.structuredContent.get("items", [])
|
|
21
|
+
return items[0] if items else None
|
|
22
|
+
|
|
23
|
+
# Fall back to parsing text content
|
|
18
24
|
for block in result.content:
|
|
19
25
|
text = getattr(block, "text", "").strip()
|
|
20
26
|
if not text:
|
|
21
27
|
continue
|
|
22
28
|
try:
|
|
23
29
|
parsed = orjson.loads(text)
|
|
24
|
-
#
|
|
30
|
+
# Handle {"items": [...]} wrapper format
|
|
31
|
+
if isinstance(parsed, dict) and "items" in parsed:
|
|
32
|
+
items = parsed["items"]
|
|
33
|
+
return items[0] if items else None
|
|
34
|
+
# Handle bare list format (legacy)
|
|
25
35
|
if isinstance(parsed, list):
|
|
26
36
|
return parsed[0] if parsed else None
|
|
27
37
|
return parsed
|
|
@@ -54,7 +64,7 @@ async def test_search_tools(
|
|
|
54
64
|
"lean_loogle",
|
|
55
65
|
{"query": "Nat"},
|
|
56
66
|
)
|
|
57
|
-
loogle_entry =
|
|
67
|
+
loogle_entry = _first_result_item(loogle)
|
|
58
68
|
if loogle_entry is None:
|
|
59
69
|
pytest.skip("lean_loogle did not return JSON content")
|
|
60
70
|
assert {"module", "name", "type"} <= set(loogle_entry.keys())
|
|
@@ -78,7 +88,7 @@ async def test_search_tools(
|
|
|
78
88
|
},
|
|
79
89
|
)
|
|
80
90
|
# Now returns JSON array of StateSearchResult models
|
|
81
|
-
state_entry =
|
|
91
|
+
state_entry = _first_result_item(state_search)
|
|
82
92
|
if state_entry is None:
|
|
83
93
|
pytest.skip("lean_state_search did not return JSON content")
|
|
84
94
|
assert "name" in state_entry
|
|
@@ -92,7 +102,7 @@ async def test_search_tools(
|
|
|
92
102
|
},
|
|
93
103
|
)
|
|
94
104
|
# Now returns JSON array of PremiseResult models
|
|
95
|
-
hammer_entry =
|
|
105
|
+
hammer_entry = _first_result_item(hammer)
|
|
96
106
|
if hammer_entry is None:
|
|
97
107
|
pytest.skip("lean_hammer_premise did not return JSON content")
|
|
98
108
|
assert "name" in hammer_entry
|
|
@@ -104,7 +114,7 @@ async def test_search_tools(
|
|
|
104
114
|
"project_root": str(goal_file.parent),
|
|
105
115
|
},
|
|
106
116
|
)
|
|
107
|
-
local_entry =
|
|
117
|
+
local_entry = _first_result_item(local_search)
|
|
108
118
|
if local_entry is None:
|
|
109
119
|
message = result_text(local_search).strip()
|
|
110
120
|
if "ripgrep" in message.lower():
|
|
@@ -120,7 +130,7 @@ async def test_search_tools(
|
|
|
120
130
|
"lean_leansearch",
|
|
121
131
|
{"query": "Nat.succ"},
|
|
122
132
|
)
|
|
123
|
-
entry =
|
|
133
|
+
entry = _first_result_item(leansearch)
|
|
124
134
|
if entry is None:
|
|
125
135
|
pytest.skip("lean_leansearch did not return JSON content")
|
|
126
136
|
assert {"module_name", "name", "type"} <= set(entry.keys())
|
|
@@ -133,7 +143,7 @@ async def test_search_tools(
|
|
|
133
143
|
"num_results": 3,
|
|
134
144
|
},
|
|
135
145
|
)
|
|
136
|
-
finder_results =
|
|
146
|
+
finder_results = _first_result_item(finder_informal)
|
|
137
147
|
if finder_results:
|
|
138
148
|
assert isinstance(finder_results, dict) and len(finder_results.keys()) == 3
|
|
139
149
|
assert {"full_name", "formal_statement", "informal_statement"} <= set(
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Test that tool outputs are properly structured JSON, not double-encoded strings.
|
|
2
|
+
|
|
3
|
+
This test addresses an issue where tools returning lists would produce
|
|
4
|
+
double-encoded JSON in the structuredContent field. For example, instead of:
|
|
5
|
+
|
|
6
|
+
{"items": [{"severity": "error", ...}]}
|
|
7
|
+
|
|
8
|
+
The output would be:
|
|
9
|
+
|
|
10
|
+
{"result": "[\\n {\\\"severity\\\": \\\"error\\\", ...}]"}
|
|
11
|
+
|
|
12
|
+
Where the list is encoded as an escaped string instead of a proper JSON array.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import textwrap
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import AsyncContextManager
|
|
22
|
+
|
|
23
|
+
import pytest
|
|
24
|
+
|
|
25
|
+
from tests.helpers.mcp_client import MCPClient
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.fixture(scope="module")
|
|
29
|
+
def error_file(test_project_path: Path) -> Path:
|
|
30
|
+
"""Create a Lean file with a type error for testing diagnostics."""
|
|
31
|
+
path = test_project_path / "StructuredOutputTest.lean"
|
|
32
|
+
content = textwrap.dedent(
|
|
33
|
+
"""
|
|
34
|
+
import Mathlib
|
|
35
|
+
|
|
36
|
+
-- This line has a type error: assigning String to Nat
|
|
37
|
+
def badDef : Nat := "not a number"
|
|
38
|
+
"""
|
|
39
|
+
).strip()
|
|
40
|
+
if not path.exists() or path.read_text(encoding="utf-8") != content + "\n":
|
|
41
|
+
path.write_text(content + "\n", encoding="utf-8")
|
|
42
|
+
return path
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.mark.asyncio
|
|
46
|
+
async def test_diagnostics_structured_output_not_double_encoded(
|
|
47
|
+
mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
|
|
48
|
+
error_file: Path,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Verify diagnostics are returned as structured data, not double-encoded JSON.
|
|
51
|
+
|
|
52
|
+
The structuredContent should contain an 'items' field with a list of
|
|
53
|
+
diagnostic objects, not a 'result' field with an escaped JSON string.
|
|
54
|
+
"""
|
|
55
|
+
async with mcp_client_factory() as client:
|
|
56
|
+
result = await client.call_tool(
|
|
57
|
+
"lean_diagnostic_messages",
|
|
58
|
+
{"file_path": str(error_file)},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# structuredContent should be present
|
|
62
|
+
assert result.structuredContent is not None, (
|
|
63
|
+
"Tool should return structured content"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
structured = result.structuredContent
|
|
67
|
+
|
|
68
|
+
# The result should have an 'items' field, not a 'result' field
|
|
69
|
+
# If it has 'result' with a string value, that indicates double-encoding
|
|
70
|
+
if "result" in structured:
|
|
71
|
+
result_value = structured["result"]
|
|
72
|
+
if isinstance(result_value, str):
|
|
73
|
+
# Try to parse it as JSON to confirm it's double-encoded
|
|
74
|
+
try:
|
|
75
|
+
parsed = json.loads(result_value)
|
|
76
|
+
pytest.fail(
|
|
77
|
+
f"Diagnostics are double-encoded! "
|
|
78
|
+
f"structuredContent['result'] is a JSON string that parses to: {type(parsed).__name__}. "
|
|
79
|
+
f"Expected structuredContent to contain 'items' with a list directly."
|
|
80
|
+
)
|
|
81
|
+
except json.JSONDecodeError:
|
|
82
|
+
pass # Not JSON, different issue
|
|
83
|
+
|
|
84
|
+
# Should have 'items' field with a list
|
|
85
|
+
assert "items" in structured, (
|
|
86
|
+
f"Expected 'items' field in structuredContent, got keys: {list(structured.keys())}"
|
|
87
|
+
)
|
|
88
|
+
items = structured["items"]
|
|
89
|
+
assert isinstance(items, list), (
|
|
90
|
+
f"Expected 'items' to be a list, got {type(items).__name__}"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Each item should be a dict with proper fields, not strings
|
|
94
|
+
for i, item in enumerate(items):
|
|
95
|
+
assert isinstance(item, dict), (
|
|
96
|
+
f"Item {i} should be a dict, got {type(item).__name__}. "
|
|
97
|
+
f"This suggests the list items are double-encoded as strings."
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Verify the diagnostic has the expected fields as proper types
|
|
101
|
+
assert "severity" in item, f"Item {i} missing 'severity' field"
|
|
102
|
+
assert isinstance(item["severity"], str), (
|
|
103
|
+
f"Item {i} 'severity' should be a string, got {type(item['severity']).__name__}"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
assert "message" in item, f"Item {i} missing 'message' field"
|
|
107
|
+
assert isinstance(item["message"], str), (
|
|
108
|
+
f"Item {i} 'message' should be a string, got {type(item['message']).__name__}"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
assert "line" in item, f"Item {i} missing 'line' field"
|
|
112
|
+
assert isinstance(item["line"], int), (
|
|
113
|
+
f"Item {i} 'line' should be an int, got {type(item['line']).__name__}"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
assert "column" in item, f"Item {i} missing 'column' field"
|
|
117
|
+
assert isinstance(item["column"], int), (
|
|
118
|
+
f"Item {i} 'column' should be an int, got {type(item['column']).__name__}"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# We should have at least one diagnostic (the type error)
|
|
122
|
+
assert len(items) >= 1, (
|
|
123
|
+
"Expected at least one diagnostic for the type error in the test file"
|
|
124
|
+
)
|
|
@@ -1,142 +0,0 @@
|
|
|
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
|
-
from orjson import loads as _json_loads
|
|
12
|
-
from pathlib import Path
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
INSTALL_URL = "https://github.com/BurntSushi/ripgrep#installation"
|
|
16
|
-
|
|
17
|
-
_PLATFORM_INSTRUCTIONS: dict[str, Iterable[str]] = {
|
|
18
|
-
"Windows": (
|
|
19
|
-
"winget install BurntSushi.ripgrep.MSVC",
|
|
20
|
-
"choco install ripgrep",
|
|
21
|
-
),
|
|
22
|
-
"Darwin": ("brew install ripgrep",),
|
|
23
|
-
"Linux": (
|
|
24
|
-
"sudo apt-get install ripgrep",
|
|
25
|
-
"sudo dnf install ripgrep",
|
|
26
|
-
),
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def check_ripgrep_status() -> tuple[bool, str]:
|
|
31
|
-
"""Check whether ``rg`` is available on PATH and return status + message."""
|
|
32
|
-
|
|
33
|
-
if shutil.which("rg"):
|
|
34
|
-
return True, ""
|
|
35
|
-
|
|
36
|
-
system = platform.system()
|
|
37
|
-
platform_instructions = _PLATFORM_INSTRUCTIONS.get(
|
|
38
|
-
system, ("Check alternative installation methods.",)
|
|
39
|
-
)
|
|
40
|
-
|
|
41
|
-
lines = [
|
|
42
|
-
"ripgrep (rg) was not found on your PATH. The lean_local_search tool uses ripgrep for fast declaration search.",
|
|
43
|
-
"",
|
|
44
|
-
"Installation options:",
|
|
45
|
-
*(f" - {item}" for item in platform_instructions),
|
|
46
|
-
f"More installation options: {INSTALL_URL}",
|
|
47
|
-
]
|
|
48
|
-
|
|
49
|
-
return False, "\n".join(lines)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def lean_local_search(
|
|
53
|
-
query: str,
|
|
54
|
-
limit: int = 32,
|
|
55
|
-
project_root: Path | None = None,
|
|
56
|
-
) -> list[dict[str, str]]:
|
|
57
|
-
"""Search Lean declarations matching ``query`` using ripgrep; results include theorems, lemmas, defs, classes, instances, structures, inductives, abbrevs, and opaque decls."""
|
|
58
|
-
root = (project_root or Path.cwd()).resolve()
|
|
59
|
-
|
|
60
|
-
pattern = (
|
|
61
|
-
rf"^\s*(?:theorem|lemma|def|axiom|class|instance|structure|inductive|abbrev|opaque)\s+"
|
|
62
|
-
rf"(?:[A-Za-z0-9_'.]+\.)*{re.escape(query)}[A-Za-z0-9_'.]*(?:\s|:)"
|
|
63
|
-
)
|
|
64
|
-
|
|
65
|
-
command = [
|
|
66
|
-
"rg",
|
|
67
|
-
"--json",
|
|
68
|
-
"--no-ignore",
|
|
69
|
-
"--smart-case",
|
|
70
|
-
"--hidden",
|
|
71
|
-
"--color",
|
|
72
|
-
"never",
|
|
73
|
-
"--no-messages",
|
|
74
|
-
"-g",
|
|
75
|
-
"*.lean",
|
|
76
|
-
"-g",
|
|
77
|
-
"!.git/**",
|
|
78
|
-
"-g",
|
|
79
|
-
"!.lake/build/**",
|
|
80
|
-
pattern,
|
|
81
|
-
str(root),
|
|
82
|
-
]
|
|
83
|
-
|
|
84
|
-
if lean_src := _get_lean_src_search_path():
|
|
85
|
-
command.append(lean_src)
|
|
86
|
-
|
|
87
|
-
result = subprocess.run(command, capture_output=True, text=True, cwd=str(root))
|
|
88
|
-
|
|
89
|
-
matches = []
|
|
90
|
-
for line in result.stdout.splitlines():
|
|
91
|
-
if not line or (event := _json_loads(line)).get("type") != "match":
|
|
92
|
-
continue
|
|
93
|
-
|
|
94
|
-
data = event["data"]
|
|
95
|
-
parts = data["lines"]["text"].lstrip().split(maxsplit=2)
|
|
96
|
-
if len(parts) < 2:
|
|
97
|
-
continue
|
|
98
|
-
|
|
99
|
-
decl_kind, decl_name = parts[0], parts[1].rstrip(":")
|
|
100
|
-
file_path = Path(data["path"]["text"])
|
|
101
|
-
abs_path = (
|
|
102
|
-
file_path if file_path.is_absolute() else (root / file_path).resolve()
|
|
103
|
-
)
|
|
104
|
-
|
|
105
|
-
try:
|
|
106
|
-
display_path = str(abs_path.relative_to(root))
|
|
107
|
-
except ValueError:
|
|
108
|
-
display_path = str(file_path)
|
|
109
|
-
|
|
110
|
-
matches.append({"name": decl_name, "kind": decl_kind, "file": display_path})
|
|
111
|
-
|
|
112
|
-
if len(matches) >= limit:
|
|
113
|
-
break
|
|
114
|
-
|
|
115
|
-
if result.returncode not in (0, 1) and not matches:
|
|
116
|
-
error_msg = f"ripgrep exited with code {result.returncode}"
|
|
117
|
-
if result.stderr:
|
|
118
|
-
error_msg += f"\n{result.stderr}"
|
|
119
|
-
raise RuntimeError(error_msg)
|
|
120
|
-
|
|
121
|
-
return matches
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
@lru_cache(maxsize=1)
|
|
125
|
-
def _get_lean_src_search_path() -> str | None:
|
|
126
|
-
"""Return the Lean stdlib directory, if available (cache once)."""
|
|
127
|
-
try:
|
|
128
|
-
completed = subprocess.run(
|
|
129
|
-
["lean", "--print-prefix"], capture_output=True, text=True
|
|
130
|
-
)
|
|
131
|
-
except (FileNotFoundError, subprocess.CalledProcessError):
|
|
132
|
-
return None
|
|
133
|
-
|
|
134
|
-
prefix = completed.stdout.strip()
|
|
135
|
-
if not prefix:
|
|
136
|
-
return None
|
|
137
|
-
|
|
138
|
-
candidate = Path(prefix).expanduser().resolve() / "src"
|
|
139
|
-
if candidate.exists():
|
|
140
|
-
return str(candidate)
|
|
141
|
-
|
|
142
|
-
return None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|