lean-lsp-mcp 0.16.1__py3-none-any.whl → 0.17.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.
@@ -33,4 +33,7 @@ After finding a name: lean_local_search to verify, lean_hover_info for signature
33
33
 
34
34
  ## Return Formats
35
35
  List tools return JSON arrays. Empty = `[]`.
36
+
37
+ ## Error Handling
38
+ Check `isError` in responses: `true` means failure (timeout/LSP error), while `[]` with `isError: false` means no results found.
36
39
  """
lean_lsp_mcp/loogle.py CHANGED
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import hashlib
6
7
  import json
7
8
  import logging
8
9
  import os
@@ -34,7 +35,7 @@ def loogle_remote(query: str, num_results: int) -> list[LoogleResult] | str:
34
35
  f"https://loogle.lean-lang.org/json?q={urllib.parse.quote(query)}",
35
36
  headers={"User-Agent": "lean-lsp-mcp/0.1"},
36
37
  )
37
- with urllib.request.urlopen(req, timeout=20) as response:
38
+ with urllib.request.urlopen(req, timeout=10) as response:
38
39
  results = orjson.loads(response.read())
39
40
  if "hits" not in results:
40
41
  return "No results found."
@@ -52,18 +53,25 @@ def loogle_remote(query: str, num_results: int) -> list[LoogleResult] | str:
52
53
 
53
54
 
54
55
  class LoogleManager:
55
- """Manages local loogle installation and async subprocess."""
56
+ """Manages local loogle installation and async subprocess.
57
+
58
+ Args:
59
+ cache_dir: Directory for loogle repo and indices (default: ~/.cache/lean-lsp-mcp/loogle)
60
+ project_path: Optional Lean project path to index its .lake/packages dependencies
61
+ """
56
62
 
57
63
  REPO_URL = "https://github.com/nomeata/loogle.git"
58
64
  READY_SIGNAL = "Loogle is ready."
59
65
 
60
- def __init__(self, cache_dir: Path | None = None):
66
+ def __init__(self, cache_dir: Path | None = None, project_path: Path | None = None):
61
67
  self.cache_dir = cache_dir or get_cache_dir()
62
68
  self.repo_dir = self.cache_dir / "repo"
63
69
  self.index_dir = self.cache_dir / "index"
70
+ self.project_path = project_path
64
71
  self.process: asyncio.subprocess.Process | None = None
65
72
  self._ready = False
66
73
  self._lock = asyncio.Lock()
74
+ self._extra_paths: list[Path] = []
67
75
 
68
76
  @property
69
77
  def binary_path(self) -> Path:
@@ -181,21 +189,54 @@ class LoogleManager:
181
189
  return False, err
182
190
  return True, ""
183
191
 
192
+ def _discover_project_paths(self) -> list[Path]:
193
+ """Find .lake/packages lib paths from the user's project."""
194
+ if not self.project_path:
195
+ return []
196
+ paths = []
197
+ # Check packages directory
198
+ lake_packages = self.project_path / ".lake" / "packages"
199
+ if lake_packages.exists():
200
+ for pkg_dir in lake_packages.iterdir():
201
+ if not pkg_dir.is_dir():
202
+ continue
203
+ lib_path = pkg_dir / ".lake" / "build" / "lib" / "lean"
204
+ if lib_path.exists():
205
+ paths.append(lib_path)
206
+ # Also add the project's own build output
207
+ project_lib = self.project_path / ".lake" / "build" / "lib" / "lean"
208
+ if project_lib.exists():
209
+ paths.append(project_lib)
210
+ return sorted(paths)
211
+
184
212
  def _get_index_path(self) -> Path:
185
- return self.index_dir / f"mathlib-{self._get_mathlib_version()}.idx"
213
+ base = f"mathlib-{self._get_mathlib_version()}"
214
+ if self._extra_paths:
215
+ # Include hash of extra paths for project-specific index
216
+ paths_str = ":".join(str(p) for p in sorted(self._extra_paths))
217
+ path_hash = hashlib.sha256(paths_str.encode()).hexdigest()[:8]
218
+ return self.index_dir / f"{base}-{path_hash}.idx"
219
+ return self.index_dir / f"{base}.idx"
186
220
 
187
221
  def _cleanup_old_indices(self) -> None:
188
- """Remove old index files from previous mathlib versions."""
222
+ """Remove old index files from previous mathlib versions.
223
+
224
+ Cleans up both mathlib-only indexes (mathlib-<version>.idx) and
225
+ project-specific indexes (mathlib-<version>-<hash>.idx) that don't
226
+ match the current mathlib version.
227
+ """
189
228
  if not self.index_dir.exists():
190
229
  return
191
- current = self._get_index_path()
230
+ current_mathlib = f"mathlib-{self._get_mathlib_version()}"
192
231
  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
232
+ # Keep indexes with current mathlib version (both base and project-specific)
233
+ if idx.name.startswith(current_mathlib):
234
+ continue
235
+ try:
236
+ idx.unlink()
237
+ logger.info(f"Removed old index: {idx.name}")
238
+ except Exception:
239
+ pass
199
240
 
200
241
  def _build_index(self) -> Path | None:
201
242
  index_path = self._get_index_path()
@@ -205,17 +246,37 @@ class LoogleManager:
205
246
  return None
206
247
  self.index_dir.mkdir(parents=True, exist_ok=True)
207
248
  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,
249
+
250
+ # Build command with extra paths
251
+ cmd = [str(self.binary_path), "--write-index", str(index_path), "--json"]
252
+ for path in self._extra_paths:
253
+ cmd.extend(["--path", str(path)])
254
+ cmd.append("") # Empty query for index building
255
+
256
+ if self._extra_paths:
257
+ logger.info(
258
+ f"Building search index with {len(self._extra_paths)} extra paths..."
213
259
  )
260
+ else:
261
+ logger.info("Building search index...")
262
+ try:
263
+ self._run(cmd, timeout=600)
214
264
  return index_path if index_path.exists() else None
215
265
  except Exception as e:
216
266
  logger.error(f"Index build error: {e}")
217
267
  return None
218
268
 
269
+ def set_project_path(self, project_path: Path | None) -> bool:
270
+ """Update project path and rediscover extra paths. Returns True if paths changed."""
271
+ self.project_path = project_path
272
+ new_paths = self._discover_project_paths()
273
+ if new_paths != self._extra_paths:
274
+ self._extra_paths = new_paths
275
+ if new_paths:
276
+ logger.info(f"Discovered {len(new_paths)} project library paths")
277
+ return True
278
+ return False
279
+
219
280
  def ensure_installed(self) -> bool:
220
281
  ok, err = self._check_prerequisites()
221
282
  if not ok:
@@ -223,6 +284,10 @@ class LoogleManager:
223
284
  return False
224
285
  if not self._clone_repo() or not self._build_loogle():
225
286
  return False
287
+ # Discover project paths before building index
288
+ self._extra_paths = self._discover_project_paths()
289
+ if self._extra_paths:
290
+ logger.info(f"Indexing {len(self._extra_paths)} project library paths")
226
291
  if not self._build_index():
227
292
  logger.warning("Index build failed, loogle will build on startup")
228
293
  return self.is_installed
@@ -234,10 +299,26 @@ class LoogleManager:
234
299
  if not ok:
235
300
  logger.error(f"Loogle environment check failed: {err}")
236
301
  return False
302
+
303
+ # Check if project paths changed and we need to rebuild index
304
+ if self.project_path:
305
+ new_paths = self._discover_project_paths()
306
+ if new_paths != self._extra_paths:
307
+ self._extra_paths = new_paths
308
+ # Build new index if paths changed
309
+ self._build_index()
310
+
237
311
  cmd = [str(self.binary_path), "--json", "--interactive"]
238
312
  if (idx := self._get_index_path()).exists():
239
313
  cmd.extend(["--read-index", str(idx)])
240
- logger.info("Starting loogle subprocess...")
314
+ # Add extra paths for runtime search (in case not all are indexed)
315
+ for path in self._extra_paths:
316
+ cmd.extend(["--path", str(path)])
317
+
318
+ if self._extra_paths:
319
+ logger.info(f"Starting loogle with {len(self._extra_paths)} extra paths...")
320
+ else:
321
+ logger.info("Starting loogle subprocess...")
241
322
  try:
242
323
  self.process = await asyncio.create_subprocess_exec(
243
324
  *cmd,
lean_lsp_mcp/models.py CHANGED
@@ -46,7 +46,15 @@ class DiagnosticMessage(BaseModel):
46
46
 
47
47
  class GoalState(BaseModel):
48
48
  line_context: str = Field(description="Source line where goals were queried")
49
- goals: str = Field(description="Goal state (before→after if column omitted)")
49
+ goals: Optional[List[str]] = Field(
50
+ None, description="Goal list at specified column position"
51
+ )
52
+ goals_before: Optional[List[str]] = Field(
53
+ None, description="Goals at line start (when column omitted)"
54
+ )
55
+ goals_after: Optional[List[str]] = Field(
56
+ None, description="Goals at line end (when column omitted)"
57
+ )
50
58
 
51
59
 
52
60
  class CompletionItem(BaseModel):
@@ -94,8 +102,8 @@ class FileOutline(BaseModel):
94
102
 
95
103
  class AttemptResult(BaseModel):
96
104
  snippet: str = Field(description="Code snippet that was tried")
97
- goal_state: Optional[str] = Field(
98
- None, description="Goal state after applying snippet"
105
+ goals: List[str] = Field(
106
+ default_factory=list, description="Goal list after applying snippet"
99
107
  )
100
108
  diagnostics: List[DiagnosticMessage] = Field(
101
109
  default_factory=list, description="Diagnostics for this attempt"
@@ -118,3 +126,80 @@ class RunResult(BaseModel):
118
126
  class DeclarationInfo(BaseModel):
119
127
  file_path: str = Field(description="Path to declaration file")
120
128
  content: str = Field(description="File content")
129
+
130
+
131
+ # Wrapper models for list-returning tools
132
+ # FastMCP flattens bare lists into separate TextContent blocks, causing serialization issues.
133
+ # Wrapping in a model ensures proper JSON serialization.
134
+
135
+
136
+ class DiagnosticsResult(BaseModel):
137
+ """Wrapper for diagnostic messages list."""
138
+
139
+ items: List[DiagnosticMessage] = Field(
140
+ default_factory=list, description="List of diagnostic messages"
141
+ )
142
+
143
+
144
+ class CompletionsResult(BaseModel):
145
+ """Wrapper for completions list."""
146
+
147
+ items: List[CompletionItem] = Field(
148
+ default_factory=list, description="List of completion items"
149
+ )
150
+
151
+
152
+ class MultiAttemptResult(BaseModel):
153
+ """Wrapper for multi-attempt results list."""
154
+
155
+ items: List[AttemptResult] = Field(
156
+ default_factory=list, description="List of attempt results"
157
+ )
158
+
159
+
160
+ class LocalSearchResults(BaseModel):
161
+ """Wrapper for local search results list."""
162
+
163
+ items: List[LocalSearchResult] = Field(
164
+ default_factory=list, description="List of local search results"
165
+ )
166
+
167
+
168
+ class LeanSearchResults(BaseModel):
169
+ """Wrapper for LeanSearch results list."""
170
+
171
+ items: List[LeanSearchResult] = Field(
172
+ default_factory=list, description="List of LeanSearch results"
173
+ )
174
+
175
+
176
+ class LoogleResults(BaseModel):
177
+ """Wrapper for Loogle results list."""
178
+
179
+ items: List[LoogleResult] = Field(
180
+ default_factory=list, description="List of Loogle results"
181
+ )
182
+
183
+
184
+ class LeanFinderResults(BaseModel):
185
+ """Wrapper for Lean Finder results list."""
186
+
187
+ items: List[LeanFinderResult] = Field(
188
+ default_factory=list, description="List of Lean Finder results"
189
+ )
190
+
191
+
192
+ class StateSearchResults(BaseModel):
193
+ """Wrapper for state search results list."""
194
+
195
+ items: List[StateSearchResult] = Field(
196
+ default_factory=list, description="List of state search results"
197
+ )
198
+
199
+
200
+ class PremiseResults(BaseModel):
201
+ """Wrapper for premise results list."""
202
+
203
+ items: List[PremiseResult] = Field(
204
+ default_factory=list, description="List of premise results"
205
+ )
@@ -8,6 +8,7 @@ import platform
8
8
  import re
9
9
  import shutil
10
10
  import subprocess
11
+ import threading
11
12
  from orjson import loads as _json_loads
12
13
  from pathlib import Path
13
14
 
@@ -27,6 +28,21 @@ _PLATFORM_INSTRUCTIONS: dict[str, Iterable[str]] = {
27
28
  }
28
29
 
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
+
30
46
  def check_ripgrep_status() -> tuple[bool, str]:
31
47
  """Check whether ``rg`` is available on PATH and return status + message."""
32
48
 
@@ -84,38 +100,116 @@ def lean_local_search(
84
100
  if lean_src := _get_lean_src_search_path():
85
101
  command.append(lean_src)
86
102
 
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
103
+ process = _create_ripgrep_process(command, cwd=str(root))
93
104
 
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
- )
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
104
112
 
113
+ def _drain_stderr(pipe) -> None:
114
+ nonlocal stderr_chars, stderr_truncated
105
115
  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})
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()
111
134
 
112
- if len(matches) >= limit:
113
- break
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
114
169
 
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}"
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}"
119
213
  raise RuntimeError(error_msg)
120
214
 
121
215
  return matches
lean_lsp_mcp/server.py CHANGED
@@ -1,79 +1,80 @@
1
1
  import asyncio
2
+ import functools
2
3
  import os
3
4
  import re
4
5
  import time
5
- from typing import Annotated, List, Optional, Dict
6
- from contextlib import asynccontextmanager
7
- from collections.abc import AsyncIterator
8
- from dataclasses import dataclass
9
6
  import urllib
10
- import orjson
11
- import functools
12
7
  import uuid
8
+ from collections.abc import AsyncIterator
9
+ from contextlib import asynccontextmanager
10
+ from dataclasses import dataclass
13
11
  from pathlib import Path
12
+ from typing import Annotated, Dict, List, Optional
14
13
 
15
- from pydantic import BaseModel, Field
16
- from mcp.server.fastmcp import Context, FastMCP
17
- from mcp.server.fastmcp.utilities.logging import get_logger, configure_logging
14
+ import orjson
15
+ from leanclient import DocumentContentChange, LeanLSPClient
18
16
  from mcp.server.auth.settings import AuthSettings
17
+ from mcp.server.fastmcp import Context, FastMCP
18
+ from mcp.server.fastmcp.utilities.logging import configure_logging, get_logger
19
19
  from mcp.types import ToolAnnotations
20
- from leanclient import LeanLSPClient, DocumentContentChange
20
+ from pydantic import Field
21
21
 
22
22
  from lean_lsp_mcp.client_utils import (
23
+ infer_project_path,
23
24
  setup_client_for_file,
24
25
  startup_client,
25
- infer_project_path,
26
26
  )
27
27
  from lean_lsp_mcp.file_utils import get_file_contents
28
28
  from lean_lsp_mcp.instructions import INSTRUCTIONS
29
- from lean_lsp_mcp.search_utils import check_ripgrep_status, lean_local_search
30
29
  from lean_lsp_mcp.loogle import LoogleManager, loogle_remote
31
- from lean_lsp_mcp.outline_utils import generate_outline_data
32
30
  from lean_lsp_mcp.models import (
33
- LocalSearchResult,
34
- LeanSearchResult,
35
- LoogleResult,
36
- LeanFinderResult,
37
- StateSearchResult,
38
- PremiseResult,
31
+ AttemptResult,
32
+ BuildResult,
33
+ CompletionItem,
34
+ CompletionsResult,
35
+ DeclarationInfo,
39
36
  DiagnosticMessage,
37
+ # Wrapper models for list-returning tools
38
+ DiagnosticsResult,
39
+ FileOutline,
40
40
  GoalState,
41
- CompletionItem,
42
41
  HoverInfo,
43
- TermGoalState,
44
- FileOutline,
45
- AttemptResult,
46
- BuildResult,
42
+ LeanFinderResult,
43
+ LeanFinderResults,
44
+ LeanSearchResult,
45
+ LeanSearchResults,
46
+ LocalSearchResult,
47
+ LocalSearchResults,
48
+ LoogleResult,
49
+ LoogleResults,
50
+ MultiAttemptResult,
51
+ PremiseResult,
52
+ PremiseResults,
47
53
  RunResult,
48
- DeclarationInfo,
54
+ StateSearchResult,
55
+ StateSearchResults,
56
+ TermGoalState,
49
57
  )
58
+ from lean_lsp_mcp.outline_utils import generate_outline_data
59
+ from lean_lsp_mcp.search_utils import check_ripgrep_status, lean_local_search
50
60
  from lean_lsp_mcp.utils import (
51
61
  COMPLETION_KIND,
62
+ LeanToolError,
63
+ OptionalTokenVerifier,
52
64
  OutputCapture,
65
+ check_lsp_response,
53
66
  deprecated,
67
+ extract_goals_list,
54
68
  extract_range,
55
69
  filter_diagnostics_by_position,
56
70
  find_start_position,
57
- format_goal,
58
71
  get_declaration_range,
59
- OptionalTokenVerifier,
60
72
  )
61
73
 
62
74
  # LSP Diagnostic severity: 1=error, 2=warning, 3=info, 4=hint
63
75
  DIAGNOSTIC_SEVERITY: Dict[int, str] = {1: "error", 2: "warning", 3: "info", 4: "hint"}
64
76
 
65
77
 
66
- class LeanToolError(Exception):
67
- pass
68
-
69
-
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
78
  _LOG_LEVEL = os.environ.get("LEAN_LOG_LEVEL", "INFO")
78
79
  configure_logging("CRITICAL" if _LOG_LEVEL == "NONE" else _LOG_LEVEL)
79
80
  logger = get_logger(__name__)
@@ -107,7 +108,7 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
107
108
  # Initialize local loogle if enabled via env var or CLI
108
109
  if os.environ.get("LEAN_LOOGLE_LOCAL", "").lower() in ("1", "true", "yes"):
109
110
  logger.info("Local loogle enabled, initializing...")
110
- loogle_manager = LoogleManager()
111
+ loogle_manager = LoogleManager(project_path=lean_project_path)
111
112
  if loogle_manager.ensure_installed():
112
113
  if await loogle_manager.start():
113
114
  loogle_local_available = True
@@ -414,7 +415,7 @@ def diagnostic_messages(
414
415
  declaration_name: Annotated[
415
416
  Optional[str], Field(description="Filter to declaration (slow)")
416
417
  ] = None,
417
- ) -> str:
418
+ ) -> DiagnosticsResult:
418
419
  """Get compiler diagnostics (errors, warnings, infos) for a Lean file."""
419
420
  rel_path = setup_client_for_file(ctx, file_path)
420
421
  if not rel_path:
@@ -442,8 +443,9 @@ def diagnostic_messages(
442
443
  end_line=end_line_0,
443
444
  inactivity_timeout=15.0,
444
445
  )
446
+ check_lsp_response(diagnostics, "get_diagnostics")
445
447
 
446
- return _to_json_array(_to_diagnostic_messages(diagnostics))
448
+ return DiagnosticsResult(items=_to_diagnostic_messages(diagnostics))
447
449
 
448
450
 
449
451
  @mcp.tool(
@@ -491,15 +493,18 @@ def goal(
491
493
  (i for i, c in enumerate(line_context) if not c.isspace()), 0
492
494
  )
493
495
  goal_start = client.get_goal(rel_path, line - 1, column_start)
496
+ check_lsp_response(goal_start, "get_goal", allow_none=True)
494
497
  goal_end = client.get_goal(rel_path, line - 1, column_end)
495
- before = format_goal(goal_start, None)
496
- after = format_goal(goal_end, None)
497
- goals = f"{before} → {after}" if before != after else after
498
- return GoalState(line_context=line_context, goals=goals)
498
+ return GoalState(
499
+ line_context=line_context,
500
+ goals_before=extract_goals_list(goal_start),
501
+ goals_after=extract_goals_list(goal_end),
502
+ )
499
503
  else:
500
504
  goal_result = client.get_goal(rel_path, line - 1, column - 1)
505
+ check_lsp_response(goal_result, "get_goal", allow_none=True)
501
506
  return GoalState(
502
- line_context=line_context, goals=format_goal(goal_result, None)
507
+ line_context=line_context, goals=extract_goals_list(goal_result)
503
508
  )
504
509
 
505
510
 
@@ -540,6 +545,7 @@ def term_goal(
540
545
  column = len(line_context)
541
546
 
542
547
  term_goal_result = client.get_term_goal(rel_path, line - 1, column - 1)
548
+ check_lsp_response(term_goal_result, "get_term_goal", allow_none=True)
543
549
  expected_type = None
544
550
  if term_goal_result is not None:
545
551
  rendered = term_goal_result.get("goal")
@@ -575,6 +581,7 @@ def hover(
575
581
  client.open_file(rel_path)
576
582
  file_content = client.get_file_content(rel_path)
577
583
  hover_info = client.get_hover(rel_path, line - 1, column - 1)
584
+ check_lsp_response(hover_info, "get_hover", allow_none=True)
578
585
  if hover_info is None:
579
586
  raise LeanToolError(f"No hover information at line {line}, column {column}")
580
587
 
@@ -586,6 +593,7 @@ def hover(
586
593
 
587
594
  # Add diagnostics if available
588
595
  diagnostics = client.get_diagnostics(rel_path)
596
+ check_lsp_response(diagnostics, "get_diagnostics")
589
597
  filtered = filter_diagnostics_by_position(diagnostics, line - 1, column - 1)
590
598
 
591
599
  return HoverInfo(
@@ -610,7 +618,7 @@ def completions(
610
618
  line: Annotated[int, Field(description="Line number (1-indexed)", ge=1)],
611
619
  column: Annotated[int, Field(description="Column number (1-indexed)", ge=1)],
612
620
  max_completions: Annotated[int, Field(description="Max completions", ge=1)] = 32,
613
- ) -> str:
621
+ ) -> CompletionsResult:
614
622
  """Get IDE autocompletions. Use on INCOMPLETE code (after `.` or partial name)."""
615
623
  rel_path = setup_client_for_file(ctx, file_path)
616
624
  if not rel_path:
@@ -622,6 +630,7 @@ def completions(
622
630
  client.open_file(rel_path)
623
631
  content = client.get_file_content(rel_path)
624
632
  raw_completions = client.get_completions(rel_path, line - 1, column - 1)
633
+ check_lsp_response(raw_completions, "get_completions")
625
634
 
626
635
  # Convert to CompletionItem models
627
636
  items: List[CompletionItem] = []
@@ -639,7 +648,7 @@ def completions(
639
648
  )
640
649
 
641
650
  if not items:
642
- return "[]"
651
+ return CompletionsResult(items=[])
643
652
 
644
653
  # Find the sort term: The last word/identifier before the cursor
645
654
  lines = content.splitlines()
@@ -666,7 +675,7 @@ def completions(
666
675
  items.sort(key=lambda x: x.label.lower())
667
676
 
668
677
  # Truncate if too many results
669
- return _to_json_array(items[:max_completions])
678
+ return CompletionsResult(items=items[:max_completions])
670
679
 
671
680
 
672
681
  @mcp.tool(
@@ -741,7 +750,7 @@ def multi_attempt(
741
750
  snippets: Annotated[
742
751
  List[str], Field(description="Tactics to try (3+ recommended)")
743
752
  ],
744
- ) -> str:
753
+ ) -> MultiAttemptResult:
745
754
  """Try multiple tactics without modifying file. Returns goal state for each."""
746
755
  rel_path = setup_client_for_file(ctx, file_path)
747
756
  if not rel_path:
@@ -753,8 +762,6 @@ def multi_attempt(
753
762
  client.open_file(rel_path)
754
763
 
755
764
  try:
756
- client.open_file(rel_path)
757
-
758
765
  results: List[AttemptResult] = []
759
766
  # Avoid mutating caller-provided snippets; normalize locally per attempt
760
767
  for snippet in snippets:
@@ -769,19 +776,20 @@ def multi_attempt(
769
776
  # Apply the change to the file, capture diagnostics and goal state
770
777
  client.update_file(rel_path, [change])
771
778
  diag = client.get_diagnostics(rel_path)
779
+ check_lsp_response(diag, "get_diagnostics")
772
780
  filtered_diag = filter_diagnostics_by_position(diag, line - 1, None)
773
781
  # Use the snippet text length without any trailing newline for the column
774
782
  goal_result = client.get_goal(rel_path, line - 1, len(snippet_str))
775
- goal_state = format_goal(goal_result, None)
783
+ goals = extract_goals_list(goal_result)
776
784
  results.append(
777
785
  AttemptResult(
778
786
  snippet=snippet_str,
779
- goal_state=goal_state,
787
+ goals=goals,
780
788
  diagnostics=_to_diagnostic_messages(filtered_diag),
781
789
  )
782
790
  )
783
791
 
784
- return _to_json_array(results)
792
+ return MultiAttemptResult(items=results)
785
793
  finally:
786
794
  try:
787
795
  client.close_files([rel_path])
@@ -837,6 +845,7 @@ def run_code(
837
845
  client.open_file(rel_path)
838
846
  opened_file = True
839
847
  raw_diagnostics = client.get_diagnostics(rel_path, inactivity_timeout=15.0)
848
+ check_lsp_response(raw_diagnostics, "get_diagnostics")
840
849
  finally:
841
850
  if opened_file:
842
851
  try:
@@ -878,7 +887,7 @@ def local_search(
878
887
  project_root: Annotated[
879
888
  Optional[str], Field(description="Project root (inferred if omitted)")
880
889
  ] = None,
881
- ) -> str:
890
+ ) -> LocalSearchResults:
882
891
  """Fast local search to verify declarations exist. Use BEFORE trying a lemma name."""
883
892
  if not _RG_AVAILABLE:
884
893
  raise LocalSearchError(_RG_MESSAGE)
@@ -910,7 +919,7 @@ def local_search(
910
919
  LocalSearchResult(name=r["name"], kind=r["kind"], file=r["file"])
911
920
  for r in raw_results
912
921
  ]
913
- return _to_json_array(results)
922
+ return LocalSearchResults(items=results)
914
923
  except RuntimeError as exc:
915
924
  raise LocalSearchError(f"Search failed: {exc}")
916
925
 
@@ -929,7 +938,7 @@ def leansearch(
929
938
  ctx: Context,
930
939
  query: Annotated[str, Field(description="Natural language or Lean term query")],
931
940
  num_results: Annotated[int, Field(description="Max results", ge=1)] = 5,
932
- ) -> str:
941
+ ) -> LeanSearchResults:
933
942
  """Search Mathlib via leansearch.net using natural language.
934
943
 
935
944
  Examples: "sum of two even numbers is even", "Cauchy-Schwarz inequality",
@@ -945,11 +954,11 @@ def leansearch(
945
954
  method="POST",
946
955
  )
947
956
 
948
- with urllib.request.urlopen(req, timeout=20) as response:
957
+ with urllib.request.urlopen(req, timeout=10) as response:
949
958
  results = orjson.loads(response.read())
950
959
 
951
960
  if not results or not results[0]:
952
- return "[]"
961
+ return LeanSearchResults(items=[])
953
962
 
954
963
  raw_results = [r["result"] for r in results[0][:num_results]]
955
964
  items = [
@@ -961,7 +970,7 @@ def leansearch(
961
970
  )
962
971
  for r in raw_results
963
972
  ]
964
- return _to_json_array(items)
973
+ return LeanSearchResults(items=items)
965
974
 
966
975
 
967
976
  @mcp.tool(
@@ -979,7 +988,7 @@ async def loogle(
979
988
  str, Field(description="Type pattern, constant, or name substring")
980
989
  ],
981
990
  num_results: Annotated[int, Field(description="Max results", ge=1)] = 8,
982
- ) -> str:
991
+ ) -> LoogleResults:
983
992
  """Search Mathlib by type signature via loogle.lean-lang.org.
984
993
 
985
994
  Examples: `Real.sin`, `"comm"`, `(?a → ?b) → List ?a → List ?b`,
@@ -989,10 +998,15 @@ async def loogle(
989
998
 
990
999
  # Try local loogle first if available (no rate limiting)
991
1000
  if app_ctx.loogle_local_available and app_ctx.loogle_manager:
1001
+ # Update project path if it changed (adds new library paths)
1002
+ if app_ctx.lean_project_path != app_ctx.loogle_manager.project_path:
1003
+ if app_ctx.loogle_manager.set_project_path(app_ctx.lean_project_path):
1004
+ # Restart to pick up new paths
1005
+ await app_ctx.loogle_manager.stop()
992
1006
  try:
993
1007
  results = await app_ctx.loogle_manager.query(query, num_results)
994
1008
  if not results:
995
- return "No results found."
1009
+ return LoogleResults(items=[])
996
1010
  items = [
997
1011
  LoogleResult(
998
1012
  name=r.get("name", ""),
@@ -1001,7 +1015,7 @@ async def loogle(
1001
1015
  )
1002
1016
  for r in results
1003
1017
  ]
1004
- return _to_json_array(items)
1018
+ return LoogleResults(items=items)
1005
1019
  except Exception as e:
1006
1020
  logger.warning(f"Local loogle failed: {e}, falling back to remote")
1007
1021
 
@@ -1010,13 +1024,15 @@ async def loogle(
1010
1024
  now = int(time.time())
1011
1025
  rate_limit[:] = [t for t in rate_limit if now - t < 30]
1012
1026
  if len(rate_limit) >= 3:
1013
- return "Rate limit exceeded: 3 requests per 30s. Use --loogle-local to avoid limits."
1027
+ raise LeanToolError(
1028
+ "Rate limit exceeded: 3 requests per 30s. Use --loogle-local to avoid limits."
1029
+ )
1014
1030
  rate_limit.append(now)
1015
1031
 
1016
1032
  result = loogle_remote(query, num_results)
1017
1033
  if isinstance(result, str):
1018
- return result # Error message
1019
- return _to_json_array(result)
1034
+ raise LeanToolError(result) # Error message from remote
1035
+ return LoogleResults(items=result)
1020
1036
 
1021
1037
 
1022
1038
  @mcp.tool(
@@ -1033,7 +1049,7 @@ def leanfinder(
1033
1049
  ctx: Context,
1034
1050
  query: Annotated[str, Field(description="Mathematical concept or proof state")],
1035
1051
  num_results: Annotated[int, Field(description="Max results", ge=1)] = 5,
1036
- ) -> str:
1052
+ ) -> LeanFinderResults:
1037
1053
  """Semantic search by mathematical meaning via Lean Finder.
1038
1054
 
1039
1055
  Examples: "commutativity of addition on natural numbers",
@@ -1047,7 +1063,7 @@ def leanfinder(
1047
1063
  )
1048
1064
 
1049
1065
  results: List[LeanFinderResult] = []
1050
- with urllib.request.urlopen(req, timeout=30) as response:
1066
+ with urllib.request.urlopen(req, timeout=10) as response:
1051
1067
  data = orjson.loads(response.read())
1052
1068
  for result in data["results"]:
1053
1069
  if (
@@ -1065,7 +1081,7 @@ def leanfinder(
1065
1081
  )
1066
1082
  )
1067
1083
 
1068
- return _to_json_array(results)
1084
+ return LeanFinderResults(items=results)
1069
1085
 
1070
1086
 
1071
1087
  @mcp.tool(
@@ -1084,7 +1100,7 @@ def state_search(
1084
1100
  line: Annotated[int, Field(description="Line number (1-indexed)", ge=1)],
1085
1101
  column: Annotated[int, Field(description="Column number (1-indexed)", ge=1)],
1086
1102
  num_results: Annotated[int, Field(description="Max results", ge=1)] = 5,
1087
- ) -> str:
1103
+ ) -> StateSearchResults:
1088
1104
  """Find lemmas to close the goal at a position. Searches premise-search.com."""
1089
1105
  rel_path = setup_client_for_file(ctx, file_path)
1090
1106
  if not rel_path:
@@ -1110,11 +1126,11 @@ def state_search(
1110
1126
  method="GET",
1111
1127
  )
1112
1128
 
1113
- with urllib.request.urlopen(req, timeout=20) as response:
1129
+ with urllib.request.urlopen(req, timeout=10) as response:
1114
1130
  results = orjson.loads(response.read())
1115
1131
 
1116
1132
  items = [StateSearchResult(name=r["name"]) for r in results]
1117
- return _to_json_array(items)
1133
+ return StateSearchResults(items=items)
1118
1134
 
1119
1135
 
1120
1136
  @mcp.tool(
@@ -1133,7 +1149,7 @@ def hammer_premise(
1133
1149
  line: Annotated[int, Field(description="Line number (1-indexed)", ge=1)],
1134
1150
  column: Annotated[int, Field(description="Column number (1-indexed)", ge=1)],
1135
1151
  num_results: Annotated[int, Field(description="Max results", ge=1)] = 32,
1136
- ) -> str:
1152
+ ) -> PremiseResults:
1137
1153
  """Get premise suggestions for automation tactics at a goal position.
1138
1154
 
1139
1155
  Returns lemma names to try with `simp only [...]`, `aesop`, or as hints.
@@ -1170,11 +1186,11 @@ def hammer_premise(
1170
1186
  data=orjson.dumps(data),
1171
1187
  )
1172
1188
 
1173
- with urllib.request.urlopen(req, timeout=20) as response:
1189
+ with urllib.request.urlopen(req, timeout=10) as response:
1174
1190
  results = orjson.loads(response.read())
1175
1191
 
1176
1192
  items = [PremiseResult(name=r["name"]) for r in results]
1177
- return _to_json_array(items)
1193
+ return PremiseResults(items=items)
1178
1194
 
1179
1195
 
1180
1196
  if __name__ == "__main__":
lean_lsp_mcp/utils.py CHANGED
@@ -2,11 +2,39 @@ import os
2
2
  import secrets
3
3
  import sys
4
4
  import tempfile
5
- from typing import List, Dict, Optional, Callable
5
+ from typing import Any, List, Dict, Optional, Callable
6
6
 
7
7
  from mcp.server.auth.provider import AccessToken, TokenVerifier
8
8
 
9
9
 
10
+ class LeanToolError(Exception):
11
+ """Exception raised when a Lean MCP tool operation fails."""
12
+
13
+ pass
14
+
15
+
16
+ def check_lsp_response(
17
+ response: Any, operation: str, *, allow_none: bool = False
18
+ ) -> Any:
19
+ """Check an LSP response for error patterns and raise if found.
20
+
21
+ Args:
22
+ response: The response from a leanclient LSP operation
23
+ operation: Human-readable description of the operation
24
+ allow_none: If False (default), None raises LeanToolError (timeout).
25
+ If True, None is allowed (for operations where None is valid).
26
+
27
+ Raises:
28
+ LeanToolError: If response indicates failure
29
+ """
30
+ if response is None and not allow_none:
31
+ raise LeanToolError(f"LSP timeout during {operation}")
32
+ if isinstance(response, dict) and "error" in response:
33
+ msg = response["error"].get("message", "unknown error")
34
+ raise LeanToolError(f"LSP error during {operation}: {msg}")
35
+ return response
36
+
37
+
10
38
  class OutputCapture:
11
39
  """Capture any output to stdout and stderr at the file descriptor level."""
12
40
 
@@ -80,11 +108,11 @@ def format_diagnostics(diagnostics: List[Dict], select_line: int = -1) -> List[s
80
108
  return msgs
81
109
 
82
110
 
83
- def format_goal(goal, default_msg):
84
- if goal is None:
85
- return default_msg
86
- rendered = goal.get("rendered")
87
- return rendered.replace("```lean\n", "").replace("\n```", "") if rendered else None
111
+ def extract_goals_list(goal_response: dict | None) -> List[str]:
112
+ """Extract goals list from LSP response, returning empty list if no goals."""
113
+ if goal_response is None:
114
+ return []
115
+ return goal_response.get("goals", [])
88
116
 
89
117
 
90
118
  def _utf16_index_to_py_index(text: str, utf16_index: int) -> int | None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lean-lsp-mcp
3
- Version: 0.16.1
3
+ Version: 0.17.0
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.7.0
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"
@@ -0,0 +1,17 @@
1
+ lean_lsp_mcp/__init__.py,sha256=MN_bNFyb5-p33JWWGbrlUYBd1UUMQKtZYGC9KCh2mtM,1403
2
+ lean_lsp_mcp/__main__.py,sha256=XnpTzfJc0T-j9tHtdkA8ovTr1c139ffTewcJGhxYDaM,49
3
+ lean_lsp_mcp/client_utils.py,sha256=HgPuB35rMitn2Xm8SCAErsFLq15trB6VMz3FDFgmPd8,4897
4
+ lean_lsp_mcp/file_utils.py,sha256=kCTYQSfmV-R2cm_NCi_L8W5Dcsm0_rTOPpTtpyAin78,1365
5
+ lean_lsp_mcp/instructions.py,sha256=iJk_oD67tqNaC8K5OXEuXafULKSbbiHjZAsSRebOwdw,1904
6
+ lean_lsp_mcp/loogle.py,sha256=zUgnDWoTIqa4G6GXStAIxxJUR545YbU8Z-8KMjddKV0,15500
7
+ lean_lsp_mcp/models.py,sha256=2pLmvNsrMdn4vO1k119Jw8gqYeaeGKewWW0q1TabBCY,6604
8
+ lean_lsp_mcp/outline_utils.py,sha256=-eoZNbx2eaKaYmuyFJnwUMWP8I9YXNWusue_2OYpDBM,10981
9
+ lean_lsp_mcp/search_utils.py,sha256=MLqKGe4bhEvyfFLIBCmiDxkbcH4O5J3vl9mWnRSb_v0,6801
10
+ lean_lsp_mcp/server.py,sha256=1G7-FZz1TNJ7uU36eSTm9yponWyMO3QhQiyOhhTNOH8,40626
11
+ lean_lsp_mcp/utils.py,sha256=MmGgdhrLEvCtRRVNgN_vflO9_A25h76QbJXhBD-OKt0,12721
12
+ lean_lsp_mcp-0.17.0.dist-info/licenses/LICENSE,sha256=CQlxnf0tQyoVrBE93JYvAUYxv6Z5Yg6sX0pwogOkFvo,1071
13
+ lean_lsp_mcp-0.17.0.dist-info/METADATA,sha256=W23YEfDLfEd_C7YdjynKhmyId5wSYfqdoZ8CyVoeCzo,20787
14
+ lean_lsp_mcp-0.17.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
15
+ lean_lsp_mcp-0.17.0.dist-info/entry_points.txt,sha256=nQbvwctWkWD7I-2f4VrdVQBZYGUw8CnUnFC6QjXxOSE,51
16
+ lean_lsp_mcp-0.17.0.dist-info/top_level.txt,sha256=LGEK0lgMSNPIQ6mG8EO-adaZEGPi_0daDs004epOTF0,13
17
+ lean_lsp_mcp-0.17.0.dist-info/RECORD,,
@@ -1,17 +0,0 @@
1
- lean_lsp_mcp/__init__.py,sha256=MN_bNFyb5-p33JWWGbrlUYBd1UUMQKtZYGC9KCh2mtM,1403
2
- lean_lsp_mcp/__main__.py,sha256=XnpTzfJc0T-j9tHtdkA8ovTr1c139ffTewcJGhxYDaM,49
3
- lean_lsp_mcp/client_utils.py,sha256=HgPuB35rMitn2Xm8SCAErsFLq15trB6VMz3FDFgmPd8,4897
4
- lean_lsp_mcp/file_utils.py,sha256=kCTYQSfmV-R2cm_NCi_L8W5Dcsm0_rTOPpTtpyAin78,1365
5
- lean_lsp_mcp/instructions.py,sha256=S1y834V8v-SFSYJlxxy6Dj-Z0szMyEBT5SkEyM6Npr8,1756
6
- lean_lsp_mcp/loogle.py,sha256=ChybtPM8jOxP8s28358yNqcLiYvGlQqkAEFFLzR87Zw,11971
7
- lean_lsp_mcp/models.py,sha256=M8CmTg0_NL7KwcQ7UX_Zk7ZG1zXoWLINr41NPs_no2Y,4301
8
- lean_lsp_mcp/outline_utils.py,sha256=-eoZNbx2eaKaYmuyFJnwUMWP8I9YXNWusue_2OYpDBM,10981
9
- lean_lsp_mcp/search_utils.py,sha256=X2LPynDNLi767UDxbxHpMccOkbnfKJKv_HxvRNxIXM4,3984
10
- lean_lsp_mcp/server.py,sha256=8QEkRlbLEKif4NfagD2l1min1w1IFF7Jw1TUUOK-xFg,39481
11
- lean_lsp_mcp/utils.py,sha256=355kzyB3dkwU7_4Mfcg--JXEorFaE2gtqs6-HbH5rRE,11722
12
- lean_lsp_mcp-0.16.1.dist-info/licenses/LICENSE,sha256=CQlxnf0tQyoVrBE93JYvAUYxv6Z5Yg6sX0pwogOkFvo,1071
13
- lean_lsp_mcp-0.16.1.dist-info/METADATA,sha256=nwfjUYlagiUb49IkT3AWoOmJwH4fnwJ5ElyvD9V8zGw,20819
14
- lean_lsp_mcp-0.16.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
15
- lean_lsp_mcp-0.16.1.dist-info/entry_points.txt,sha256=nQbvwctWkWD7I-2f4VrdVQBZYGUw8CnUnFC6QjXxOSE,51
16
- lean_lsp_mcp-0.16.1.dist-info/top_level.txt,sha256=LGEK0lgMSNPIQ6mG8EO-adaZEGPi_0daDs004epOTF0,13
17
- lean_lsp_mcp-0.16.1.dist-info/RECORD,,