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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {lean_lsp_mcp-0.14.1 → lean_lsp_mcp-0.15.0}/PKG-INFO +24 -3
  2. lean_lsp_mcp-0.14.1/src/lean_lsp_mcp.egg-info/PKG-INFO → lean_lsp_mcp-0.15.0/README.md +21 -22
  3. {lean_lsp_mcp-0.14.1 → lean_lsp_mcp-0.15.0}/pyproject.toml +6 -3
  4. {lean_lsp_mcp-0.14.1 → lean_lsp_mcp-0.15.0}/src/lean_lsp_mcp/__init__.py +19 -0
  5. lean_lsp_mcp-0.15.0/src/lean_lsp_mcp/loogle.py +276 -0
  6. {lean_lsp_mcp-0.14.1 → lean_lsp_mcp-0.15.0}/src/lean_lsp_mcp/outline_utils.py +97 -59
  7. {lean_lsp_mcp-0.14.1 → lean_lsp_mcp-0.15.0}/src/lean_lsp_mcp/server.py +44 -18
  8. lean_lsp_mcp-0.14.1/README.md → lean_lsp_mcp-0.15.0/src/lean_lsp_mcp.egg-info/PKG-INFO +43 -0
  9. {lean_lsp_mcp-0.14.1 → lean_lsp_mcp-0.15.0}/src/lean_lsp_mcp.egg-info/SOURCES.txt +1 -0
  10. {lean_lsp_mcp-0.14.1 → lean_lsp_mcp-0.15.0}/src/lean_lsp_mcp.egg-info/requires.txt +2 -2
  11. {lean_lsp_mcp-0.14.1 → lean_lsp_mcp-0.15.0}/tests/test_outline.py +30 -16
  12. {lean_lsp_mcp-0.14.1 → lean_lsp_mcp-0.15.0}/LICENSE +0 -0
  13. {lean_lsp_mcp-0.14.1 → lean_lsp_mcp-0.15.0}/setup.cfg +0 -0
  14. {lean_lsp_mcp-0.14.1 → lean_lsp_mcp-0.15.0}/src/lean_lsp_mcp/__main__.py +0 -0
  15. {lean_lsp_mcp-0.14.1 → lean_lsp_mcp-0.15.0}/src/lean_lsp_mcp/client_utils.py +0 -0
  16. {lean_lsp_mcp-0.14.1 → lean_lsp_mcp-0.15.0}/src/lean_lsp_mcp/file_utils.py +0 -0
  17. {lean_lsp_mcp-0.14.1 → lean_lsp_mcp-0.15.0}/src/lean_lsp_mcp/instructions.py +0 -0
  18. {lean_lsp_mcp-0.14.1 → lean_lsp_mcp-0.15.0}/src/lean_lsp_mcp/search_utils.py +0 -0
  19. {lean_lsp_mcp-0.14.1 → lean_lsp_mcp-0.15.0}/src/lean_lsp_mcp/utils.py +0 -0
  20. {lean_lsp_mcp-0.14.1 → lean_lsp_mcp-0.15.0}/src/lean_lsp_mcp.egg-info/dependency_links.txt +0 -0
  21. {lean_lsp_mcp-0.14.1 → lean_lsp_mcp-0.15.0}/src/lean_lsp_mcp.egg-info/entry_points.txt +0 -0
  22. {lean_lsp_mcp-0.14.1 → lean_lsp_mcp-0.15.0}/src/lean_lsp_mcp.egg-info/top_level.txt +0 -0
  23. {lean_lsp_mcp-0.14.1 → lean_lsp_mcp-0.15.0}/tests/test_diagnostic_line_range.py +0 -0
  24. {lean_lsp_mcp-0.14.1 → lean_lsp_mcp-0.15.0}/tests/test_editor_tools.py +0 -0
  25. {lean_lsp_mcp-0.14.1 → lean_lsp_mcp-0.15.0}/tests/test_file_caching.py +0 -0
  26. {lean_lsp_mcp-0.14.1 → lean_lsp_mcp-0.15.0}/tests/test_logging.py +0 -0
  27. {lean_lsp_mcp-0.14.1 → lean_lsp_mcp-0.15.0}/tests/test_misc_tools.py +0 -0
  28. {lean_lsp_mcp-0.14.1 → lean_lsp_mcp-0.15.0}/tests/test_project_tools.py +0 -0
  29. {lean_lsp_mcp-0.14.1 → lean_lsp_mcp-0.15.0}/tests/test_search_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lean-lsp-mcp
3
- Version: 0.14.1
3
+ Version: 0.15.0
4
4
  Summary: Lean Theorem Prover MCP
5
5
  Author-email: Oliver Dressler <hey@oli.show>
6
6
  License-Expression: MIT
@@ -8,8 +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.5.5
12
- Requires-Dist: mcp[cli]==1.21.2
11
+ Requires-Dist: leanclient==0.6.1
12
+ Requires-Dist: mcp[cli]==1.23.1
13
13
  Requires-Dist: orjson>=3.11.1
14
14
  Provides-Extra: lint
15
15
  Requires-Dist: ruff>=0.2.0; extra == "lint"
@@ -348,6 +348,7 @@ Search for Lean definitions and theorems using [loogle.lean-lang.org](https://lo
348
348
 
349
349
  - Supports queries by constant, lemma name, subexpression, type, or conclusion.
350
350
  - Example: `Real.sin`, `"differ"`, `_ * (_ ^ _)`, `(?a -> ?b) -> List ?a -> List ?b`, `|- tsum _ = _ * tsum _`
351
+ - **Local mode available**: Use `--loogle-local` to run loogle locally (avoids rate limits, see [Local Loogle](#local-loogle) section)
351
352
 
352
353
  <details>
353
354
  <summary>Example output (`Real.sin`)</summary>
@@ -464,6 +465,8 @@ This MCP server works out-of-the-box without any configuration. However, a few o
464
465
  - `LEAN_LSP_MCP_TOKEN`: Secret token for bearer authentication when using `streamable-http` or `sse` transport.
465
466
  - `LEAN_STATE_SEARCH_URL`: URL for a self-hosted [premise-search.com](https://premise-search.com) instance.
466
467
  - `LEAN_HAMMER_URL`: URL for a self-hosted [Lean Hammer Premise Search](https://github.com/hanwenzhu/lean-premise-server) instance.
468
+ - `LEAN_LOOGLE_LOCAL`: Set to `true`, `1`, or `yes` to enable local loogle (see [Local Loogle](#local-loogle) section).
469
+ - `LEAN_LOOGLE_CACHE_DIR`: Override the cache directory for local loogle (default: `~/.cache/lean-lsp-mcp/loogle`).
467
470
 
468
471
  You can also often set these environment variables in your MCP client configuration:
469
472
  <details>
@@ -519,6 +522,24 @@ uvx lean-lsp-mcp --transport streamable-http
519
522
 
520
523
  Clients should then include the token in the `Authorization` header.
521
524
 
525
+ ### Local Loogle
526
+
527
+ Run loogle locally to avoid the remote API's rate limit (3 req/30s). First run takes ~5-10 minutes to build; subsequent runs start in seconds.
528
+
529
+ ```bash
530
+ # Enable via CLI
531
+ uvx lean-lsp-mcp --loogle-local
532
+
533
+ # Or via environment variable
534
+ export LEAN_LOOGLE_LOCAL=true
535
+ ```
536
+
537
+ **Requirements:** `git`, `lake` ([elan](https://github.com/leanprover/elan)), ~2GB disk space.
538
+
539
+ **Note:** Local loogle is currently only supported on Unix systems (Linux/macOS). Windows users should use WSL or the remote API.
540
+
541
+ Falls back to remote API if local loogle fails.
542
+
522
543
  ## Notes on MCP Security
523
544
 
524
545
  There are many valid security concerns with the Model Context Protocol (MCP) in general!
@@ -1,25 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: lean-lsp-mcp
3
- Version: 0.14.1
4
- Summary: Lean Theorem Prover MCP
5
- Author-email: Oliver Dressler <hey@oli.show>
6
- License-Expression: MIT
7
- Project-URL: Repository, https://github.com/oOo0oOo/lean-lsp-mcp
8
- Requires-Python: >=3.10
9
- Description-Content-Type: text/markdown
10
- License-File: LICENSE
11
- Requires-Dist: leanclient==0.5.5
12
- Requires-Dist: mcp[cli]==1.21.2
13
- Requires-Dist: orjson>=3.11.1
14
- Provides-Extra: lint
15
- Requires-Dist: ruff>=0.2.0; extra == "lint"
16
- Provides-Extra: dev
17
- Requires-Dist: ruff>=0.2.0; extra == "dev"
18
- Requires-Dist: pytest>=8.3; extra == "dev"
19
- Requires-Dist: anyio>=4.4; extra == "dev"
20
- Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
21
- Dynamic: license-file
22
-
23
1
  <h1 align="center">
24
2
  lean-lsp-mcp
25
3
  </h1>
@@ -348,6 +326,7 @@ Search for Lean definitions and theorems using [loogle.lean-lang.org](https://lo
348
326
 
349
327
  - Supports queries by constant, lemma name, subexpression, type, or conclusion.
350
328
  - Example: `Real.sin`, `"differ"`, `_ * (_ ^ _)`, `(?a -> ?b) -> List ?a -> List ?b`, `|- tsum _ = _ * tsum _`
329
+ - **Local mode available**: Use `--loogle-local` to run loogle locally (avoids rate limits, see [Local Loogle](#local-loogle) section)
351
330
 
352
331
  <details>
353
332
  <summary>Example output (`Real.sin`)</summary>
@@ -464,6 +443,8 @@ This MCP server works out-of-the-box without any configuration. However, a few o
464
443
  - `LEAN_LSP_MCP_TOKEN`: Secret token for bearer authentication when using `streamable-http` or `sse` transport.
465
444
  - `LEAN_STATE_SEARCH_URL`: URL for a self-hosted [premise-search.com](https://premise-search.com) instance.
466
445
  - `LEAN_HAMMER_URL`: URL for a self-hosted [Lean Hammer Premise Search](https://github.com/hanwenzhu/lean-premise-server) instance.
446
+ - `LEAN_LOOGLE_LOCAL`: Set to `true`, `1`, or `yes` to enable local loogle (see [Local Loogle](#local-loogle) section).
447
+ - `LEAN_LOOGLE_CACHE_DIR`: Override the cache directory for local loogle (default: `~/.cache/lean-lsp-mcp/loogle`).
467
448
 
468
449
  You can also often set these environment variables in your MCP client configuration:
469
450
  <details>
@@ -519,6 +500,24 @@ uvx lean-lsp-mcp --transport streamable-http
519
500
 
520
501
  Clients should then include the token in the `Authorization` header.
521
502
 
503
+ ### Local Loogle
504
+
505
+ Run loogle locally to avoid the remote API's rate limit (3 req/30s). First run takes ~5-10 minutes to build; subsequent runs start in seconds.
506
+
507
+ ```bash
508
+ # Enable via CLI
509
+ uvx lean-lsp-mcp --loogle-local
510
+
511
+ # Or via environment variable
512
+ export LEAN_LOOGLE_LOCAL=true
513
+ ```
514
+
515
+ **Requirements:** `git`, `lake` ([elan](https://github.com/leanprover/elan)), ~2GB disk space.
516
+
517
+ **Note:** Local loogle is currently only supported on Unix systems (Linux/macOS). Windows users should use WSL or the remote API.
518
+
519
+ Falls back to remote API if local loogle fails.
520
+
522
521
  ## Notes on MCP Security
523
522
 
524
523
  There are many valid security concerns with the Model Context Protocol (MCP) in general!
@@ -1,14 +1,14 @@
1
1
  [project]
2
2
  name = "lean-lsp-mcp"
3
- version = "0.14.1"
3
+ version = "0.15.0"
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.5.5",
11
- "mcp[cli]==1.21.2",
10
+ "leanclient==0.6.1",
11
+ "mcp[cli]==1.23.1",
12
12
  "orjson>=3.11.1",
13
13
  ]
14
14
 
@@ -28,6 +28,9 @@ dev = [
28
28
 
29
29
  [tool.pytest.ini_options]
30
30
  asyncio_mode = "auto"
31
+ markers = [
32
+ "slow: marks tests as slow (deselect with '-m \"not slow\"')",
33
+ ]
31
34
 
32
35
  [tool.setuptools]
33
36
  packages = ["lean_lsp_mcp"]
@@ -1,4 +1,5 @@
1
1
  import argparse
2
+ import os
2
3
 
3
4
  from lean_lsp_mcp.server import mcp
4
5
 
@@ -24,7 +25,25 @@ def main():
24
25
  default=8000,
25
26
  help="Host port for transport",
26
27
  )
28
+ parser.add_argument(
29
+ "--loogle-local",
30
+ action="store_true",
31
+ help="Enable local loogle (auto-installs on first run, ~5-10 min). "
32
+ "Avoids rate limits and network dependencies.",
33
+ )
34
+ parser.add_argument(
35
+ "--loogle-cache-dir",
36
+ type=str,
37
+ help="Override loogle cache location (default: ~/.cache/lean-lsp-mcp/loogle)",
38
+ )
27
39
  args = parser.parse_args()
40
+
41
+ # Set env vars from CLI args (CLI takes precedence over env vars)
42
+ if args.loogle_local:
43
+ os.environ["LEAN_LOOGLE_LOCAL"] = "true"
44
+ if args.loogle_cache_dir:
45
+ os.environ["LEAN_LOOGLE_CACHE_DIR"] = args.loogle_cache_dir
46
+
28
47
  mcp.settings.host = args.host
29
48
  mcp.settings.port = args.port
30
49
  mcp.run(transport=args.transport)
@@ -0,0 +1,276 @@
1
+ """Loogle search - local subprocess and remote API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import os
9
+ import shutil
10
+ import subprocess
11
+ import urllib.parse
12
+ import urllib.request
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import orjson
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def get_cache_dir() -> Path:
22
+ if d := os.environ.get("LEAN_LOOGLE_CACHE_DIR"):
23
+ return Path(d)
24
+ xdg = os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")
25
+ return Path(xdg) / "lean-lsp-mcp" / "loogle"
26
+
27
+
28
+ def loogle_remote(query: str, num_results: int) -> list[dict] | str:
29
+ """Query the remote loogle API."""
30
+ try:
31
+ req = urllib.request.Request(
32
+ f"https://loogle.lean-lang.org/json?q={urllib.parse.quote(query)}",
33
+ headers={"User-Agent": "lean-lsp-mcp/0.1"},
34
+ )
35
+ with urllib.request.urlopen(req, timeout=20) as response:
36
+ results = orjson.loads(response.read())
37
+ if "hits" not in results:
38
+ return "No results found."
39
+ results = results["hits"][:num_results]
40
+ for r in results:
41
+ r.pop("doc", None)
42
+ return results
43
+ except Exception as e:
44
+ return f"loogle error:\n{e}"
45
+
46
+
47
+ class LoogleManager:
48
+ """Manages local loogle installation and async subprocess."""
49
+
50
+ REPO_URL = "https://github.com/nomeata/loogle.git"
51
+ READY_SIGNAL = "Loogle is ready."
52
+
53
+ def __init__(self, cache_dir: Path | None = None):
54
+ self.cache_dir = cache_dir or get_cache_dir()
55
+ self.repo_dir = self.cache_dir / "repo"
56
+ self.index_dir = self.cache_dir / "index"
57
+ self.process: asyncio.subprocess.Process | None = None
58
+ self._ready = False
59
+ self._lock = asyncio.Lock()
60
+
61
+ @property
62
+ def binary_path(self) -> Path:
63
+ return self.repo_dir / ".lake" / "build" / "bin" / "loogle"
64
+
65
+ @property
66
+ def is_installed(self) -> bool:
67
+ return self.binary_path.exists()
68
+
69
+ @property
70
+ def is_running(self) -> bool:
71
+ return (
72
+ self._ready and self.process is not None and self.process.returncode is None
73
+ )
74
+
75
+ def _check_prerequisites(self) -> tuple[bool, str]:
76
+ if not shutil.which("git"):
77
+ return False, "git not found in PATH"
78
+ if not shutil.which("lake"):
79
+ return (
80
+ False,
81
+ "lake not found (install elan: https://github.com/leanprover/elan)",
82
+ )
83
+ return True, ""
84
+
85
+ def _run(
86
+ self, cmd: list[str], timeout: int = 300, cwd: Path | None = None
87
+ ) -> subprocess.CompletedProcess:
88
+ env = os.environ.copy()
89
+ env["LAKE_ARTIFACT_CACHE"] = "false"
90
+ return subprocess.run(
91
+ cmd,
92
+ capture_output=True,
93
+ text=True,
94
+ timeout=timeout,
95
+ cwd=cwd or self.repo_dir,
96
+ env=env,
97
+ )
98
+
99
+ def _clone_repo(self) -> bool:
100
+ if self.repo_dir.exists():
101
+ return True
102
+ logger.info(f"Cloning loogle to {self.repo_dir}...")
103
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
104
+ try:
105
+ r = self._run(
106
+ ["git", "clone", "--depth", "1", self.REPO_URL, str(self.repo_dir)],
107
+ cwd=self.cache_dir,
108
+ )
109
+ if r.returncode != 0:
110
+ logger.error(f"Clone failed: {r.stderr}")
111
+ return False
112
+ return True
113
+ except Exception as e:
114
+ logger.error(f"Clone error: {e}")
115
+ return False
116
+
117
+ def _build_loogle(self) -> bool:
118
+ if self.is_installed:
119
+ return True
120
+ if not self.repo_dir.exists():
121
+ return False
122
+ logger.info("Downloading mathlib cache...")
123
+ try:
124
+ self._run(["lake", "exe", "cache", "get"], timeout=600)
125
+ except Exception as e:
126
+ logger.warning(f"Cache download: {e}")
127
+ logger.info("Building loogle...")
128
+ try:
129
+ return self._run(["lake", "build"], timeout=900).returncode == 0
130
+ except Exception as e:
131
+ logger.error(f"Build error: {e}")
132
+ return False
133
+
134
+ def _get_mathlib_version(self) -> str:
135
+ try:
136
+ manifest = json.loads((self.repo_dir / "lake-manifest.json").read_text())
137
+ for pkg in manifest.get("packages", []):
138
+ if pkg.get("name") == "mathlib":
139
+ return pkg.get("rev", "unknown")[:12]
140
+ except Exception:
141
+ pass
142
+ return "unknown"
143
+
144
+ def _get_index_path(self) -> Path:
145
+ return self.index_dir / f"mathlib-{self._get_mathlib_version()}.idx"
146
+
147
+ def _cleanup_old_indices(self) -> None:
148
+ """Remove old index files from previous mathlib versions."""
149
+ if not self.index_dir.exists():
150
+ return
151
+ current = self._get_index_path()
152
+ for idx in self.index_dir.glob("*.idx"):
153
+ if idx != current:
154
+ try:
155
+ idx.unlink()
156
+ logger.info(f"Removed old index: {idx.name}")
157
+ except Exception:
158
+ pass
159
+
160
+ def _build_index(self) -> Path | None:
161
+ index_path = self._get_index_path()
162
+ if index_path.exists():
163
+ return index_path
164
+ if not self.is_installed:
165
+ return None
166
+ self.index_dir.mkdir(parents=True, exist_ok=True)
167
+ self._cleanup_old_indices()
168
+ logger.info("Building search index...")
169
+ try:
170
+ self._run(
171
+ [str(self.binary_path), "--write-index", str(index_path), "--json", ""],
172
+ timeout=600,
173
+ )
174
+ return index_path if index_path.exists() else None
175
+ except Exception as e:
176
+ logger.error(f"Index build error: {e}")
177
+ return None
178
+
179
+ def ensure_installed(self) -> bool:
180
+ ok, err = self._check_prerequisites()
181
+ if not ok:
182
+ logger.warning(f"Prerequisites: {err}")
183
+ return False
184
+ if not self._clone_repo() or not self._build_loogle():
185
+ return False
186
+ if not self._build_index():
187
+ logger.warning("Index build failed, loogle will build on startup")
188
+ return self.is_installed
189
+
190
+ async def start(self) -> bool:
191
+ if self.process is not None and self.process.returncode is None:
192
+ return self._ready
193
+ if not self.is_installed:
194
+ return False
195
+ cmd = [str(self.binary_path), "--json", "--interactive"]
196
+ if (idx := self._get_index_path()).exists():
197
+ cmd.extend(["--read-index", str(idx)])
198
+ logger.info("Starting loogle subprocess...")
199
+ try:
200
+ self.process = await asyncio.create_subprocess_exec(
201
+ *cmd,
202
+ stdin=asyncio.subprocess.PIPE,
203
+ stdout=asyncio.subprocess.PIPE,
204
+ stderr=asyncio.subprocess.DEVNULL,
205
+ cwd=self.repo_dir,
206
+ )
207
+ line = await asyncio.wait_for(self.process.stdout.readline(), timeout=120)
208
+ if self.READY_SIGNAL in line.decode():
209
+ self._ready = True
210
+ logger.info("Loogle ready")
211
+ return True
212
+ return False
213
+ except asyncio.TimeoutError:
214
+ logger.error("Loogle startup timeout")
215
+ return False
216
+ except Exception as e:
217
+ logger.error(f"Start failed: {e}")
218
+ return False
219
+
220
+ async def query(self, q: str, num_results: int = 8) -> list[dict[str, Any]]:
221
+ async with self._lock:
222
+ # Try up to 2 attempts (initial + one restart)
223
+ for attempt in range(2):
224
+ if (
225
+ not self._ready
226
+ or self.process is None
227
+ or self.process.returncode is not None
228
+ ):
229
+ if attempt > 0:
230
+ raise RuntimeError("Loogle subprocess not ready")
231
+ self._ready = False
232
+ if not await self.start():
233
+ raise RuntimeError("Failed to start loogle")
234
+ continue
235
+
236
+ try:
237
+ self.process.stdin.write(f"{q}\n".encode())
238
+ await self.process.stdin.drain()
239
+ line = await asyncio.wait_for(
240
+ self.process.stdout.readline(), timeout=30
241
+ )
242
+ response = json.loads(line.decode())
243
+ if err := response.get("error"):
244
+ logger.warning(f"Query error: {err}")
245
+ return []
246
+ return [
247
+ {
248
+ "name": h.get("name", ""),
249
+ "type": h.get("type", ""),
250
+ "module": h.get("module", ""),
251
+ "doc": h.get("doc"),
252
+ }
253
+ for h in response.get("hits", [])[:num_results]
254
+ ]
255
+ except asyncio.TimeoutError:
256
+ raise RuntimeError("Query timeout") from None
257
+ except json.JSONDecodeError as e:
258
+ raise RuntimeError(f"Invalid response: {e}") from e
259
+
260
+ raise RuntimeError("Loogle subprocess not ready")
261
+
262
+ async def stop(self) -> None:
263
+ if self.process:
264
+ try:
265
+ self.process.terminate()
266
+ await asyncio.wait_for(self.process.wait(), timeout=5)
267
+ except asyncio.TimeoutError:
268
+ self.process.kill()
269
+ try:
270
+ await asyncio.wait_for(self.process.wait(), timeout=2)
271
+ except asyncio.TimeoutError:
272
+ pass
273
+ except Exception:
274
+ pass
275
+ self.process = None
276
+ self._ready = False
@@ -8,38 +8,45 @@ METHOD_KIND = {6, "method"}
8
8
  KIND_TAGS = {"namespace": "Ns"}
9
9
 
10
10
 
11
- def _get_info_trees(client: LeanLSPClient, path: str, symbols: List[Dict]) -> Dict[str, str]:
11
+ def _get_info_trees(
12
+ client: LeanLSPClient, path: str, symbols: List[Dict]
13
+ ) -> Dict[str, str]:
12
14
  """Insert #info_trees commands, collect diagnostics, then revert changes."""
13
15
  if not symbols:
14
16
  return {}
15
17
 
16
18
  symbol_by_line = {}
17
19
  changes = []
18
- for i, sym in enumerate(sorted(symbols, key=lambda s: s['range']['start']['line'])):
19
- line = sym['range']['start']['line'] + i
20
- symbol_by_line[line] = sym['name']
20
+ for i, sym in enumerate(sorted(symbols, key=lambda s: s["range"]["start"]["line"])):
21
+ line = sym["range"]["start"]["line"] + i
22
+ symbol_by_line[line] = sym["name"]
21
23
  changes.append(DocumentContentChange("#info_trees in\n", [line, 0], [line, 0]))
22
24
 
23
25
  client.update_file(path, changes)
24
26
  diagnostics = client.get_diagnostics(path)
25
27
 
26
28
  info_trees = {
27
- symbol_by_line[diag['range']['start']['line']]: diag['message']
29
+ symbol_by_line[diag["range"]["start"]["line"]]: diag["message"]
28
30
  for diag in diagnostics
29
- if diag['severity'] == 3 and diag['range']['start']['line'] in symbol_by_line
31
+ if diag["severity"] == 3 and diag["range"]["start"]["line"] in symbol_by_line
30
32
  }
31
33
 
32
34
  # Revert in reverse order
33
- client.update_file(path, [
34
- DocumentContentChange("", [line, 0], [line + 1, 0])
35
- for line in sorted(symbol_by_line.keys(), reverse=True)
36
- ])
35
+ client.update_file(
36
+ path,
37
+ [
38
+ DocumentContentChange("", [line, 0], [line + 1, 0])
39
+ for line in sorted(symbol_by_line.keys(), reverse=True)
40
+ ],
41
+ )
37
42
  return info_trees
38
43
 
39
44
 
40
45
  def _extract_type(info: str, name: str) -> Optional[str]:
41
46
  """Extract type signature from info tree message."""
42
- if m := re.search(rf' • \[Term\] {re.escape(name)} \(isBinder := true\) : ([^@]+) @', info):
47
+ if m := re.search(
48
+ rf" • \[Term\] {re.escape(name)} \(isBinder := true\) : ([^@]+) @", info
49
+ ):
43
50
  return m.group(1).strip()
44
51
  return None
45
52
 
@@ -47,14 +54,16 @@ def _extract_type(info: str, name: str) -> Optional[str]:
47
54
  def _extract_fields(info: str, name: str) -> List[Tuple[str, str]]:
48
55
  """Extract structure/class fields from info tree message."""
49
56
  fields = []
50
- for pattern in [rf'{re.escape(name)}\.(\w+)', rf'@{re.escape(name)}\.(\w+)']:
51
- for m in re.finditer(rf' • \[Term\] {pattern} \(isBinder := true\) : (.+?) @', info):
57
+ for pattern in [rf"{re.escape(name)}\.(\w+)", rf"@{re.escape(name)}\.(\w+)"]:
58
+ for m in re.finditer(
59
+ rf" • \[Term\] {pattern} \(isBinder := true\) : (.+?) @", info
60
+ ):
52
61
  field_name, full_type = m.groups()
53
62
  # Clean up the type signature
54
- if ']' in full_type:
55
- field_type = full_type[full_type.rfind(']')+1:].lstrip('').strip()
56
- elif '' in full_type:
57
- field_type = full_type.split('')[-1].strip()
63
+ if "]" in full_type:
64
+ field_type = full_type[full_type.rfind("]") + 1 :].lstrip("").strip()
65
+ elif "" in full_type:
66
+ field_type = full_type.split("")[-1].strip()
58
67
  else:
59
68
  field_type = full_type.strip()
60
69
  fields.append((field_name, field_type))
@@ -68,51 +77,61 @@ def _extract_declarations(content: str, start: int, end: int) -> List[Dict]:
68
77
 
69
78
  while i < min(end, len(lines)):
70
79
  line = lines[i].strip()
71
- for keyword in ['theorem', 'lemma', 'def']:
80
+ for keyword in ["theorem", "lemma", "def"]:
72
81
  if line.startswith(f"{keyword} "):
73
- name = line[len(keyword):].strip().split()[0]
74
- if name and not name.startswith('_'):
82
+ name = line[len(keyword) :].strip().split()[0]
83
+ if name and not name.startswith("_"):
75
84
  # Collect until :=
76
85
  decl_lines = [line]
77
86
  j = i + 1
78
- while j < min(end, len(lines)) and ':=' not in ' '.join(decl_lines):
79
- if (next_line := lines[j].strip()) and not next_line.startswith('--'):
87
+ while j < min(end, len(lines)) and ":=" not in " ".join(decl_lines):
88
+ if (next_line := lines[j].strip()) and not next_line.startswith(
89
+ "--"
90
+ ):
80
91
  decl_lines.append(next_line)
81
92
  j += 1
82
93
 
83
94
  # Extract signature (everything before :=, minus keyword and name)
84
- full_decl = ' '.join(decl_lines)
95
+ full_decl = " ".join(decl_lines)
85
96
  type_sig = None
86
- if ':=' in full_decl:
87
- sig_part = full_decl.split(':=', 1)[0].strip()[len(keyword):].strip()
97
+ if ":=" in full_decl:
98
+ sig_part = (
99
+ full_decl.split(":=", 1)[0].strip()[len(keyword) :].strip()
100
+ )
88
101
  if sig_part.startswith(name):
89
- type_sig = sig_part[len(name):].strip()
90
-
91
- decls.append({
92
- 'name': name,
93
- 'kind': 'method',
94
- 'range': {'start': {'line': i, 'character': 0},
95
- 'end': {'line': i, 'character': len(lines[i])}},
96
- '_keyword': keyword,
97
- '_type': type_sig
98
- })
102
+ type_sig = sig_part[len(name) :].strip()
103
+
104
+ decls.append(
105
+ {
106
+ "name": name,
107
+ "kind": "method",
108
+ "range": {
109
+ "start": {"line": i, "character": 0},
110
+ "end": {"line": i, "character": len(lines[i])},
111
+ },
112
+ "_keyword": keyword,
113
+ "_type": type_sig,
114
+ }
115
+ )
99
116
  break
100
117
  i += 1
101
118
  return decls
102
119
 
103
120
 
104
- def _flatten_symbols(symbols: List[Dict], indent: int = 0, content: str = "") -> List[Tuple[Dict, int]]:
121
+ def _flatten_symbols(
122
+ symbols: List[Dict], indent: int = 0, content: str = ""
123
+ ) -> List[Tuple[Dict, int]]:
105
124
  """Recursively flatten symbol hierarchy, extracting declarations from namespaces."""
106
125
  result = []
107
126
  for sym in symbols:
108
127
  result.append((sym, indent))
109
- children = sym.get('children', [])
128
+ children = sym.get("children", [])
110
129
 
111
130
  # Extract theorem/lemma/def from namespace bodies
112
- if content and sym.get('kind') == 'namespace':
113
- ns_range = sym['range']
114
- ns_start = ns_range['start']['line']
115
- ns_end = ns_range['end']['line']
131
+ if content and sym.get("kind") == "namespace":
132
+ ns_range = sym["range"]
133
+ ns_start = ns_range["start"]["line"]
134
+ ns_end = ns_range["end"]["line"]
116
135
  children = children + _extract_declarations(content, ns_start, ns_end)
117
136
 
118
137
  if children:
@@ -120,32 +139,36 @@ def _flatten_symbols(symbols: List[Dict], indent: int = 0, content: str = "") ->
120
139
  return result
121
140
 
122
141
 
123
- def _detect_tag(name: str, kind: str, type_sig: str, has_fields: bool, keyword: Optional[str]) -> str:
142
+ def _detect_tag(
143
+ name: str, kind: str, type_sig: str, has_fields: bool, keyword: Optional[str]
144
+ ) -> str:
124
145
  """Determine the appropriate tag for a symbol."""
125
146
  if has_fields:
126
- return "Class" if '' in type_sig else "Struct"
147
+ return "Class" if "" in type_sig else "Struct"
127
148
  if name == "example":
128
149
  return "Ex"
129
- if keyword in {'theorem', 'lemma'}:
150
+ if keyword in {"theorem", "lemma"}:
130
151
  return "Thm"
131
- if type_sig and any(marker in type_sig for marker in ['', '=']):
152
+ if type_sig and any(marker in type_sig for marker in ["", "="]):
132
153
  return "Thm"
133
- if type_sig and '' in type_sig.replace('', '', 1): # More than one arrow
154
+ if type_sig and "" in type_sig.replace("", "", 1): # More than one arrow
134
155
  return "Thm"
135
156
  return KIND_TAGS.get(kind, "Def")
136
157
 
137
158
 
138
159
  def _format_symbol(sym: Dict, type_sigs: Dict, fields_map: Dict, indent: int) -> str:
139
160
  """Format a single symbol with its type signature and fields."""
140
- name = sym['name']
141
- type_sig = sym.get('_type') or type_sigs.get(name, "")
161
+ name = sym["name"]
162
+ type_sig = sym.get("_type") or type_sigs.get(name, "")
142
163
  fields = fields_map.get(name, [])
143
164
 
144
- tag = _detect_tag(name, sym.get('kind', ''), type_sig, bool(fields), sym.get('_keyword'))
165
+ tag = _detect_tag(
166
+ name, sym.get("kind", ""), type_sig, bool(fields), sym.get("_keyword")
167
+ )
145
168
  prefix = "\t" * indent
146
169
 
147
- start = sym['range']['start']['line'] + 1
148
- end = sym['range']['end']['line'] + 1
170
+ start = sym["range"]["start"]["line"] + 1
171
+ end = sym["range"]["end"]["line"] + 1
149
172
  line_info = f"L{start}" if start == end else f"L{start}-{end}"
150
173
 
151
174
  result = f"{prefix}[{tag}: {line_info}] {name}"
@@ -164,8 +187,11 @@ def generate_outline(client: LeanLSPClient, path: str) -> str:
164
187
  content = client.get_file_content(path)
165
188
 
166
189
  # Extract imports
167
- imports = [line.strip()[7:] for line in content.splitlines()
168
- if line.strip().startswith("import ")]
190
+ imports = [
191
+ line.strip()[7:]
192
+ for line in content.splitlines()
193
+ if line.strip().startswith("import ")
194
+ ]
169
195
 
170
196
  symbols = client.get_document_symbols(path)
171
197
  if not symbols and not imports:
@@ -175,14 +201,24 @@ def generate_outline(client: LeanLSPClient, path: str) -> str:
175
201
  all_symbols = _flatten_symbols(symbols, content=content)
176
202
 
177
203
  # Get info trees only for LSP symbols (not extracted declarations)
178
- lsp_methods = [s for s, _ in all_symbols if s.get('kind') in METHOD_KIND and '_keyword' not in s]
204
+ lsp_methods = [
205
+ s
206
+ for s, _ in all_symbols
207
+ if s.get("kind") in METHOD_KIND and "_keyword" not in s
208
+ ]
179
209
  info_trees = _get_info_trees(client, path, lsp_methods)
180
210
 
181
211
  # Extract type signatures and fields from info trees
182
- type_sigs = {name: sig for name, info in info_trees.items()
183
- if (sig := _extract_type(info, name))}
184
- fields_map = {name: fields for name, info in info_trees.items()
185
- if (fields := _extract_fields(info, name))}
212
+ type_sigs = {
213
+ name: sig
214
+ for name, info in info_trees.items()
215
+ if (sig := _extract_type(info, name))
216
+ }
217
+ fields_map = {
218
+ name: fields
219
+ for name, info in info_trees.items()
220
+ if (fields := _extract_fields(info, name))
221
+ }
186
222
 
187
223
  # Build output
188
224
  parts = []
@@ -193,7 +229,9 @@ def generate_outline(client: LeanLSPClient, path: str) -> str:
193
229
  declarations = [
194
230
  _format_symbol(sym, type_sigs, fields_map, indent)
195
231
  for sym, indent in all_symbols
196
- if sym.get('kind') in METHOD_KIND or sym.get('_keyword') or sym.get('kind') == 'namespace'
232
+ if sym.get("kind") in METHOD_KIND
233
+ or sym.get("_keyword")
234
+ or sym.get("kind") == "namespace"
197
235
  ]
198
236
  parts.append("## Declarations\n" + "".join(declarations).rstrip())
199
237
 
@@ -26,6 +26,7 @@ from lean_lsp_mcp.client_utils import (
26
26
  from lean_lsp_mcp.file_utils import get_file_contents
27
27
  from lean_lsp_mcp.instructions import INSTRUCTIONS
28
28
  from lean_lsp_mcp.search_utils import check_ripgrep_status, lean_local_search
29
+ from lean_lsp_mcp.loogle import LoogleManager, loogle_remote
29
30
  from lean_lsp_mcp.outline_utils import generate_outline
30
31
  from lean_lsp_mcp.utils import (
31
32
  OutputCapture,
@@ -56,10 +57,15 @@ class AppContext:
56
57
  client: LeanLSPClient | None
57
58
  rate_limit: Dict[str, List[int]]
58
59
  lean_search_available: bool
60
+ loogle_manager: LoogleManager | None = None
61
+ loogle_local_available: bool = False
59
62
 
60
63
 
61
64
  @asynccontextmanager
62
65
  async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
66
+ loogle_manager: LoogleManager | None = None
67
+ loogle_local_available = False
68
+
63
69
  try:
64
70
  lean_project_path_str = os.environ.get("LEAN_PROJECT_PATH", "").strip()
65
71
  if not lean_project_path_str:
@@ -67,6 +73,19 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
67
73
  else:
68
74
  lean_project_path = Path(lean_project_path_str).resolve()
69
75
 
76
+ # Initialize local loogle if enabled via env var or CLI
77
+ if os.environ.get("LEAN_LOOGLE_LOCAL", "").lower() in ("1", "true", "yes"):
78
+ logger.info("Local loogle enabled, initializing...")
79
+ loogle_manager = LoogleManager()
80
+ if loogle_manager.ensure_installed():
81
+ if await loogle_manager.start():
82
+ loogle_local_available = True
83
+ logger.info("Local loogle started successfully")
84
+ else:
85
+ logger.warning("Local loogle failed to start, will use remote API")
86
+ else:
87
+ logger.warning("Local loogle installation failed, will use remote API")
88
+
70
89
  context = AppContext(
71
90
  lean_project_path=lean_project_path,
72
91
  client=None,
@@ -78,6 +97,8 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
78
97
  "hammer_premise": [],
79
98
  },
80
99
  lean_search_available=_RG_AVAILABLE,
100
+ loogle_manager=loogle_manager,
101
+ loogle_local_available=loogle_local_available,
81
102
  )
82
103
  yield context
83
104
  finally:
@@ -86,6 +107,9 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
86
107
  if context.client:
87
108
  context.client.close()
88
109
 
110
+ if loogle_manager:
111
+ await loogle_manager.stop()
112
+
89
113
 
90
114
  mcp_kwargs = dict(
91
115
  name="Lean LSP",
@@ -322,6 +346,7 @@ def diagnostic_messages(
322
346
  return "Invalid Lean file path: Unable to start LSP server or load file"
323
347
 
324
348
  client: LeanLSPClient = ctx.request_context.lifespan_context.client
349
+ client.open_file(rel_path)
325
350
 
326
351
  # If declaration_name is provided, get its range and use that for filtering
327
352
  if declaration_name:
@@ -821,8 +846,7 @@ def leansearch(ctx: Context, query: str, num_results: int = 5) -> List[Dict] | s
821
846
 
822
847
 
823
848
  @mcp.tool("lean_loogle")
824
- @rate_limited("loogle", max_requests=3, per_seconds=30)
825
- def loogle(ctx: Context, query: str, num_results: int = 8) -> List[dict] | str:
849
+ async def loogle(ctx: Context, query: str, num_results: int = 8) -> List[dict] | str:
826
850
  """Search for definitions and theorems using loogle.
827
851
 
828
852
  Query patterns:
@@ -841,25 +865,27 @@ def loogle(ctx: Context, query: str, num_results: int = 8) -> List[dict] | str:
841
865
  Returns:
842
866
  List[dict] | str: Search results or error msg
843
867
  """
844
- try:
845
- req = urllib.request.Request(
846
- f"https://loogle.lean-lang.org/json?q={urllib.parse.quote(query)}",
847
- headers={"User-Agent": "lean-lsp-mcp/0.1"},
848
- method="GET",
849
- )
868
+ app_ctx: AppContext = ctx.request_context.lifespan_context
850
869
 
851
- with urllib.request.urlopen(req, timeout=20) as response:
852
- results = orjson.loads(response.read())
870
+ # Try local loogle first if available (no rate limiting)
871
+ if app_ctx.loogle_local_available and app_ctx.loogle_manager:
872
+ try:
873
+ results = await app_ctx.loogle_manager.query(query, num_results)
874
+ for result in results:
875
+ result.pop("doc", None)
876
+ return results if results else "No results found."
877
+ except Exception as e:
878
+ logger.warning(f"Local loogle failed: {e}, falling back to remote")
853
879
 
854
- if "hits" not in results:
855
- return "No results found."
880
+ # Fall back to remote (with rate limiting)
881
+ rate_limit = app_ctx.rate_limit["loogle"]
882
+ now = int(time.time())
883
+ rate_limit[:] = [t for t in rate_limit if now - t < 30]
884
+ if len(rate_limit) >= 3:
885
+ return "Rate limit exceeded: 3 requests per 30s. Use --loogle-local to avoid limits."
886
+ rate_limit.append(now)
856
887
 
857
- results = results["hits"][:num_results]
858
- for result in results:
859
- result.pop("doc", None)
860
- return results
861
- except Exception as e:
862
- return f"loogle error:\n{str(e)}"
888
+ return loogle_remote(query, num_results)
863
889
 
864
890
 
865
891
  @mcp.tool("lean_leanfinder")
@@ -1,3 +1,25 @@
1
+ Metadata-Version: 2.4
2
+ Name: lean-lsp-mcp
3
+ Version: 0.15.0
4
+ Summary: Lean Theorem Prover MCP
5
+ Author-email: Oliver Dressler <hey@oli.show>
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/oOo0oOo/lean-lsp-mcp
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: leanclient==0.6.1
12
+ Requires-Dist: mcp[cli]==1.23.1
13
+ Requires-Dist: orjson>=3.11.1
14
+ Provides-Extra: lint
15
+ Requires-Dist: ruff>=0.2.0; extra == "lint"
16
+ Provides-Extra: dev
17
+ Requires-Dist: ruff>=0.2.0; extra == "dev"
18
+ Requires-Dist: pytest>=8.3; extra == "dev"
19
+ Requires-Dist: anyio>=4.4; extra == "dev"
20
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
21
+ Dynamic: license-file
22
+
1
23
  <h1 align="center">
2
24
  lean-lsp-mcp
3
25
  </h1>
@@ -326,6 +348,7 @@ Search for Lean definitions and theorems using [loogle.lean-lang.org](https://lo
326
348
 
327
349
  - Supports queries by constant, lemma name, subexpression, type, or conclusion.
328
350
  - Example: `Real.sin`, `"differ"`, `_ * (_ ^ _)`, `(?a -> ?b) -> List ?a -> List ?b`, `|- tsum _ = _ * tsum _`
351
+ - **Local mode available**: Use `--loogle-local` to run loogle locally (avoids rate limits, see [Local Loogle](#local-loogle) section)
329
352
 
330
353
  <details>
331
354
  <summary>Example output (`Real.sin`)</summary>
@@ -442,6 +465,8 @@ This MCP server works out-of-the-box without any configuration. However, a few o
442
465
  - `LEAN_LSP_MCP_TOKEN`: Secret token for bearer authentication when using `streamable-http` or `sse` transport.
443
466
  - `LEAN_STATE_SEARCH_URL`: URL for a self-hosted [premise-search.com](https://premise-search.com) instance.
444
467
  - `LEAN_HAMMER_URL`: URL for a self-hosted [Lean Hammer Premise Search](https://github.com/hanwenzhu/lean-premise-server) instance.
468
+ - `LEAN_LOOGLE_LOCAL`: Set to `true`, `1`, or `yes` to enable local loogle (see [Local Loogle](#local-loogle) section).
469
+ - `LEAN_LOOGLE_CACHE_DIR`: Override the cache directory for local loogle (default: `~/.cache/lean-lsp-mcp/loogle`).
445
470
 
446
471
  You can also often set these environment variables in your MCP client configuration:
447
472
  <details>
@@ -497,6 +522,24 @@ uvx lean-lsp-mcp --transport streamable-http
497
522
 
498
523
  Clients should then include the token in the `Authorization` header.
499
524
 
525
+ ### Local Loogle
526
+
527
+ Run loogle locally to avoid the remote API's rate limit (3 req/30s). First run takes ~5-10 minutes to build; subsequent runs start in seconds.
528
+
529
+ ```bash
530
+ # Enable via CLI
531
+ uvx lean-lsp-mcp --loogle-local
532
+
533
+ # Or via environment variable
534
+ export LEAN_LOOGLE_LOCAL=true
535
+ ```
536
+
537
+ **Requirements:** `git`, `lake` ([elan](https://github.com/leanprover/elan)), ~2GB disk space.
538
+
539
+ **Note:** Local loogle is currently only supported on Unix systems (Linux/macOS). Windows users should use WSL or the remote API.
540
+
541
+ Falls back to remote API if local loogle fails.
542
+
500
543
  ## Notes on MCP Security
501
544
 
502
545
  There are many valid security concerns with the Model Context Protocol (MCP) in general!
@@ -6,6 +6,7 @@ src/lean_lsp_mcp/__main__.py
6
6
  src/lean_lsp_mcp/client_utils.py
7
7
  src/lean_lsp_mcp/file_utils.py
8
8
  src/lean_lsp_mcp/instructions.py
9
+ src/lean_lsp_mcp/loogle.py
9
10
  src/lean_lsp_mcp/outline_utils.py
10
11
  src/lean_lsp_mcp/search_utils.py
11
12
  src/lean_lsp_mcp/server.py
@@ -1,5 +1,5 @@
1
- leanclient==0.5.5
2
- mcp[cli]==1.21.2
1
+ leanclient==0.6.1
2
+ mcp[cli]==1.23.1
3
3
  orjson>=3.11.1
4
4
 
5
5
  [dev]
@@ -28,12 +28,14 @@ async def test_outline_simple_files(
28
28
  test_project_path / "StructTest.lean",
29
29
  test_project_path / "TheoremTest.lean",
30
30
  ]
31
-
31
+
32
32
  async with mcp_client_factory() as client:
33
33
  for test_file in test_files:
34
- result = await client.call_tool("lean_file_outline", {"file_path": str(test_file)})
34
+ result = await client.call_tool(
35
+ "lean_file_outline", {"file_path": str(test_file)}
36
+ )
35
37
  outline = result_text(result)
36
-
38
+
37
39
  # Basic structure checks
38
40
  assert "## Imports" in outline or "## Declarations" in outline
39
41
  assert len(outline) > 0
@@ -46,19 +48,21 @@ async def test_mathlib_outline_structure(
46
48
  ) -> None:
47
49
  """Test outline generation with a real Mathlib file."""
48
50
  async with mcp_client_factory() as client:
49
- result = await client.call_tool("lean_file_outline", {"file_path": str(mathlib_nat_basic)})
51
+ result = await client.call_tool(
52
+ "lean_file_outline", {"file_path": str(mathlib_nat_basic)}
53
+ )
50
54
  outline = result_text(result)
51
-
55
+
52
56
  # Basic structure checks (no filename header now)
53
57
  assert "## Imports" in outline
54
58
  assert "## Declarations" in outline
55
-
59
+
56
60
  # Should have imports from Mathlib
57
61
  assert "Mathlib.Data.Nat.Init" in outline
58
-
62
+
59
63
  # Should have namespace (new format)
60
64
  assert "[Ns:" in outline and "Nat" in outline
61
-
65
+
62
66
  # Should have instance declarations
63
67
  assert "instLinearOrder" in outline or "LinearOrder" in outline
64
68
 
@@ -70,13 +74,15 @@ async def test_mathlib_outline_has_line_numbers(
70
74
  ) -> None:
71
75
  """Verify line numbers are present in outline."""
72
76
  async with mcp_client_factory() as client:
73
- result = await client.call_tool("lean_file_outline", {"file_path": str(mathlib_nat_basic)})
77
+ result = await client.call_tool(
78
+ "lean_file_outline", {"file_path": str(mathlib_nat_basic)}
79
+ )
74
80
  outline = result_text(result)
75
-
81
+
76
82
  # Should have line numbers in format "[Tag: L27-135]" or "[Tag: L31]"
77
- line_pattern = r'L(\d+)(?:-(\d+))?'
83
+ line_pattern = r"L(\d+)(?:-(\d+))?"
78
84
  matches = re.findall(line_pattern, outline)
79
-
85
+
80
86
  assert len(matches) > 0, "Should have line number annotations"
81
87
 
82
88
 
@@ -87,7 +93,9 @@ async def test_mathlib_outline_has_types(
87
93
  ) -> None:
88
94
  """Verify type signatures are included."""
89
95
  async with mcp_client_factory() as client:
90
- result = await client.call_tool("lean_file_outline", {"file_path": str(mathlib_nat_basic)})
96
+ result = await client.call_tool(
97
+ "lean_file_outline", {"file_path": str(mathlib_nat_basic)}
98
+ )
91
99
  outline = result_text(result)
92
100
 
93
101
  # Should have type annotations with ":"
@@ -105,13 +113,19 @@ async def test_mathlib_outline_file_cleanup(
105
113
  original_content = mathlib_nat_basic.read_text()
106
114
 
107
115
  # Generate outline (which inserts and removes #info_trees lines)
108
- await client.call_tool("lean_file_outline", {"file_path": str(mathlib_nat_basic)})
116
+ await client.call_tool(
117
+ "lean_file_outline", {"file_path": str(mathlib_nat_basic)}
118
+ )
109
119
 
110
120
  # Read file content again
111
121
  final_content = mathlib_nat_basic.read_text()
112
122
 
113
123
  # File should be unchanged
114
- assert final_content == original_content, "File should be restored to original state after outline generation"
124
+ assert final_content == original_content, (
125
+ "File should be restored to original state after outline generation"
126
+ )
115
127
 
116
128
  # Specifically check that no #info_trees lines remain
117
- assert "#info_trees" not in final_content, "No #info_trees directives should remain in file"
129
+ assert "#info_trees" not in final_content, (
130
+ "No #info_trees directives should remain in file"
131
+ )
File without changes
File without changes