lean-lsp-mcp 0.11.0__tar.gz → 0.11.2__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.11.0/src/lean_lsp_mcp.egg-info → lean_lsp_mcp-0.11.2}/PKG-INFO +29 -2
  2. {lean_lsp_mcp-0.11.0 → lean_lsp_mcp-0.11.2}/README.md +29 -2
  3. {lean_lsp_mcp-0.11.0 → lean_lsp_mcp-0.11.2}/pyproject.toml +1 -1
  4. {lean_lsp_mcp-0.11.0 → lean_lsp_mcp-0.11.2}/src/lean_lsp_mcp/client_utils.py +49 -25
  5. {lean_lsp_mcp-0.11.0 → lean_lsp_mcp-0.11.2}/src/lean_lsp_mcp/search_utils.py +26 -39
  6. {lean_lsp_mcp-0.11.0 → lean_lsp_mcp-0.11.2}/src/lean_lsp_mcp/server.py +72 -87
  7. {lean_lsp_mcp-0.11.0 → lean_lsp_mcp-0.11.2}/src/lean_lsp_mcp/utils.py +68 -34
  8. {lean_lsp_mcp-0.11.0 → lean_lsp_mcp-0.11.2/src/lean_lsp_mcp.egg-info}/PKG-INFO +29 -2
  9. {lean_lsp_mcp-0.11.0 → lean_lsp_mcp-0.11.2}/tests/test_search_tools.py +7 -9
  10. {lean_lsp_mcp-0.11.0 → lean_lsp_mcp-0.11.2}/LICENSE +0 -0
  11. {lean_lsp_mcp-0.11.0 → lean_lsp_mcp-0.11.2}/setup.cfg +0 -0
  12. {lean_lsp_mcp-0.11.0 → lean_lsp_mcp-0.11.2}/src/lean_lsp_mcp/__init__.py +0 -0
  13. {lean_lsp_mcp-0.11.0 → lean_lsp_mcp-0.11.2}/src/lean_lsp_mcp/__main__.py +0 -0
  14. {lean_lsp_mcp-0.11.0 → lean_lsp_mcp-0.11.2}/src/lean_lsp_mcp/file_utils.py +0 -0
  15. {lean_lsp_mcp-0.11.0 → lean_lsp_mcp-0.11.2}/src/lean_lsp_mcp/instructions.py +0 -0
  16. {lean_lsp_mcp-0.11.0 → lean_lsp_mcp-0.11.2}/src/lean_lsp_mcp.egg-info/SOURCES.txt +0 -0
  17. {lean_lsp_mcp-0.11.0 → lean_lsp_mcp-0.11.2}/src/lean_lsp_mcp.egg-info/dependency_links.txt +0 -0
  18. {lean_lsp_mcp-0.11.0 → lean_lsp_mcp-0.11.2}/src/lean_lsp_mcp.egg-info/entry_points.txt +0 -0
  19. {lean_lsp_mcp-0.11.0 → lean_lsp_mcp-0.11.2}/src/lean_lsp_mcp.egg-info/requires.txt +0 -0
  20. {lean_lsp_mcp-0.11.0 → lean_lsp_mcp-0.11.2}/src/lean_lsp_mcp.egg-info/top_level.txt +0 -0
  21. {lean_lsp_mcp-0.11.0 → lean_lsp_mcp-0.11.2}/tests/test_editor_tools.py +0 -0
  22. {lean_lsp_mcp-0.11.0 → lean_lsp_mcp-0.11.2}/tests/test_logging.py +0 -0
  23. {lean_lsp_mcp-0.11.0 → lean_lsp_mcp-0.11.2}/tests/test_misc_tools.py +0 -0
  24. {lean_lsp_mcp-0.11.0 → lean_lsp_mcp-0.11.2}/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.11.0
3
+ Version: 0.11.2
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>
@@ -520,6 +543,10 @@ uv sync --all-extras
520
543
  uv run pytest tests
521
544
  ```
522
545
 
546
+ ## Publications using lean-lsp-mcp
547
+
548
+ - Ax-Prover: A Deep Reasoning Agentic Framework for Theorem Proving in Mathematics and Quantum Physics [arxiv](https://arxiv.org/abs/2510.12787)
549
+
523
550
  ## Related Projects
524
551
 
525
552
  - [LeanTool](https://github.com/GasStationManager/LeanTool)
@@ -55,7 +55,11 @@ OR using the setup wizard:
55
55
 
56
56
  Ctrl+Shift+P > "MCP: Add Server..." > "Command (stdio)" > "uvx lean-lsp-mcp" > "lean-lsp" (or any name you like) > Global or Workspace
57
57
 
58
- OR manually add config to `mcp.json`:
58
+ OR manually adding config by opening `mcp.json` with:
59
+
60
+ Ctrl+Shift+P > "MCP: Open User Configuration"
61
+
62
+ and adding the following
59
63
 
60
64
  ```jsonc
61
65
  {
@@ -70,6 +74,25 @@ OR manually add config to `mcp.json`:
70
74
  }
71
75
  }
72
76
  ```
77
+
78
+ If you installed VSCode on Windows and are using WSL2 as your development environment, you may need to use this config instead:
79
+
80
+ ```jsonc
81
+ {
82
+ "servers": {
83
+ "lean-lsp": {
84
+ "type": "stdio",
85
+ "command": "wsl.exe",
86
+ "args": [
87
+ "uvx",
88
+ "lean-lsp-mcp"
89
+ ]
90
+ }
91
+ }
92
+ }
93
+ ```
94
+ If that doesn't work, you can try cloning this repository and replace `"lean-lsp-mcp"` with `"/path/to/cloned/lean-lsp-mcp"`.
95
+
73
96
  </details>
74
97
 
75
98
  <details>
@@ -498,6 +521,10 @@ uv sync --all-extras
498
521
  uv run pytest tests
499
522
  ```
500
523
 
524
+ ## Publications using lean-lsp-mcp
525
+
526
+ - Ax-Prover: A Deep Reasoning Agentic Framework for Theorem Proving in Mathematics and Quantum Physics [arxiv](https://arxiv.org/abs/2510.12787)
527
+
501
528
  ## Related Projects
502
529
 
503
530
  - [LeanTool](https://github.com/GasStationManager/LeanTool)
@@ -517,4 +544,4 @@ Citing this repository is highly appreciated but not required by the license.
517
544
  month = {3},
518
545
  year = {2025}
519
546
  }
520
- ```
547
+ ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lean-lsp-mcp"
3
- version = "0.11.0"
3
+ version = "0.11.2"
4
4
  description = "Lean Theorem Prover MCP"
5
5
  authors = [{name="Oliver Dressler", email="hey@oli.show"}]
6
6
  readme = "README.md"
@@ -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
@@ -56,10 +56,10 @@ def lean_local_search(
56
56
  ) -> list[dict[str, str]]:
57
57
  """Search Lean declarations matching ``query`` using ripgrep; results include theorems, lemmas, defs, classes, instances, structures, inductives, abbrevs, and opaque decls."""
58
58
  root = (project_root or Path.cwd()).resolve()
59
- escaped_query = re.escape(query)
60
- ripgrep_pattern = (
59
+
60
+ pattern = (
61
61
  rf"^\s*(?:theorem|lemma|def|axiom|class|instance|structure|inductive|abbrev|opaque)\s+"
62
- rf"{escaped_query}[A-Za-z0-9_'.]*(?:\s|:)"
62
+ rf"(?:[A-Za-z0-9_'.]+\.)*{re.escape(query)}[A-Za-z0-9_'.]*(?:\s|:)"
63
63
  )
64
64
 
65
65
  command = [
@@ -70,68 +70,55 @@ def lean_local_search(
70
70
  "--hidden",
71
71
  "--color",
72
72
  "never",
73
+ "--no-messages",
73
74
  "-g",
74
75
  "*.lean",
75
76
  "-g",
76
77
  "!.git/**",
77
78
  "-g",
78
79
  "!.lake/build/**",
79
- ripgrep_pattern,
80
- ".",
80
+ pattern,
81
+ str(root),
81
82
  ]
82
83
 
83
- lean_src_path = _get_lean_src_search_path()
84
- if lean_src_path is not None:
85
- command.append(lean_src_path)
86
-
87
- completed = subprocess.run(
88
- command,
89
- capture_output=True,
90
- text=True,
91
- cwd=str(root),
92
- )
84
+ if lean_src := _get_lean_src_search_path():
85
+ command.append(lean_src)
93
86
 
94
- results: list[dict[str, str]] = []
87
+ result = subprocess.run(command, capture_output=True, text=True, cwd=str(root))
95
88
 
96
- for raw_line in completed.stdout.splitlines():
97
- if not raw_line:
98
- continue
99
-
100
- event = _json_loads(raw_line)
101
-
102
- if event.get("type") != "match":
89
+ matches = []
90
+ for line in result.stdout.splitlines():
91
+ if not line or (event := _json_loads(line)).get("type") != "match":
103
92
  continue
104
93
 
105
94
  data = event["data"]
106
- line_text = data["lines"]["text"]
107
- parts = line_text.lstrip().split(maxsplit=2)
95
+ parts = data["lines"]["text"].lstrip().split(maxsplit=2)
108
96
  if len(parts) < 2:
109
97
  continue
110
98
 
111
- decl_kind, raw_name = parts[0], parts[1]
112
- decl_name = raw_name.rstrip(":")
113
-
114
- path_text = data["path"]["text"]
115
- file_path = Path(path_text)
116
- absolute_path = (
99
+ decl_kind, decl_name = parts[0], parts[1].rstrip(":")
100
+ file_path = Path(data["path"]["text"])
101
+ abs_path = (
117
102
  file_path if file_path.is_absolute() else (root / file_path).resolve()
118
103
  )
104
+
119
105
  try:
120
- display_path = str(absolute_path.relative_to(root))
106
+ display_path = str(abs_path.relative_to(root))
121
107
  except ValueError:
122
108
  display_path = str(file_path)
123
109
 
124
- results.append({"name": decl_name, "kind": decl_kind, "file": display_path})
110
+ matches.append({"name": decl_name, "kind": decl_kind, "file": display_path})
125
111
 
126
- if len(results) >= limit:
112
+ if len(matches) >= limit:
127
113
  break
128
114
 
129
- if completed.returncode not in (0, 1):
130
- raise RuntimeError(
131
- f"ripgrep exited with code {completed.returncode}\n{completed.stderr}"
132
- )
115
+ if result.returncode not in (0, 1) and not matches:
116
+ error_msg = f"ripgrep exited with code {result.returncode}"
117
+ if result.stderr:
118
+ error_msg += f"\n{result.stderr}"
119
+ raise RuntimeError(error_msg)
133
120
 
134
- return results
121
+ return matches
135
122
 
136
123
 
137
124
  @lru_cache(maxsize=1)
@@ -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}")
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(format_diagnostics(diag, select_line=line - 1))
576
+ # Use the snippet text length without any trailing newline for the column
577
+ goal = client.get_goal(rel_path, line - 1, len(snippet_str))
578
+ formatted_goal = format_goal(goal, "Missing goal")
579
+ results.append(f"{snippet_str}:\n {formatted_goal}\n\n{formatted_diag}")
576
580
 
577
- # Make sure it's clean after the attempts
578
- client.close_files([rel_path])
579
- return results
581
+ return results
582
+ finally:
583
+ try:
584
+ client.close_files([rel_path])
585
+ except Exception as exc: # pragma: no cover - close failures only logged
586
+ logger.warning(
587
+ "Failed to close `%s` after multi_attempt: %s", rel_path, exc
588
+ )
580
589
 
581
590
 
582
591
  @mcp.tool("lean_run_code")
@@ -674,11 +683,10 @@ def local_search(
674
683
  return _RG_MESSAGE
675
684
 
676
685
  stored_root = ctx.request_context.lifespan_context.lean_project_path
677
- project_root = Path(stored_root) if stored_root else None
678
- results = lean_local_search(
679
- query=query.strip(), limit=limit, project_root=project_root
680
- )
681
- return results
686
+ if stored_root is None:
687
+ return "Lean project path not set. Call a file-based tool (like lean_goal) first to set the project path."
688
+
689
+ return lean_local_search(query=query.strip(), limit=limit, project_root=stored_root)
682
690
 
683
691
 
684
692
  @mcp.tool("lean_leansearch")
@@ -702,9 +710,7 @@ def leansearch(ctx: Context, query: str, num_results: int = 5) -> List[Dict] | s
702
710
  """
703
711
  try:
704
712
  headers = {"User-Agent": "lean-lsp-mcp/0.1", "Content-Type": "application/json"}
705
- payload = json.dumps(
706
- {"num_results": str(num_results), "query": [query]}
707
- ).encode("utf-8")
713
+ payload = orjson.dumps({"num_results": str(num_results), "query": [query]})
708
714
 
709
715
  req = urllib.request.Request(
710
716
  "https://leansearch.net/search",
@@ -714,7 +720,7 @@ def leansearch(ctx: Context, query: str, num_results: int = 5) -> List[Dict] | s
714
720
  )
715
721
 
716
722
  with urllib.request.urlopen(req, timeout=20) as response:
717
- results = json.loads(response.read().decode("utf-8"))
723
+ results = orjson.loads(response.read())
718
724
 
719
725
  if not results or not results[0]:
720
726
  return "No results found."
@@ -760,14 +766,14 @@ def loogle(ctx: Context, query: str, num_results: int = 8) -> List[dict] | str:
760
766
  )
761
767
 
762
768
  with urllib.request.urlopen(req, timeout=20) as response:
763
- results = json.loads(response.read().decode("utf-8"))
769
+ results = orjson.loads(response.read())
764
770
 
765
771
  if "hits" not in results:
766
772
  return "No results found."
767
773
 
768
774
  results = results["hits"][:num_results]
769
775
  for result in results:
770
- result.pop("doc")
776
+ result.pop("doc", None)
771
777
  return results
772
778
  except Exception as e:
773
779
  return f"loogle error:\n{str(e)}"
@@ -775,73 +781,52 @@ def loogle(ctx: Context, query: str, num_results: int = 8) -> List[dict] | str:
775
781
 
776
782
  @mcp.tool("lean_leanfinder")
777
783
  @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.
784
+ def leanfinder(ctx: Context, query: str, num_results: int = 5) -> List[Dict] | str:
785
+ """Search Mathlib theorems/definitions semantically by mathematical concept or proof state using Lean Finder.
782
786
 
783
787
  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
+ - Natural language mathematical statement: "For any natural numbers n and m, the sum n+m is equal to m+n."
789
+ - Natural language questions: "I'm working with algebraic elements over a field extension … Does this imply that the minimal polynomials of x and y are equal?"
790
+ - Proof state. For better results, enter a proof state followed by how you want to transform the proof state.
791
+ - Statement definition: Fragment or the whole statement definition.
788
792
 
789
- Tips: Mix informal math terms with Lean identifiers. Multiple targeted queries beat one complex query.
793
+ Tips: Multiple targeted queries beat one complex query.
790
794
 
791
795
  Args:
792
- query (str): Mathematical concepts combined with Lean terms
796
+ query (str): Mathematical concept or proof state
793
797
  num_results (int, optional): Max results. Defaults to 5.
794
798
 
795
799
  Returns:
796
- List[tuple] | str: (lean_statement, english_description) pairs or error
800
+ List[Dict] | str: List of Lean statement objects (full name, formal statement, informal statement) or error msg
797
801
  """
798
802
  try:
799
803
  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
-
804
+ request_url = (
805
+ "https://bxrituxuhpc70w8w.us-east-1.aws.endpoints.huggingface.cloud"
806
+ )
807
+ payload = orjson.dumps({"inputs": query, "top_k": int(num_results)})
802
808
  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",
809
+ request_url, data=payload, headers=headers, method="POST"
807
810
  )
808
811
 
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
-
812
+ results = []
819
813
  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"
814
+ data = orjson.loads(response.read())
815
+ for result in data["results"]:
816
+ if (
817
+ "https://leanprover-community.github.io/mathlib4_docs"
818
+ not in result["url"]
819
+ ): # Do not include results from other sources other than mathlib4, since users might not have imported them
820
+ continue
821
+ full_name = re.search(r"pattern=(.*?)#doc", result["url"]).group(1)
822
+ obj = {
823
+ "full_name": full_name,
824
+ "formal_statement": result["formal_statement"],
825
+ "informal_statement": result["informal_statement"],
826
+ }
827
+ results.append(obj)
828
+
829
+ return results if results else "Lean Finder: No results parsed"
845
830
  except Exception as e:
846
831
  return f"Lean Finder Error:\n{str(e)}"
847
832
 
@@ -887,7 +872,7 @@ def state_search(
887
872
  )
888
873
 
889
874
  with urllib.request.urlopen(req, timeout=20) as response:
890
- results = json.loads(response.read().decode("utf-8"))
875
+ results = orjson.loads(response.read())
891
876
 
892
877
  for result in results:
893
878
  result.pop("rev")
@@ -941,11 +926,11 @@ def hammer_premise(
941
926
  "Content-Type": "application/json",
942
927
  },
943
928
  method="POST",
944
- data=json.dumps(data).encode("utf-8"),
929
+ data=orjson.dumps(data),
945
930
  )
946
931
 
947
932
  with urllib.request.urlopen(req, timeout=20) as response:
948
- results = json.loads(response.read().decode("utf-8"))
933
+ results = orjson.loads(response.read())
949
934
 
950
935
  results = [result["name"] for result in results]
951
936
  results.insert(0, f"Results for line:\n{f_line}")
@@ -1,4 +1,5 @@
1
1
  import os
2
+ import secrets
2
3
  import sys
3
4
  import tempfile
4
5
  from typing import List, Dict, Optional
@@ -41,6 +42,19 @@ class OutputCapture:
41
42
  return self.captured_output
42
43
 
43
44
 
45
+ class OptionalTokenVerifier(TokenVerifier):
46
+ """Minimal verifier that accepts a single pre-shared token."""
47
+
48
+ def __init__(self, expected_token: str):
49
+ self._expected_token = expected_token
50
+
51
+ async def verify_token(self, token: str | None) -> AccessToken | None:
52
+ if token is None or not secrets.compare_digest(token, self._expected_token):
53
+ return None
54
+ # AccessToken requires both client_id and scopes parameters to be provided.
55
+ return AccessToken(token=token, client_id="lean-lsp-mcp-optional", scopes=[])
56
+
57
+
44
58
  def format_diagnostics(diagnostics: List[Dict], select_line: int = -1) -> List[str]:
45
59
  """Format the diagnostics messages.
46
60
 
@@ -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.2
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>
@@ -520,6 +543,10 @@ uv sync --all-extras
520
543
  uv run pytest tests
521
544
  ```
522
545
 
546
+ ## Publications using lean-lsp-mcp
547
+
548
+ - Ax-Prover: A Deep Reasoning Agentic Framework for Theorem Proving in Mathematics and Quantum Physics [arxiv](https://arxiv.org/abs/2510.12787)
549
+
523
550
  ## Related Projects
524
551
 
525
552
  - [LeanTool](https://github.com/GasStationManager/LeanTool)
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- import json
3
+ import orjson
4
4
  from collections.abc import Callable
5
5
  from pathlib import Path
6
6
  from typing import AsyncContextManager
@@ -16,8 +16,8 @@ def _first_json_block(result) -> dict[str, str] | None:
16
16
  if not text:
17
17
  continue
18
18
  try:
19
- return json.loads(text)
20
- except json.JSONDecodeError:
19
+ return orjson.loads(text)
20
+ except orjson.JSONDecodeError:
21
21
  continue
22
22
  return None
23
23
 
@@ -120,12 +120,10 @@ async def test_search_tools(
120
120
  )
121
121
  finder_results = _first_json_block(finder_informal)
122
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)
123
+ assert isinstance(finder_results, dict) and len(finder_results.keys()) == 3
124
+ assert {"full_name", "formal_statement", "informal_statement"} <= set(
125
+ finder_results.keys()
126
+ )
128
127
  else:
129
128
  finder_text = result_text(finder_informal)
130
129
  assert finder_text and len(finder_text) > 0
131
-
File without changes
File without changes