lean-lsp-mcp 0.10.2__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.
Files changed (24) hide show
  1. {lean_lsp_mcp-0.10.2/src/lean_lsp_mcp.egg-info → lean_lsp_mcp-0.11.0}/PKG-INFO +35 -9
  2. {lean_lsp_mcp-0.10.2 → lean_lsp_mcp-0.11.0}/README.md +33 -7
  3. {lean_lsp_mcp-0.10.2 → lean_lsp_mcp-0.11.0}/pyproject.toml +2 -2
  4. {lean_lsp_mcp-0.10.2 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp/client_utils.py +31 -28
  5. {lean_lsp_mcp-0.10.2 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp/file_utils.py +0 -1
  6. {lean_lsp_mcp-0.10.2 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp/instructions.py +1 -0
  7. {lean_lsp_mcp-0.10.2 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp/server.py +190 -14
  8. {lean_lsp_mcp-0.10.2 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp/utils.py +53 -11
  9. {lean_lsp_mcp-0.10.2 → lean_lsp_mcp-0.11.0/src/lean_lsp_mcp.egg-info}/PKG-INFO +35 -9
  10. {lean_lsp_mcp-0.10.2 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp.egg-info/requires.txt +1 -1
  11. {lean_lsp_mcp-0.10.2 → lean_lsp_mcp-0.11.0}/tests/test_search_tools.py +20 -0
  12. {lean_lsp_mcp-0.10.2 → lean_lsp_mcp-0.11.0}/LICENSE +0 -0
  13. {lean_lsp_mcp-0.10.2 → lean_lsp_mcp-0.11.0}/setup.cfg +0 -0
  14. {lean_lsp_mcp-0.10.2 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp/__init__.py +0 -0
  15. {lean_lsp_mcp-0.10.2 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp/__main__.py +0 -0
  16. {lean_lsp_mcp-0.10.2 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp/search_utils.py +0 -0
  17. {lean_lsp_mcp-0.10.2 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp.egg-info/SOURCES.txt +0 -0
  18. {lean_lsp_mcp-0.10.2 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp.egg-info/dependency_links.txt +0 -0
  19. {lean_lsp_mcp-0.10.2 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp.egg-info/entry_points.txt +0 -0
  20. {lean_lsp_mcp-0.10.2 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp.egg-info/top_level.txt +0 -0
  21. {lean_lsp_mcp-0.10.2 → lean_lsp_mcp-0.11.0}/tests/test_editor_tools.py +0 -0
  22. {lean_lsp_mcp-0.10.2 → lean_lsp_mcp-0.11.0}/tests/test_logging.py +0 -0
  23. {lean_lsp_mcp-0.10.2 → lean_lsp_mcp-0.11.0}/tests/test_misc_tools.py +0 -0
  24. {lean_lsp_mcp-0.10.2 → 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.10.2
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.3.1
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 `leansearch`, `loogle`, `lean_hammer` and `lean_state_search` to find relevant theorems and definitions.
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 (creates or updates a .mcp.json file in the current directory)
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).
@@ -134,7 +132,7 @@ You can find more details about MCP server configuration for Claude Code [here](
134
132
 
135
133
  #### Claude Skill: Lean4 Theorem Proving
136
134
 
137
- If you are using [Claude Desktop](https://modelcontextprotocol.io/quickstart/user) or [Claude Code](https://claude.ai/code), you can also install the [Lean4 Theorem Proving Skill](https://github.com/cameronfreer/lean4-skills/tree/main/lean4-theorem-proving). This skill provides additional prompts and templates for interacting with Lean4 projects and includes a section on interacting with the `lean-lsp-mcp` server.
135
+ If you are using [Claude Desktop](https://modelcontextprotocol.io/quickstart/user) or [Claude Code](https://claude.ai/code), you can also install the [Lean4 Theorem Proving Skill](https://github.com/cameronfreer/lean4-skills/tree/main/plugins/lean4-theorem-proving). This skill provides additional prompts and templates for interacting with Lean4 projects and includes a section on interacting with the `lean-lsp-mcp` server.
138
136
 
139
137
  ### 4. Install ripgrep (optional but recommended)
140
138
 
@@ -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 all external tools are separately **rate limited to 3 requests per 30 seconds**.
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 `leansearch`, `loogle`, `lean_hammer` and `lean_state_search` to find relevant theorems and definitions.
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 (creates or updates a .mcp.json file in the current directory)
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).
@@ -112,7 +110,7 @@ You can find more details about MCP server configuration for Claude Code [here](
112
110
 
113
111
  #### Claude Skill: Lean4 Theorem Proving
114
112
 
115
- If you are using [Claude Desktop](https://modelcontextprotocol.io/quickstart/user) or [Claude Code](https://claude.ai/code), you can also install the [Lean4 Theorem Proving Skill](https://github.com/cameronfreer/lean4-skills/tree/main/lean4-theorem-proving). This skill provides additional prompts and templates for interacting with Lean4 projects and includes a section on interacting with the `lean-lsp-mcp` server.
113
+ If you are using [Claude Desktop](https://modelcontextprotocol.io/quickstart/user) or [Claude Code](https://claude.ai/code), you can also install the [Lean4 Theorem Proving Skill](https://github.com/cameronfreer/lean4-skills/tree/main/plugins/lean4-theorem-proving). This skill provides additional prompts and templates for interacting with Lean4 projects and includes a section on interacting with the `lean-lsp-mcp` server.
116
114
 
117
115
  ### 4. Install ripgrep (optional but recommended)
118
116
 
@@ -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 all external tools are separately **rate limited to 3 requests per 30 seconds**.
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.10.2"
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.3.1",
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
- 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
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:
@@ -1,4 +1,3 @@
1
- import os
2
1
  from typing import Optional, Dict
3
2
  from pathlib import Path
4
3
 
@@ -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
  """
@@ -1,3 +1,4 @@
1
+ import asyncio
1
2
  import os
2
3
  import re
3
4
  import time
@@ -9,6 +10,7 @@ import urllib
9
10
  import json
10
11
  import functools
11
12
  import subprocess
13
+ import uuid
12
14
  from pathlib import Path
13
15
 
14
16
  from mcp.server.fastmcp import Context, FastMCP
@@ -16,7 +18,7 @@ from mcp.server.fastmcp.utilities.logging import get_logger, configure_logging
16
18
  from mcp.server.auth.settings import AuthSettings
17
19
  from leanclient import LeanLSPClient, DocumentContentChange
18
20
 
19
- 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
20
22
  from lean_lsp_mcp.file_utils import get_file_contents, update_file
21
23
  from lean_lsp_mcp.instructions import INSTRUCTIONS
22
24
  from lean_lsp_mcp.search_utils import check_ripgrep_status, lean_local_search
@@ -66,6 +68,7 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
66
68
  rate_limit={
67
69
  "leansearch": [],
68
70
  "loogle": [],
71
+ "leanfinder": [],
69
72
  "lean_state_search": [],
70
73
  "hammer_premise": [],
71
74
  },
@@ -103,7 +106,14 @@ def rate_limited(category: str, max_requests: int, per_seconds: int):
103
106
  def decorator(func):
104
107
  @functools.wraps(func)
105
108
  def wrapper(*args, **kwargs):
106
- rate_limit = kwargs["ctx"].request_context.lifespan_context.rate_limit
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
107
117
  current_time = int(time.time())
108
118
  rate_limit[category] = [
109
119
  timestamp
@@ -123,7 +133,9 @@ def rate_limited(category: str, max_requests: int, per_seconds: int):
123
133
 
124
134
  # Project level tools
125
135
  @mcp.tool("lean_build")
126
- def lsp_build(ctx: Context, lean_project_path: str = None, clean: bool = False) -> str:
136
+ async def lsp_build(
137
+ ctx: Context, lean_project_path: str = None, clean: bool = False
138
+ ) -> str:
127
139
  """Build the Lean project and restart the LSP Server.
128
140
 
129
141
  Use only if needed (e.g. new imports).
@@ -141,10 +153,17 @@ def lsp_build(ctx: Context, lean_project_path: str = None, clean: bool = False)
141
153
  lean_project_path_obj = Path(lean_project_path).resolve()
142
154
  ctx.request_context.lifespan_context.lean_project_path = lean_project_path_obj
143
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
+
144
162
  build_output = ""
145
163
  try:
146
164
  client: LeanLSPClient = ctx.request_context.lifespan_context.client
147
165
  if client:
166
+ ctx.request_context.lifespan_context.client = None
148
167
  client.close()
149
168
  ctx.request_context.lifespan_context.file_content_hashes.clear()
150
169
 
@@ -152,13 +171,65 @@ def lsp_build(ctx: Context, lean_project_path: str = None, clean: bool = False)
152
171
  subprocess.run(["lake", "clean"], cwd=lean_project_path_obj, check=False)
153
172
  logger.info("Ran `lake clean`")
154
173
 
155
- with OutputCapture() as output:
156
- client = LeanLSPClient(lean_project_path_obj, initial_build=True)
174
+ # Fetch cache
175
+ subprocess.run(
176
+ ["lake", "exe", "cache", "get"], cwd=lean_project_path_obj, check=False
177
+ )
178
+
179
+ # Run build with progress reporting
180
+ process = await asyncio.create_subprocess_exec(
181
+ "lake",
182
+ "build",
183
+ "--verbose",
184
+ cwd=lean_project_path_obj,
185
+ stdout=asyncio.subprocess.PIPE,
186
+ stderr=asyncio.subprocess.STDOUT,
187
+ )
188
+
189
+ output_lines = []
190
+
191
+ while True:
192
+ line = await process.stdout.readline()
193
+ if not line:
194
+ break
195
+
196
+ line_str = line.decode("utf-8", errors="replace").rstrip()
197
+ output_lines.append(line_str)
198
+
199
+ # Parse progress: look for pattern like "[2/8]" or "[10/100]"
200
+ match = re.search(r"\[(\d+)/(\d+)\]", line_str)
201
+ if match:
202
+ current_job = int(match.group(1))
203
+ total_jobs = int(match.group(2))
204
+
205
+ # Extract what's being built
206
+ # Line format: "ℹ [2/8] Built TestLeanBuild.Basic (1.6s)"
207
+ desc_match = re.search(
208
+ r"\[\d+/\d+\]\s+(.+?)(?:\s+\(\d+\.?\d*[ms]+\))?$", line_str
209
+ )
210
+ description = desc_match.group(1) if desc_match else "Building"
211
+
212
+ # Report progress using dynamic totals from Lake
213
+ await ctx.report_progress(
214
+ progress=current_job, total=total_jobs, message=description
215
+ )
216
+
217
+ await process.wait()
218
+
219
+ if process.returncode != 0:
220
+ build_output = "\n".join(output_lines)
221
+ raise Exception(f"Build failed with return code {process.returncode}")
222
+
223
+ # Start LSP client (without initial build since we just did it)
224
+ with OutputCapture():
225
+ client = LeanLSPClient(
226
+ lean_project_path_obj, initial_build=False, prevent_cache_get=True
227
+ )
157
228
 
158
229
  logger.info("Built project and re-started LSP client")
159
230
 
160
231
  ctx.request_context.lifespan_context.client = client
161
- build_output = output.get_output()
232
+ build_output = "\n".join(output_lines)
162
233
  return build_output
163
234
  except Exception as e:
164
235
  return f"Error during build:\n{str(e)}\n{build_output}"
@@ -521,11 +592,13 @@ def run_code(ctx: Context, code: str) -> List[str] | str:
521
592
  Returns:
522
593
  List[str] | str: Diagnostics msgs or error msg
523
594
  """
524
- lean_project_path = ctx.request_context.lifespan_context.lean_project_path
595
+ lifespan_context = ctx.request_context.lifespan_context
596
+ lean_project_path = lifespan_context.lean_project_path
525
597
  if lean_project_path is None:
526
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."
527
599
 
528
- rel_path = "temp_snippet.lean"
600
+ # Use a unique snippet filename to avoid collisions under concurrency
601
+ rel_path = f"_mcp_snippet_{uuid.uuid4().hex}.lean"
529
602
  abs_path = lean_project_path / rel_path
530
603
 
531
604
  try:
@@ -534,14 +607,44 @@ def run_code(ctx: Context, code: str) -> List[str] | str:
534
607
  except Exception as e:
535
608
  return f"Error writing code snippet to file `{abs_path}`:\n{str(e)}"
536
609
 
537
- client: LeanLSPClient = ctx.request_context.lifespan_context.client
538
- diagnostics = format_diagnostics(client.get_diagnostics(rel_path))
539
- client.close_files([rel_path])
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
540
615
 
541
616
  try:
542
- os.remove(abs_path)
543
- except Exception as e:
544
- return f"Error removing temporary file `{abs_path}`:\n{str(e)}"
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}"
545
648
 
546
649
  return (
547
650
  diagnostics
@@ -670,6 +773,79 @@ def loogle(ctx: Context, query: str, num_results: int = 8) -> List[dict] | str:
670
773
  return f"loogle error:\n{str(e)}"
671
774
 
672
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
+
673
849
  @mcp.tool("lean_state_search")
674
850
  @rate_limited("lean_state_search", max_requests=3, per_seconds=30)
675
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 start_line < 0 or end_line >= len(lines):
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
- if start_line == end_line:
95
- return lines[start_line][start_char:end_char]
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 < 1 or line_number > len(lines):
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
- if column < 0 or column >= len(lines):
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.10.2
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.3.1
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 `leansearch`, `loogle`, `lean_hammer` and `lean_state_search` to find relevant theorems and definitions.
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 (creates or updates a .mcp.json file in the current directory)
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).
@@ -134,7 +132,7 @@ You can find more details about MCP server configuration for Claude Code [here](
134
132
 
135
133
  #### Claude Skill: Lean4 Theorem Proving
136
134
 
137
- If you are using [Claude Desktop](https://modelcontextprotocol.io/quickstart/user) or [Claude Code](https://claude.ai/code), you can also install the [Lean4 Theorem Proving Skill](https://github.com/cameronfreer/lean4-skills/tree/main/lean4-theorem-proving). This skill provides additional prompts and templates for interacting with Lean4 projects and includes a section on interacting with the `lean-lsp-mcp` server.
135
+ If you are using [Claude Desktop](https://modelcontextprotocol.io/quickstart/user) or [Claude Code](https://claude.ai/code), you can also install the [Lean4 Theorem Proving Skill](https://github.com/cameronfreer/lean4-skills/tree/main/plugins/lean4-theorem-proving). This skill provides additional prompts and templates for interacting with Lean4 projects and includes a section on interacting with the `lean-lsp-mcp` server.
138
136
 
139
137
  ### 4. Install ripgrep (optional but recommended)
140
138
 
@@ -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 all external tools are separately **rate limited to 3 requests per 30 seconds**.
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/).
@@ -1,4 +1,4 @@
1
- leanclient==0.3.1
1
+ leanclient==0.4.0
2
2
  mcp[cli]==1.19.0
3
3
  orjson>=3.11.1
4
4
 
@@ -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