lean-lsp-mcp 0.10.3__tar.gz → 0.11.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.
- {lean_lsp_mcp-0.10.3/src/lean_lsp_mcp.egg-info → lean_lsp_mcp-0.11.0}/PKG-INFO +34 -8
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/README.md +32 -6
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/pyproject.toml +2 -2
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp/client_utils.py +31 -28
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp/file_utils.py +0 -1
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp/instructions.py +1 -0
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp/server.py +150 -21
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp/utils.py +53 -11
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0/src/lean_lsp_mcp.egg-info}/PKG-INFO +34 -8
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp.egg-info/requires.txt +1 -1
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/tests/test_search_tools.py +20 -0
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/LICENSE +0 -0
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/setup.cfg +0 -0
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp/__init__.py +0 -0
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp/__main__.py +0 -0
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp/search_utils.py +0 -0
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp.egg-info/SOURCES.txt +0 -0
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp.egg-info/dependency_links.txt +0 -0
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp.egg-info/entry_points.txt +0 -0
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp.egg-info/top_level.txt +0 -0
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/tests/test_editor_tools.py +0 -0
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/tests/test_logging.py +0 -0
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/tests/test_misc_tools.py +0 -0
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/tests/test_project_tools.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lean-lsp-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0
|
|
4
4
|
Summary: Lean Theorem Prover MCP
|
|
5
5
|
Author-email: Oliver Dressler <hey@oli.show>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -8,7 +8,7 @@ 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.
|
|
11
|
+
Requires-Dist: leanclient==0.4.0
|
|
12
12
|
Requires-Dist: mcp[cli]==1.19.0
|
|
13
13
|
Requires-Dist: orjson>=3.11.1
|
|
14
14
|
Provides-Extra: lint
|
|
@@ -43,7 +43,7 @@ MCP server that allows agentic interaction with the [Lean theorem prover](https:
|
|
|
43
43
|
## Key Features
|
|
44
44
|
|
|
45
45
|
* **Rich Lean Interaction**: Access diagnostics, goal states, term information, hover documentation and more.
|
|
46
|
-
* **External Search Tools**: Use `
|
|
46
|
+
* **External Search Tools**: Use `LeanSearch`, `Loogle`, `Lean Finder`, `Lean Hammer` and `Lean State Search` to find relevant theorems and definitions.
|
|
47
47
|
* **Easy Setup**: Simple configuration for various clients, including VSCode, Cursor and Claude Code.
|
|
48
48
|
|
|
49
49
|
## Setup
|
|
@@ -122,11 +122,9 @@ Run one of these commands in the root directory of your Lean project (where `lak
|
|
|
122
122
|
# Local-scoped MCP server
|
|
123
123
|
claude mcp add lean-lsp uvx lean-lsp-mcp
|
|
124
124
|
|
|
125
|
-
# OR project-scoped MCP server
|
|
125
|
+
# OR project-scoped MCP server
|
|
126
|
+
# (creates or updates a .mcp.json file in the current directory)
|
|
126
127
|
claude mcp add lean-lsp -s project uvx lean-lsp-mcp
|
|
127
|
-
|
|
128
|
-
# OR If you run into issues with the project path (e.g. the language server directory cannot be found), you can also set it manually e.g.
|
|
129
|
-
claude mcp add lean-lsp uvx lean-lsp-mcp -e LEAN_PROJECT_PATH=$PWD
|
|
130
128
|
```
|
|
131
129
|
|
|
132
130
|
You can find more details about MCP server configuration for Claude Code [here](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/tutorials#configure-mcp-servers).
|
|
@@ -284,7 +282,9 @@ This tool requires [ripgrep](https://github.com/BurntSushi/ripgrep?tab=readme-ov
|
|
|
284
282
|
|
|
285
283
|
### External Search Tools
|
|
286
284
|
|
|
287
|
-
Currently
|
|
285
|
+
Currently most external tools are separately **rate limited to 3 requests per 30 seconds**. Please don't ruin the fun for everyone by overusing these amazing free services!
|
|
286
|
+
|
|
287
|
+
Please cite the original authors of these tools if you use them!
|
|
288
288
|
|
|
289
289
|
#### lean_leansearch
|
|
290
290
|
|
|
@@ -337,6 +337,32 @@ Search for Lean definitions and theorems using [loogle.lean-lang.org](https://lo
|
|
|
337
337
|
```
|
|
338
338
|
</details>
|
|
339
339
|
|
|
340
|
+
#### lean_leanfinder
|
|
341
|
+
|
|
342
|
+
Semantic search for Mathlib theorems using [Lean Finder](https://huggingface.co/spaces/delta-lab-ai/Lean-Finder).
|
|
343
|
+
|
|
344
|
+
[Arxiv Paper](https://arxiv.org/abs/2510.15940)
|
|
345
|
+
|
|
346
|
+
- Supports informal descriptions, user questions, proof states, and statement fragments.
|
|
347
|
+
- Examples: `algebraic elements x,y over K with same minimal polynomial`, `Does y being a root of minpoly(x) imply minpoly(x)=minpoly(y)?`, `⊢ |re z| ≤ ‖z‖` + `transform to squared norm inequality`, `theorem restrict Ioi: restrict Ioi e = restrict Ici e`
|
|
348
|
+
|
|
349
|
+
<details>
|
|
350
|
+
<summary>Example output</summary>
|
|
351
|
+
|
|
352
|
+
Query: `Does y being a root of minpoly(x) imply minpoly(x)=minpoly(y)?`
|
|
353
|
+
|
|
354
|
+
```json
|
|
355
|
+
[
|
|
356
|
+
[
|
|
357
|
+
"/-- If `y : L` is a root of `minpoly K x`, then `minpoly K y = minpoly K x`. -/\ntheorem eq_of_root {x y : L} (hx : IsAlgebraic K x)\n (h_ev : Polynomial.aeval y (minpoly K x) = 0) : minpoly K y = minpoly K x :=\n ((eq_iff_aeval_minpoly_eq_zero hx.isIntegral).mpr h_ev).symm",
|
|
358
|
+
|
|
359
|
+
"Let $L/K$ be a field extension, and let $x, y \\in L$ be elements such that $y$ is a root of the minimal polynomial of $x$ over $K$. If $x$ is algebraic over $K$, then the minimal polynomial of $y$ over $K$ is equal to the minimal polynomial of $x$ over $K$, i.e., $\\text{minpoly}_K(y) = \\text{minpoly}_K(x)$. This means that if $y$ satisfies the polynomial equation defined by $x$, then $y$ shares the same minimal polynomial as $x$."
|
|
360
|
+
],
|
|
361
|
+
...
|
|
362
|
+
]
|
|
363
|
+
```
|
|
364
|
+
</details>
|
|
365
|
+
|
|
340
366
|
#### lean_state_search
|
|
341
367
|
|
|
342
368
|
Search for applicable theorems for the current proof goal using [premise-search.com](https://premise-search.com/).
|
|
@@ -21,7 +21,7 @@ MCP server that allows agentic interaction with the [Lean theorem prover](https:
|
|
|
21
21
|
## Key Features
|
|
22
22
|
|
|
23
23
|
* **Rich Lean Interaction**: Access diagnostics, goal states, term information, hover documentation and more.
|
|
24
|
-
* **External Search Tools**: Use `
|
|
24
|
+
* **External Search Tools**: Use `LeanSearch`, `Loogle`, `Lean Finder`, `Lean Hammer` and `Lean State Search` to find relevant theorems and definitions.
|
|
25
25
|
* **Easy Setup**: Simple configuration for various clients, including VSCode, Cursor and Claude Code.
|
|
26
26
|
|
|
27
27
|
## Setup
|
|
@@ -100,11 +100,9 @@ Run one of these commands in the root directory of your Lean project (where `lak
|
|
|
100
100
|
# Local-scoped MCP server
|
|
101
101
|
claude mcp add lean-lsp uvx lean-lsp-mcp
|
|
102
102
|
|
|
103
|
-
# OR project-scoped MCP server
|
|
103
|
+
# OR project-scoped MCP server
|
|
104
|
+
# (creates or updates a .mcp.json file in the current directory)
|
|
104
105
|
claude mcp add lean-lsp -s project uvx lean-lsp-mcp
|
|
105
|
-
|
|
106
|
-
# OR If you run into issues with the project path (e.g. the language server directory cannot be found), you can also set it manually e.g.
|
|
107
|
-
claude mcp add lean-lsp uvx lean-lsp-mcp -e LEAN_PROJECT_PATH=$PWD
|
|
108
106
|
```
|
|
109
107
|
|
|
110
108
|
You can find more details about MCP server configuration for Claude Code [here](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/tutorials#configure-mcp-servers).
|
|
@@ -262,7 +260,9 @@ This tool requires [ripgrep](https://github.com/BurntSushi/ripgrep?tab=readme-ov
|
|
|
262
260
|
|
|
263
261
|
### External Search Tools
|
|
264
262
|
|
|
265
|
-
Currently
|
|
263
|
+
Currently most external tools are separately **rate limited to 3 requests per 30 seconds**. Please don't ruin the fun for everyone by overusing these amazing free services!
|
|
264
|
+
|
|
265
|
+
Please cite the original authors of these tools if you use them!
|
|
266
266
|
|
|
267
267
|
#### lean_leansearch
|
|
268
268
|
|
|
@@ -315,6 +315,32 @@ Search for Lean definitions and theorems using [loogle.lean-lang.org](https://lo
|
|
|
315
315
|
```
|
|
316
316
|
</details>
|
|
317
317
|
|
|
318
|
+
#### lean_leanfinder
|
|
319
|
+
|
|
320
|
+
Semantic search for Mathlib theorems using [Lean Finder](https://huggingface.co/spaces/delta-lab-ai/Lean-Finder).
|
|
321
|
+
|
|
322
|
+
[Arxiv Paper](https://arxiv.org/abs/2510.15940)
|
|
323
|
+
|
|
324
|
+
- Supports informal descriptions, user questions, proof states, and statement fragments.
|
|
325
|
+
- Examples: `algebraic elements x,y over K with same minimal polynomial`, `Does y being a root of minpoly(x) imply minpoly(x)=minpoly(y)?`, `⊢ |re z| ≤ ‖z‖` + `transform to squared norm inequality`, `theorem restrict Ioi: restrict Ioi e = restrict Ici e`
|
|
326
|
+
|
|
327
|
+
<details>
|
|
328
|
+
<summary>Example output</summary>
|
|
329
|
+
|
|
330
|
+
Query: `Does y being a root of minpoly(x) imply minpoly(x)=minpoly(y)?`
|
|
331
|
+
|
|
332
|
+
```json
|
|
333
|
+
[
|
|
334
|
+
[
|
|
335
|
+
"/-- If `y : L` is a root of `minpoly K x`, then `minpoly K y = minpoly K x`. -/\ntheorem eq_of_root {x y : L} (hx : IsAlgebraic K x)\n (h_ev : Polynomial.aeval y (minpoly K x) = 0) : minpoly K y = minpoly K x :=\n ((eq_iff_aeval_minpoly_eq_zero hx.isIntegral).mpr h_ev).symm",
|
|
336
|
+
|
|
337
|
+
"Let $L/K$ be a field extension, and let $x, y \\in L$ be elements such that $y$ is a root of the minimal polynomial of $x$ over $K$. If $x$ is algebraic over $K$, then the minimal polynomial of $y$ over $K$ is equal to the minimal polynomial of $x$ over $K$, i.e., $\\text{minpoly}_K(y) = \\text{minpoly}_K(x)$. This means that if $y$ satisfies the polynomial equation defined by $x$, then $y$ shares the same minimal polynomial as $x$."
|
|
338
|
+
],
|
|
339
|
+
...
|
|
340
|
+
]
|
|
341
|
+
```
|
|
342
|
+
</details>
|
|
343
|
+
|
|
318
344
|
#### lean_state_search
|
|
319
345
|
|
|
320
346
|
Search for applicable theorems for the current proof goal using [premise-search.com](https://premise-search.com/).
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "lean-lsp-mcp"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.11.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.
|
|
10
|
+
"leanclient==0.4.0",
|
|
11
11
|
"mcp[cli]==1.19.0",
|
|
12
12
|
"orjson>=3.11.1",
|
|
13
13
|
]
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
|
+
from threading import Lock
|
|
2
3
|
|
|
3
4
|
from mcp.server.fastmcp import Context
|
|
4
5
|
from mcp.server.fastmcp.utilities.logging import get_logger
|
|
@@ -9,6 +10,7 @@ from lean_lsp_mcp.utils import OutputCapture
|
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
logger = get_logger(__name__)
|
|
13
|
+
CLIENT_LOCK = Lock()
|
|
12
14
|
|
|
13
15
|
|
|
14
16
|
def startup_client(ctx: Context):
|
|
@@ -17,34 +19,35 @@ def startup_client(ctx: Context):
|
|
|
17
19
|
Args:
|
|
18
20
|
ctx (Context): Context object.
|
|
19
21
|
"""
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
22
|
+
with CLIENT_LOCK:
|
|
23
|
+
lean_project_path = ctx.request_context.lifespan_context.lean_project_path
|
|
24
|
+
if lean_project_path is None:
|
|
25
|
+
raise ValueError("lean project path is not set.")
|
|
26
|
+
|
|
27
|
+
# Check if already correct client
|
|
28
|
+
client: LeanLSPClient | None = ctx.request_context.lifespan_context.client
|
|
29
|
+
|
|
30
|
+
if client is not None:
|
|
31
|
+
# Both are Path objects now, direct comparison works
|
|
32
|
+
if client.project_path == lean_project_path:
|
|
33
|
+
return # Client already set up correctly - reuse it!
|
|
34
|
+
# Different project path - close old client
|
|
35
|
+
client.close()
|
|
36
|
+
ctx.request_context.lifespan_context.file_content_hashes.clear()
|
|
37
|
+
|
|
38
|
+
# Need to create a new client
|
|
39
|
+
with OutputCapture() as output:
|
|
40
|
+
try:
|
|
41
|
+
client = LeanLSPClient(lean_project_path)
|
|
42
|
+
logger.info(f"Connected to Lean language server at {lean_project_path}")
|
|
43
|
+
except Exception as e:
|
|
44
|
+
logger.warning(f"Initial connection failed, trying with build: {e}")
|
|
45
|
+
client = LeanLSPClient(lean_project_path, initial_build=True)
|
|
46
|
+
logger.info(f"Connected with initial build to {lean_project_path}")
|
|
47
|
+
build_output = output.get_output()
|
|
48
|
+
if build_output:
|
|
49
|
+
logger.debug(f"Build output: {build_output}")
|
|
50
|
+
ctx.request_context.lifespan_context.client = client
|
|
48
51
|
|
|
49
52
|
|
|
50
53
|
def valid_lean_project_path(path: Path | str) -> bool:
|
|
@@ -11,5 +11,6 @@ INSTRUCTIONS = """## General Rules
|
|
|
11
11
|
- lean_hover_info: Documentation about terms and lean syntax.
|
|
12
12
|
- lean_leansearch: Search theorems using natural language or Lean terms.
|
|
13
13
|
- lean_loogle: Search definitions and theorems by name, type, or subexpression.
|
|
14
|
+
- lean_leanfinder: Semantic search for theorems using Lean Finder.
|
|
14
15
|
- lean_state_search: Search theorems using goal-based search.
|
|
15
16
|
"""
|
|
@@ -10,6 +10,7 @@ import urllib
|
|
|
10
10
|
import json
|
|
11
11
|
import functools
|
|
12
12
|
import subprocess
|
|
13
|
+
import uuid
|
|
13
14
|
from pathlib import Path
|
|
14
15
|
|
|
15
16
|
from mcp.server.fastmcp import Context, FastMCP
|
|
@@ -17,7 +18,7 @@ from mcp.server.fastmcp.utilities.logging import get_logger, configure_logging
|
|
|
17
18
|
from mcp.server.auth.settings import AuthSettings
|
|
18
19
|
from leanclient import LeanLSPClient, DocumentContentChange
|
|
19
20
|
|
|
20
|
-
from lean_lsp_mcp.client_utils import setup_client_for_file
|
|
21
|
+
from lean_lsp_mcp.client_utils import setup_client_for_file, startup_client
|
|
21
22
|
from lean_lsp_mcp.file_utils import get_file_contents, update_file
|
|
22
23
|
from lean_lsp_mcp.instructions import INSTRUCTIONS
|
|
23
24
|
from lean_lsp_mcp.search_utils import check_ripgrep_status, lean_local_search
|
|
@@ -67,6 +68,7 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
|
|
|
67
68
|
rate_limit={
|
|
68
69
|
"leansearch": [],
|
|
69
70
|
"loogle": [],
|
|
71
|
+
"leanfinder": [],
|
|
70
72
|
"lean_state_search": [],
|
|
71
73
|
"hammer_premise": [],
|
|
72
74
|
},
|
|
@@ -104,7 +106,14 @@ def rate_limited(category: str, max_requests: int, per_seconds: int):
|
|
|
104
106
|
def decorator(func):
|
|
105
107
|
@functools.wraps(func)
|
|
106
108
|
def wrapper(*args, **kwargs):
|
|
107
|
-
|
|
109
|
+
ctx = kwargs.get("ctx")
|
|
110
|
+
if ctx is None:
|
|
111
|
+
if not args:
|
|
112
|
+
raise KeyError(
|
|
113
|
+
"rate_limited wrapper requires ctx as a keyword argument or the first positional argument"
|
|
114
|
+
)
|
|
115
|
+
ctx = args[0]
|
|
116
|
+
rate_limit = ctx.request_context.lifespan_context.rate_limit
|
|
108
117
|
current_time = int(time.time())
|
|
109
118
|
rate_limit[category] = [
|
|
110
119
|
timestamp
|
|
@@ -124,7 +133,9 @@ def rate_limited(category: str, max_requests: int, per_seconds: int):
|
|
|
124
133
|
|
|
125
134
|
# Project level tools
|
|
126
135
|
@mcp.tool("lean_build")
|
|
127
|
-
async def lsp_build(
|
|
136
|
+
async def lsp_build(
|
|
137
|
+
ctx: Context, lean_project_path: str = None, clean: bool = False
|
|
138
|
+
) -> str:
|
|
128
139
|
"""Build the Lean project and restart the LSP Server.
|
|
129
140
|
|
|
130
141
|
Use only if needed (e.g. new imports).
|
|
@@ -142,10 +153,17 @@ async def lsp_build(ctx: Context, lean_project_path: str = None, clean: bool = F
|
|
|
142
153
|
lean_project_path_obj = Path(lean_project_path).resolve()
|
|
143
154
|
ctx.request_context.lifespan_context.lean_project_path = lean_project_path_obj
|
|
144
155
|
|
|
156
|
+
if lean_project_path_obj is None:
|
|
157
|
+
return (
|
|
158
|
+
"Lean project path not known yet. Provide `lean_project_path` explicitly or call a "
|
|
159
|
+
"tool that infers it (e.g. `lean_goal`) before running `lean_build`."
|
|
160
|
+
)
|
|
161
|
+
|
|
145
162
|
build_output = ""
|
|
146
163
|
try:
|
|
147
164
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
148
165
|
if client:
|
|
166
|
+
ctx.request_context.lifespan_context.client = None
|
|
149
167
|
client.close()
|
|
150
168
|
ctx.request_context.lifespan_context.file_content_hashes.clear()
|
|
151
169
|
|
|
@@ -154,14 +172,18 @@ async def lsp_build(ctx: Context, lean_project_path: str = None, clean: bool = F
|
|
|
154
172
|
logger.info("Ran `lake clean`")
|
|
155
173
|
|
|
156
174
|
# Fetch cache
|
|
157
|
-
subprocess.run(
|
|
175
|
+
subprocess.run(
|
|
176
|
+
["lake", "exe", "cache", "get"], cwd=lean_project_path_obj, check=False
|
|
177
|
+
)
|
|
158
178
|
|
|
159
179
|
# Run build with progress reporting
|
|
160
180
|
process = await asyncio.create_subprocess_exec(
|
|
161
|
-
"lake",
|
|
181
|
+
"lake",
|
|
182
|
+
"build",
|
|
183
|
+
"--verbose",
|
|
162
184
|
cwd=lean_project_path_obj,
|
|
163
185
|
stdout=asyncio.subprocess.PIPE,
|
|
164
|
-
stderr=asyncio.subprocess.STDOUT
|
|
186
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
165
187
|
)
|
|
166
188
|
|
|
167
189
|
output_lines = []
|
|
@@ -171,25 +193,25 @@ async def lsp_build(ctx: Context, lean_project_path: str = None, clean: bool = F
|
|
|
171
193
|
if not line:
|
|
172
194
|
break
|
|
173
195
|
|
|
174
|
-
line_str = line.decode(
|
|
196
|
+
line_str = line.decode("utf-8", errors="replace").rstrip()
|
|
175
197
|
output_lines.append(line_str)
|
|
176
198
|
|
|
177
199
|
# Parse progress: look for pattern like "[2/8]" or "[10/100]"
|
|
178
|
-
match = re.search(r
|
|
200
|
+
match = re.search(r"\[(\d+)/(\d+)\]", line_str)
|
|
179
201
|
if match:
|
|
180
202
|
current_job = int(match.group(1))
|
|
181
203
|
total_jobs = int(match.group(2))
|
|
182
204
|
|
|
183
205
|
# Extract what's being built
|
|
184
206
|
# Line format: "ℹ [2/8] Built TestLeanBuild.Basic (1.6s)"
|
|
185
|
-
desc_match = re.search(
|
|
207
|
+
desc_match = re.search(
|
|
208
|
+
r"\[\d+/\d+\]\s+(.+?)(?:\s+\(\d+\.?\d*[ms]+\))?$", line_str
|
|
209
|
+
)
|
|
186
210
|
description = desc_match.group(1) if desc_match else "Building"
|
|
187
211
|
|
|
188
212
|
# Report progress using dynamic totals from Lake
|
|
189
213
|
await ctx.report_progress(
|
|
190
|
-
progress=current_job,
|
|
191
|
-
total=total_jobs,
|
|
192
|
-
message=description
|
|
214
|
+
progress=current_job, total=total_jobs, message=description
|
|
193
215
|
)
|
|
194
216
|
|
|
195
217
|
await process.wait()
|
|
@@ -200,7 +222,9 @@ async def lsp_build(ctx: Context, lean_project_path: str = None, clean: bool = F
|
|
|
200
222
|
|
|
201
223
|
# Start LSP client (without initial build since we just did it)
|
|
202
224
|
with OutputCapture():
|
|
203
|
-
client = LeanLSPClient(
|
|
225
|
+
client = LeanLSPClient(
|
|
226
|
+
lean_project_path_obj, initial_build=False, prevent_cache_get=True
|
|
227
|
+
)
|
|
204
228
|
|
|
205
229
|
logger.info("Built project and re-started LSP client")
|
|
206
230
|
|
|
@@ -568,11 +592,13 @@ def run_code(ctx: Context, code: str) -> List[str] | str:
|
|
|
568
592
|
Returns:
|
|
569
593
|
List[str] | str: Diagnostics msgs or error msg
|
|
570
594
|
"""
|
|
571
|
-
|
|
595
|
+
lifespan_context = ctx.request_context.lifespan_context
|
|
596
|
+
lean_project_path = lifespan_context.lean_project_path
|
|
572
597
|
if lean_project_path is None:
|
|
573
598
|
return "No valid Lean project path found. Run another tool (e.g. `lean_diagnostic_messages`) first to set it up or set the LEAN_PROJECT_PATH environment variable."
|
|
574
599
|
|
|
575
|
-
|
|
600
|
+
# Use a unique snippet filename to avoid collisions under concurrency
|
|
601
|
+
rel_path = f"_mcp_snippet_{uuid.uuid4().hex}.lean"
|
|
576
602
|
abs_path = lean_project_path / rel_path
|
|
577
603
|
|
|
578
604
|
try:
|
|
@@ -581,14 +607,44 @@ def run_code(ctx: Context, code: str) -> List[str] | str:
|
|
|
581
607
|
except Exception as e:
|
|
582
608
|
return f"Error writing code snippet to file `{abs_path}`:\n{str(e)}"
|
|
583
609
|
|
|
584
|
-
client: LeanLSPClient =
|
|
585
|
-
diagnostics =
|
|
586
|
-
|
|
610
|
+
client: LeanLSPClient | None = lifespan_context.client
|
|
611
|
+
diagnostics: List[str] | str = []
|
|
612
|
+
close_error: str | None = None
|
|
613
|
+
remove_error: str | None = None
|
|
614
|
+
opened_file = False
|
|
587
615
|
|
|
588
616
|
try:
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
617
|
+
if client is None:
|
|
618
|
+
startup_client(ctx)
|
|
619
|
+
client = lifespan_context.client
|
|
620
|
+
if client is None:
|
|
621
|
+
return "Failed to initialize Lean client for run_code."
|
|
622
|
+
|
|
623
|
+
assert client is not None # startup_client guarantees an initialized client
|
|
624
|
+
client.open_file(rel_path)
|
|
625
|
+
opened_file = True
|
|
626
|
+
diagnostics = format_diagnostics(client.get_diagnostics(rel_path))
|
|
627
|
+
finally:
|
|
628
|
+
if opened_file:
|
|
629
|
+
try:
|
|
630
|
+
client.close_files([rel_path])
|
|
631
|
+
except Exception as exc: # pragma: no cover - close failures only logged
|
|
632
|
+
close_error = str(exc)
|
|
633
|
+
logger.warning("Failed to close `%s` after run_code: %s", rel_path, exc)
|
|
634
|
+
try:
|
|
635
|
+
os.remove(abs_path)
|
|
636
|
+
except FileNotFoundError:
|
|
637
|
+
pass
|
|
638
|
+
except Exception as e:
|
|
639
|
+
remove_error = str(e)
|
|
640
|
+
logger.warning(
|
|
641
|
+
"Failed to remove temporary Lean snippet `%s`: %s", abs_path, e
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
if remove_error:
|
|
645
|
+
return f"Error removing temporary file `{abs_path}`:\n{remove_error}"
|
|
646
|
+
if close_error:
|
|
647
|
+
return f"Error closing temporary Lean document `{rel_path}`:\n{close_error}"
|
|
592
648
|
|
|
593
649
|
return (
|
|
594
650
|
diagnostics
|
|
@@ -717,6 +773,79 @@ def loogle(ctx: Context, query: str, num_results: int = 8) -> List[dict] | str:
|
|
|
717
773
|
return f"loogle error:\n{str(e)}"
|
|
718
774
|
|
|
719
775
|
|
|
776
|
+
@mcp.tool("lean_leanfinder")
|
|
777
|
+
@rate_limited("leanfinder", max_requests=10, per_seconds=30)
|
|
778
|
+
def leanfinder(
|
|
779
|
+
ctx: Context, query: str, num_results: int = 5
|
|
780
|
+
) -> List[tuple] | str:
|
|
781
|
+
"""Search Mathlib theorems/definitions semantically by mathematical concept using Lean Finder.
|
|
782
|
+
|
|
783
|
+
Effective query types:
|
|
784
|
+
- Math + API: "setAverage Icc interval", "integral_pow symmetric bounds"
|
|
785
|
+
- Conceptual: "algebraic elements same minimal polynomial", "quadrature nodes"
|
|
786
|
+
- Structure: "Finset expect sum commute", "polynomial degree bounded eval"
|
|
787
|
+
- Natural: "average equals point values", "root implies equal polynomials"
|
|
788
|
+
|
|
789
|
+
Tips: Mix informal math terms with Lean identifiers. Multiple targeted queries beat one complex query.
|
|
790
|
+
|
|
791
|
+
Args:
|
|
792
|
+
query (str): Mathematical concepts combined with Lean terms
|
|
793
|
+
num_results (int, optional): Max results. Defaults to 5.
|
|
794
|
+
|
|
795
|
+
Returns:
|
|
796
|
+
List[tuple] | str: (lean_statement, english_description) pairs or error
|
|
797
|
+
"""
|
|
798
|
+
try:
|
|
799
|
+
headers = {"User-Agent": "lean-lsp-mcp/0.1", "Content-Type": "application/json"}
|
|
800
|
+
payload = json.dumps({"data": [query, num_results, "Normal"]}).encode("utf-8")
|
|
801
|
+
|
|
802
|
+
req = urllib.request.Request(
|
|
803
|
+
"https://delta-lab-ai-lean-finder.hf.space/gradio_api/call/retrieve",
|
|
804
|
+
data=payload,
|
|
805
|
+
headers=headers,
|
|
806
|
+
method="POST",
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
with urllib.request.urlopen(req, timeout=10) as response:
|
|
810
|
+
event_data = json.loads(response.read().decode("utf-8"))
|
|
811
|
+
event_id = event_data.get("event_id")
|
|
812
|
+
|
|
813
|
+
if not event_id:
|
|
814
|
+
return "Lean Finder has timed out or errored. It might be warming up, try a second time in 2 minutes."
|
|
815
|
+
|
|
816
|
+
result_url = f"https://delta-lab-ai-lean-finder.hf.space/gradio_api/call/retrieve/{event_id}"
|
|
817
|
+
req = urllib.request.Request(result_url, headers=headers, method="GET")
|
|
818
|
+
|
|
819
|
+
with urllib.request.urlopen(req, timeout=30) as response:
|
|
820
|
+
for line in response:
|
|
821
|
+
line = line.decode("utf-8").strip()
|
|
822
|
+
if line.startswith("data: "):
|
|
823
|
+
data = json.loads(line[6:])
|
|
824
|
+
if isinstance(data, list) and len(data) > 0:
|
|
825
|
+
html = data[0] if isinstance(data[0], str) else str(data)
|
|
826
|
+
|
|
827
|
+
# Parse HTML table rows
|
|
828
|
+
rows = re.findall(
|
|
829
|
+
r"<tr><td>\d+</td><td>(.*?)</td><td>(.*?)</td></tr>",
|
|
830
|
+
html, re.DOTALL
|
|
831
|
+
)
|
|
832
|
+
results = []
|
|
833
|
+
for formal_cell, informal_cell in rows:
|
|
834
|
+
formal = re.search(r"<code[^>]*>(.*?)</code>", formal_cell, re.DOTALL)
|
|
835
|
+
informal = re.search(r"<span[^>]*>(.*?)</span>", informal_cell, re.DOTALL)
|
|
836
|
+
if formal:
|
|
837
|
+
results.append((
|
|
838
|
+
formal.group(1).strip(),
|
|
839
|
+
informal.group(1).strip() if informal else ""
|
|
840
|
+
))
|
|
841
|
+
|
|
842
|
+
return results if results else "Lean Finder: No results parsed"
|
|
843
|
+
|
|
844
|
+
return "Lean Finder: No results received"
|
|
845
|
+
except Exception as e:
|
|
846
|
+
return f"Lean Finder Error:\n{str(e)}"
|
|
847
|
+
|
|
848
|
+
|
|
720
849
|
@mcp.tool("lean_state_search")
|
|
721
850
|
@rate_limited("lean_state_search", max_requests=3, per_seconds=30)
|
|
722
851
|
def state_search(
|
|
@@ -73,6 +73,27 @@ def format_goal(goal, default_msg):
|
|
|
73
73
|
return rendered.replace("```lean\n", "").replace("\n```", "") if rendered else None
|
|
74
74
|
|
|
75
75
|
|
|
76
|
+
def _utf16_index_to_py_index(text: str, utf16_index: int) -> int | None:
|
|
77
|
+
"""Convert an LSP UTF-16 column index into a Python string index."""
|
|
78
|
+
if utf16_index < 0:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
units = 0
|
|
82
|
+
for idx, ch in enumerate(text):
|
|
83
|
+
code_point = ord(ch)
|
|
84
|
+
next_units = units + (2 if code_point > 0xFFFF else 1)
|
|
85
|
+
|
|
86
|
+
if utf16_index < next_units:
|
|
87
|
+
return idx
|
|
88
|
+
if utf16_index == next_units:
|
|
89
|
+
return idx + 1
|
|
90
|
+
|
|
91
|
+
units = next_units
|
|
92
|
+
if units >= utf16_index:
|
|
93
|
+
return len(text)
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
76
97
|
def extract_range(content: str, range: dict) -> str:
|
|
77
98
|
"""Extract the text from the content based on the range.
|
|
78
99
|
|
|
@@ -88,16 +109,36 @@ def extract_range(content: str, range: dict) -> str:
|
|
|
88
109
|
end_line = range["end"]["line"]
|
|
89
110
|
end_char = range["end"]["character"]
|
|
90
111
|
|
|
91
|
-
lines = content.splitlines()
|
|
92
|
-
if
|
|
112
|
+
lines = content.splitlines(keepends=True)
|
|
113
|
+
if not lines:
|
|
114
|
+
lines = [""]
|
|
115
|
+
|
|
116
|
+
line_offsets: List[int] = []
|
|
117
|
+
offset = 0
|
|
118
|
+
for line in lines:
|
|
119
|
+
line_offsets.append(offset)
|
|
120
|
+
offset += len(line)
|
|
121
|
+
total_length = len(content)
|
|
122
|
+
|
|
123
|
+
def position_to_offset(line: int, character: int) -> int | None:
|
|
124
|
+
if line == len(lines) and character == 0:
|
|
125
|
+
return total_length
|
|
126
|
+
if line < 0 or line >= len(lines):
|
|
127
|
+
return None
|
|
128
|
+
py_index = _utf16_index_to_py_index(lines[line], character)
|
|
129
|
+
if py_index is None:
|
|
130
|
+
return None
|
|
131
|
+
if py_index > len(lines[line]):
|
|
132
|
+
return None
|
|
133
|
+
return line_offsets[line] + py_index
|
|
134
|
+
|
|
135
|
+
start_offset = position_to_offset(start_line, start_char)
|
|
136
|
+
end_offset = position_to_offset(end_line, end_char)
|
|
137
|
+
|
|
138
|
+
if start_offset is None or end_offset is None or start_offset > end_offset:
|
|
93
139
|
return "Range out of bounds"
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
else:
|
|
97
|
-
selected_lines = lines[start_line : end_line + 1]
|
|
98
|
-
selected_lines[0] = selected_lines[0][start_char:]
|
|
99
|
-
selected_lines[-1] = selected_lines[-1][:end_char]
|
|
100
|
-
return "\n".join(selected_lines)
|
|
140
|
+
|
|
141
|
+
return content[start_offset:end_offset]
|
|
101
142
|
|
|
102
143
|
|
|
103
144
|
def find_start_position(content: str, query: str) -> dict | None:
|
|
@@ -136,13 +177,14 @@ def format_line(
|
|
|
136
177
|
"""
|
|
137
178
|
lines = file_content.splitlines()
|
|
138
179
|
line_number -= 1
|
|
139
|
-
if line_number <
|
|
180
|
+
if line_number < 0 or line_number >= len(lines):
|
|
140
181
|
return "Line number out of range"
|
|
141
182
|
line = lines[line_number]
|
|
142
183
|
if column is None:
|
|
143
184
|
return line
|
|
144
185
|
column -= 1
|
|
145
|
-
|
|
186
|
+
# Allow placing the cursor at end-of-line (column == len(line))
|
|
187
|
+
if column < 0 or column > len(line):
|
|
146
188
|
return "Invalid column number"
|
|
147
189
|
return f"{line[:column]}{cursor_tag}{line[column:]}"
|
|
148
190
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lean-lsp-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0
|
|
4
4
|
Summary: Lean Theorem Prover MCP
|
|
5
5
|
Author-email: Oliver Dressler <hey@oli.show>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -8,7 +8,7 @@ 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.
|
|
11
|
+
Requires-Dist: leanclient==0.4.0
|
|
12
12
|
Requires-Dist: mcp[cli]==1.19.0
|
|
13
13
|
Requires-Dist: orjson>=3.11.1
|
|
14
14
|
Provides-Extra: lint
|
|
@@ -43,7 +43,7 @@ MCP server that allows agentic interaction with the [Lean theorem prover](https:
|
|
|
43
43
|
## Key Features
|
|
44
44
|
|
|
45
45
|
* **Rich Lean Interaction**: Access diagnostics, goal states, term information, hover documentation and more.
|
|
46
|
-
* **External Search Tools**: Use `
|
|
46
|
+
* **External Search Tools**: Use `LeanSearch`, `Loogle`, `Lean Finder`, `Lean Hammer` and `Lean State Search` to find relevant theorems and definitions.
|
|
47
47
|
* **Easy Setup**: Simple configuration for various clients, including VSCode, Cursor and Claude Code.
|
|
48
48
|
|
|
49
49
|
## Setup
|
|
@@ -122,11 +122,9 @@ Run one of these commands in the root directory of your Lean project (where `lak
|
|
|
122
122
|
# Local-scoped MCP server
|
|
123
123
|
claude mcp add lean-lsp uvx lean-lsp-mcp
|
|
124
124
|
|
|
125
|
-
# OR project-scoped MCP server
|
|
125
|
+
# OR project-scoped MCP server
|
|
126
|
+
# (creates or updates a .mcp.json file in the current directory)
|
|
126
127
|
claude mcp add lean-lsp -s project uvx lean-lsp-mcp
|
|
127
|
-
|
|
128
|
-
# OR If you run into issues with the project path (e.g. the language server directory cannot be found), you can also set it manually e.g.
|
|
129
|
-
claude mcp add lean-lsp uvx lean-lsp-mcp -e LEAN_PROJECT_PATH=$PWD
|
|
130
128
|
```
|
|
131
129
|
|
|
132
130
|
You can find more details about MCP server configuration for Claude Code [here](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/tutorials#configure-mcp-servers).
|
|
@@ -284,7 +282,9 @@ This tool requires [ripgrep](https://github.com/BurntSushi/ripgrep?tab=readme-ov
|
|
|
284
282
|
|
|
285
283
|
### External Search Tools
|
|
286
284
|
|
|
287
|
-
Currently
|
|
285
|
+
Currently most external tools are separately **rate limited to 3 requests per 30 seconds**. Please don't ruin the fun for everyone by overusing these amazing free services!
|
|
286
|
+
|
|
287
|
+
Please cite the original authors of these tools if you use them!
|
|
288
288
|
|
|
289
289
|
#### lean_leansearch
|
|
290
290
|
|
|
@@ -337,6 +337,32 @@ Search for Lean definitions and theorems using [loogle.lean-lang.org](https://lo
|
|
|
337
337
|
```
|
|
338
338
|
</details>
|
|
339
339
|
|
|
340
|
+
#### lean_leanfinder
|
|
341
|
+
|
|
342
|
+
Semantic search for Mathlib theorems using [Lean Finder](https://huggingface.co/spaces/delta-lab-ai/Lean-Finder).
|
|
343
|
+
|
|
344
|
+
[Arxiv Paper](https://arxiv.org/abs/2510.15940)
|
|
345
|
+
|
|
346
|
+
- Supports informal descriptions, user questions, proof states, and statement fragments.
|
|
347
|
+
- Examples: `algebraic elements x,y over K with same minimal polynomial`, `Does y being a root of minpoly(x) imply minpoly(x)=minpoly(y)?`, `⊢ |re z| ≤ ‖z‖` + `transform to squared norm inequality`, `theorem restrict Ioi: restrict Ioi e = restrict Ici e`
|
|
348
|
+
|
|
349
|
+
<details>
|
|
350
|
+
<summary>Example output</summary>
|
|
351
|
+
|
|
352
|
+
Query: `Does y being a root of minpoly(x) imply minpoly(x)=minpoly(y)?`
|
|
353
|
+
|
|
354
|
+
```json
|
|
355
|
+
[
|
|
356
|
+
[
|
|
357
|
+
"/-- If `y : L` is a root of `minpoly K x`, then `minpoly K y = minpoly K x`. -/\ntheorem eq_of_root {x y : L} (hx : IsAlgebraic K x)\n (h_ev : Polynomial.aeval y (minpoly K x) = 0) : minpoly K y = minpoly K x :=\n ((eq_iff_aeval_minpoly_eq_zero hx.isIntegral).mpr h_ev).symm",
|
|
358
|
+
|
|
359
|
+
"Let $L/K$ be a field extension, and let $x, y \\in L$ be elements such that $y$ is a root of the minimal polynomial of $x$ over $K$. If $x$ is algebraic over $K$, then the minimal polynomial of $y$ over $K$ is equal to the minimal polynomial of $x$ over $K$, i.e., $\\text{minpoly}_K(y) = \\text{minpoly}_K(x)$. This means that if $y$ satisfies the polynomial equation defined by $x$, then $y$ shares the same minimal polynomial as $x$."
|
|
360
|
+
],
|
|
361
|
+
...
|
|
362
|
+
]
|
|
363
|
+
```
|
|
364
|
+
</details>
|
|
365
|
+
|
|
340
366
|
#### lean_state_search
|
|
341
367
|
|
|
342
368
|
Search for applicable theorems for the current proof goal using [premise-search.com](https://premise-search.com/).
|
|
@@ -109,3 +109,23 @@ async def test_search_tools(
|
|
|
109
109
|
if entry is None:
|
|
110
110
|
pytest.skip("lean_leansearch did not return JSON content")
|
|
111
111
|
assert {"module_name", "name", "type"} <= set(entry.keys())
|
|
112
|
+
|
|
113
|
+
# Test lean_finder with different query types
|
|
114
|
+
finder_informal = await client.call_tool(
|
|
115
|
+
"lean_leanfinder",
|
|
116
|
+
{
|
|
117
|
+
"query": "If two algebraic elements have the same minimal polynomial, are they related by a field isomorphism?",
|
|
118
|
+
"num_results": 3,
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
finder_results = _first_json_block(finder_informal)
|
|
122
|
+
if finder_results:
|
|
123
|
+
assert isinstance(finder_results, list) and len(finder_results) > 0
|
|
124
|
+
assert isinstance(finder_results[0], list) and len(finder_results[0]) == 2
|
|
125
|
+
formal, informal = finder_results[0]
|
|
126
|
+
assert isinstance(formal, str) and len(formal) > 0
|
|
127
|
+
assert isinstance(informal, str)
|
|
128
|
+
else:
|
|
129
|
+
finder_text = result_text(finder_informal)
|
|
130
|
+
assert finder_text and len(finder_text) > 0
|
|
131
|
+
|
|
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
|