lean-lsp-mcp 0.11.0__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,3 +1,4 @@
1
+ import os
1
2
  from pathlib import Path
2
3
  from threading import Lock
3
4
 
@@ -64,35 +65,58 @@ def valid_lean_project_path(path: Path | str) -> bool:
64
65
 
65
66
 
66
67
  def setup_client_for_file(ctx: Context, file_path: str) -> str | None:
67
- """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."""
68
69
 
69
- Args:
70
- ctx (Context): Context object.
71
- 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
72
74
 
73
- Returns:
74
- str: Relative file path if the client is set up correctly, otherwise None.
75
- """
76
- # Check if the file_path works for the current lean_project_path.
77
- lean_project_path = ctx.request_context.lifespan_context.lean_project_path
78
- if lean_project_path is not None:
79
- 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])
80
101
  if rel_path is not None:
81
- startup_client(ctx)
82
102
  return rel_path
83
103
 
84
- # Try to find the correct project path by checking all directories in file_path.
85
- file_path_obj = Path(file_path)
86
- rel_path = None
87
- for parent in file_path_obj.parents:
88
- if valid_lean_project_path(parent):
89
- lean_project_path = parent
90
- 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])
91
115
  if rel_path is not None:
92
- ctx.request_context.lifespan_context.lean_project_path = (
93
- lean_project_path
94
- )
95
- startup_client(ctx)
96
- 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)
97
121
 
98
- return rel_path
122
+ return None
lean_lsp_mcp/server.py CHANGED
@@ -7,7 +7,7 @@ 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
13
  import uuid
@@ -555,28 +555,37 @@ def multi_attempt(
555
555
  update_file(ctx, rel_path)
556
556
  client: LeanLSPClient = ctx.request_context.lifespan_context.client
557
557
 
558
- client.open_file(rel_path)
558
+ try:
559
+ client.open_file(rel_path)
559
560
 
560
- results = []
561
- snippets[0] += "\n" # Extra newline for the first snippet
562
- for snippet in snippets:
563
- # Create a DocumentContentChange for the snippet
564
- change = DocumentContentChange(
565
- snippet + "\n",
566
- [line - 1, 0],
567
- [line, 0],
568
- )
569
- # Apply the change to the file, capture diagnostics and goal state
570
- client.update_file(rel_path, [change])
571
- diag = client.get_diagnostics(rel_path)
572
- formatted_diag = "\n".join(format_diagnostics(diag, select_line=line - 1))
573
- goal = client.get_goal(rel_path, line - 1, len(snippet))
574
- formatted_goal = format_goal(goal, "Missing goal")
575
- results.append(f"{snippet}:\n {formatted_goal}\n\n{formatted_diag}")
576
-
577
- # Make sure it's clean after the attempts
578
- client.close_files([rel_path])
579
- return results
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}")
582
+
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)
580
589
 
581
590
 
582
591
  @mcp.tool("lean_run_code")
@@ -702,9 +711,9 @@ def leansearch(ctx: Context, query: str, num_results: int = 5) -> List[Dict] | s
702
711
  """
703
712
  try:
704
713
  headers = {"User-Agent": "lean-lsp-mcp/0.1", "Content-Type": "application/json"}
705
- payload = json.dumps(
714
+ payload = orjson.dumps(
706
715
  {"num_results": str(num_results), "query": [query]}
707
- ).encode("utf-8")
716
+ )
708
717
 
709
718
  req = urllib.request.Request(
710
719
  "https://leansearch.net/search",
@@ -714,7 +723,7 @@ def leansearch(ctx: Context, query: str, num_results: int = 5) -> List[Dict] | s
714
723
  )
715
724
 
716
725
  with urllib.request.urlopen(req, timeout=20) as response:
717
- results = json.loads(response.read().decode("utf-8"))
726
+ results = orjson.loads(response.read())
718
727
 
719
728
  if not results or not results[0]:
720
729
  return "No results found."
@@ -760,14 +769,14 @@ def loogle(ctx: Context, query: str, num_results: int = 8) -> List[dict] | str:
760
769
  )
761
770
 
762
771
  with urllib.request.urlopen(req, timeout=20) as response:
763
- results = json.loads(response.read().decode("utf-8"))
772
+ results = orjson.loads(response.read())
764
773
 
765
774
  if "hits" not in results:
766
775
  return "No results found."
767
776
 
768
777
  results = results["hits"][:num_results]
769
778
  for result in results:
770
- result.pop("doc")
779
+ result.pop("doc", None)
771
780
  return results
772
781
  except Exception as e:
773
782
  return f"loogle error:\n{str(e)}"
@@ -797,7 +806,7 @@ def leanfinder(
797
806
  """
798
807
  try:
799
808
  headers = {"User-Agent": "lean-lsp-mcp/0.1", "Content-Type": "application/json"}
800
- payload = json.dumps({"data": [query, num_results, "Normal"]}).encode("utf-8")
809
+ payload = orjson.dumps({"data": [query, num_results, "Normal"]})
801
810
 
802
811
  req = urllib.request.Request(
803
812
  "https://delta-lab-ai-lean-finder.hf.space/gradio_api/call/retrieve",
@@ -807,7 +816,7 @@ def leanfinder(
807
816
  )
808
817
 
809
818
  with urllib.request.urlopen(req, timeout=10) as response:
810
- event_data = json.loads(response.read().decode("utf-8"))
819
+ event_data = orjson.loads(response.read())
811
820
  event_id = event_data.get("event_id")
812
821
 
813
822
  if not event_id:
@@ -820,7 +829,7 @@ def leanfinder(
820
829
  for line in response:
821
830
  line = line.decode("utf-8").strip()
822
831
  if line.startswith("data: "):
823
- data = json.loads(line[6:])
832
+ data = orjson.loads(line[6:])
824
833
  if isinstance(data, list) and len(data) > 0:
825
834
  html = data[0] if isinstance(data[0], str) else str(data)
826
835
 
@@ -887,7 +896,7 @@ def state_search(
887
896
  )
888
897
 
889
898
  with urllib.request.urlopen(req, timeout=20) as response:
890
- results = json.loads(response.read().decode("utf-8"))
899
+ results = orjson.loads(response.read())
891
900
 
892
901
  for result in results:
893
902
  result.pop("rev")
@@ -941,11 +950,11 @@ def hammer_premise(
941
950
  "Content-Type": "application/json",
942
951
  },
943
952
  method="POST",
944
- data=json.dumps(data).encode("utf-8"),
953
+ data=orjson.dumps(data),
945
954
  )
946
955
 
947
956
  with urllib.request.urlopen(req, timeout=20) as response:
948
- results = json.loads(response.read().decode("utf-8"))
957
+ results = orjson.loads(response.read())
949
958
 
950
959
  results = [result["name"] for result in results]
951
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
 
@@ -190,38 +204,58 @@ def format_line(
190
204
 
191
205
 
192
206
  def filter_diagnostics_by_position(
193
- diagnostics: List[Dict], line: int, column: Optional[int]
207
+ diagnostics: List[Dict], line: Optional[int], column: Optional[int]
194
208
  ) -> List[Dict]:
195
- """Find diagnostics at a specific position.
196
-
197
- Args:
198
- diagnostics (List[Dict]): List of diagnostics.
199
- line (int): The line number (0-indexed).
200
- column (Optional[int]): The column number (0-indexed).
201
-
202
- Returns:
203
- List[Dict]: List of diagnostics at the specified position.
204
- """
205
- if column is None:
206
- return [
207
- d
208
- for d in diagnostics
209
- if d["range"]["start"]["line"] <= line <= d["range"]["end"]["line"]
210
- ]
211
-
212
- return [
213
- d
214
- for d in diagnostics
215
- if d["range"]["start"]["line"] <= line <= d["range"]["end"]["line"]
216
- and d["range"]["start"]["character"] <= column < d["range"]["end"]["character"]
217
- ]
218
-
219
-
220
- class OptionalTokenVerifier(TokenVerifier):
221
- def __init__(self, expected_token: str):
222
- self.expected_token = expected_token
223
-
224
- async def verify_token(self, token: str) -> AccessToken | None:
225
- if token == self.expected_token:
226
- return AccessToken(token=token, client_id="lean-lsp-mcp", scopes=["user"])
227
- 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.11.0
3
+ Version: 0.11.1
4
4
  Summary: Lean Theorem Prover MCP
5
5
  Author-email: Oliver Dressler <hey@oli.show>
6
6
  License-Expression: MIT
@@ -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>
@@ -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=M7ep3-bDaGjN_tc3cvaWuyG00js0cwH9npMMdOgDDBw,3672
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=-qrXb1ESuuoJz3-XAmox22ePPsoB1tbDMXSd84Qc8EM,35075
8
- lean_lsp_mcp/utils.py,sha256=HQA0bJSl5LWuIUmo7wPi7BMfP7R6ITOJbn5dOA2UfOk,7219
9
- lean_lsp_mcp-0.11.0.dist-info/licenses/LICENSE,sha256=CQlxnf0tQyoVrBE93JYvAUYxv6Z5Yg6sX0pwogOkFvo,1071
10
- lean_lsp_mcp-0.11.0.dist-info/METADATA,sha256=I3JjZPWmoawz4x_BA4PJ_AVDmg07q_YtvtnDoIHOvgc,18872
11
- lean_lsp_mcp-0.11.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
12
- lean_lsp_mcp-0.11.0.dist-info/entry_points.txt,sha256=nQbvwctWkWD7I-2f4VrdVQBZYGUw8CnUnFC6QjXxOSE,51
13
- lean_lsp_mcp-0.11.0.dist-info/top_level.txt,sha256=LGEK0lgMSNPIQ6mG8EO-adaZEGPi_0daDs004epOTF0,13
14
- lean_lsp_mcp-0.11.0.dist-info/RECORD,,