lean-lsp-mcp 0.10.3__py3-none-any.whl → 0.11.1__py3-none-any.whl

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.
@@ -1,4 +1,6 @@
1
+ import os
1
2
  from pathlib import Path
3
+ from threading import Lock
2
4
 
3
5
  from mcp.server.fastmcp import Context
4
6
  from mcp.server.fastmcp.utilities.logging import get_logger
@@ -9,6 +11,7 @@ from lean_lsp_mcp.utils import OutputCapture
9
11
 
10
12
 
11
13
  logger = get_logger(__name__)
14
+ CLIENT_LOCK = Lock()
12
15
 
13
16
 
14
17
  def startup_client(ctx: Context):
@@ -17,34 +20,35 @@ def startup_client(ctx: Context):
17
20
  Args:
18
21
  ctx (Context): Context object.
19
22
  """
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
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
48
52
 
49
53
 
50
54
  def valid_lean_project_path(path: Path | str) -> bool:
@@ -61,35 +65,58 @@ def valid_lean_project_path(path: Path | str) -> bool:
61
65
 
62
66
 
63
67
  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.
68
+ """Ensure the LSP client matches the file's Lean project and return its relative path."""
65
69
 
66
- Args:
67
- ctx (Context): Context object.
68
- file_path (str): Absolute path to the Lean file.
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
69
74
 
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)
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])
77
101
  if rel_path is not None:
78
- startup_client(ctx)
79
102
  return rel_path
80
103
 
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)
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])
88
115
  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
116
+ return rel_path
117
+ else:
118
+ project_cache[current_dir] = ""
119
+ prev_dir = current_dir
120
+ current_dir = os.path.dirname(current_dir)
94
121
 
95
- return rel_path
122
+ return None
@@ -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
  """
lean_lsp_mcp/server.py CHANGED
@@ -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 json
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
- 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
 
@@ -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
- client.open_file(rel_path)
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
- results = []
537
- snippets[0] += "\n" # Extra newline for the first snippet
538
- for snippet in snippets:
539
- # Create a DocumentContentChange for the snippet
540
- change = DocumentContentChange(
541
- snippet + "\n",
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
- lean_project_path = ctx.request_context.lifespan_context.lean_project_path
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
- rel_path = "temp_snippet.lean"
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 = ctx.request_context.lifespan_context.client
585
- diagnostics = format_diagnostics(client.get_diagnostics(rel_path))
586
- client.close_files([rel_path])
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
- os.remove(abs_path)
590
- except Exception as e:
591
- return f"Error removing temporary file `{abs_path}`:\n{str(e)}"
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 = json.dumps(
714
+ payload = orjson.dumps(
650
715
  {"num_results": str(num_results), "query": [query]}
651
- ).encode("utf-8")
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 = json.loads(response.read().decode("utf-8"))
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 = json.loads(response.read().decode("utf-8"))
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 = json.loads(response.read().decode("utf-8"))
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=json.dumps(data).encode("utf-8"),
953
+ data=orjson.dumps(data),
816
954
  )
817
955
 
818
956
  with urllib.request.urlopen(req, timeout=20) as response:
819
- results = json.loads(response.read().decode("utf-8"))
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}")
lean_lsp_mcp/utils.py CHANGED
@@ -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 start_line < 0 or end_line >= len(lines):
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
- 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)
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 < 1 or line_number > len(lines):
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
- if column < 0 or column >= len(lines):
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
- """Find diagnostics at a specific position.
154
-
155
- Args:
156
- diagnostics (List[Dict]): List of diagnostics.
157
- line (int): The line number (0-indexed).
158
- column (Optional[int]): The column number (0-indexed).
159
-
160
- Returns:
161
- List[Dict]: List of diagnostics at the specified position.
162
- """
163
- if column is None:
164
- return [
165
- d
166
- for d in diagnostics
167
- if d["range"]["start"]["line"] <= line <= d["range"]["end"]["line"]
168
- ]
169
-
170
- return [
171
- d
172
- for d in diagnostics
173
- if d["range"]["start"]["line"] <= line <= d["range"]["end"]["line"]
174
- and d["range"]["start"]["character"] <= column < d["range"]["end"]["character"]
175
- ]
176
-
177
-
178
- class OptionalTokenVerifier(TokenVerifier):
179
- def __init__(self, expected_token: str):
180
- self.expected_token = expected_token
181
-
182
- async def verify_token(self, token: str) -> AccessToken | None:
183
- if token == self.expected_token:
184
- return AccessToken(token=token, client_id="lean-lsp-mcp", scopes=["user"])
185
- return None
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.10.3
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.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
@@ -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 add config to `mcp.json`:
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 (creates or updates a .mcp.json file in the current directory)
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 all external tools are separately **rate limited to 3 requests per 30 seconds**.
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/).
@@ -0,0 +1,14 @@
1
+ lean_lsp_mcp/__init__.py,sha256=lxqDq0G_sI2iu2Nniy-pTW7BE9Ux7ZXeDoGf0OAWIDc,763
2
+ lean_lsp_mcp/__main__.py,sha256=XnpTzfJc0T-j9tHtdkA8ovTr1c139ffTewcJGhxYDaM,49
3
+ lean_lsp_mcp/client_utils.py,sha256=Z-2AoFBA36UO8oeauTNUtquE6Yz_ZRTKz_9n1iapyJ4,4519
4
+ lean_lsp_mcp/file_utils.py,sha256=qddegF-T5-egZop8dPe_3Cma-3rRSKsAErVDQLecmbE,2916
5
+ lean_lsp_mcp/instructions.py,sha256=y_gHlbeJoKnPohmcSVrQQds6mbBO1en-lxnXAfEypZE,892
6
+ lean_lsp_mcp/search_utils.py,sha256=cNpWzR1TuNyvRxCeCIb0HoEHGsCIW1ypqN1R1ZpbkdI,4170
7
+ lean_lsp_mcp/server.py,sha256=jK2bwi7iiM_agXtwRNFAMIpAo5FLDJ2FKfdDLvbaWfY,35433
8
+ lean_lsp_mcp/utils.py,sha256=zLu2VIhaX4yocY07F3Z94LB2jRGrkH1ID9SjR3poE9A,8255
9
+ lean_lsp_mcp-0.11.1.dist-info/licenses/LICENSE,sha256=CQlxnf0tQyoVrBE93JYvAUYxv6Z5Yg6sX0pwogOkFvo,1071
10
+ lean_lsp_mcp-0.11.1.dist-info/METADATA,sha256=IWxzDA2qqh5yK7jxCr36R6z7HuwwLV2bzoFlGqUWEoQ,19444
11
+ lean_lsp_mcp-0.11.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
12
+ lean_lsp_mcp-0.11.1.dist-info/entry_points.txt,sha256=nQbvwctWkWD7I-2f4VrdVQBZYGUw8CnUnFC6QjXxOSE,51
13
+ lean_lsp_mcp-0.11.1.dist-info/top_level.txt,sha256=LGEK0lgMSNPIQ6mG8EO-adaZEGPi_0daDs004epOTF0,13
14
+ lean_lsp_mcp-0.11.1.dist-info/RECORD,,
@@ -1,14 +0,0 @@
1
- lean_lsp_mcp/__init__.py,sha256=lxqDq0G_sI2iu2Nniy-pTW7BE9Ux7ZXeDoGf0OAWIDc,763
2
- lean_lsp_mcp/__main__.py,sha256=XnpTzfJc0T-j9tHtdkA8ovTr1c139ffTewcJGhxYDaM,49
3
- lean_lsp_mcp/client_utils.py,sha256=mN0wQlGLMVR8bV0OqVJ8YHq6vUoty4MzebvA9A3u4dM,3502
4
- lean_lsp_mcp/file_utils.py,sha256=6dVceZOCb28KsKtoub4QPD272TrtKzFZedg4H-N6KgA,2926
5
- lean_lsp_mcp/instructions.py,sha256=qdGW1Qh14Gb_ezrMLU_SrS_F1X_r065KIsQu0HamwnU,825
6
- lean_lsp_mcp/search_utils.py,sha256=cNpWzR1TuNyvRxCeCIb0HoEHGsCIW1ypqN1R1ZpbkdI,4170
7
- lean_lsp_mcp/server.py,sha256=Nsm8NY_Oiq-JTAHrtcintZP7AudZfIL-72MOuCP2Ju8,29773
8
- lean_lsp_mcp/utils.py,sha256=8wqjD5gfZhIkM1vLxvLoDkh-RQx_kDrxdRRUb1VQAbI,6040
9
- lean_lsp_mcp-0.10.3.dist-info/licenses/LICENSE,sha256=CQlxnf0tQyoVrBE93JYvAUYxv6Z5Yg6sX0pwogOkFvo,1071
10
- lean_lsp_mcp-0.10.3.dist-info/METADATA,sha256=_q8CLTiyHTAZ4bM6nCzLPLm_LDeoqI2_qe-87NoCVOw,17463
11
- lean_lsp_mcp-0.10.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
12
- lean_lsp_mcp-0.10.3.dist-info/entry_points.txt,sha256=nQbvwctWkWD7I-2f4VrdVQBZYGUw8CnUnFC6QjXxOSE,51
13
- lean_lsp_mcp-0.10.3.dist-info/top_level.txt,sha256=LGEK0lgMSNPIQ6mG8EO-adaZEGPi_0daDs004epOTF0,13
14
- lean_lsp_mcp-0.10.3.dist-info/RECORD,,