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 +26 -1
- lean_lsp_mcp/client_utils.py +122 -0
- lean_lsp_mcp/file_utils.py +100 -0
- lean_lsp_mcp/instructions.py +16 -0
- lean_lsp_mcp/search_utils.py +142 -0
- lean_lsp_mcp/server.py +723 -272
- lean_lsp_mcp/utils.py +228 -10
- lean_lsp_mcp-0.11.2.dist-info/METADATA +569 -0
- lean_lsp_mcp-0.11.2.dist-info/RECORD +14 -0
- {lean_lsp_mcp-0.1.7.dist-info → lean_lsp_mcp-0.11.2.dist-info}/WHEEL +1 -1
- lean_lsp_mcp/prompts.py +0 -42
- lean_lsp_mcp-0.1.7.dist-info/METADATA +0 -191
- lean_lsp_mcp-0.1.7.dist-info/RECORD +0 -11
- {lean_lsp_mcp-0.1.7.dist-info → lean_lsp_mcp-0.11.2.dist-info}/entry_points.txt +0 -0
- {lean_lsp_mcp-0.1.7.dist-info → lean_lsp_mcp-0.11.2.dist-info}/licenses/LICENSE +0 -0
- {lean_lsp_mcp-0.1.7.dist-info → lean_lsp_mcp-0.11.2.dist-info}/top_level.txt +0 -0
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
|
-
|
|
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
|