lean-lsp-mcp 0.1.7__py3-none-any.whl → 0.11.2__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.
lean_lsp_mcp/__init__.py CHANGED
@@ -1,5 +1,30 @@
1
+ import argparse
2
+
1
3
  from lean_lsp_mcp.server import mcp
2
4
 
3
5
 
4
6
  def main():
5
- mcp.run()
7
+ parser = argparse.ArgumentParser(description="Lean LSP MCP Server")
8
+ parser.add_argument(
9
+ "--transport",
10
+ type=str,
11
+ choices=["stdio", "streamable-http", "sse"],
12
+ default="stdio",
13
+ help="Transport method for the server. Default is 'stdio'.",
14
+ )
15
+ parser.add_argument(
16
+ "--host",
17
+ type=str,
18
+ default="127.0.0.1",
19
+ help="Host address for transport",
20
+ )
21
+ parser.add_argument(
22
+ "--port",
23
+ type=int,
24
+ default=8000,
25
+ help="Host port for transport",
26
+ )
27
+ args = parser.parse_args()
28
+ mcp.settings.host = args.host
29
+ mcp.settings.port = args.port
30
+ mcp.run(transport=args.transport)
@@ -0,0 +1,122 @@
1
+ import os
2
+ from pathlib import Path
3
+ from threading import Lock
4
+
5
+ from mcp.server.fastmcp import Context
6
+ from mcp.server.fastmcp.utilities.logging import get_logger
7
+ from leanclient import LeanLSPClient
8
+
9
+ from lean_lsp_mcp.file_utils import get_relative_file_path
10
+ from lean_lsp_mcp.utils import OutputCapture
11
+
12
+
13
+ logger = get_logger(__name__)
14
+ CLIENT_LOCK = Lock()
15
+
16
+
17
+ def startup_client(ctx: Context):
18
+ """Initialize the Lean LSP client if not already set up.
19
+
20
+ Args:
21
+ ctx (Context): Context object.
22
+ """
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
52
+
53
+
54
+ def valid_lean_project_path(path: Path | str) -> bool:
55
+ """Check if the given path is a valid Lean project path (contains a lean-toolchain file).
56
+
57
+ Args:
58
+ path (Path | str): Absolute path to check.
59
+
60
+ Returns:
61
+ bool: True if valid Lean project path, False otherwise.
62
+ """
63
+ path_obj = Path(path) if isinstance(path, str) else path
64
+ return (path_obj / "lean-toolchain").is_file()
65
+
66
+
67
+ def setup_client_for_file(ctx: Context, file_path: str) -> str | None:
68
+ """Ensure the LSP client matches the file's Lean project and return its relative path."""
69
+
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
74
+
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])
101
+ if rel_path is not None:
102
+ return rel_path
103
+
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])
115
+ if rel_path is not None:
116
+ return rel_path
117
+ else:
118
+ project_cache[current_dir] = ""
119
+ prev_dir = current_dir
120
+ current_dir = os.path.dirname(current_dir)
121
+
122
+ return None
@@ -0,0 +1,100 @@
1
+ from typing import Optional, Dict
2
+ from pathlib import Path
3
+
4
+ from mcp.server.fastmcp import Context
5
+ from mcp.server.fastmcp.utilities.logging import get_logger
6
+ from leanclient import LeanLSPClient
7
+
8
+
9
+ logger = get_logger(__name__)
10
+
11
+
12
+ def get_relative_file_path(lean_project_path: Path, file_path: str) -> Optional[str]:
13
+ """Convert path relative to project path.
14
+
15
+ Args:
16
+ lean_project_path (Path): Path to the Lean project root.
17
+ file_path (str): File path.
18
+
19
+ Returns:
20
+ str: Relative file path.
21
+ """
22
+ file_path_obj = Path(file_path)
23
+
24
+ # Check if absolute path
25
+ if file_path_obj.is_absolute() and file_path_obj.exists():
26
+ try:
27
+ return str(file_path_obj.relative_to(lean_project_path))
28
+ except ValueError:
29
+ # File is not in this project
30
+ return None
31
+
32
+ # Check if relative to project path
33
+ path = lean_project_path / file_path
34
+ if path.exists():
35
+ return str(path.relative_to(lean_project_path))
36
+
37
+ # Check if relative to CWD
38
+ cwd = Path.cwd()
39
+ path = cwd / file_path
40
+ if path.exists():
41
+ try:
42
+ return str(path.relative_to(lean_project_path))
43
+ except ValueError:
44
+ return None
45
+
46
+ return None
47
+
48
+
49
+ def get_file_contents(abs_path: str) -> str:
50
+ for enc in ("utf-8", "latin-1"):
51
+ try:
52
+ with open(abs_path, "r", encoding=enc) as f:
53
+ return f.read()
54
+ except UnicodeDecodeError:
55
+ continue
56
+ with open(abs_path, "r", encoding=None) as f:
57
+ return f.read()
58
+
59
+
60
+ def update_file(ctx: Context, rel_path: str) -> str:
61
+ """Update the file contents in the context.
62
+ Args:
63
+ ctx (Context): Context object.
64
+ rel_path (str): Relative file path.
65
+
66
+ Returns:
67
+ str: Updated file contents.
68
+ """
69
+ # Get file contents and hash
70
+ abs_path = ctx.request_context.lifespan_context.lean_project_path / rel_path
71
+ file_content = get_file_contents(str(abs_path))
72
+ hashed_file = hash(file_content)
73
+
74
+ # Check if file_contents have changed
75
+ file_content_hashes: Dict[str, str] = (
76
+ ctx.request_context.lifespan_context.file_content_hashes
77
+ )
78
+ if rel_path not in file_content_hashes:
79
+ file_content_hashes[rel_path] = hashed_file
80
+ return file_content
81
+
82
+ elif hashed_file == file_content_hashes[rel_path]:
83
+ return file_content
84
+
85
+ # Update file_contents
86
+ file_content_hashes[rel_path] = hashed_file
87
+
88
+ # Reload file in LSP
89
+ client: LeanLSPClient = ctx.request_context.lifespan_context.client
90
+ try:
91
+ client.close_files([rel_path])
92
+ except FileNotFoundError as e:
93
+ logger.warning(
94
+ f"Attempted to close file {rel_path} that wasn't open in LSP client: {e}"
95
+ )
96
+ except Exception as e:
97
+ logger.error(
98
+ f"Unexpected error closing file {rel_path}: {type(e).__name__}: {e}"
99
+ )
100
+ return file_content
@@ -0,0 +1,16 @@
1
+ INSTRUCTIONS = """## General Rules
2
+ - All line and column numbers are 1-indexed (use lean_file_contents if unsure).
3
+ - Always analyze/search context before each file edit.
4
+ - This MCP does NOT make permanent file changes. Use other tools for editing.
5
+ - Work iteratively: Small steps, intermediate sorries, frequent checks.
6
+
7
+ ## Key Tools
8
+ - lean_local_search: Confirm declarations (theorems/lemmas/defs/etc.) exist. VERY USEFUL AND FAST!
9
+ - lean_goal: Check proof state. USE OFTEN!
10
+ - lean_diagnostic_messages: Understand the current proof situation.
11
+ - lean_hover_info: Documentation about terms and lean syntax.
12
+ - lean_leansearch: Search theorems using natural language or Lean terms.
13
+ - lean_loogle: Search definitions and theorems by name, type, or subexpression.
14
+ - lean_leanfinder: Semantic search for theorems using Lean Finder.
15
+ - lean_state_search: Search theorems using goal-based search.
16
+ """
@@ -0,0 +1,142 @@
1
+ """Utilities for Lean search tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from functools import lru_cache
7
+ import platform
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ from orjson import loads as _json_loads
12
+ from pathlib import Path
13
+
14
+
15
+ INSTALL_URL = "https://github.com/BurntSushi/ripgrep#installation"
16
+
17
+ _PLATFORM_INSTRUCTIONS: dict[str, Iterable[str]] = {
18
+ "Windows": (
19
+ "winget install BurntSushi.ripgrep.MSVC",
20
+ "choco install ripgrep",
21
+ ),
22
+ "Darwin": ("brew install ripgrep",),
23
+ "Linux": (
24
+ "sudo apt-get install ripgrep",
25
+ "sudo dnf install ripgrep",
26
+ ),
27
+ }
28
+
29
+
30
+ def check_ripgrep_status() -> tuple[bool, str]:
31
+ """Check whether ``rg`` is available on PATH and return status + message."""
32
+
33
+ if shutil.which("rg"):
34
+ return True, ""
35
+
36
+ system = platform.system()
37
+ platform_instructions = _PLATFORM_INSTRUCTIONS.get(
38
+ system, ("Check alternative installation methods.",)
39
+ )
40
+
41
+ lines = [
42
+ "ripgrep (rg) was not found on your PATH. The lean_local_search tool uses ripgrep for fast declaration search.",
43
+ "",
44
+ "Installation options:",
45
+ *(f" - {item}" for item in platform_instructions),
46
+ f"More installation options: {INSTALL_URL}",
47
+ ]
48
+
49
+ return False, "\n".join(lines)
50
+
51
+
52
+ def lean_local_search(
53
+ query: str,
54
+ limit: int = 32,
55
+ project_root: Path | None = None,
56
+ ) -> list[dict[str, str]]:
57
+ """Search Lean declarations matching ``query`` using ripgrep; results include theorems, lemmas, defs, classes, instances, structures, inductives, abbrevs, and opaque decls."""
58
+ root = (project_root or Path.cwd()).resolve()
59
+
60
+ pattern = (
61
+ rf"^\s*(?:theorem|lemma|def|axiom|class|instance|structure|inductive|abbrev|opaque)\s+"
62
+ rf"(?:[A-Za-z0-9_'.]+\.)*{re.escape(query)}[A-Za-z0-9_'.]*(?:\s|:)"
63
+ )
64
+
65
+ command = [
66
+ "rg",
67
+ "--json",
68
+ "--no-ignore",
69
+ "--smart-case",
70
+ "--hidden",
71
+ "--color",
72
+ "never",
73
+ "--no-messages",
74
+ "-g",
75
+ "*.lean",
76
+ "-g",
77
+ "!.git/**",
78
+ "-g",
79
+ "!.lake/build/**",
80
+ pattern,
81
+ str(root),
82
+ ]
83
+
84
+ if lean_src := _get_lean_src_search_path():
85
+ command.append(lean_src)
86
+
87
+ result = subprocess.run(command, capture_output=True, text=True, cwd=str(root))
88
+
89
+ matches = []
90
+ for line in result.stdout.splitlines():
91
+ if not line or (event := _json_loads(line)).get("type") != "match":
92
+ continue
93
+
94
+ data = event["data"]
95
+ parts = data["lines"]["text"].lstrip().split(maxsplit=2)
96
+ if len(parts) < 2:
97
+ continue
98
+
99
+ decl_kind, decl_name = parts[0], parts[1].rstrip(":")
100
+ file_path = Path(data["path"]["text"])
101
+ abs_path = (
102
+ file_path if file_path.is_absolute() else (root / file_path).resolve()
103
+ )
104
+
105
+ try:
106
+ display_path = str(abs_path.relative_to(root))
107
+ except ValueError:
108
+ display_path = str(file_path)
109
+
110
+ matches.append({"name": decl_name, "kind": decl_kind, "file": display_path})
111
+
112
+ if len(matches) >= limit:
113
+ break
114
+
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)
120
+
121
+ return matches
122
+
123
+
124
+ @lru_cache(maxsize=1)
125
+ def _get_lean_src_search_path() -> str | None:
126
+ """Return the Lean stdlib directory, if available (cache once)."""
127
+ try:
128
+ completed = subprocess.run(
129
+ ["lean", "--print-prefix"], capture_output=True, text=True
130
+ )
131
+ except (FileNotFoundError, subprocess.CalledProcessError):
132
+ return None
133
+
134
+ prefix = completed.stdout.strip()
135
+ if not prefix:
136
+ return None
137
+
138
+ candidate = Path(prefix).expanduser().resolve() / "src"
139
+ if candidate.exists():
140
+ return str(candidate)
141
+
142
+ return None