lean-lsp-mcp 0.10.3__tar.gz → 0.11.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. {lean_lsp_mcp-0.10.3/src/lean_lsp_mcp.egg-info → lean_lsp_mcp-0.11.0}/PKG-INFO +34 -8
  2. {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/README.md +32 -6
  3. {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/pyproject.toml +2 -2
  4. {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp/client_utils.py +31 -28
  5. {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp/file_utils.py +0 -1
  6. {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp/instructions.py +1 -0
  7. {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp/server.py +150 -21
  8. {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp/utils.py +53 -11
  9. {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0/src/lean_lsp_mcp.egg-info}/PKG-INFO +34 -8
  10. {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp.egg-info/requires.txt +1 -1
  11. {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/tests/test_search_tools.py +20 -0
  12. {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/LICENSE +0 -0
  13. {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/setup.cfg +0 -0
  14. {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp/__init__.py +0 -0
  15. {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp/__main__.py +0 -0
  16. {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp/search_utils.py +0 -0
  17. {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp.egg-info/SOURCES.txt +0 -0
  18. {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp.egg-info/dependency_links.txt +0 -0
  19. {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp.egg-info/entry_points.txt +0 -0
  20. {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/src/lean_lsp_mcp.egg-info/top_level.txt +0 -0
  21. {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/tests/test_editor_tools.py +0 -0
  22. {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/tests/test_logging.py +0 -0
  23. {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/tests/test_misc_tools.py +0 -0
  24. {lean_lsp_mcp-0.10.3 → lean_lsp_mcp-0.11.0}/tests/test_project_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lean-lsp-mcp
3
- Version: 0.10.3
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).
@@ -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).
@@ -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.3"
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
  """
@@ -10,6 +10,7 @@ import urllib
10
10
  import json
11
11
  import functools
12
12
  import subprocess
13
+ import uuid
13
14
  from pathlib import Path
14
15
 
15
16
  from mcp.server.fastmcp import Context, FastMCP
@@ -17,7 +18,7 @@ from mcp.server.fastmcp.utilities.logging import get_logger, configure_logging
17
18
  from mcp.server.auth.settings import AuthSettings
18
19
  from leanclient import LeanLSPClient, DocumentContentChange
19
20
 
20
- from lean_lsp_mcp.client_utils import setup_client_for_file
21
+ from lean_lsp_mcp.client_utils import setup_client_for_file, startup_client
21
22
  from lean_lsp_mcp.file_utils import get_file_contents, update_file
22
23
  from lean_lsp_mcp.instructions import INSTRUCTIONS
23
24
  from lean_lsp_mcp.search_utils import check_ripgrep_status, lean_local_search
@@ -67,6 +68,7 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
67
68
  rate_limit={
68
69
  "leansearch": [],
69
70
  "loogle": [],
71
+ "leanfinder": [],
70
72
  "lean_state_search": [],
71
73
  "hammer_premise": [],
72
74
  },
@@ -104,7 +106,14 @@ def rate_limited(category: str, max_requests: int, per_seconds: int):
104
106
  def decorator(func):
105
107
  @functools.wraps(func)
106
108
  def wrapper(*args, **kwargs):
107
- 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
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(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:
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(["lake", "exe", "cache", "get"], cwd=lean_project_path_obj, check=False)
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", "build", "--verbose",
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('utf-8', errors='replace').rstrip()
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'\[(\d+)/(\d+)\]', line_str)
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(r'\[\d+/\d+\]\s+(.+?)(?:\s+\(\d+\.?\d*[ms]+\))?$', line_str)
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(lean_project_path_obj, initial_build=False, prevent_cache_get=True)
225
+ client = LeanLSPClient(
226
+ lean_project_path_obj, initial_build=False, prevent_cache_get=True
227
+ )
204
228
 
205
229
  logger.info("Built project and re-started LSP client")
206
230
 
@@ -568,11 +592,13 @@ def run_code(ctx: Context, code: str) -> List[str] | str:
568
592
  Returns:
569
593
  List[str] | str: Diagnostics msgs or error msg
570
594
  """
571
- 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
572
597
  if lean_project_path is None:
573
598
  return "No valid Lean project path found. Run another tool (e.g. `lean_diagnostic_messages`) first to set it up or set the LEAN_PROJECT_PATH environment variable."
574
599
 
575
- 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"
576
602
  abs_path = lean_project_path / rel_path
577
603
 
578
604
  try:
@@ -581,14 +607,44 @@ def run_code(ctx: Context, code: str) -> List[str] | str:
581
607
  except Exception as e:
582
608
  return f"Error writing code snippet to file `{abs_path}`:\n{str(e)}"
583
609
 
584
- client: LeanLSPClient = ctx.request_context.lifespan_context.client
585
- diagnostics = format_diagnostics(client.get_diagnostics(rel_path))
586
- 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
587
615
 
588
616
  try:
589
- os.remove(abs_path)
590
- except Exception as e:
591
- 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}"
592
648
 
593
649
  return (
594
650
  diagnostics
@@ -717,6 +773,79 @@ def loogle(ctx: Context, query: str, num_results: int = 8) -> List[dict] | str:
717
773
  return f"loogle error:\n{str(e)}"
718
774
 
719
775
 
776
+ @mcp.tool("lean_leanfinder")
777
+ @rate_limited("leanfinder", max_requests=10, per_seconds=30)
778
+ def leanfinder(
779
+ ctx: Context, query: str, num_results: int = 5
780
+ ) -> List[tuple] | str:
781
+ """Search Mathlib theorems/definitions semantically by mathematical concept using Lean Finder.
782
+
783
+ Effective query types:
784
+ - Math + API: "setAverage Icc interval", "integral_pow symmetric bounds"
785
+ - Conceptual: "algebraic elements same minimal polynomial", "quadrature nodes"
786
+ - Structure: "Finset expect sum commute", "polynomial degree bounded eval"
787
+ - Natural: "average equals point values", "root implies equal polynomials"
788
+
789
+ Tips: Mix informal math terms with Lean identifiers. Multiple targeted queries beat one complex query.
790
+
791
+ Args:
792
+ query (str): Mathematical concepts combined with Lean terms
793
+ num_results (int, optional): Max results. Defaults to 5.
794
+
795
+ Returns:
796
+ List[tuple] | str: (lean_statement, english_description) pairs or error
797
+ """
798
+ try:
799
+ headers = {"User-Agent": "lean-lsp-mcp/0.1", "Content-Type": "application/json"}
800
+ payload = json.dumps({"data": [query, num_results, "Normal"]}).encode("utf-8")
801
+
802
+ req = urllib.request.Request(
803
+ "https://delta-lab-ai-lean-finder.hf.space/gradio_api/call/retrieve",
804
+ data=payload,
805
+ headers=headers,
806
+ method="POST",
807
+ )
808
+
809
+ with urllib.request.urlopen(req, timeout=10) as response:
810
+ event_data = json.loads(response.read().decode("utf-8"))
811
+ event_id = event_data.get("event_id")
812
+
813
+ if not event_id:
814
+ return "Lean Finder has timed out or errored. It might be warming up, try a second time in 2 minutes."
815
+
816
+ result_url = f"https://delta-lab-ai-lean-finder.hf.space/gradio_api/call/retrieve/{event_id}"
817
+ req = urllib.request.Request(result_url, headers=headers, method="GET")
818
+
819
+ with urllib.request.urlopen(req, timeout=30) as response:
820
+ for line in response:
821
+ line = line.decode("utf-8").strip()
822
+ if line.startswith("data: "):
823
+ data = json.loads(line[6:])
824
+ if isinstance(data, list) and len(data) > 0:
825
+ html = data[0] if isinstance(data[0], str) else str(data)
826
+
827
+ # Parse HTML table rows
828
+ rows = re.findall(
829
+ r"<tr><td>\d+</td><td>(.*?)</td><td>(.*?)</td></tr>",
830
+ html, re.DOTALL
831
+ )
832
+ results = []
833
+ for formal_cell, informal_cell in rows:
834
+ formal = re.search(r"<code[^>]*>(.*?)</code>", formal_cell, re.DOTALL)
835
+ informal = re.search(r"<span[^>]*>(.*?)</span>", informal_cell, re.DOTALL)
836
+ if formal:
837
+ results.append((
838
+ formal.group(1).strip(),
839
+ informal.group(1).strip() if informal else ""
840
+ ))
841
+
842
+ return results if results else "Lean Finder: No results parsed"
843
+
844
+ return "Lean Finder: No results received"
845
+ except Exception as e:
846
+ return f"Lean Finder Error:\n{str(e)}"
847
+
848
+
720
849
  @mcp.tool("lean_state_search")
721
850
  @rate_limited("lean_state_search", max_requests=3, per_seconds=30)
722
851
  def state_search(
@@ -73,6 +73,27 @@ def format_goal(goal, default_msg):
73
73
  return rendered.replace("```lean\n", "").replace("\n```", "") if rendered else None
74
74
 
75
75
 
76
+ def _utf16_index_to_py_index(text: str, utf16_index: int) -> int | None:
77
+ """Convert an LSP UTF-16 column index into a Python string index."""
78
+ if utf16_index < 0:
79
+ return None
80
+
81
+ units = 0
82
+ for idx, ch in enumerate(text):
83
+ code_point = ord(ch)
84
+ next_units = units + (2 if code_point > 0xFFFF else 1)
85
+
86
+ if utf16_index < next_units:
87
+ return idx
88
+ if utf16_index == next_units:
89
+ return idx + 1
90
+
91
+ units = next_units
92
+ if units >= utf16_index:
93
+ return len(text)
94
+ return None
95
+
96
+
76
97
  def extract_range(content: str, range: dict) -> str:
77
98
  """Extract the text from the content based on the range.
78
99
 
@@ -88,16 +109,36 @@ def extract_range(content: str, range: dict) -> str:
88
109
  end_line = range["end"]["line"]
89
110
  end_char = range["end"]["character"]
90
111
 
91
- lines = content.splitlines()
92
- if 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.3
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).
@@ -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