lean-lsp-mcp 0.13.2__py3-none-any.whl → 0.14.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.
@@ -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"
lean_lsp_mcp/server.py CHANGED
@@ -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
- stored_root = ctx.request_context.lifespan_context.lean_project_path
728
- if stored_root is None:
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
- return lean_local_search(query=query.strip(), limit=limit, project_root=stored_root)
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")
lean_lsp_mcp/utils.py CHANGED
@@ -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.13.2
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
  [![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](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
- #### lean_file_contents
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
 
@@ -0,0 +1,15 @@
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=HgPuB35rMitn2Xm8SCAErsFLq15trB6VMz3FDFgmPd8,4897
4
+ lean_lsp_mcp/file_utils.py,sha256=kCTYQSfmV-R2cm_NCi_L8W5Dcsm0_rTOPpTtpyAin78,1365
5
+ lean_lsp_mcp/instructions.py,sha256=GUOCDILr5N4H_kNE5hiXtzy4Sz9tu-BnE7Y0ktXIF9M,955
6
+ lean_lsp_mcp/outline_utils.py,sha256=bXBpLp_QnxmvwoP2y1juCYog2eln6329MAKuOXOz0-E,7807
7
+ lean_lsp_mcp/search_utils.py,sha256=X2LPynDNLi767UDxbxHpMccOkbnfKJKv_HxvRNxIXM4,3984
8
+ lean_lsp_mcp/server.py,sha256=qf0iRVeWrrvX91EmJsgbx7DW8kwn28zMs1WyfkxCh5A,37644
9
+ lean_lsp_mcp/utils.py,sha256=qY2Ef82SmD46y0IgyX1jimigkgr6Q8-Hrme-yUYSBGo,11094
10
+ lean_lsp_mcp-0.14.1.dist-info/licenses/LICENSE,sha256=CQlxnf0tQyoVrBE93JYvAUYxv6Z5Yg6sX0pwogOkFvo,1071
11
+ lean_lsp_mcp-0.14.1.dist-info/METADATA,sha256=XzcCebo_ZF4-alJTmjY5Cu2M7OpVgEJmCriT4Sl2CMw,19855
12
+ lean_lsp_mcp-0.14.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
13
+ lean_lsp_mcp-0.14.1.dist-info/entry_points.txt,sha256=nQbvwctWkWD7I-2f4VrdVQBZYGUw8CnUnFC6QjXxOSE,51
14
+ lean_lsp_mcp-0.14.1.dist-info/top_level.txt,sha256=LGEK0lgMSNPIQ6mG8EO-adaZEGPi_0daDs004epOTF0,13
15
+ lean_lsp_mcp-0.14.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=lzWOuiuho-PrI_ARH0Kdn93yBXmK--G3izccMsuu-7g,4930
4
- lean_lsp_mcp/file_utils.py,sha256=kCTYQSfmV-R2cm_NCi_L8W5Dcsm0_rTOPpTtpyAin78,1365
5
- lean_lsp_mcp/instructions.py,sha256=1Xgh_fkdRpz-lqdl6kpETdwEu-IGRQGIdSdU7o9t5hc,853
6
- lean_lsp_mcp/search_utils.py,sha256=X2LPynDNLi767UDxbxHpMccOkbnfKJKv_HxvRNxIXM4,3984
7
- lean_lsp_mcp/server.py,sha256=g_CcgMl4aOE0MqcNv5O0DvSR7mN_fSdmJQAddDSclfA,36261
8
- lean_lsp_mcp/utils.py,sha256=YE6o6eswOi47AYojITQ2RcR-DspqKtgACeV-O7xgOKM,10554
9
- lean_lsp_mcp-0.13.2.dist-info/licenses/LICENSE,sha256=CQlxnf0tQyoVrBE93JYvAUYxv6Z5Yg6sX0pwogOkFvo,1071
10
- lean_lsp_mcp-0.13.2.dist-info/METADATA,sha256=8UFbXM1OVizqVDOthRMcbQo5mN4VeGMFm-qINdvvXC8,19626
11
- lean_lsp_mcp-0.13.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
12
- lean_lsp_mcp-0.13.2.dist-info/entry_points.txt,sha256=nQbvwctWkWD7I-2f4VrdVQBZYGUw8CnUnFC6QjXxOSE,51
13
- lean_lsp_mcp-0.13.2.dist-info/top_level.txt,sha256=LGEK0lgMSNPIQ6mG8EO-adaZEGPi_0daDs004epOTF0,13
14
- lean_lsp_mcp-0.13.2.dist-info/RECORD,,