lean-lsp-mcp 0.13.1__tar.gz → 0.14.0__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 (28) hide show
  1. {lean_lsp_mcp-0.13.1/src/lean_lsp_mcp.egg-info → lean_lsp_mcp-0.14.0}/PKG-INFO +11 -7
  2. {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/README.md +8 -4
  3. {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/pyproject.toml +3 -3
  4. {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/src/lean_lsp_mcp/client_utils.py +6 -7
  5. {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/src/lean_lsp_mcp/instructions.py +1 -0
  6. lean_lsp_mcp-0.14.0/src/lean_lsp_mcp/outline_utils.py +200 -0
  7. {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/src/lean_lsp_mcp/server.py +45 -4
  8. {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/src/lean_lsp_mcp/utils.py +20 -1
  9. {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0/src/lean_lsp_mcp.egg-info}/PKG-INFO +11 -7
  10. {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/src/lean_lsp_mcp.egg-info/SOURCES.txt +2 -0
  11. {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/src/lean_lsp_mcp.egg-info/requires.txt +2 -2
  12. {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/tests/test_diagnostic_line_range.py +26 -123
  13. {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/tests/test_file_caching.py +18 -24
  14. lean_lsp_mcp-0.14.0/tests/test_outline.py +117 -0
  15. {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/LICENSE +0 -0
  16. {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/setup.cfg +0 -0
  17. {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/src/lean_lsp_mcp/__init__.py +0 -0
  18. {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/src/lean_lsp_mcp/__main__.py +0 -0
  19. {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/src/lean_lsp_mcp/file_utils.py +0 -0
  20. {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/src/lean_lsp_mcp/search_utils.py +0 -0
  21. {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/src/lean_lsp_mcp.egg-info/dependency_links.txt +0 -0
  22. {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/src/lean_lsp_mcp.egg-info/entry_points.txt +0 -0
  23. {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/src/lean_lsp_mcp.egg-info/top_level.txt +0 -0
  24. {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/tests/test_editor_tools.py +0 -0
  25. {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/tests/test_logging.py +0 -0
  26. {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/tests/test_misc_tools.py +0 -0
  27. {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/tests/test_project_tools.py +0 -0
  28. {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/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.13.1
3
+ Version: 0.14.0
4
4
  Summary: Lean Theorem Prover MCP
5
5
  Author-email: Oliver Dressler <hey@oli.show>
6
6
  License-Expression: MIT
@@ -8,8 +8,8 @@ Project-URL: Repository, https://github.com/oOo0oOo/lean-lsp-mcp
8
8
  Requires-Python: >=3.10
9
9
  Description-Content-Type: text/markdown
10
10
  License-File: LICENSE
11
- Requires-Dist: leanclient==0.5.3
12
- Requires-Dist: mcp[cli]==1.21.1
11
+ Requires-Dist: leanclient==0.5.5
12
+ Requires-Dist: mcp[cli]==1.21.2
13
13
  Requires-Dist: orjson>=3.11.1
14
14
  Provides-Extra: lint
15
15
  Requires-Dist: ruff>=0.2.0; extra == "lint"
@@ -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
 
@@ -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
  [![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)
@@ -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
- #### lean_file_contents
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
 
@@ -1,14 +1,14 @@
1
1
  [project]
2
2
  name = "lean-lsp-mcp"
3
- version = "0.13.1"
3
+ version = "0.14.0"
4
4
  description = "Lean Theorem Prover MCP"
5
5
  authors = [{name="Oliver Dressler", email="hey@oli.show"}]
6
6
  readme = "README.md"
7
7
  requires-python = ">=3.10"
8
8
  license = "MIT"
9
9
  dependencies = [
10
- "leanclient==0.5.3",
11
- "mcp[cli]==1.21.1",
10
+ "leanclient==0.5.5",
11
+ "mcp[cli]==1.21.2",
12
12
  "orjson>=3.11.1",
13
13
  ]
14
14
 
@@ -36,14 +36,13 @@ def startup_client(ctx: Context):
36
36
  client.close()
37
37
 
38
38
  # Need to create a new client
39
+ # In test environments, prevent repeated cache downloads
40
+ prevent_cache = bool(os.environ.get("LEAN_LSP_TEST_MODE"))
39
41
  with OutputCapture() as output:
40
- try:
41
- client = LeanLSPClient(lean_project_path)
42
- logger.info(f"Connected to Lean language server at {lean_project_path}")
43
- except Exception as e:
44
- logger.warning(f"Initial connection failed, trying with build: {e}")
45
- client = LeanLSPClient(lean_project_path, initial_build=True)
46
- logger.info(f"Connected with initial build to {lean_project_path}")
42
+ client = LeanLSPClient(
43
+ lean_project_path, initial_build=False, prevent_cache_get=prevent_cache
44
+ )
45
+ logger.info(f"Connected to Lean language server at {lean_project_path}")
47
46
  build_output = output.get_output()
48
47
  if build_output:
49
48
  logger.debug(f"Build output: {build_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
- 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")
@@ -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.1
3
+ Version: 0.14.0
4
4
  Summary: Lean Theorem Prover MCP
5
5
  Author-email: Oliver Dressler <hey@oli.show>
6
6
  License-Expression: MIT
@@ -8,8 +8,8 @@ Project-URL: Repository, https://github.com/oOo0oOo/lean-lsp-mcp
8
8
  Requires-Python: >=3.10
9
9
  Description-Content-Type: text/markdown
10
10
  License-File: LICENSE
11
- Requires-Dist: leanclient==0.5.3
12
- Requires-Dist: mcp[cli]==1.21.1
11
+ Requires-Dist: leanclient==0.5.5
12
+ Requires-Dist: mcp[cli]==1.21.2
13
13
  Requires-Dist: orjson>=3.11.1
14
14
  Provides-Extra: lint
15
15
  Requires-Dist: ruff>=0.2.0; extra == "lint"
@@ -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
 
@@ -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
@@ -1,5 +1,5 @@
1
- leanclient==0.5.3
2
- mcp[cli]==1.21.1
1
+ leanclient==0.5.5
2
+ mcp[cli]==1.21.2
3
3
  orjson>=3.11.1
4
4
 
5
5
  [dev]
@@ -10,7 +10,7 @@ import pytest
10
10
  from tests.helpers.mcp_client import MCPClient, result_text
11
11
 
12
12
 
13
- @pytest.fixture()
13
+ @pytest.fixture(scope="module")
14
14
  def diagnostic_file(test_project_path: Path) -> Path:
15
15
  path = test_project_path / "DiagnosticTest.lean"
16
16
  content = textwrap.dedent(
@@ -34,46 +34,30 @@ def diagnostic_file(test_project_path: Path) -> Path:
34
34
  trivial
35
35
  """
36
36
  ).strip()
37
- path.write_text(content + "\n", encoding="utf-8")
37
+ if not path.exists() or path.read_text(encoding="utf-8") != content + "\n":
38
+ path.write_text(content + "\n", encoding="utf-8")
38
39
  return path
39
40
 
40
41
 
41
42
  @pytest.mark.asyncio
42
- async def test_diagnostic_messages_without_line_range(
43
+ async def test_diagnostic_messages_line_filtering(
43
44
  mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
44
45
  diagnostic_file: Path,
45
46
  ) -> None:
46
- """Test getting all diagnostic messages without line range filtering."""
47
+ """Test all line range filtering scenarios in one client session."""
47
48
  async with mcp_client_factory() as client:
49
+ # Test 1: Get all diagnostic messages without line range filtering
48
50
  diagnostics = await client.call_tool(
49
51
  "lean_diagnostic_messages",
50
52
  {"file_path": str(diagnostic_file)},
51
53
  )
52
54
  diag_text = result_text(diagnostics)
53
-
54
55
  # Should contain both errors
55
56
  assert "string" in diag_text.lower() or "error" in diag_text.lower()
56
- # Check that multiple diagnostics are returned (at least the two errors we created)
57
57
  assert diag_text.count("severity") >= 2
58
+ all_diag_text = diag_text
58
59
 
59
-
60
- @pytest.mark.asyncio
61
- async def test_diagnostic_messages_with_start_line(
62
- mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
63
- diagnostic_file: Path,
64
- ) -> None:
65
- """Test getting diagnostic messages starting from a specific line."""
66
- async with mcp_client_factory() as client:
67
- # First get all diagnostics to see what we have
68
- all_diagnostics = await client.call_tool(
69
- "lean_diagnostic_messages",
70
- {
71
- "file_path": str(diagnostic_file),
72
- },
73
- )
74
- all_diag_text = result_text(all_diagnostics)
75
-
76
- # Get diagnostics starting from line 10 (should only include the second error)
60
+ # Test 2: Get diagnostics starting from line 10
77
61
  diagnostics = await client.call_tool(
78
62
  "lean_diagnostic_messages",
79
63
  {
@@ -82,35 +66,16 @@ async def test_diagnostic_messages_with_start_line(
82
66
  },
83
67
  )
84
68
  diag_text = result_text(diagnostics)
85
-
86
69
  # Should contain the second error (line 13: anotherError)
87
70
  assert "123" in diag_text or "error" in diag_text.lower()
88
- # Should have fewer diagnostics than all_diagnostics
89
71
  assert len(diag_text) < len(all_diag_text)
90
72
 
91
-
92
- @pytest.mark.asyncio
93
- async def test_diagnostic_messages_with_line_range(
94
- mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
95
- diagnostic_file: Path,
96
- ) -> None:
97
- """Test getting diagnostic messages for a specific line range."""
98
- async with mcp_client_factory() as client:
99
- # First, get all diagnostics to see what lines they're actually on
100
- all_diagnostics = await client.call_tool(
101
- "lean_diagnostic_messages",
102
- {"file_path": str(diagnostic_file)},
103
- )
104
- all_diag_text = result_text(all_diagnostics)
105
-
106
- # Extract line numbers from the diagnostics (format: "l7c23-l7c31")
73
+ # Test 3: Get diagnostics for specific line range
107
74
  import re
108
75
 
109
76
  line_matches = re.findall(r"l(\d+)c", all_diag_text)
110
77
  if line_matches:
111
78
  first_error_line = int(line_matches[0])
112
-
113
- # Get diagnostics only up to that error line
114
79
  diagnostics = await client.call_tool(
115
80
  "lean_diagnostic_messages",
116
81
  {
@@ -120,21 +85,10 @@ async def test_diagnostic_messages_with_line_range(
120
85
  },
121
86
  )
122
87
  diag_text = result_text(diagnostics)
123
-
124
- # Should contain the first error
125
88
  assert "string" in diag_text.lower() or len(diag_text) > 0
126
- # Should be fewer diagnostics than all
127
89
  assert len(diag_text) < len(all_diag_text)
128
90
 
129
-
130
- @pytest.mark.asyncio
131
- async def test_diagnostic_messages_with_no_errors_in_range(
132
- mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
133
- diagnostic_file: Path,
134
- ) -> None:
135
- """Test getting diagnostic messages for a range with no errors."""
136
- async with mcp_client_factory() as client:
137
- # Get diagnostics only for lines 14-17 (valid theorem, should have no errors)
91
+ # Test 4: Get diagnostics for range with no errors (lines 14-17)
138
92
  diagnostics = await client.call_tool(
139
93
  "lean_diagnostic_messages",
140
94
  {
@@ -144,9 +98,6 @@ async def test_diagnostic_messages_with_no_errors_in_range(
144
98
  },
145
99
  )
146
100
  diag_text = result_text(diagnostics)
147
-
148
- # Should indicate no errors or be empty
149
- # The exact format depends on how the tool formats an empty result
150
101
  assert (
151
102
  "no" in diag_text.lower()
152
103
  or len(diag_text.strip()) == 0
@@ -154,7 +105,7 @@ async def test_diagnostic_messages_with_no_errors_in_range(
154
105
  )
155
106
 
156
107
 
157
- @pytest.fixture()
108
+ @pytest.fixture(scope="module")
158
109
  def declaration_diagnostic_file(test_project_path: Path) -> Path:
159
110
  """Create a test file with multiple declarations, some with errors."""
160
111
  path = test_project_path / "DeclarationDiagnosticTest.lean"
@@ -175,29 +126,28 @@ def declaration_diagnostic_file(test_project_path: Path) -> Path:
175
126
  def anotherValidFunction : String := "hello"
176
127
  """
177
128
  ).strip()
178
- path.write_text(content + "\n", encoding="utf-8")
129
+ if not path.exists() or path.read_text(encoding="utf-8") != content + "\n":
130
+ path.write_text(content + "\n", encoding="utf-8")
179
131
  return path
180
132
 
181
133
 
182
134
  @pytest.mark.asyncio
183
- async def test_diagnostic_messages_with_declaration_name_valid(
135
+ async def test_diagnostic_messages_declaration_filtering(
184
136
  mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
185
137
  declaration_diagnostic_file: Path,
186
138
  ) -> None:
187
- """Test filtering diagnostics by a specific declaration name."""
139
+ """Test all declaration-based filtering scenarios in one client session."""
188
140
  async with mcp_client_factory() as client:
189
- # Get all diagnostics first to verify file has errors
141
+ # Test 1: Get all diagnostics first to verify file has errors
190
142
  all_diagnostics = await client.call_tool(
191
143
  "lean_diagnostic_messages",
192
144
  {"file_path": str(declaration_diagnostic_file)},
193
145
  )
194
146
  all_diag_text = result_text(all_diagnostics)
195
-
196
- # File should have diagnostics (contains intentional errors)
197
147
  assert len(all_diag_text) > 0
198
148
  assert "string" in all_diag_text.lower() or "type" in all_diag_text.lower()
199
149
 
200
- # Get diagnostics for firstTheorem only
150
+ # Test 2: Get diagnostics for firstTheorem only
201
151
  diagnostics = await client.call_tool(
202
152
  "lean_diagnostic_messages",
203
153
  {
@@ -206,33 +156,10 @@ async def test_diagnostic_messages_with_declaration_name_valid(
206
156
  },
207
157
  )
208
158
  diag_text = result_text(diagnostics)
209
-
210
- # Should contain error from firstTheorem
211
- # The exact error message may vary, but should reference the theorem
212
159
  assert len(diag_text) > 0
213
-
214
- # Filtered diagnostics should be shorter than or equal to all diagnostics
215
160
  assert len(diag_text) <= len(all_diag_text)
216
161
 
217
-
218
- @pytest.mark.asyncio
219
- async def test_diagnostic_messages_with_declaration_name_with_errors(
220
- mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
221
- declaration_diagnostic_file: Path,
222
- ) -> None:
223
- """Test filtering by declaration that has type errors."""
224
- async with mcp_client_factory() as client:
225
- # Get all diagnostics first to verify file has errors
226
- all_diagnostics = await client.call_tool(
227
- "lean_diagnostic_messages",
228
- {"file_path": str(declaration_diagnostic_file)},
229
- )
230
- all_diag_text = result_text(all_diagnostics)
231
-
232
- # File should have diagnostics (contains intentional errors)
233
- assert len(all_diag_text) > 0
234
-
235
- # Get diagnostics for secondTheorem (has type error in statement)
162
+ # Test 3: Get diagnostics for secondTheorem (has type error in statement)
236
163
  diagnostics = await client.call_tool(
237
164
  "lean_diagnostic_messages",
238
165
  {
@@ -241,20 +168,10 @@ async def test_diagnostic_messages_with_declaration_name_with_errors(
241
168
  },
242
169
  )
243
170
  diag_text = result_text(diagnostics)
244
-
245
- # secondTheorem has type errors, should have diagnostics
246
171
  assert len(diag_text) > 0
247
172
  assert isinstance(diag_text, str)
248
173
 
249
-
250
- @pytest.mark.asyncio
251
- async def test_diagnostic_messages_with_declaration_name_no_errors(
252
- mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
253
- declaration_diagnostic_file: Path,
254
- ) -> None:
255
- """Test filtering by declaration that has no errors."""
256
- async with mcp_client_factory() as client:
257
- # Get diagnostics for validFunction (no errors)
174
+ # Test 4: Get diagnostics for validFunction (no errors)
258
175
  diagnostics = await client.call_tool(
259
176
  "lean_diagnostic_messages",
260
177
  {
@@ -263,8 +180,6 @@ async def test_diagnostic_messages_with_declaration_name_no_errors(
263
180
  },
264
181
  )
265
182
  diag_text = result_text(diagnostics)
266
-
267
- # Should indicate no errors or be empty
268
183
  assert (
269
184
  "no" in diag_text.lower()
270
185
  or len(diag_text.strip()) == 0
@@ -273,13 +188,13 @@ async def test_diagnostic_messages_with_declaration_name_no_errors(
273
188
 
274
189
 
275
190
  @pytest.mark.asyncio
276
- async def test_diagnostic_messages_with_nonexistent_declaration(
191
+ async def test_diagnostic_messages_declaration_edge_cases(
277
192
  mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
278
193
  declaration_diagnostic_file: Path,
279
194
  ) -> None:
280
- """Test error handling when declaration name doesn't exist."""
195
+ """Test edge cases for declaration-based filtering."""
281
196
  async with mcp_client_factory() as client:
282
- # Try to get diagnostics for non-existent declaration
197
+ # Test 1: Non-existent declaration
283
198
  result = await client.call_tool(
284
199
  "lean_diagnostic_messages",
285
200
  {
@@ -288,21 +203,10 @@ async def test_diagnostic_messages_with_nonexistent_declaration(
288
203
  },
289
204
  )
290
205
  result_str = result_text(result)
291
-
292
- # Should return error message about declaration not found
293
206
  assert "not found" in result_str.lower()
294
207
  assert "nonExistentTheorem" in result_str
295
208
 
296
-
297
- @pytest.mark.asyncio
298
- async def test_diagnostic_messages_declaration_name_takes_precedence(
299
- mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
300
- declaration_diagnostic_file: Path,
301
- ) -> None:
302
- """Test that declaration_name takes precedence over start_line/end_line."""
303
- async with mcp_client_factory() as client:
304
- # Use declaration_name with conflicting start_line/end_line
305
- # declaration_name should take precedence
209
+ # Test 2: declaration_name takes precedence over start_line/end_line
306
210
  diagnostics = await client.call_tool(
307
211
  "lean_diagnostic_messages",
308
212
  {
@@ -313,13 +217,11 @@ async def test_diagnostic_messages_declaration_name_takes_precedence(
313
217
  },
314
218
  )
315
219
  diag_text = result_text(diagnostics)
316
-
317
220
  # Should get diagnostics for firstTheorem, not lines 1-3
318
- # (which would only include imports)
319
221
  assert len(diag_text) > 0
320
222
 
321
223
 
322
- @pytest.fixture()
224
+ @pytest.fixture(scope="module")
323
225
  def kernel_error_file(test_project_path: Path) -> Path:
324
226
  """File with kernel error as first error (issue #63)."""
325
227
  path = test_project_path / "KernelErrorTest.lean"
@@ -334,7 +236,8 @@ def kernel_error_file(test_project_path: Path) -> Path:
334
236
  lemma test_lemma : False := by rfl
335
237
  """
336
238
  ).strip()
337
- path.write_text(content + "\n", encoding="utf-8")
239
+ if not path.exists() or path.read_text(encoding="utf-8") != content + "\n":
240
+ path.write_text(content + "\n", encoding="utf-8")
338
241
  return path
339
242
 
340
243
 
@@ -25,19 +25,34 @@ theorem cachedTheorem : cachedValue = 42 := by rfl
25
25
 
26
26
 
27
27
  @pytest.mark.asyncio
28
- async def test_disk_changes_detected(
28
+ async def test_file_caching(
29
29
  mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
30
30
  cache_test_file: Path,
31
31
  ) -> None:
32
- """Disk changes must be detected and reprocessed correctly."""
32
+ """Test file caching: disk changes detected and tools share state correctly."""
33
33
 
34
34
  async with mcp_client_factory() as client:
35
+ # Test 1: Multiple tools share file state correctly
36
+ await client.call_tool(
37
+ "lean_diagnostic_messages", {"file_path": str(cache_test_file)}
38
+ )
39
+ await client.call_tool(
40
+ "lean_goal", {"file_path": str(cache_test_file), "line": 5}
41
+ )
42
+ hover = await client.call_tool(
43
+ "lean_hover_info",
44
+ {"file_path": str(cache_test_file), "line": 3, "column": 5},
45
+ )
46
+ assert "cachedValue" in result_text(hover)
47
+
48
+ # Test 2: Disk changes are detected and reprocessed correctly
35
49
  goal1 = await client.call_tool(
36
50
  "lean_goal", {"file_path": str(cache_test_file), "line": 5}
37
51
  )
38
52
  result1 = result_text(goal1)
39
53
  assert "no goals" in result1.lower()
40
54
 
55
+ # Modify file on disk
41
56
  cache_test_file.write_text(
42
57
  """import Mathlib
43
58
 
@@ -48,6 +63,7 @@ theorem cachedTheorem : cachedValue = 42 := by sorry
48
63
  encoding="utf-8",
49
64
  )
50
65
 
66
+ # Verify change is detected
51
67
  goal2 = await client.call_tool(
52
68
  "lean_goal", {"file_path": str(cache_test_file), "line": 5}
53
69
  )
@@ -56,25 +72,3 @@ theorem cachedTheorem : cachedValue = 42 := by sorry
56
72
  assert "cachedValue = 42" in result2, (
57
73
  f"Should show goal at sorry, got: {result2}"
58
74
  )
59
-
60
-
61
- @pytest.mark.asyncio
62
- async def test_multiple_tools_share_file(
63
- mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
64
- cache_test_file: Path,
65
- ) -> None:
66
- """Different tools must reuse cached file state correctly."""
67
-
68
- async with mcp_client_factory() as client:
69
- await client.call_tool(
70
- "lean_diagnostic_messages", {"file_path": str(cache_test_file)}
71
- )
72
- await client.call_tool(
73
- "lean_goal", {"file_path": str(cache_test_file), "line": 5}
74
- )
75
- hover = await client.call_tool(
76
- "lean_hover_info",
77
- {"file_path": str(cache_test_file), "line": 3, "column": 5},
78
- )
79
-
80
- assert "cachedValue" in result_text(hover)
@@ -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