lean-lsp-mcp 0.10.3__tar.gz → 0.11.1__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.1}/PKG-INFO +58 -9
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.1}/README.md +57 -8
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.1}/pyproject.toml +2 -2
- lean_lsp_mcp-0.11.1/src/lean_lsp_mcp/client_utils.py +122 -0
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.1}/src/lean_lsp_mcp/file_utils.py +0 -1
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.1}/src/lean_lsp_mcp/instructions.py +1 -0
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.1}/src/lean_lsp_mcp/server.py +189 -51
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.1}/src/lean_lsp_mcp/utils.py +121 -45
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.1/src/lean_lsp_mcp.egg-info}/PKG-INFO +58 -9
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.1}/src/lean_lsp_mcp.egg-info/requires.txt +1 -1
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.1}/tests/test_search_tools.py +23 -3
- lean_lsp_mcp-0.10.3/src/lean_lsp_mcp/client_utils.py +0 -95
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.1}/LICENSE +0 -0
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.1}/setup.cfg +0 -0
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.1}/src/lean_lsp_mcp/__init__.py +0 -0
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.1}/src/lean_lsp_mcp/__main__.py +0 -0
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.1}/src/lean_lsp_mcp/search_utils.py +0 -0
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.1}/src/lean_lsp_mcp.egg-info/SOURCES.txt +0 -0
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.1}/src/lean_lsp_mcp.egg-info/dependency_links.txt +0 -0
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.1}/src/lean_lsp_mcp.egg-info/entry_points.txt +0 -0
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.1}/src/lean_lsp_mcp.egg-info/top_level.txt +0 -0
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.1}/tests/test_editor_tools.py +0 -0
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.1}/tests/test_logging.py +0 -0
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.1}/tests/test_misc_tools.py +0 -0
- {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.1}/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.1
|
|
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
|
|
@@ -77,7 +77,11 @@ OR using the setup wizard:
|
|
|
77
77
|
|
|
78
78
|
Ctrl+Shift+P > "MCP: Add Server..." > "Command (stdio)" > "uvx lean-lsp-mcp" > "lean-lsp" (or any name you like) > Global or Workspace
|
|
79
79
|
|
|
80
|
-
OR manually
|
|
80
|
+
OR manually adding config by opening `mcp.json` with:
|
|
81
|
+
|
|
82
|
+
Ctrl+Shift+P > "MCP: Open User Configuration"
|
|
83
|
+
|
|
84
|
+
and adding the following
|
|
81
85
|
|
|
82
86
|
```jsonc
|
|
83
87
|
{
|
|
@@ -92,6 +96,25 @@ OR manually add config to `mcp.json`:
|
|
|
92
96
|
}
|
|
93
97
|
}
|
|
94
98
|
```
|
|
99
|
+
|
|
100
|
+
If you installed VSCode on Windows and are using WSL2 as your development environment, you may need to use this config instead:
|
|
101
|
+
|
|
102
|
+
```jsonc
|
|
103
|
+
{
|
|
104
|
+
"servers": {
|
|
105
|
+
"lean-lsp": {
|
|
106
|
+
"type": "stdio",
|
|
107
|
+
"command": "wsl.exe",
|
|
108
|
+
"args": [
|
|
109
|
+
"uvx",
|
|
110
|
+
"lean-lsp-mcp"
|
|
111
|
+
]
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
If that doesn't work, you can try cloning this repository and replace `"lean-lsp-mcp"` with `"/path/to/cloned/lean-lsp-mcp"`.
|
|
117
|
+
|
|
95
118
|
</details>
|
|
96
119
|
|
|
97
120
|
<details>
|
|
@@ -122,11 +145,9 @@ Run one of these commands in the root directory of your Lean project (where `lak
|
|
|
122
145
|
# Local-scoped MCP server
|
|
123
146
|
claude mcp add lean-lsp uvx lean-lsp-mcp
|
|
124
147
|
|
|
125
|
-
# OR project-scoped MCP server
|
|
148
|
+
# OR project-scoped MCP server
|
|
149
|
+
# (creates or updates a .mcp.json file in the current directory)
|
|
126
150
|
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
151
|
```
|
|
131
152
|
|
|
132
153
|
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 +305,9 @@ This tool requires [ripgrep](https://github.com/BurntSushi/ripgrep?tab=readme-ov
|
|
|
284
305
|
|
|
285
306
|
### External Search Tools
|
|
286
307
|
|
|
287
|
-
Currently
|
|
308
|
+
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!
|
|
309
|
+
|
|
310
|
+
Please cite the original authors of these tools if you use them!
|
|
288
311
|
|
|
289
312
|
#### lean_leansearch
|
|
290
313
|
|
|
@@ -337,6 +360,32 @@ Search for Lean definitions and theorems using [loogle.lean-lang.org](https://lo
|
|
|
337
360
|
```
|
|
338
361
|
</details>
|
|
339
362
|
|
|
363
|
+
#### lean_leanfinder
|
|
364
|
+
|
|
365
|
+
Semantic search for Mathlib theorems using [Lean Finder](https://huggingface.co/spaces/delta-lab-ai/Lean-Finder).
|
|
366
|
+
|
|
367
|
+
[Arxiv Paper](https://arxiv.org/abs/2510.15940)
|
|
368
|
+
|
|
369
|
+
- Supports informal descriptions, user questions, proof states, and statement fragments.
|
|
370
|
+
- 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`
|
|
371
|
+
|
|
372
|
+
<details>
|
|
373
|
+
<summary>Example output</summary>
|
|
374
|
+
|
|
375
|
+
Query: `Does y being a root of minpoly(x) imply minpoly(x)=minpoly(y)?`
|
|
376
|
+
|
|
377
|
+
```json
|
|
378
|
+
[
|
|
379
|
+
[
|
|
380
|
+
"/-- 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",
|
|
381
|
+
|
|
382
|
+
"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$."
|
|
383
|
+
],
|
|
384
|
+
...
|
|
385
|
+
]
|
|
386
|
+
```
|
|
387
|
+
</details>
|
|
388
|
+
|
|
340
389
|
#### lean_state_search
|
|
341
390
|
|
|
342
391
|
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
|
|
@@ -55,7 +55,11 @@ OR using the setup wizard:
|
|
|
55
55
|
|
|
56
56
|
Ctrl+Shift+P > "MCP: Add Server..." > "Command (stdio)" > "uvx lean-lsp-mcp" > "lean-lsp" (or any name you like) > Global or Workspace
|
|
57
57
|
|
|
58
|
-
OR manually
|
|
58
|
+
OR manually adding config by opening `mcp.json` with:
|
|
59
|
+
|
|
60
|
+
Ctrl+Shift+P > "MCP: Open User Configuration"
|
|
61
|
+
|
|
62
|
+
and adding the following
|
|
59
63
|
|
|
60
64
|
```jsonc
|
|
61
65
|
{
|
|
@@ -70,6 +74,25 @@ OR manually add config to `mcp.json`:
|
|
|
70
74
|
}
|
|
71
75
|
}
|
|
72
76
|
```
|
|
77
|
+
|
|
78
|
+
If you installed VSCode on Windows and are using WSL2 as your development environment, you may need to use this config instead:
|
|
79
|
+
|
|
80
|
+
```jsonc
|
|
81
|
+
{
|
|
82
|
+
"servers": {
|
|
83
|
+
"lean-lsp": {
|
|
84
|
+
"type": "stdio",
|
|
85
|
+
"command": "wsl.exe",
|
|
86
|
+
"args": [
|
|
87
|
+
"uvx",
|
|
88
|
+
"lean-lsp-mcp"
|
|
89
|
+
]
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
If that doesn't work, you can try cloning this repository and replace `"lean-lsp-mcp"` with `"/path/to/cloned/lean-lsp-mcp"`.
|
|
95
|
+
|
|
73
96
|
</details>
|
|
74
97
|
|
|
75
98
|
<details>
|
|
@@ -100,11 +123,9 @@ Run one of these commands in the root directory of your Lean project (where `lak
|
|
|
100
123
|
# Local-scoped MCP server
|
|
101
124
|
claude mcp add lean-lsp uvx lean-lsp-mcp
|
|
102
125
|
|
|
103
|
-
# OR project-scoped MCP server
|
|
126
|
+
# OR project-scoped MCP server
|
|
127
|
+
# (creates or updates a .mcp.json file in the current directory)
|
|
104
128
|
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
129
|
```
|
|
109
130
|
|
|
110
131
|
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 +283,9 @@ This tool requires [ripgrep](https://github.com/BurntSushi/ripgrep?tab=readme-ov
|
|
|
262
283
|
|
|
263
284
|
### External Search Tools
|
|
264
285
|
|
|
265
|
-
Currently
|
|
286
|
+
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!
|
|
287
|
+
|
|
288
|
+
Please cite the original authors of these tools if you use them!
|
|
266
289
|
|
|
267
290
|
#### lean_leansearch
|
|
268
291
|
|
|
@@ -315,6 +338,32 @@ Search for Lean definitions and theorems using [loogle.lean-lang.org](https://lo
|
|
|
315
338
|
```
|
|
316
339
|
</details>
|
|
317
340
|
|
|
341
|
+
#### lean_leanfinder
|
|
342
|
+
|
|
343
|
+
Semantic search for Mathlib theorems using [Lean Finder](https://huggingface.co/spaces/delta-lab-ai/Lean-Finder).
|
|
344
|
+
|
|
345
|
+
[Arxiv Paper](https://arxiv.org/abs/2510.15940)
|
|
346
|
+
|
|
347
|
+
- Supports informal descriptions, user questions, proof states, and statement fragments.
|
|
348
|
+
- 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`
|
|
349
|
+
|
|
350
|
+
<details>
|
|
351
|
+
<summary>Example output</summary>
|
|
352
|
+
|
|
353
|
+
Query: `Does y being a root of minpoly(x) imply minpoly(x)=minpoly(y)?`
|
|
354
|
+
|
|
355
|
+
```json
|
|
356
|
+
[
|
|
357
|
+
[
|
|
358
|
+
"/-- 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",
|
|
359
|
+
|
|
360
|
+
"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$."
|
|
361
|
+
],
|
|
362
|
+
...
|
|
363
|
+
]
|
|
364
|
+
```
|
|
365
|
+
</details>
|
|
366
|
+
|
|
318
367
|
#### lean_state_search
|
|
319
368
|
|
|
320
369
|
Search for applicable theorems for the current proof goal using [premise-search.com](https://premise-search.com/).
|
|
@@ -491,4 +540,4 @@ Citing this repository is highly appreciated but not required by the license.
|
|
|
491
540
|
month = {3},
|
|
492
541
|
year = {2025}
|
|
493
542
|
}
|
|
494
|
-
```
|
|
543
|
+
```
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "lean-lsp-mcp"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.11.1"
|
|
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
|
]
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from threading import Lock
|
|
4
|
+
|
|
5
|
+
from mcp.server.fastmcp import Context
|
|
6
|
+
from mcp.server.fastmcp.utilities.logging import get_logger
|
|
7
|
+
from leanclient import LeanLSPClient
|
|
8
|
+
|
|
9
|
+
from lean_lsp_mcp.file_utils import get_relative_file_path
|
|
10
|
+
from lean_lsp_mcp.utils import OutputCapture
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
logger = get_logger(__name__)
|
|
14
|
+
CLIENT_LOCK = Lock()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def startup_client(ctx: Context):
|
|
18
|
+
"""Initialize the Lean LSP client if not already set up.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
ctx (Context): Context object.
|
|
22
|
+
"""
|
|
23
|
+
with CLIENT_LOCK:
|
|
24
|
+
lean_project_path = ctx.request_context.lifespan_context.lean_project_path
|
|
25
|
+
if lean_project_path is None:
|
|
26
|
+
raise ValueError("lean project path is not set.")
|
|
27
|
+
|
|
28
|
+
# Check if already correct client
|
|
29
|
+
client: LeanLSPClient | None = ctx.request_context.lifespan_context.client
|
|
30
|
+
|
|
31
|
+
if client is not None:
|
|
32
|
+
# Both are Path objects now, direct comparison works
|
|
33
|
+
if client.project_path == lean_project_path:
|
|
34
|
+
return # Client already set up correctly - reuse it!
|
|
35
|
+
# Different project path - close old client
|
|
36
|
+
client.close()
|
|
37
|
+
ctx.request_context.lifespan_context.file_content_hashes.clear()
|
|
38
|
+
|
|
39
|
+
# Need to create a new client
|
|
40
|
+
with OutputCapture() as output:
|
|
41
|
+
try:
|
|
42
|
+
client = LeanLSPClient(lean_project_path)
|
|
43
|
+
logger.info(f"Connected to Lean language server at {lean_project_path}")
|
|
44
|
+
except Exception as e:
|
|
45
|
+
logger.warning(f"Initial connection failed, trying with build: {e}")
|
|
46
|
+
client = LeanLSPClient(lean_project_path, initial_build=True)
|
|
47
|
+
logger.info(f"Connected with initial build to {lean_project_path}")
|
|
48
|
+
build_output = output.get_output()
|
|
49
|
+
if build_output:
|
|
50
|
+
logger.debug(f"Build output: {build_output}")
|
|
51
|
+
ctx.request_context.lifespan_context.client = client
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def valid_lean_project_path(path: Path | str) -> bool:
|
|
55
|
+
"""Check if the given path is a valid Lean project path (contains a lean-toolchain file).
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
path (Path | str): Absolute path to check.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
bool: True if valid Lean project path, False otherwise.
|
|
62
|
+
"""
|
|
63
|
+
path_obj = Path(path) if isinstance(path, str) else path
|
|
64
|
+
return (path_obj / "lean-toolchain").is_file()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def setup_client_for_file(ctx: Context, file_path: str) -> str | None:
|
|
68
|
+
"""Ensure the LSP client matches the file's Lean project and return its relative path."""
|
|
69
|
+
|
|
70
|
+
lifespan = ctx.request_context.lifespan_context
|
|
71
|
+
project_cache = getattr(lifespan, "project_cache", {})
|
|
72
|
+
if not hasattr(lifespan, "project_cache"):
|
|
73
|
+
lifespan.project_cache = project_cache
|
|
74
|
+
|
|
75
|
+
abs_file_path = os.path.abspath(file_path)
|
|
76
|
+
file_dir = os.path.dirname(abs_file_path)
|
|
77
|
+
|
|
78
|
+
def activate_project(project_path: Path, cache_dirs: list[str]) -> str | None:
|
|
79
|
+
project_path_obj = project_path
|
|
80
|
+
rel = get_relative_file_path(project_path_obj, file_path)
|
|
81
|
+
if rel is None:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
project_path_obj = project_path_obj.resolve()
|
|
85
|
+
lifespan.lean_project_path = project_path_obj
|
|
86
|
+
|
|
87
|
+
cache_targets: list[str] = []
|
|
88
|
+
for directory in cache_dirs + [str(project_path_obj)]:
|
|
89
|
+
if directory and directory not in cache_targets:
|
|
90
|
+
cache_targets.append(directory)
|
|
91
|
+
|
|
92
|
+
for directory in cache_targets:
|
|
93
|
+
project_cache[directory] = project_path_obj
|
|
94
|
+
|
|
95
|
+
startup_client(ctx)
|
|
96
|
+
return rel
|
|
97
|
+
|
|
98
|
+
# Fast path: current Lean project already valid for this file
|
|
99
|
+
if lifespan.lean_project_path is not None:
|
|
100
|
+
rel_path = activate_project(lifespan.lean_project_path, [file_dir])
|
|
101
|
+
if rel_path is not None:
|
|
102
|
+
return rel_path
|
|
103
|
+
|
|
104
|
+
# Walk up from file directory to root, using cache hits or lean-toolchain
|
|
105
|
+
prev_dir = None
|
|
106
|
+
current_dir = file_dir
|
|
107
|
+
while current_dir and current_dir != prev_dir:
|
|
108
|
+
cached_root = project_cache.get(current_dir)
|
|
109
|
+
if cached_root:
|
|
110
|
+
rel_path = activate_project(Path(cached_root), [current_dir])
|
|
111
|
+
if rel_path is not None:
|
|
112
|
+
return rel_path
|
|
113
|
+
elif valid_lean_project_path(current_dir):
|
|
114
|
+
rel_path = activate_project(Path(current_dir), [current_dir])
|
|
115
|
+
if rel_path is not None:
|
|
116
|
+
return rel_path
|
|
117
|
+
else:
|
|
118
|
+
project_cache[current_dir] = ""
|
|
119
|
+
prev_dir = current_dir
|
|
120
|
+
current_dir = os.path.dirname(current_dir)
|
|
121
|
+
|
|
122
|
+
return None
|
|
@@ -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
|
"""
|
|
@@ -7,9 +7,10 @@ from contextlib import asynccontextmanager
|
|
|
7
7
|
from collections.abc import AsyncIterator
|
|
8
8
|
from dataclasses import dataclass
|
|
9
9
|
import urllib
|
|
10
|
-
import
|
|
10
|
+
import orjson
|
|
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
|
|
|
@@ -531,28 +555,37 @@ def multi_attempt(
|
|
|
531
555
|
update_file(ctx, rel_path)
|
|
532
556
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
533
557
|
|
|
534
|
-
|
|
558
|
+
try:
|
|
559
|
+
client.open_file(rel_path)
|
|
560
|
+
|
|
561
|
+
results = []
|
|
562
|
+
# Avoid mutating caller-provided snippets; normalize locally per attempt
|
|
563
|
+
for snippet in snippets:
|
|
564
|
+
snippet_str = snippet.rstrip("\n")
|
|
565
|
+
payload = f"{snippet_str}\n"
|
|
566
|
+
# Create a DocumentContentChange for the snippet
|
|
567
|
+
change = DocumentContentChange(
|
|
568
|
+
payload,
|
|
569
|
+
[line - 1, 0],
|
|
570
|
+
[line, 0],
|
|
571
|
+
)
|
|
572
|
+
# Apply the change to the file, capture diagnostics and goal state
|
|
573
|
+
client.update_file(rel_path, [change])
|
|
574
|
+
diag = client.get_diagnostics(rel_path)
|
|
575
|
+
formatted_diag = "\n".join(
|
|
576
|
+
format_diagnostics(diag, select_line=line - 1)
|
|
577
|
+
)
|
|
578
|
+
# Use the snippet text length without any trailing newline for the column
|
|
579
|
+
goal = client.get_goal(rel_path, line - 1, len(snippet_str))
|
|
580
|
+
formatted_goal = format_goal(goal, "Missing goal")
|
|
581
|
+
results.append(f"{snippet_str}:\n {formatted_goal}\n\n{formatted_diag}")
|
|
535
582
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
[line - 1, 0],
|
|
543
|
-
[line, 0],
|
|
544
|
-
)
|
|
545
|
-
# Apply the change to the file, capture diagnostics and goal state
|
|
546
|
-
client.update_file(rel_path, [change])
|
|
547
|
-
diag = client.get_diagnostics(rel_path)
|
|
548
|
-
formatted_diag = "\n".join(format_diagnostics(diag, select_line=line - 1))
|
|
549
|
-
goal = client.get_goal(rel_path, line - 1, len(snippet))
|
|
550
|
-
formatted_goal = format_goal(goal, "Missing goal")
|
|
551
|
-
results.append(f"{snippet}:\n {formatted_goal}\n\n{formatted_diag}")
|
|
552
|
-
|
|
553
|
-
# Make sure it's clean after the attempts
|
|
554
|
-
client.close_files([rel_path])
|
|
555
|
-
return results
|
|
583
|
+
return results
|
|
584
|
+
finally:
|
|
585
|
+
try:
|
|
586
|
+
client.close_files([rel_path])
|
|
587
|
+
except Exception as exc: # pragma: no cover - close failures only logged
|
|
588
|
+
logger.warning("Failed to close `%s` after multi_attempt: %s", rel_path, exc)
|
|
556
589
|
|
|
557
590
|
|
|
558
591
|
@mcp.tool("lean_run_code")
|
|
@@ -568,11 +601,13 @@ def run_code(ctx: Context, code: str) -> List[str] | str:
|
|
|
568
601
|
Returns:
|
|
569
602
|
List[str] | str: Diagnostics msgs or error msg
|
|
570
603
|
"""
|
|
571
|
-
|
|
604
|
+
lifespan_context = ctx.request_context.lifespan_context
|
|
605
|
+
lean_project_path = lifespan_context.lean_project_path
|
|
572
606
|
if lean_project_path is None:
|
|
573
607
|
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
608
|
|
|
575
|
-
|
|
609
|
+
# Use a unique snippet filename to avoid collisions under concurrency
|
|
610
|
+
rel_path = f"_mcp_snippet_{uuid.uuid4().hex}.lean"
|
|
576
611
|
abs_path = lean_project_path / rel_path
|
|
577
612
|
|
|
578
613
|
try:
|
|
@@ -581,14 +616,44 @@ def run_code(ctx: Context, code: str) -> List[str] | str:
|
|
|
581
616
|
except Exception as e:
|
|
582
617
|
return f"Error writing code snippet to file `{abs_path}`:\n{str(e)}"
|
|
583
618
|
|
|
584
|
-
client: LeanLSPClient =
|
|
585
|
-
diagnostics =
|
|
586
|
-
|
|
619
|
+
client: LeanLSPClient | None = lifespan_context.client
|
|
620
|
+
diagnostics: List[str] | str = []
|
|
621
|
+
close_error: str | None = None
|
|
622
|
+
remove_error: str | None = None
|
|
623
|
+
opened_file = False
|
|
587
624
|
|
|
588
625
|
try:
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
626
|
+
if client is None:
|
|
627
|
+
startup_client(ctx)
|
|
628
|
+
client = lifespan_context.client
|
|
629
|
+
if client is None:
|
|
630
|
+
return "Failed to initialize Lean client for run_code."
|
|
631
|
+
|
|
632
|
+
assert client is not None # startup_client guarantees an initialized client
|
|
633
|
+
client.open_file(rel_path)
|
|
634
|
+
opened_file = True
|
|
635
|
+
diagnostics = format_diagnostics(client.get_diagnostics(rel_path))
|
|
636
|
+
finally:
|
|
637
|
+
if opened_file:
|
|
638
|
+
try:
|
|
639
|
+
client.close_files([rel_path])
|
|
640
|
+
except Exception as exc: # pragma: no cover - close failures only logged
|
|
641
|
+
close_error = str(exc)
|
|
642
|
+
logger.warning("Failed to close `%s` after run_code: %s", rel_path, exc)
|
|
643
|
+
try:
|
|
644
|
+
os.remove(abs_path)
|
|
645
|
+
except FileNotFoundError:
|
|
646
|
+
pass
|
|
647
|
+
except Exception as e:
|
|
648
|
+
remove_error = str(e)
|
|
649
|
+
logger.warning(
|
|
650
|
+
"Failed to remove temporary Lean snippet `%s`: %s", abs_path, e
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
if remove_error:
|
|
654
|
+
return f"Error removing temporary file `{abs_path}`:\n{remove_error}"
|
|
655
|
+
if close_error:
|
|
656
|
+
return f"Error closing temporary Lean document `{rel_path}`:\n{close_error}"
|
|
592
657
|
|
|
593
658
|
return (
|
|
594
659
|
diagnostics
|
|
@@ -646,9 +711,9 @@ def leansearch(ctx: Context, query: str, num_results: int = 5) -> List[Dict] | s
|
|
|
646
711
|
"""
|
|
647
712
|
try:
|
|
648
713
|
headers = {"User-Agent": "lean-lsp-mcp/0.1", "Content-Type": "application/json"}
|
|
649
|
-
payload =
|
|
714
|
+
payload = orjson.dumps(
|
|
650
715
|
{"num_results": str(num_results), "query": [query]}
|
|
651
|
-
)
|
|
716
|
+
)
|
|
652
717
|
|
|
653
718
|
req = urllib.request.Request(
|
|
654
719
|
"https://leansearch.net/search",
|
|
@@ -658,7 +723,7 @@ def leansearch(ctx: Context, query: str, num_results: int = 5) -> List[Dict] | s
|
|
|
658
723
|
)
|
|
659
724
|
|
|
660
725
|
with urllib.request.urlopen(req, timeout=20) as response:
|
|
661
|
-
results =
|
|
726
|
+
results = orjson.loads(response.read())
|
|
662
727
|
|
|
663
728
|
if not results or not results[0]:
|
|
664
729
|
return "No results found."
|
|
@@ -704,19 +769,92 @@ def loogle(ctx: Context, query: str, num_results: int = 8) -> List[dict] | str:
|
|
|
704
769
|
)
|
|
705
770
|
|
|
706
771
|
with urllib.request.urlopen(req, timeout=20) as response:
|
|
707
|
-
results =
|
|
772
|
+
results = orjson.loads(response.read())
|
|
708
773
|
|
|
709
774
|
if "hits" not in results:
|
|
710
775
|
return "No results found."
|
|
711
776
|
|
|
712
777
|
results = results["hits"][:num_results]
|
|
713
778
|
for result in results:
|
|
714
|
-
result.pop("doc")
|
|
779
|
+
result.pop("doc", None)
|
|
715
780
|
return results
|
|
716
781
|
except Exception as e:
|
|
717
782
|
return f"loogle error:\n{str(e)}"
|
|
718
783
|
|
|
719
784
|
|
|
785
|
+
@mcp.tool("lean_leanfinder")
|
|
786
|
+
@rate_limited("leanfinder", max_requests=10, per_seconds=30)
|
|
787
|
+
def leanfinder(
|
|
788
|
+
ctx: Context, query: str, num_results: int = 5
|
|
789
|
+
) -> List[tuple] | str:
|
|
790
|
+
"""Search Mathlib theorems/definitions semantically by mathematical concept using Lean Finder.
|
|
791
|
+
|
|
792
|
+
Effective query types:
|
|
793
|
+
- Math + API: "setAverage Icc interval", "integral_pow symmetric bounds"
|
|
794
|
+
- Conceptual: "algebraic elements same minimal polynomial", "quadrature nodes"
|
|
795
|
+
- Structure: "Finset expect sum commute", "polynomial degree bounded eval"
|
|
796
|
+
- Natural: "average equals point values", "root implies equal polynomials"
|
|
797
|
+
|
|
798
|
+
Tips: Mix informal math terms with Lean identifiers. Multiple targeted queries beat one complex query.
|
|
799
|
+
|
|
800
|
+
Args:
|
|
801
|
+
query (str): Mathematical concepts combined with Lean terms
|
|
802
|
+
num_results (int, optional): Max results. Defaults to 5.
|
|
803
|
+
|
|
804
|
+
Returns:
|
|
805
|
+
List[tuple] | str: (lean_statement, english_description) pairs or error
|
|
806
|
+
"""
|
|
807
|
+
try:
|
|
808
|
+
headers = {"User-Agent": "lean-lsp-mcp/0.1", "Content-Type": "application/json"}
|
|
809
|
+
payload = orjson.dumps({"data": [query, num_results, "Normal"]})
|
|
810
|
+
|
|
811
|
+
req = urllib.request.Request(
|
|
812
|
+
"https://delta-lab-ai-lean-finder.hf.space/gradio_api/call/retrieve",
|
|
813
|
+
data=payload,
|
|
814
|
+
headers=headers,
|
|
815
|
+
method="POST",
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
with urllib.request.urlopen(req, timeout=10) as response:
|
|
819
|
+
event_data = orjson.loads(response.read())
|
|
820
|
+
event_id = event_data.get("event_id")
|
|
821
|
+
|
|
822
|
+
if not event_id:
|
|
823
|
+
return "Lean Finder has timed out or errored. It might be warming up, try a second time in 2 minutes."
|
|
824
|
+
|
|
825
|
+
result_url = f"https://delta-lab-ai-lean-finder.hf.space/gradio_api/call/retrieve/{event_id}"
|
|
826
|
+
req = urllib.request.Request(result_url, headers=headers, method="GET")
|
|
827
|
+
|
|
828
|
+
with urllib.request.urlopen(req, timeout=30) as response:
|
|
829
|
+
for line in response:
|
|
830
|
+
line = line.decode("utf-8").strip()
|
|
831
|
+
if line.startswith("data: "):
|
|
832
|
+
data = orjson.loads(line[6:])
|
|
833
|
+
if isinstance(data, list) and len(data) > 0:
|
|
834
|
+
html = data[0] if isinstance(data[0], str) else str(data)
|
|
835
|
+
|
|
836
|
+
# Parse HTML table rows
|
|
837
|
+
rows = re.findall(
|
|
838
|
+
r"<tr><td>\d+</td><td>(.*?)</td><td>(.*?)</td></tr>",
|
|
839
|
+
html, re.DOTALL
|
|
840
|
+
)
|
|
841
|
+
results = []
|
|
842
|
+
for formal_cell, informal_cell in rows:
|
|
843
|
+
formal = re.search(r"<code[^>]*>(.*?)</code>", formal_cell, re.DOTALL)
|
|
844
|
+
informal = re.search(r"<span[^>]*>(.*?)</span>", informal_cell, re.DOTALL)
|
|
845
|
+
if formal:
|
|
846
|
+
results.append((
|
|
847
|
+
formal.group(1).strip(),
|
|
848
|
+
informal.group(1).strip() if informal else ""
|
|
849
|
+
))
|
|
850
|
+
|
|
851
|
+
return results if results else "Lean Finder: No results parsed"
|
|
852
|
+
|
|
853
|
+
return "Lean Finder: No results received"
|
|
854
|
+
except Exception as e:
|
|
855
|
+
return f"Lean Finder Error:\n{str(e)}"
|
|
856
|
+
|
|
857
|
+
|
|
720
858
|
@mcp.tool("lean_state_search")
|
|
721
859
|
@rate_limited("lean_state_search", max_requests=3, per_seconds=30)
|
|
722
860
|
def state_search(
|
|
@@ -758,7 +896,7 @@ def state_search(
|
|
|
758
896
|
)
|
|
759
897
|
|
|
760
898
|
with urllib.request.urlopen(req, timeout=20) as response:
|
|
761
|
-
results =
|
|
899
|
+
results = orjson.loads(response.read())
|
|
762
900
|
|
|
763
901
|
for result in results:
|
|
764
902
|
result.pop("rev")
|
|
@@ -812,11 +950,11 @@ def hammer_premise(
|
|
|
812
950
|
"Content-Type": "application/json",
|
|
813
951
|
},
|
|
814
952
|
method="POST",
|
|
815
|
-
data=
|
|
953
|
+
data=orjson.dumps(data),
|
|
816
954
|
)
|
|
817
955
|
|
|
818
956
|
with urllib.request.urlopen(req, timeout=20) as response:
|
|
819
|
-
results =
|
|
957
|
+
results = orjson.loads(response.read())
|
|
820
958
|
|
|
821
959
|
results = [result["name"] for result in results]
|
|
822
960
|
results.insert(0, f"Results for line:\n{f_line}")
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import os
|
|
2
|
+
import secrets
|
|
2
3
|
import sys
|
|
3
4
|
import tempfile
|
|
4
5
|
from typing import List, Dict, Optional
|
|
@@ -41,6 +42,19 @@ class OutputCapture:
|
|
|
41
42
|
return self.captured_output
|
|
42
43
|
|
|
43
44
|
|
|
45
|
+
class OptionalTokenVerifier(TokenVerifier):
|
|
46
|
+
"""Minimal verifier that accepts a single pre-shared token."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, expected_token: str):
|
|
49
|
+
self._expected_token = expected_token
|
|
50
|
+
|
|
51
|
+
async def verify_token(self, token: str | None) -> AccessToken | None:
|
|
52
|
+
if token is None or not secrets.compare_digest(token, self._expected_token):
|
|
53
|
+
return None
|
|
54
|
+
# AccessToken requires both client_id and scopes parameters to be provided.
|
|
55
|
+
return AccessToken(token=token, client_id="lean-lsp-mcp-optional", scopes=[])
|
|
56
|
+
|
|
57
|
+
|
|
44
58
|
def format_diagnostics(diagnostics: List[Dict], select_line: int = -1) -> List[str]:
|
|
45
59
|
"""Format the diagnostics messages.
|
|
46
60
|
|
|
@@ -73,6 +87,27 @@ def format_goal(goal, default_msg):
|
|
|
73
87
|
return rendered.replace("```lean\n", "").replace("\n```", "") if rendered else None
|
|
74
88
|
|
|
75
89
|
|
|
90
|
+
def _utf16_index_to_py_index(text: str, utf16_index: int) -> int | None:
|
|
91
|
+
"""Convert an LSP UTF-16 column index into a Python string index."""
|
|
92
|
+
if utf16_index < 0:
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
units = 0
|
|
96
|
+
for idx, ch in enumerate(text):
|
|
97
|
+
code_point = ord(ch)
|
|
98
|
+
next_units = units + (2 if code_point > 0xFFFF else 1)
|
|
99
|
+
|
|
100
|
+
if utf16_index < next_units:
|
|
101
|
+
return idx
|
|
102
|
+
if utf16_index == next_units:
|
|
103
|
+
return idx + 1
|
|
104
|
+
|
|
105
|
+
units = next_units
|
|
106
|
+
if units >= utf16_index:
|
|
107
|
+
return len(text)
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
76
111
|
def extract_range(content: str, range: dict) -> str:
|
|
77
112
|
"""Extract the text from the content based on the range.
|
|
78
113
|
|
|
@@ -88,16 +123,36 @@ def extract_range(content: str, range: dict) -> str:
|
|
|
88
123
|
end_line = range["end"]["line"]
|
|
89
124
|
end_char = range["end"]["character"]
|
|
90
125
|
|
|
91
|
-
lines = content.splitlines()
|
|
92
|
-
if
|
|
126
|
+
lines = content.splitlines(keepends=True)
|
|
127
|
+
if not lines:
|
|
128
|
+
lines = [""]
|
|
129
|
+
|
|
130
|
+
line_offsets: List[int] = []
|
|
131
|
+
offset = 0
|
|
132
|
+
for line in lines:
|
|
133
|
+
line_offsets.append(offset)
|
|
134
|
+
offset += len(line)
|
|
135
|
+
total_length = len(content)
|
|
136
|
+
|
|
137
|
+
def position_to_offset(line: int, character: int) -> int | None:
|
|
138
|
+
if line == len(lines) and character == 0:
|
|
139
|
+
return total_length
|
|
140
|
+
if line < 0 or line >= len(lines):
|
|
141
|
+
return None
|
|
142
|
+
py_index = _utf16_index_to_py_index(lines[line], character)
|
|
143
|
+
if py_index is None:
|
|
144
|
+
return None
|
|
145
|
+
if py_index > len(lines[line]):
|
|
146
|
+
return None
|
|
147
|
+
return line_offsets[line] + py_index
|
|
148
|
+
|
|
149
|
+
start_offset = position_to_offset(start_line, start_char)
|
|
150
|
+
end_offset = position_to_offset(end_line, end_char)
|
|
151
|
+
|
|
152
|
+
if start_offset is None or end_offset is None or start_offset > end_offset:
|
|
93
153
|
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)
|
|
154
|
+
|
|
155
|
+
return content[start_offset:end_offset]
|
|
101
156
|
|
|
102
157
|
|
|
103
158
|
def find_start_position(content: str, query: str) -> dict | None:
|
|
@@ -136,50 +191,71 @@ def format_line(
|
|
|
136
191
|
"""
|
|
137
192
|
lines = file_content.splitlines()
|
|
138
193
|
line_number -= 1
|
|
139
|
-
if line_number <
|
|
194
|
+
if line_number < 0 or line_number >= len(lines):
|
|
140
195
|
return "Line number out of range"
|
|
141
196
|
line = lines[line_number]
|
|
142
197
|
if column is None:
|
|
143
198
|
return line
|
|
144
199
|
column -= 1
|
|
145
|
-
|
|
200
|
+
# Allow placing the cursor at end-of-line (column == len(line))
|
|
201
|
+
if column < 0 or column > len(line):
|
|
146
202
|
return "Invalid column number"
|
|
147
203
|
return f"{line[:column]}{cursor_tag}{line[column:]}"
|
|
148
204
|
|
|
149
205
|
|
|
150
206
|
def filter_diagnostics_by_position(
|
|
151
|
-
diagnostics: List[Dict], line: int, column: Optional[int]
|
|
207
|
+
diagnostics: List[Dict], line: Optional[int], column: Optional[int]
|
|
152
208
|
) -> List[Dict]:
|
|
153
|
-
"""
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
209
|
+
"""Return diagnostics that intersect the requested (0-indexed) position."""
|
|
210
|
+
|
|
211
|
+
if line is None:
|
|
212
|
+
return list(diagnostics)
|
|
213
|
+
|
|
214
|
+
matches: List[Dict] = []
|
|
215
|
+
for diagnostic in diagnostics:
|
|
216
|
+
diagnostic_range = diagnostic.get("range") or diagnostic.get("fullRange")
|
|
217
|
+
if not diagnostic_range:
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
start = diagnostic_range.get("start", {})
|
|
221
|
+
end = diagnostic_range.get("end", {})
|
|
222
|
+
start_line = start.get("line")
|
|
223
|
+
end_line = end.get("line")
|
|
224
|
+
|
|
225
|
+
if start_line is None or end_line is None:
|
|
226
|
+
continue
|
|
227
|
+
if line < start_line or line > end_line:
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
start_char = start.get("character")
|
|
231
|
+
end_char = end.get("character")
|
|
232
|
+
|
|
233
|
+
if column is None:
|
|
234
|
+
if (
|
|
235
|
+
line == end_line
|
|
236
|
+
and line != start_line
|
|
237
|
+
and end_char is not None
|
|
238
|
+
and end_char == 0
|
|
239
|
+
):
|
|
240
|
+
continue
|
|
241
|
+
matches.append(diagnostic)
|
|
242
|
+
continue
|
|
243
|
+
|
|
244
|
+
if start_char is None:
|
|
245
|
+
start_char = 0
|
|
246
|
+
if end_char is None:
|
|
247
|
+
end_char = column + 1
|
|
248
|
+
|
|
249
|
+
if start_line == end_line and start_char == end_char:
|
|
250
|
+
if column == start_char:
|
|
251
|
+
matches.append(diagnostic)
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
if line == start_line and column < start_char:
|
|
255
|
+
continue
|
|
256
|
+
if line == end_line and column >= end_char:
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
matches.append(diagnostic)
|
|
260
|
+
|
|
261
|
+
return matches
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lean-lsp-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.1
|
|
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
|
|
@@ -77,7 +77,11 @@ OR using the setup wizard:
|
|
|
77
77
|
|
|
78
78
|
Ctrl+Shift+P > "MCP: Add Server..." > "Command (stdio)" > "uvx lean-lsp-mcp" > "lean-lsp" (or any name you like) > Global or Workspace
|
|
79
79
|
|
|
80
|
-
OR manually
|
|
80
|
+
OR manually adding config by opening `mcp.json` with:
|
|
81
|
+
|
|
82
|
+
Ctrl+Shift+P > "MCP: Open User Configuration"
|
|
83
|
+
|
|
84
|
+
and adding the following
|
|
81
85
|
|
|
82
86
|
```jsonc
|
|
83
87
|
{
|
|
@@ -92,6 +96,25 @@ OR manually add config to `mcp.json`:
|
|
|
92
96
|
}
|
|
93
97
|
}
|
|
94
98
|
```
|
|
99
|
+
|
|
100
|
+
If you installed VSCode on Windows and are using WSL2 as your development environment, you may need to use this config instead:
|
|
101
|
+
|
|
102
|
+
```jsonc
|
|
103
|
+
{
|
|
104
|
+
"servers": {
|
|
105
|
+
"lean-lsp": {
|
|
106
|
+
"type": "stdio",
|
|
107
|
+
"command": "wsl.exe",
|
|
108
|
+
"args": [
|
|
109
|
+
"uvx",
|
|
110
|
+
"lean-lsp-mcp"
|
|
111
|
+
]
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
If that doesn't work, you can try cloning this repository and replace `"lean-lsp-mcp"` with `"/path/to/cloned/lean-lsp-mcp"`.
|
|
117
|
+
|
|
95
118
|
</details>
|
|
96
119
|
|
|
97
120
|
<details>
|
|
@@ -122,11 +145,9 @@ Run one of these commands in the root directory of your Lean project (where `lak
|
|
|
122
145
|
# Local-scoped MCP server
|
|
123
146
|
claude mcp add lean-lsp uvx lean-lsp-mcp
|
|
124
147
|
|
|
125
|
-
# OR project-scoped MCP server
|
|
148
|
+
# OR project-scoped MCP server
|
|
149
|
+
# (creates or updates a .mcp.json file in the current directory)
|
|
126
150
|
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
151
|
```
|
|
131
152
|
|
|
132
153
|
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 +305,9 @@ This tool requires [ripgrep](https://github.com/BurntSushi/ripgrep?tab=readme-ov
|
|
|
284
305
|
|
|
285
306
|
### External Search Tools
|
|
286
307
|
|
|
287
|
-
Currently
|
|
308
|
+
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!
|
|
309
|
+
|
|
310
|
+
Please cite the original authors of these tools if you use them!
|
|
288
311
|
|
|
289
312
|
#### lean_leansearch
|
|
290
313
|
|
|
@@ -337,6 +360,32 @@ Search for Lean definitions and theorems using [loogle.lean-lang.org](https://lo
|
|
|
337
360
|
```
|
|
338
361
|
</details>
|
|
339
362
|
|
|
363
|
+
#### lean_leanfinder
|
|
364
|
+
|
|
365
|
+
Semantic search for Mathlib theorems using [Lean Finder](https://huggingface.co/spaces/delta-lab-ai/Lean-Finder).
|
|
366
|
+
|
|
367
|
+
[Arxiv Paper](https://arxiv.org/abs/2510.15940)
|
|
368
|
+
|
|
369
|
+
- Supports informal descriptions, user questions, proof states, and statement fragments.
|
|
370
|
+
- 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`
|
|
371
|
+
|
|
372
|
+
<details>
|
|
373
|
+
<summary>Example output</summary>
|
|
374
|
+
|
|
375
|
+
Query: `Does y being a root of minpoly(x) imply minpoly(x)=minpoly(y)?`
|
|
376
|
+
|
|
377
|
+
```json
|
|
378
|
+
[
|
|
379
|
+
[
|
|
380
|
+
"/-- 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",
|
|
381
|
+
|
|
382
|
+
"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$."
|
|
383
|
+
],
|
|
384
|
+
...
|
|
385
|
+
]
|
|
386
|
+
```
|
|
387
|
+
</details>
|
|
388
|
+
|
|
340
389
|
#### lean_state_search
|
|
341
390
|
|
|
342
391
|
Search for applicable theorems for the current proof goal using [premise-search.com](https://premise-search.com/).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import orjson
|
|
4
4
|
from collections.abc import Callable
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
from typing import AsyncContextManager
|
|
@@ -16,8 +16,8 @@ def _first_json_block(result) -> dict[str, str] | None:
|
|
|
16
16
|
if not text:
|
|
17
17
|
continue
|
|
18
18
|
try:
|
|
19
|
-
return
|
|
20
|
-
except
|
|
19
|
+
return orjson.loads(text)
|
|
20
|
+
except orjson.JSONDecodeError:
|
|
21
21
|
continue
|
|
22
22
|
return None
|
|
23
23
|
|
|
@@ -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
|
+
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
from pathlib import Path
|
|
2
|
-
|
|
3
|
-
from mcp.server.fastmcp import Context
|
|
4
|
-
from mcp.server.fastmcp.utilities.logging import get_logger
|
|
5
|
-
from leanclient import LeanLSPClient
|
|
6
|
-
|
|
7
|
-
from lean_lsp_mcp.file_utils import get_relative_file_path
|
|
8
|
-
from lean_lsp_mcp.utils import OutputCapture
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
logger = get_logger(__name__)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def startup_client(ctx: Context):
|
|
15
|
-
"""Initialize the Lean LSP client if not already set up.
|
|
16
|
-
|
|
17
|
-
Args:
|
|
18
|
-
ctx (Context): Context object.
|
|
19
|
-
"""
|
|
20
|
-
lean_project_path = ctx.request_context.lifespan_context.lean_project_path
|
|
21
|
-
if lean_project_path is None:
|
|
22
|
-
raise ValueError("lean project path is not set.")
|
|
23
|
-
|
|
24
|
-
# Check if already correct client
|
|
25
|
-
client: LeanLSPClient | None = ctx.request_context.lifespan_context.client
|
|
26
|
-
|
|
27
|
-
if client is not None:
|
|
28
|
-
# Both are Path objects now, direct comparison works
|
|
29
|
-
if client.project_path == lean_project_path:
|
|
30
|
-
return # Client already set up correctly - reuse it!
|
|
31
|
-
# Different project path - close old client
|
|
32
|
-
client.close()
|
|
33
|
-
ctx.request_context.lifespan_context.file_content_hashes.clear()
|
|
34
|
-
|
|
35
|
-
# Need to create a new client
|
|
36
|
-
with OutputCapture() as output:
|
|
37
|
-
try:
|
|
38
|
-
client = LeanLSPClient(lean_project_path)
|
|
39
|
-
logger.info(f"Connected to Lean language server at {lean_project_path}")
|
|
40
|
-
except Exception as e:
|
|
41
|
-
logger.warning(f"Initial connection failed, trying with build: {e}")
|
|
42
|
-
client = LeanLSPClient(lean_project_path, initial_build=True)
|
|
43
|
-
logger.info(f"Connected with initial build to {lean_project_path}")
|
|
44
|
-
build_output = output.get_output()
|
|
45
|
-
if build_output:
|
|
46
|
-
logger.debug(f"Build output: {build_output}")
|
|
47
|
-
ctx.request_context.lifespan_context.client = client
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def valid_lean_project_path(path: Path | str) -> bool:
|
|
51
|
-
"""Check if the given path is a valid Lean project path (contains a lean-toolchain file).
|
|
52
|
-
|
|
53
|
-
Args:
|
|
54
|
-
path (Path | str): Absolute path to check.
|
|
55
|
-
|
|
56
|
-
Returns:
|
|
57
|
-
bool: True if valid Lean project path, False otherwise.
|
|
58
|
-
"""
|
|
59
|
-
path_obj = Path(path) if isinstance(path, str) else path
|
|
60
|
-
return (path_obj / "lean-toolchain").is_file()
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def setup_client_for_file(ctx: Context, file_path: str) -> str | None:
|
|
64
|
-
"""Check if the current LSP client is already set up and correct for this file. Otherwise, set it up.
|
|
65
|
-
|
|
66
|
-
Args:
|
|
67
|
-
ctx (Context): Context object.
|
|
68
|
-
file_path (str): Absolute path to the Lean file.
|
|
69
|
-
|
|
70
|
-
Returns:
|
|
71
|
-
str: Relative file path if the client is set up correctly, otherwise None.
|
|
72
|
-
"""
|
|
73
|
-
# Check if the file_path works for the current lean_project_path.
|
|
74
|
-
lean_project_path = ctx.request_context.lifespan_context.lean_project_path
|
|
75
|
-
if lean_project_path is not None:
|
|
76
|
-
rel_path = get_relative_file_path(lean_project_path, file_path)
|
|
77
|
-
if rel_path is not None:
|
|
78
|
-
startup_client(ctx)
|
|
79
|
-
return rel_path
|
|
80
|
-
|
|
81
|
-
# Try to find the correct project path by checking all directories in file_path.
|
|
82
|
-
file_path_obj = Path(file_path)
|
|
83
|
-
rel_path = None
|
|
84
|
-
for parent in file_path_obj.parents:
|
|
85
|
-
if valid_lean_project_path(parent):
|
|
86
|
-
lean_project_path = parent
|
|
87
|
-
rel_path = get_relative_file_path(lean_project_path, file_path)
|
|
88
|
-
if rel_path is not None:
|
|
89
|
-
ctx.request_context.lifespan_context.lean_project_path = (
|
|
90
|
-
lean_project_path
|
|
91
|
-
)
|
|
92
|
-
startup_client(ctx)
|
|
93
|
-
break
|
|
94
|
-
|
|
95
|
-
return rel_path
|
|
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
|