lean-lsp-mcp 0.13.2__tar.gz → 0.14.1__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.
- {lean_lsp_mcp-0.13.2/src/lean_lsp_mcp.egg-info → lean_lsp_mcp-0.14.1}/PKG-INFO +9 -5
- {lean_lsp_mcp-0.13.2 → lean_lsp_mcp-0.14.1}/README.md +8 -4
- {lean_lsp_mcp-0.13.2 → lean_lsp_mcp-0.14.1}/pyproject.toml +1 -1
- {lean_lsp_mcp-0.13.2 → lean_lsp_mcp-0.14.1}/src/lean_lsp_mcp/client_utils.py +1 -3
- {lean_lsp_mcp-0.13.2 → lean_lsp_mcp-0.14.1}/src/lean_lsp_mcp/instructions.py +1 -0
- lean_lsp_mcp-0.14.1/src/lean_lsp_mcp/outline_utils.py +200 -0
- {lean_lsp_mcp-0.13.2 → lean_lsp_mcp-0.14.1}/src/lean_lsp_mcp/server.py +45 -4
- {lean_lsp_mcp-0.13.2 → lean_lsp_mcp-0.14.1}/src/lean_lsp_mcp/utils.py +20 -1
- {lean_lsp_mcp-0.13.2 → lean_lsp_mcp-0.14.1/src/lean_lsp_mcp.egg-info}/PKG-INFO +9 -5
- {lean_lsp_mcp-0.13.2 → lean_lsp_mcp-0.14.1}/src/lean_lsp_mcp.egg-info/SOURCES.txt +2 -0
- {lean_lsp_mcp-0.13.2 → lean_lsp_mcp-0.14.1}/tests/test_diagnostic_line_range.py +4 -3
- lean_lsp_mcp-0.14.1/tests/test_outline.py +117 -0
- {lean_lsp_mcp-0.13.2 → lean_lsp_mcp-0.14.1}/LICENSE +0 -0
- {lean_lsp_mcp-0.13.2 → lean_lsp_mcp-0.14.1}/setup.cfg +0 -0
- {lean_lsp_mcp-0.13.2 → lean_lsp_mcp-0.14.1}/src/lean_lsp_mcp/__init__.py +0 -0
- {lean_lsp_mcp-0.13.2 → lean_lsp_mcp-0.14.1}/src/lean_lsp_mcp/__main__.py +0 -0
- {lean_lsp_mcp-0.13.2 → lean_lsp_mcp-0.14.1}/src/lean_lsp_mcp/file_utils.py +0 -0
- {lean_lsp_mcp-0.13.2 → lean_lsp_mcp-0.14.1}/src/lean_lsp_mcp/search_utils.py +0 -0
- {lean_lsp_mcp-0.13.2 → lean_lsp_mcp-0.14.1}/src/lean_lsp_mcp.egg-info/dependency_links.txt +0 -0
- {lean_lsp_mcp-0.13.2 → lean_lsp_mcp-0.14.1}/src/lean_lsp_mcp.egg-info/entry_points.txt +0 -0
- {lean_lsp_mcp-0.13.2 → lean_lsp_mcp-0.14.1}/src/lean_lsp_mcp.egg-info/requires.txt +0 -0
- {lean_lsp_mcp-0.13.2 → lean_lsp_mcp-0.14.1}/src/lean_lsp_mcp.egg-info/top_level.txt +0 -0
- {lean_lsp_mcp-0.13.2 → lean_lsp_mcp-0.14.1}/tests/test_editor_tools.py +0 -0
- {lean_lsp_mcp-0.13.2 → lean_lsp_mcp-0.14.1}/tests/test_file_caching.py +0 -0
- {lean_lsp_mcp-0.13.2 → lean_lsp_mcp-0.14.1}/tests/test_logging.py +0 -0
- {lean_lsp_mcp-0.13.2 → lean_lsp_mcp-0.14.1}/tests/test_misc_tools.py +0 -0
- {lean_lsp_mcp-0.13.2 → lean_lsp_mcp-0.14.1}/tests/test_project_tools.py +0 -0
- {lean_lsp_mcp-0.13.2 → lean_lsp_mcp-0.14.1}/tests/test_search_tools.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lean-lsp-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.14.1
|
|
4
4
|
Summary: Lean Theorem Prover MCP
|
|
5
5
|
Author-email: Oliver Dressler <hey@oli.show>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -66,7 +66,7 @@ MCP server that allows agentic interaction with the [Lean theorem prover](https:
|
|
|
66
66
|
### 3. Configure your IDE/Setup
|
|
67
67
|
|
|
68
68
|
<details>
|
|
69
|
-
<summary><b>VSCode</b></summary>
|
|
69
|
+
<summary><b>VSCode (Click to expand)</b></summary>
|
|
70
70
|
One-click config setup:
|
|
71
71
|
|
|
72
72
|
[](https://insiders.vscode.dev/redirect/mcp/install?name=lean-lsp&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22lean-lsp-mcp%22%5D%7D)
|
|
@@ -118,7 +118,7 @@ If that doesn't work, you can try cloning this repository and replace `"lean-lsp
|
|
|
118
118
|
</details>
|
|
119
119
|
|
|
120
120
|
<details>
|
|
121
|
-
<summary><b>Cursor</b></summary>
|
|
121
|
+
<summary><b>Cursor (Click to expand)</b></summary>
|
|
122
122
|
1. Open MCP Settings (File > Preferences > Cursor Settings > MCP)
|
|
123
123
|
|
|
124
124
|
2. "+ Add a new global MCP Server" > ("Create File")
|
|
@@ -138,7 +138,7 @@ If that doesn't work, you can try cloning this repository and replace `"lean-lsp
|
|
|
138
138
|
</details>
|
|
139
139
|
|
|
140
140
|
<details>
|
|
141
|
-
<summary><b>Claude Code</b></summary>
|
|
141
|
+
<summary><b>Claude Code (Click to expand)</b></summary>
|
|
142
142
|
Run one of these commands in the root directory of your Lean project (where `lakefile.toml` is located):
|
|
143
143
|
|
|
144
144
|
```bash
|
|
@@ -166,7 +166,11 @@ For the local search tool `lean_local_search`, install [ripgrep](https://github.
|
|
|
166
166
|
|
|
167
167
|
### File interactions (LSP)
|
|
168
168
|
|
|
169
|
-
####
|
|
169
|
+
#### lean_file_outline
|
|
170
|
+
|
|
171
|
+
Get a concise outline of a Lean file showing imports and declarations with type signatures (theorems, definitions, classes, structures).
|
|
172
|
+
|
|
173
|
+
#### lean_file_contents (DEPRECATED)
|
|
170
174
|
|
|
171
175
|
Get the contents of a Lean file, optionally with line number annotations.
|
|
172
176
|
|
|
@@ -44,7 +44,7 @@ MCP server that allows agentic interaction with the [Lean theorem prover](https:
|
|
|
44
44
|
### 3. Configure your IDE/Setup
|
|
45
45
|
|
|
46
46
|
<details>
|
|
47
|
-
<summary><b>VSCode</b></summary>
|
|
47
|
+
<summary><b>VSCode (Click to expand)</b></summary>
|
|
48
48
|
One-click config setup:
|
|
49
49
|
|
|
50
50
|
[](https://insiders.vscode.dev/redirect/mcp/install?name=lean-lsp&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22lean-lsp-mcp%22%5D%7D)
|
|
@@ -96,7 +96,7 @@ If that doesn't work, you can try cloning this repository and replace `"lean-lsp
|
|
|
96
96
|
</details>
|
|
97
97
|
|
|
98
98
|
<details>
|
|
99
|
-
<summary><b>Cursor</b></summary>
|
|
99
|
+
<summary><b>Cursor (Click to expand)</b></summary>
|
|
100
100
|
1. Open MCP Settings (File > Preferences > Cursor Settings > MCP)
|
|
101
101
|
|
|
102
102
|
2. "+ Add a new global MCP Server" > ("Create File")
|
|
@@ -116,7 +116,7 @@ If that doesn't work, you can try cloning this repository and replace `"lean-lsp
|
|
|
116
116
|
</details>
|
|
117
117
|
|
|
118
118
|
<details>
|
|
119
|
-
<summary><b>Claude Code</b></summary>
|
|
119
|
+
<summary><b>Claude Code (Click to expand)</b></summary>
|
|
120
120
|
Run one of these commands in the root directory of your Lean project (where `lakefile.toml` is located):
|
|
121
121
|
|
|
122
122
|
```bash
|
|
@@ -144,7 +144,11 @@ For the local search tool `lean_local_search`, install [ripgrep](https://github.
|
|
|
144
144
|
|
|
145
145
|
### File interactions (LSP)
|
|
146
146
|
|
|
147
|
-
####
|
|
147
|
+
#### lean_file_outline
|
|
148
|
+
|
|
149
|
+
Get a concise outline of a Lean file showing imports and declarations with type signatures (theorems, definitions, classes, structures).
|
|
150
|
+
|
|
151
|
+
#### lean_file_contents (DEPRECATED)
|
|
148
152
|
|
|
149
153
|
Get the contents of a Lean file, optionally with line number annotations.
|
|
150
154
|
|
|
@@ -40,9 +40,7 @@ def startup_client(ctx: Context):
|
|
|
40
40
|
prevent_cache = bool(os.environ.get("LEAN_LSP_TEST_MODE"))
|
|
41
41
|
with OutputCapture() as output:
|
|
42
42
|
client = LeanLSPClient(
|
|
43
|
-
lean_project_path,
|
|
44
|
-
initial_build=False,
|
|
45
|
-
prevent_cache_get=prevent_cache
|
|
43
|
+
lean_project_path, initial_build=False, prevent_cache_get=prevent_cache
|
|
46
44
|
)
|
|
47
45
|
logger.info(f"Connected to Lean language server at {lean_project_path}")
|
|
48
46
|
build_output = output.get_output()
|
|
@@ -5,6 +5,7 @@ INSTRUCTIONS = """## General Rules
|
|
|
5
5
|
- Work iteratively: Small steps, intermediate sorries, frequent checks.
|
|
6
6
|
|
|
7
7
|
## Key Tools
|
|
8
|
+
- lean_file_outline: Concise skeleton of a file (imports, docstrings, declarations). Token efficient.
|
|
8
9
|
- lean_local_search: Confirm declarations (theorems/lemmas/defs/etc.) exist. VERY USEFUL AND FAST!
|
|
9
10
|
- lean_goal: Check proof state. USE OFTEN!
|
|
10
11
|
- lean_diagnostic_messages: Understand current proof situation.
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Dict, List, Optional, Tuple
|
|
3
|
+
from leanclient import LeanLSPClient
|
|
4
|
+
from leanclient.utils import DocumentContentChange
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
METHOD_KIND = {6, "method"}
|
|
8
|
+
KIND_TAGS = {"namespace": "Ns"}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _get_info_trees(client: LeanLSPClient, path: str, symbols: List[Dict]) -> Dict[str, str]:
|
|
12
|
+
"""Insert #info_trees commands, collect diagnostics, then revert changes."""
|
|
13
|
+
if not symbols:
|
|
14
|
+
return {}
|
|
15
|
+
|
|
16
|
+
symbol_by_line = {}
|
|
17
|
+
changes = []
|
|
18
|
+
for i, sym in enumerate(sorted(symbols, key=lambda s: s['range']['start']['line'])):
|
|
19
|
+
line = sym['range']['start']['line'] + i
|
|
20
|
+
symbol_by_line[line] = sym['name']
|
|
21
|
+
changes.append(DocumentContentChange("#info_trees in\n", [line, 0], [line, 0]))
|
|
22
|
+
|
|
23
|
+
client.update_file(path, changes)
|
|
24
|
+
diagnostics = client.get_diagnostics(path)
|
|
25
|
+
|
|
26
|
+
info_trees = {
|
|
27
|
+
symbol_by_line[diag['range']['start']['line']]: diag['message']
|
|
28
|
+
for diag in diagnostics
|
|
29
|
+
if diag['severity'] == 3 and diag['range']['start']['line'] in symbol_by_line
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# Revert in reverse order
|
|
33
|
+
client.update_file(path, [
|
|
34
|
+
DocumentContentChange("", [line, 0], [line + 1, 0])
|
|
35
|
+
for line in sorted(symbol_by_line.keys(), reverse=True)
|
|
36
|
+
])
|
|
37
|
+
return info_trees
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _extract_type(info: str, name: str) -> Optional[str]:
|
|
41
|
+
"""Extract type signature from info tree message."""
|
|
42
|
+
if m := re.search(rf' • \[Term\] {re.escape(name)} \(isBinder := true\) : ([^@]+) @', info):
|
|
43
|
+
return m.group(1).strip()
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _extract_fields(info: str, name: str) -> List[Tuple[str, str]]:
|
|
48
|
+
"""Extract structure/class fields from info tree message."""
|
|
49
|
+
fields = []
|
|
50
|
+
for pattern in [rf'{re.escape(name)}\.(\w+)', rf'@{re.escape(name)}\.(\w+)']:
|
|
51
|
+
for m in re.finditer(rf' • \[Term\] {pattern} \(isBinder := true\) : (.+?) @', info):
|
|
52
|
+
field_name, full_type = m.groups()
|
|
53
|
+
# Clean up the type signature
|
|
54
|
+
if ']' in full_type:
|
|
55
|
+
field_type = full_type[full_type.rfind(']')+1:].lstrip('→ ').strip()
|
|
56
|
+
elif ' → ' in full_type:
|
|
57
|
+
field_type = full_type.split(' → ')[-1].strip()
|
|
58
|
+
else:
|
|
59
|
+
field_type = full_type.strip()
|
|
60
|
+
fields.append((field_name, field_type))
|
|
61
|
+
return fields
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _extract_declarations(content: str, start: int, end: int) -> List[Dict]:
|
|
65
|
+
"""Extract theorem/lemma/def declarations from file content."""
|
|
66
|
+
lines = content.splitlines()
|
|
67
|
+
decls, i = [], start
|
|
68
|
+
|
|
69
|
+
while i < min(end, len(lines)):
|
|
70
|
+
line = lines[i].strip()
|
|
71
|
+
for keyword in ['theorem', 'lemma', 'def']:
|
|
72
|
+
if line.startswith(f"{keyword} "):
|
|
73
|
+
name = line[len(keyword):].strip().split()[0]
|
|
74
|
+
if name and not name.startswith('_'):
|
|
75
|
+
# Collect until :=
|
|
76
|
+
decl_lines = [line]
|
|
77
|
+
j = i + 1
|
|
78
|
+
while j < min(end, len(lines)) and ':=' not in ' '.join(decl_lines):
|
|
79
|
+
if (next_line := lines[j].strip()) and not next_line.startswith('--'):
|
|
80
|
+
decl_lines.append(next_line)
|
|
81
|
+
j += 1
|
|
82
|
+
|
|
83
|
+
# Extract signature (everything before :=, minus keyword and name)
|
|
84
|
+
full_decl = ' '.join(decl_lines)
|
|
85
|
+
type_sig = None
|
|
86
|
+
if ':=' in full_decl:
|
|
87
|
+
sig_part = full_decl.split(':=', 1)[0].strip()[len(keyword):].strip()
|
|
88
|
+
if sig_part.startswith(name):
|
|
89
|
+
type_sig = sig_part[len(name):].strip()
|
|
90
|
+
|
|
91
|
+
decls.append({
|
|
92
|
+
'name': name,
|
|
93
|
+
'kind': 'method',
|
|
94
|
+
'range': {'start': {'line': i, 'character': 0},
|
|
95
|
+
'end': {'line': i, 'character': len(lines[i])}},
|
|
96
|
+
'_keyword': keyword,
|
|
97
|
+
'_type': type_sig
|
|
98
|
+
})
|
|
99
|
+
break
|
|
100
|
+
i += 1
|
|
101
|
+
return decls
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _flatten_symbols(symbols: List[Dict], indent: int = 0, content: str = "") -> List[Tuple[Dict, int]]:
|
|
105
|
+
"""Recursively flatten symbol hierarchy, extracting declarations from namespaces."""
|
|
106
|
+
result = []
|
|
107
|
+
for sym in symbols:
|
|
108
|
+
result.append((sym, indent))
|
|
109
|
+
children = sym.get('children', [])
|
|
110
|
+
|
|
111
|
+
# Extract theorem/lemma/def from namespace bodies
|
|
112
|
+
if content and sym.get('kind') == 'namespace':
|
|
113
|
+
ns_range = sym['range']
|
|
114
|
+
ns_start = ns_range['start']['line']
|
|
115
|
+
ns_end = ns_range['end']['line']
|
|
116
|
+
children = children + _extract_declarations(content, ns_start, ns_end)
|
|
117
|
+
|
|
118
|
+
if children:
|
|
119
|
+
result.extend(_flatten_symbols(children, indent + 1, content))
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _detect_tag(name: str, kind: str, type_sig: str, has_fields: bool, keyword: Optional[str]) -> str:
|
|
124
|
+
"""Determine the appropriate tag for a symbol."""
|
|
125
|
+
if has_fields:
|
|
126
|
+
return "Class" if '→' in type_sig else "Struct"
|
|
127
|
+
if name == "example":
|
|
128
|
+
return "Ex"
|
|
129
|
+
if keyword in {'theorem', 'lemma'}:
|
|
130
|
+
return "Thm"
|
|
131
|
+
if type_sig and any(marker in type_sig for marker in ['∀', '=']):
|
|
132
|
+
return "Thm"
|
|
133
|
+
if type_sig and '→' in type_sig.replace(' → ', '', 1): # More than one arrow
|
|
134
|
+
return "Thm"
|
|
135
|
+
return KIND_TAGS.get(kind, "Def")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _format_symbol(sym: Dict, type_sigs: Dict, fields_map: Dict, indent: int) -> str:
|
|
139
|
+
"""Format a single symbol with its type signature and fields."""
|
|
140
|
+
name = sym['name']
|
|
141
|
+
type_sig = sym.get('_type') or type_sigs.get(name, "")
|
|
142
|
+
fields = fields_map.get(name, [])
|
|
143
|
+
|
|
144
|
+
tag = _detect_tag(name, sym.get('kind', ''), type_sig, bool(fields), sym.get('_keyword'))
|
|
145
|
+
prefix = "\t" * indent
|
|
146
|
+
|
|
147
|
+
start = sym['range']['start']['line'] + 1
|
|
148
|
+
end = sym['range']['end']['line'] + 1
|
|
149
|
+
line_info = f"L{start}" if start == end else f"L{start}-{end}"
|
|
150
|
+
|
|
151
|
+
result = f"{prefix}[{tag}: {line_info}] {name}"
|
|
152
|
+
if type_sig:
|
|
153
|
+
result += f" : {type_sig}"
|
|
154
|
+
|
|
155
|
+
for fname, ftype in fields:
|
|
156
|
+
result += f"\n{prefix}\t{fname} : {ftype}"
|
|
157
|
+
|
|
158
|
+
return result + "\n"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def generate_outline(client: LeanLSPClient, path: str) -> str:
|
|
162
|
+
"""Generate a concise outline of a Lean file showing structure and signatures."""
|
|
163
|
+
client.open_file(path)
|
|
164
|
+
content = client.get_file_content(path)
|
|
165
|
+
|
|
166
|
+
# Extract imports
|
|
167
|
+
imports = [line.strip()[7:] for line in content.splitlines()
|
|
168
|
+
if line.strip().startswith("import ")]
|
|
169
|
+
|
|
170
|
+
symbols = client.get_document_symbols(path)
|
|
171
|
+
if not symbols and not imports:
|
|
172
|
+
return f"# {path}\n\n*No symbols or imports found*\n"
|
|
173
|
+
|
|
174
|
+
# Flatten symbol tree and extract namespace declarations
|
|
175
|
+
all_symbols = _flatten_symbols(symbols, content=content)
|
|
176
|
+
|
|
177
|
+
# Get info trees only for LSP symbols (not extracted declarations)
|
|
178
|
+
lsp_methods = [s for s, _ in all_symbols if s.get('kind') in METHOD_KIND and '_keyword' not in s]
|
|
179
|
+
info_trees = _get_info_trees(client, path, lsp_methods)
|
|
180
|
+
|
|
181
|
+
# Extract type signatures and fields from info trees
|
|
182
|
+
type_sigs = {name: sig for name, info in info_trees.items()
|
|
183
|
+
if (sig := _extract_type(info, name))}
|
|
184
|
+
fields_map = {name: fields for name, info in info_trees.items()
|
|
185
|
+
if (fields := _extract_fields(info, name))}
|
|
186
|
+
|
|
187
|
+
# Build output
|
|
188
|
+
parts = []
|
|
189
|
+
if imports:
|
|
190
|
+
parts.append("## Imports\n" + "\n".join(imports))
|
|
191
|
+
|
|
192
|
+
if symbols:
|
|
193
|
+
declarations = [
|
|
194
|
+
_format_symbol(sym, type_sigs, fields_map, indent)
|
|
195
|
+
for sym, indent in all_symbols
|
|
196
|
+
if sym.get('kind') in METHOD_KIND or sym.get('_keyword') or sym.get('kind') == 'namespace'
|
|
197
|
+
]
|
|
198
|
+
parts.append("## Declarations\n" + "".join(declarations).rstrip())
|
|
199
|
+
|
|
200
|
+
return "\n\n".join(parts) + "\n"
|
|
@@ -26,8 +26,10 @@ from lean_lsp_mcp.client_utils import (
|
|
|
26
26
|
from lean_lsp_mcp.file_utils import get_file_contents
|
|
27
27
|
from lean_lsp_mcp.instructions import INSTRUCTIONS
|
|
28
28
|
from lean_lsp_mcp.search_utils import check_ripgrep_status, lean_local_search
|
|
29
|
+
from lean_lsp_mcp.outline_utils import generate_outline
|
|
29
30
|
from lean_lsp_mcp.utils import (
|
|
30
31
|
OutputCapture,
|
|
32
|
+
deprecated,
|
|
31
33
|
extract_range,
|
|
32
34
|
filter_diagnostics_by_position,
|
|
33
35
|
find_start_position,
|
|
@@ -236,6 +238,7 @@ async def lsp_build(
|
|
|
236
238
|
|
|
237
239
|
# File level tools
|
|
238
240
|
@mcp.tool("lean_file_contents")
|
|
241
|
+
@deprecated
|
|
239
242
|
def file_contents(ctx: Context, file_path: str, annotate_lines: bool = True) -> str:
|
|
240
243
|
"""Get the text contents of a Lean file, optionally with line numbers.
|
|
241
244
|
|
|
@@ -270,6 +273,26 @@ def file_contents(ctx: Context, file_path: str, annotate_lines: bool = True) ->
|
|
|
270
273
|
return data
|
|
271
274
|
|
|
272
275
|
|
|
276
|
+
@mcp.tool("lean_file_outline")
|
|
277
|
+
def file_outline(ctx: Context, file_path: str) -> str:
|
|
278
|
+
"""Get a concise outline showing imports and declarations with type signatures (theorems, defs, classes, structures).
|
|
279
|
+
|
|
280
|
+
Highly useful and token-efficient. Slow-ish.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
file_path (str): Abs path to Lean file
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
str: Markdown formatted outline or error msg
|
|
287
|
+
"""
|
|
288
|
+
rel_path = setup_client_for_file(ctx, file_path)
|
|
289
|
+
if not rel_path:
|
|
290
|
+
return "Invalid Lean file path: Unable to start LSP server or load file"
|
|
291
|
+
|
|
292
|
+
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
293
|
+
return generate_outline(client, rel_path)
|
|
294
|
+
|
|
295
|
+
|
|
273
296
|
@mcp.tool("lean_diagnostic_messages")
|
|
274
297
|
def diagnostic_messages(
|
|
275
298
|
ctx: Context,
|
|
@@ -706,7 +729,7 @@ def run_code(ctx: Context, code: str) -> List[str] | str:
|
|
|
706
729
|
|
|
707
730
|
@mcp.tool("lean_local_search")
|
|
708
731
|
def local_search(
|
|
709
|
-
ctx: Context, query: str, limit: int = 10
|
|
732
|
+
ctx: Context, query: str, limit: int = 10, project_root: str | None = None
|
|
710
733
|
) -> List[Dict[str, str]] | str:
|
|
711
734
|
"""Confirm declarations exist in the current workspace to prevent hallucinating APIs.
|
|
712
735
|
|
|
@@ -724,11 +747,29 @@ def local_search(
|
|
|
724
747
|
if not _RG_AVAILABLE:
|
|
725
748
|
return _RG_MESSAGE
|
|
726
749
|
|
|
727
|
-
|
|
728
|
-
|
|
750
|
+
lifespan = ctx.request_context.lifespan_context
|
|
751
|
+
stored_root = lifespan.lean_project_path
|
|
752
|
+
|
|
753
|
+
if project_root:
|
|
754
|
+
try:
|
|
755
|
+
resolved_root = Path(project_root).expanduser().resolve()
|
|
756
|
+
except OSError as exc: # pragma: no cover - defensive path handling
|
|
757
|
+
return f"Invalid project root '{project_root}': {exc}"
|
|
758
|
+
if not resolved_root.exists():
|
|
759
|
+
return f"Project root '{project_root}' does not exist."
|
|
760
|
+
lifespan.lean_project_path = resolved_root
|
|
761
|
+
else:
|
|
762
|
+
resolved_root = stored_root
|
|
763
|
+
|
|
764
|
+
if resolved_root is None:
|
|
729
765
|
return "Lean project path not set. Call a file-based tool (like lean_file_contents) first to set the project path."
|
|
730
766
|
|
|
731
|
-
|
|
767
|
+
try:
|
|
768
|
+
return lean_local_search(
|
|
769
|
+
query=query.strip(), limit=limit, project_root=resolved_root
|
|
770
|
+
)
|
|
771
|
+
except RuntimeError as exc:
|
|
772
|
+
return f"lean_local_search error:\n{exc}"
|
|
732
773
|
|
|
733
774
|
|
|
734
775
|
@mcp.tool("lean_leansearch")
|
|
@@ -2,7 +2,7 @@ import os
|
|
|
2
2
|
import secrets
|
|
3
3
|
import sys
|
|
4
4
|
import tempfile
|
|
5
|
-
from typing import List, Dict, Optional
|
|
5
|
+
from typing import List, Dict, Optional, Callable
|
|
6
6
|
|
|
7
7
|
from mcp.server.auth.provider import AccessToken, TokenVerifier
|
|
8
8
|
|
|
@@ -334,3 +334,22 @@ def get_declaration_range(
|
|
|
334
334
|
e,
|
|
335
335
|
)
|
|
336
336
|
return None
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def deprecated(func_or_msg: str | Callable | None = None) -> Callable:
|
|
340
|
+
"""Mark a tool as deprecated. Can be used as @deprecated or @deprecated("msg")."""
|
|
341
|
+
msg = "Will be removed soon."
|
|
342
|
+
|
|
343
|
+
def _decorator(func: Callable) -> Callable:
|
|
344
|
+
doc = func.__doc__ or ""
|
|
345
|
+
func.__doc__ = f"DEPRECATED: {msg}\n\n{doc}"
|
|
346
|
+
return func
|
|
347
|
+
|
|
348
|
+
if isinstance(func_or_msg, str):
|
|
349
|
+
msg = func_or_msg
|
|
350
|
+
return _decorator
|
|
351
|
+
|
|
352
|
+
if func_or_msg is None:
|
|
353
|
+
return _decorator
|
|
354
|
+
|
|
355
|
+
return _decorator(func_or_msg)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lean-lsp-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.14.1
|
|
4
4
|
Summary: Lean Theorem Prover MCP
|
|
5
5
|
Author-email: Oliver Dressler <hey@oli.show>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -66,7 +66,7 @@ MCP server that allows agentic interaction with the [Lean theorem prover](https:
|
|
|
66
66
|
### 3. Configure your IDE/Setup
|
|
67
67
|
|
|
68
68
|
<details>
|
|
69
|
-
<summary><b>VSCode</b></summary>
|
|
69
|
+
<summary><b>VSCode (Click to expand)</b></summary>
|
|
70
70
|
One-click config setup:
|
|
71
71
|
|
|
72
72
|
[](https://insiders.vscode.dev/redirect/mcp/install?name=lean-lsp&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22lean-lsp-mcp%22%5D%7D)
|
|
@@ -118,7 +118,7 @@ If that doesn't work, you can try cloning this repository and replace `"lean-lsp
|
|
|
118
118
|
</details>
|
|
119
119
|
|
|
120
120
|
<details>
|
|
121
|
-
<summary><b>Cursor</b></summary>
|
|
121
|
+
<summary><b>Cursor (Click to expand)</b></summary>
|
|
122
122
|
1. Open MCP Settings (File > Preferences > Cursor Settings > MCP)
|
|
123
123
|
|
|
124
124
|
2. "+ Add a new global MCP Server" > ("Create File")
|
|
@@ -138,7 +138,7 @@ If that doesn't work, you can try cloning this repository and replace `"lean-lsp
|
|
|
138
138
|
</details>
|
|
139
139
|
|
|
140
140
|
<details>
|
|
141
|
-
<summary><b>Claude Code</b></summary>
|
|
141
|
+
<summary><b>Claude Code (Click to expand)</b></summary>
|
|
142
142
|
Run one of these commands in the root directory of your Lean project (where `lakefile.toml` is located):
|
|
143
143
|
|
|
144
144
|
```bash
|
|
@@ -166,7 +166,11 @@ For the local search tool `lean_local_search`, install [ripgrep](https://github.
|
|
|
166
166
|
|
|
167
167
|
### File interactions (LSP)
|
|
168
168
|
|
|
169
|
-
####
|
|
169
|
+
#### lean_file_outline
|
|
170
|
+
|
|
171
|
+
Get a concise outline of a Lean file showing imports and declarations with type signatures (theorems, definitions, classes, structures).
|
|
172
|
+
|
|
173
|
+
#### lean_file_contents (DEPRECATED)
|
|
170
174
|
|
|
171
175
|
Get the contents of a Lean file, optionally with line number annotations.
|
|
172
176
|
|
|
@@ -6,6 +6,7 @@ src/lean_lsp_mcp/__main__.py
|
|
|
6
6
|
src/lean_lsp_mcp/client_utils.py
|
|
7
7
|
src/lean_lsp_mcp/file_utils.py
|
|
8
8
|
src/lean_lsp_mcp/instructions.py
|
|
9
|
+
src/lean_lsp_mcp/outline_utils.py
|
|
9
10
|
src/lean_lsp_mcp/search_utils.py
|
|
10
11
|
src/lean_lsp_mcp/server.py
|
|
11
12
|
src/lean_lsp_mcp/utils.py
|
|
@@ -20,5 +21,6 @@ tests/test_editor_tools.py
|
|
|
20
21
|
tests/test_file_caching.py
|
|
21
22
|
tests/test_logging.py
|
|
22
23
|
tests/test_misc_tools.py
|
|
24
|
+
tests/test_outline.py
|
|
23
25
|
tests/test_project_tools.py
|
|
24
26
|
tests/test_search_tools.py
|
|
@@ -56,7 +56,7 @@ async def test_diagnostic_messages_line_filtering(
|
|
|
56
56
|
assert "string" in diag_text.lower() or "error" in diag_text.lower()
|
|
57
57
|
assert diag_text.count("severity") >= 2
|
|
58
58
|
all_diag_text = diag_text
|
|
59
|
-
|
|
59
|
+
|
|
60
60
|
# Test 2: Get diagnostics starting from line 10
|
|
61
61
|
diagnostics = await client.call_tool(
|
|
62
62
|
"lean_diagnostic_messages",
|
|
@@ -69,9 +69,10 @@ async def test_diagnostic_messages_line_filtering(
|
|
|
69
69
|
# Should contain the second error (line 13: anotherError)
|
|
70
70
|
assert "123" in diag_text or "error" in diag_text.lower()
|
|
71
71
|
assert len(diag_text) < len(all_diag_text)
|
|
72
|
-
|
|
72
|
+
|
|
73
73
|
# Test 3: Get diagnostics for specific line range
|
|
74
74
|
import re
|
|
75
|
+
|
|
75
76
|
line_matches = re.findall(r"l(\d+)c", all_diag_text)
|
|
76
77
|
if line_matches:
|
|
77
78
|
first_error_line = int(line_matches[0])
|
|
@@ -86,7 +87,7 @@ async def test_diagnostic_messages_line_filtering(
|
|
|
86
87
|
diag_text = result_text(diagnostics)
|
|
87
88
|
assert "string" in diag_text.lower() or len(diag_text) > 0
|
|
88
89
|
assert len(diag_text) < len(all_diag_text)
|
|
89
|
-
|
|
90
|
+
|
|
90
91
|
# Test 4: Get diagnostics for range with no errors (lines 14-17)
|
|
91
92
|
diagnostics = await client.call_tool(
|
|
92
93
|
"lean_diagnostic_messages",
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Test outline generation with various Lean files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import AsyncContextManager
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from tests.helpers.mcp_client import MCPClient, result_text
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def mathlib_nat_basic(test_project_path: Path) -> Path:
|
|
17
|
+
"""Path to Mathlib Data.Nat.Basic file."""
|
|
18
|
+
return test_project_path / ".lake/packages/mathlib/Mathlib/Data/Nat/Basic.lean"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.mark.asyncio
|
|
22
|
+
async def test_outline_simple_files(
|
|
23
|
+
mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
|
|
24
|
+
test_project_path: Path,
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Test outline generation on simple test files."""
|
|
27
|
+
test_files = [
|
|
28
|
+
test_project_path / "StructTest.lean",
|
|
29
|
+
test_project_path / "TheoremTest.lean",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
async with mcp_client_factory() as client:
|
|
33
|
+
for test_file in test_files:
|
|
34
|
+
result = await client.call_tool("lean_file_outline", {"file_path": str(test_file)})
|
|
35
|
+
outline = result_text(result)
|
|
36
|
+
|
|
37
|
+
# Basic structure checks
|
|
38
|
+
assert "## Imports" in outline or "## Declarations" in outline
|
|
39
|
+
assert len(outline) > 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.mark.asyncio
|
|
43
|
+
async def test_mathlib_outline_structure(
|
|
44
|
+
mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
|
|
45
|
+
mathlib_nat_basic: Path,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Test outline generation with a real Mathlib file."""
|
|
48
|
+
async with mcp_client_factory() as client:
|
|
49
|
+
result = await client.call_tool("lean_file_outline", {"file_path": str(mathlib_nat_basic)})
|
|
50
|
+
outline = result_text(result)
|
|
51
|
+
|
|
52
|
+
# Basic structure checks (no filename header now)
|
|
53
|
+
assert "## Imports" in outline
|
|
54
|
+
assert "## Declarations" in outline
|
|
55
|
+
|
|
56
|
+
# Should have imports from Mathlib
|
|
57
|
+
assert "Mathlib.Data.Nat.Init" in outline
|
|
58
|
+
|
|
59
|
+
# Should have namespace (new format)
|
|
60
|
+
assert "[Ns:" in outline and "Nat" in outline
|
|
61
|
+
|
|
62
|
+
# Should have instance declarations
|
|
63
|
+
assert "instLinearOrder" in outline or "LinearOrder" in outline
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.mark.asyncio
|
|
67
|
+
async def test_mathlib_outline_has_line_numbers(
|
|
68
|
+
mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
|
|
69
|
+
mathlib_nat_basic: Path,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Verify line numbers are present in outline."""
|
|
72
|
+
async with mcp_client_factory() as client:
|
|
73
|
+
result = await client.call_tool("lean_file_outline", {"file_path": str(mathlib_nat_basic)})
|
|
74
|
+
outline = result_text(result)
|
|
75
|
+
|
|
76
|
+
# Should have line numbers in format "[Tag: L27-135]" or "[Tag: L31]"
|
|
77
|
+
line_pattern = r'L(\d+)(?:-(\d+))?'
|
|
78
|
+
matches = re.findall(line_pattern, outline)
|
|
79
|
+
|
|
80
|
+
assert len(matches) > 0, "Should have line number annotations"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@pytest.mark.asyncio
|
|
84
|
+
async def test_mathlib_outline_has_types(
|
|
85
|
+
mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
|
|
86
|
+
mathlib_nat_basic: Path,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Verify type signatures are included."""
|
|
89
|
+
async with mcp_client_factory() as client:
|
|
90
|
+
result = await client.call_tool("lean_file_outline", {"file_path": str(mathlib_nat_basic)})
|
|
91
|
+
outline = result_text(result)
|
|
92
|
+
|
|
93
|
+
# Should have type annotations with ":"
|
|
94
|
+
assert "LinearOrder ℕ" in outline or ": " in outline
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@pytest.mark.asyncio
|
|
98
|
+
async def test_mathlib_outline_file_cleanup(
|
|
99
|
+
mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
|
|
100
|
+
mathlib_nat_basic: Path,
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Verify file is properly cleaned up after info_trees extraction."""
|
|
103
|
+
async with mcp_client_factory() as client:
|
|
104
|
+
# Get original file content
|
|
105
|
+
original_content = mathlib_nat_basic.read_text()
|
|
106
|
+
|
|
107
|
+
# Generate outline (which inserts and removes #info_trees lines)
|
|
108
|
+
await client.call_tool("lean_file_outline", {"file_path": str(mathlib_nat_basic)})
|
|
109
|
+
|
|
110
|
+
# Read file content again
|
|
111
|
+
final_content = mathlib_nat_basic.read_text()
|
|
112
|
+
|
|
113
|
+
# File should be unchanged
|
|
114
|
+
assert final_content == original_content, "File should be restored to original state after outline generation"
|
|
115
|
+
|
|
116
|
+
# Specifically check that no #info_trees lines remain
|
|
117
|
+
assert "#info_trees" not in final_content, "No #info_trees directives should remain in file"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|