@treedy/lsp-mcp 0.1.7 → 0.1.9
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.
- package/dist/bundled/pyright/dist/index.d.ts +2 -0
- package/dist/bundled/pyright/dist/index.js +1620 -0
- package/dist/bundled/pyright/dist/index.js.map +26 -0
- package/dist/bundled/pyright/dist/lsp/connection.d.ts +71 -0
- package/dist/bundled/pyright/dist/lsp/document-manager.d.ts +67 -0
- package/dist/bundled/pyright/dist/lsp/index.d.ts +3 -0
- package/dist/bundled/pyright/dist/lsp/types.d.ts +55 -0
- package/dist/bundled/pyright/dist/lsp-client.d.ts +55 -0
- package/dist/bundled/pyright/dist/tools/completions.d.ts +18 -0
- package/dist/bundled/pyright/dist/tools/definition.d.ts +16 -0
- package/dist/bundled/pyright/dist/tools/diagnostics.d.ts +12 -0
- package/dist/bundled/pyright/dist/tools/hover.d.ts +16 -0
- package/dist/bundled/pyright/dist/tools/references.d.ts +16 -0
- package/dist/bundled/pyright/dist/tools/rename.d.ts +18 -0
- package/dist/bundled/pyright/dist/tools/search.d.ts +20 -0
- package/dist/bundled/pyright/dist/tools/signature-help.d.ts +16 -0
- package/dist/bundled/pyright/dist/tools/status.d.ts +14 -0
- package/dist/bundled/pyright/dist/tools/symbols.d.ts +17 -0
- package/dist/bundled/pyright/dist/tools/update-document.d.ts +14 -0
- package/dist/bundled/pyright/dist/utils/position.d.ts +33 -0
- package/dist/bundled/pyright/package.json +54 -0
- package/dist/bundled/python/README.md +230 -0
- package/dist/bundled/python/pyproject.toml +61 -0
- package/dist/bundled/python/src/rope_mcp/__init__.py +3 -0
- package/dist/bundled/python/src/rope_mcp/config.py +444 -0
- package/dist/bundled/python/src/rope_mcp/lsp/__init__.py +15 -0
- package/dist/bundled/python/src/rope_mcp/lsp/client.py +863 -0
- package/dist/bundled/python/src/rope_mcp/lsp/types.py +83 -0
- package/dist/bundled/python/src/rope_mcp/pyright_client.py +147 -0
- package/dist/bundled/python/src/rope_mcp/rope_client.py +198 -0
- package/dist/bundled/python/src/rope_mcp/server.py +1217 -0
- package/dist/bundled/python/src/rope_mcp/tools/__init__.py +24 -0
- package/dist/bundled/python/src/rope_mcp/tools/change_signature.py +184 -0
- package/dist/bundled/python/src/rope_mcp/tools/completions.py +84 -0
- package/dist/bundled/python/src/rope_mcp/tools/definition.py +51 -0
- package/dist/bundled/python/src/rope_mcp/tools/diagnostics.py +18 -0
- package/dist/bundled/python/src/rope_mcp/tools/hover.py +49 -0
- package/dist/bundled/python/src/rope_mcp/tools/move.py +81 -0
- package/dist/bundled/python/src/rope_mcp/tools/references.py +60 -0
- package/dist/bundled/python/src/rope_mcp/tools/rename.py +61 -0
- package/dist/bundled/python/src/rope_mcp/tools/search.py +128 -0
- package/dist/bundled/python/src/rope_mcp/tools/symbols.py +118 -0
- package/dist/bundled/python/uv.lock +979 -0
- package/dist/bundled/typescript/dist/index.js +29772 -0
- package/dist/bundled/typescript/dist/index.js.map +211 -0
- package/dist/bundled/typescript/package.json +46 -0
- package/dist/bundled/vue/dist/index.d.ts +8 -0
- package/dist/bundled/vue/dist/index.js +21176 -0
- package/dist/bundled/vue/dist/ts-vue-service.d.ts +67 -0
- package/dist/bundled/vue/dist/vue-service.d.ts +160 -0
- package/dist/bundled/vue/package.json +45 -0
- package/dist/index.js +695 -352
- package/dist/index.js.map +6 -6
- package/package.json +1 -1
|
@@ -0,0 +1,1217 @@
|
|
|
1
|
+
"""MCP Server for Python code analysis using Rope and Pyright."""
|
|
2
|
+
|
|
3
|
+
import atexit
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
from importlib.metadata import version as pkg_version
|
|
9
|
+
from typing import Literal, Optional
|
|
10
|
+
|
|
11
|
+
from mcp.server.fastmcp import FastMCP
|
|
12
|
+
|
|
13
|
+
# Get package version at runtime
|
|
14
|
+
try:
|
|
15
|
+
__version__ = pkg_version("python-lsp-mcp")
|
|
16
|
+
except Exception:
|
|
17
|
+
__version__ = "0.3.0" # Fallback
|
|
18
|
+
|
|
19
|
+
from .config import (
|
|
20
|
+
Backend,
|
|
21
|
+
get_config,
|
|
22
|
+
SHARED_TOOLS,
|
|
23
|
+
set_python_path as config_set_python_path,
|
|
24
|
+
get_python_path_status,
|
|
25
|
+
set_active_workspace,
|
|
26
|
+
get_active_workspace,
|
|
27
|
+
resolve_file_path,
|
|
28
|
+
)
|
|
29
|
+
from .rope_client import get_client as get_rope_client
|
|
30
|
+
from .lsp import get_lsp_client, close_all_clients, close_all_clients as close_lsp_clients
|
|
31
|
+
from .tools import (
|
|
32
|
+
do_rename,
|
|
33
|
+
do_move,
|
|
34
|
+
do_change_signature,
|
|
35
|
+
get_function_signature,
|
|
36
|
+
get_completions as rope_completions,
|
|
37
|
+
get_definition as rope_definition,
|
|
38
|
+
get_hover as rope_hover,
|
|
39
|
+
get_references as rope_references,
|
|
40
|
+
get_symbols as rope_symbols,
|
|
41
|
+
get_search,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Create the MCP server
|
|
45
|
+
mcp = FastMCP("python-lsp-mcp")
|
|
46
|
+
|
|
47
|
+
# Register cleanup on exit
|
|
48
|
+
atexit.register(close_all_clients)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@mcp.tool()
|
|
52
|
+
def switch_workspace(path: str) -> str:
|
|
53
|
+
"""Switch the active workspace to a new project directory.
|
|
54
|
+
|
|
55
|
+
This will:
|
|
56
|
+
1. Set the new active workspace path.
|
|
57
|
+
2. Close all existing language server instances to save resources.
|
|
58
|
+
3. The next tool call will start a new language server for the new workspace.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
path: Absolute path to the new project root directory.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
JSON string with confirmation of the switch.
|
|
65
|
+
"""
|
|
66
|
+
abs_path = os.path.abspath(path)
|
|
67
|
+
if not os.path.isdir(abs_path):
|
|
68
|
+
return json.dumps(
|
|
69
|
+
{"error": "Invalid Path", "message": f"'{path}' is not a directory."},
|
|
70
|
+
indent=2,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Close all existing clients (LSP and Rope)
|
|
74
|
+
close_lsp_clients()
|
|
75
|
+
get_rope_client().close_all()
|
|
76
|
+
|
|
77
|
+
# Set new active workspace
|
|
78
|
+
new_workspace = set_active_workspace(abs_path)
|
|
79
|
+
|
|
80
|
+
return json.dumps(
|
|
81
|
+
{
|
|
82
|
+
"success": True,
|
|
83
|
+
"message": f"Switched active workspace to: {new_workspace}",
|
|
84
|
+
"workspace": new_workspace,
|
|
85
|
+
"info": "All previous language server instances have been closed.",
|
|
86
|
+
},
|
|
87
|
+
indent=2,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _find_workspace(file_path: str) -> str:
|
|
92
|
+
"""Find workspace root for a file."""
|
|
93
|
+
# If we have an active workspace, always use it
|
|
94
|
+
active = get_active_workspace()
|
|
95
|
+
if active:
|
|
96
|
+
return active
|
|
97
|
+
|
|
98
|
+
client = get_rope_client()
|
|
99
|
+
return client.find_workspace_for_file(file_path)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _get_effective_backend(tool: str, backend: Optional[str]) -> Backend:
|
|
103
|
+
"""Get the effective backend for a tool."""
|
|
104
|
+
if backend:
|
|
105
|
+
try:
|
|
106
|
+
return Backend(backend.lower())
|
|
107
|
+
except ValueError:
|
|
108
|
+
pass
|
|
109
|
+
return get_config().get_backend_for(tool)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@mcp.tool()
|
|
113
|
+
def hover(
|
|
114
|
+
file: str,
|
|
115
|
+
line: int,
|
|
116
|
+
column: int,
|
|
117
|
+
backend: Optional[Literal["rope", "pyright"]] = None,
|
|
118
|
+
) -> str:
|
|
119
|
+
"""Get documentation for the symbol at the given position.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
file: Path to the Python file (absolute or relative to active workspace)
|
|
123
|
+
line: 1-based line number
|
|
124
|
+
column: 1-based column number
|
|
125
|
+
backend: Backend to use (rope/pyright). Default: from config or 'rope'
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
JSON string with documentation or error message
|
|
129
|
+
"""
|
|
130
|
+
# Resolve and validate path
|
|
131
|
+
abs_file, error = resolve_file_path(file)
|
|
132
|
+
if error:
|
|
133
|
+
return json.dumps(error, indent=2)
|
|
134
|
+
|
|
135
|
+
effective_backend = _get_effective_backend("hover", backend)
|
|
136
|
+
|
|
137
|
+
if effective_backend == Backend.PYRIGHT:
|
|
138
|
+
try:
|
|
139
|
+
workspace = _find_workspace(abs_file)
|
|
140
|
+
client = get_lsp_client(workspace)
|
|
141
|
+
result = client.hover(abs_file, line, column)
|
|
142
|
+
if result:
|
|
143
|
+
return json.dumps(
|
|
144
|
+
{"contents": result.get("contents", ""), "backend": "pyright"},
|
|
145
|
+
indent=2,
|
|
146
|
+
)
|
|
147
|
+
return json.dumps(
|
|
148
|
+
{"contents": None, "message": "No hover info", "backend": "pyright"},
|
|
149
|
+
indent=2,
|
|
150
|
+
)
|
|
151
|
+
except Exception as e:
|
|
152
|
+
return json.dumps({"error": str(e), "backend": "pyright"}, indent=2)
|
|
153
|
+
else:
|
|
154
|
+
result = rope_hover(abs_file, line, column)
|
|
155
|
+
result["backend"] = "rope"
|
|
156
|
+
return json.dumps(result, indent=2)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@mcp.tool()
|
|
160
|
+
def definition(
|
|
161
|
+
file: str,
|
|
162
|
+
line: int,
|
|
163
|
+
column: int,
|
|
164
|
+
backend: Optional[Literal["rope", "pyright"]] = None,
|
|
165
|
+
) -> str:
|
|
166
|
+
"""Get the definition location for the symbol at the given position.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
file: Path to the Python file (absolute or relative to active workspace)
|
|
170
|
+
line: 1-based line number
|
|
171
|
+
column: 1-based column number
|
|
172
|
+
backend: Backend to use (rope/pyright). Default: from config or 'rope'
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
JSON string with definition location or error message
|
|
176
|
+
"""
|
|
177
|
+
# Resolve and validate path
|
|
178
|
+
abs_file, error = resolve_file_path(file)
|
|
179
|
+
if error:
|
|
180
|
+
return json.dumps(error, indent=2)
|
|
181
|
+
|
|
182
|
+
effective_backend = _get_effective_backend("definition", backend)
|
|
183
|
+
|
|
184
|
+
if effective_backend == Backend.PYRIGHT:
|
|
185
|
+
try:
|
|
186
|
+
workspace = _find_workspace(abs_file)
|
|
187
|
+
client = get_lsp_client(workspace)
|
|
188
|
+
locations = client.definition(abs_file, line, column)
|
|
189
|
+
if locations:
|
|
190
|
+
result = locations[0]
|
|
191
|
+
result["backend"] = "pyright"
|
|
192
|
+
return json.dumps(result, indent=2)
|
|
193
|
+
return json.dumps(
|
|
194
|
+
{"file": None, "message": "No definition found", "backend": "pyright"},
|
|
195
|
+
indent=2,
|
|
196
|
+
)
|
|
197
|
+
except Exception as e:
|
|
198
|
+
return json.dumps({"error": str(e), "backend": "pyright"}, indent=2)
|
|
199
|
+
else:
|
|
200
|
+
result = rope_definition(abs_file, line, column)
|
|
201
|
+
result["backend"] = "rope"
|
|
202
|
+
return json.dumps(result, indent=2)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@mcp.tool()
|
|
206
|
+
def references(
|
|
207
|
+
file: str,
|
|
208
|
+
line: int,
|
|
209
|
+
column: int,
|
|
210
|
+
backend: Optional[Literal["rope", "pyright"]] = None,
|
|
211
|
+
) -> str:
|
|
212
|
+
"""Find all references to the symbol at the given position.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
file: Path to the Python file (absolute or relative to active workspace)
|
|
216
|
+
line: 1-based line number
|
|
217
|
+
column: 1-based column number
|
|
218
|
+
backend: Backend to use (rope/pyright). Default: from config or 'rope'
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
JSON string with list of references or error message
|
|
222
|
+
"""
|
|
223
|
+
# Resolve and validate path
|
|
224
|
+
abs_file, error = resolve_file_path(file)
|
|
225
|
+
if error:
|
|
226
|
+
return json.dumps(error, indent=2)
|
|
227
|
+
|
|
228
|
+
effective_backend = _get_effective_backend("references", backend)
|
|
229
|
+
|
|
230
|
+
if effective_backend == Backend.PYRIGHT:
|
|
231
|
+
try:
|
|
232
|
+
workspace = _find_workspace(abs_file)
|
|
233
|
+
client = get_lsp_client(workspace)
|
|
234
|
+
refs = client.references(abs_file, line, column)
|
|
235
|
+
return json.dumps(
|
|
236
|
+
{"references": refs, "count": len(refs), "backend": "pyright"}, indent=2
|
|
237
|
+
)
|
|
238
|
+
except Exception as e:
|
|
239
|
+
return json.dumps({"error": str(e), "backend": "pyright"}, indent=2)
|
|
240
|
+
else:
|
|
241
|
+
result = rope_references(abs_file, line, column)
|
|
242
|
+
result["backend"] = "rope"
|
|
243
|
+
return json.dumps(result, indent=2)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@mcp.tool()
|
|
247
|
+
def completions(
|
|
248
|
+
file: str,
|
|
249
|
+
line: int,
|
|
250
|
+
column: int,
|
|
251
|
+
backend: Optional[Literal["rope", "pyright"]] = None,
|
|
252
|
+
) -> str:
|
|
253
|
+
"""Get code completion suggestions at the given position.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
file: Path to the Python file (absolute or relative to active workspace)
|
|
257
|
+
line: 1-based line number
|
|
258
|
+
column: 1-based column number
|
|
259
|
+
backend: Backend to use (rope/pyright). Default: from config or 'rope'
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
JSON string with completion items or error message
|
|
263
|
+
"""
|
|
264
|
+
# Resolve and validate path
|
|
265
|
+
abs_file, error = resolve_file_path(file)
|
|
266
|
+
if error:
|
|
267
|
+
return json.dumps(error, indent=2)
|
|
268
|
+
|
|
269
|
+
effective_backend = _get_effective_backend("completions", backend)
|
|
270
|
+
|
|
271
|
+
if effective_backend == Backend.PYRIGHT:
|
|
272
|
+
try:
|
|
273
|
+
workspace = _find_workspace(abs_file)
|
|
274
|
+
client = get_lsp_client(workspace)
|
|
275
|
+
items = client.completions(abs_file, line, column)
|
|
276
|
+
return json.dumps(
|
|
277
|
+
{"completions": items, "count": len(items), "backend": "pyright"},
|
|
278
|
+
indent=2,
|
|
279
|
+
)
|
|
280
|
+
except Exception as e:
|
|
281
|
+
return json.dumps({"error": str(e), "backend": "pyright"}, indent=2)
|
|
282
|
+
else:
|
|
283
|
+
result = rope_completions(abs_file, line, column)
|
|
284
|
+
result["backend"] = "rope"
|
|
285
|
+
return json.dumps(result, indent=2)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@mcp.tool()
|
|
289
|
+
def symbols(
|
|
290
|
+
file: str,
|
|
291
|
+
query: Optional[str] = None,
|
|
292
|
+
backend: Optional[Literal["rope", "pyright"]] = None,
|
|
293
|
+
) -> str:
|
|
294
|
+
"""Get symbols from a Python file.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
file: Path to the Python file (absolute or relative to active workspace)
|
|
298
|
+
query: Optional filter query for symbol names
|
|
299
|
+
backend: Backend to use (rope/pyright). Default: from config or 'rope'
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
JSON string with list of symbols or error message
|
|
303
|
+
"""
|
|
304
|
+
# Resolve and validate path
|
|
305
|
+
abs_file, error = resolve_file_path(file)
|
|
306
|
+
if error:
|
|
307
|
+
return json.dumps(error, indent=2)
|
|
308
|
+
|
|
309
|
+
effective_backend = _get_effective_backend("symbols", backend)
|
|
310
|
+
|
|
311
|
+
if effective_backend == Backend.PYRIGHT:
|
|
312
|
+
try:
|
|
313
|
+
workspace = _find_workspace(abs_file)
|
|
314
|
+
client = get_lsp_client(workspace)
|
|
315
|
+
syms = client.document_symbols(abs_file)
|
|
316
|
+
# Filter by query if provided
|
|
317
|
+
if query:
|
|
318
|
+
query_lower = query.lower()
|
|
319
|
+
syms = [s for s in syms if query_lower in s["name"].lower()]
|
|
320
|
+
return json.dumps(
|
|
321
|
+
{
|
|
322
|
+
"symbols": syms,
|
|
323
|
+
"count": len(syms),
|
|
324
|
+
"file": abs_file,
|
|
325
|
+
"backend": "pyright",
|
|
326
|
+
},
|
|
327
|
+
indent=2,
|
|
328
|
+
)
|
|
329
|
+
except Exception as e:
|
|
330
|
+
return json.dumps({"error": str(e), "backend": "pyright"}, indent=2)
|
|
331
|
+
else:
|
|
332
|
+
result = rope_symbols(abs_file, query)
|
|
333
|
+
result["backend"] = "rope"
|
|
334
|
+
return json.dumps(result, indent=2)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@mcp.tool()
|
|
338
|
+
def rename(file: str, line: int, column: int, new_name: str) -> str:
|
|
339
|
+
"""Rename the symbol at the given position.
|
|
340
|
+
|
|
341
|
+
This will modify files on disk to rename all occurrences of the symbol.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
file: Path to the Python file (absolute or relative to active workspace)
|
|
345
|
+
line: 1-based line number
|
|
346
|
+
column: 1-based column number
|
|
347
|
+
new_name: The new name for the symbol
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
JSON string with changes made or error message
|
|
351
|
+
"""
|
|
352
|
+
# Resolve and validate path
|
|
353
|
+
abs_file, error = resolve_file_path(file)
|
|
354
|
+
if error:
|
|
355
|
+
return json.dumps(error, indent=2)
|
|
356
|
+
|
|
357
|
+
# Determine backend (Pyright is now supported for rename)
|
|
358
|
+
# Since we moved rename to SHARED_TOOLS, we can use config logic
|
|
359
|
+
effective_backend = get_config().get_backend_for("rename")
|
|
360
|
+
|
|
361
|
+
if effective_backend == Backend.PYRIGHT:
|
|
362
|
+
try:
|
|
363
|
+
workspace = _find_workspace(abs_file)
|
|
364
|
+
client = get_lsp_client(workspace)
|
|
365
|
+
edit = client.rename(abs_file, line, column, new_name)
|
|
366
|
+
|
|
367
|
+
if not edit:
|
|
368
|
+
return json.dumps({
|
|
369
|
+
"error": "Rename returned no changes. Symbol might not be renamable or Pyright failed.",
|
|
370
|
+
"backend": "pyright"
|
|
371
|
+
}, indent=2)
|
|
372
|
+
|
|
373
|
+
# Reuse the helper from run_code_action
|
|
374
|
+
_apply_workspace_edit(edit)
|
|
375
|
+
|
|
376
|
+
return json.dumps({
|
|
377
|
+
"success": True,
|
|
378
|
+
"message": f"Renamed symbol to '{new_name}'",
|
|
379
|
+
"backend": "pyright",
|
|
380
|
+
"changes": edit.get("changes") or edit.get("documentChanges")
|
|
381
|
+
}, indent=2)
|
|
382
|
+
|
|
383
|
+
except Exception as e:
|
|
384
|
+
return json.dumps({"error": str(e), "backend": "pyright"}, indent=2)
|
|
385
|
+
else:
|
|
386
|
+
# Rope fallback
|
|
387
|
+
result = do_rename(abs_file, line, column, new_name)
|
|
388
|
+
result["backend"] = "rope"
|
|
389
|
+
return json.dumps(result, indent=2)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@mcp.tool()
|
|
393
|
+
def move(
|
|
394
|
+
file: str,
|
|
395
|
+
line: int,
|
|
396
|
+
column: int,
|
|
397
|
+
destination: str,
|
|
398
|
+
preview: bool = False,
|
|
399
|
+
) -> str:
|
|
400
|
+
"""Move a function or class to another module.
|
|
401
|
+
|
|
402
|
+
This will modify files on disk to move the symbol and update all imports.
|
|
403
|
+
Uses Rope backend for refactoring.
|
|
404
|
+
|
|
405
|
+
Args:
|
|
406
|
+
file: Path to the Python file containing the symbol
|
|
407
|
+
line: 1-based line number of the symbol to move
|
|
408
|
+
column: 1-based column number of the symbol
|
|
409
|
+
destination: Destination module path (e.g., "mypackage.utils" or "utils.py")
|
|
410
|
+
preview: If True, only show what would change without applying
|
|
411
|
+
|
|
412
|
+
Returns:
|
|
413
|
+
JSON string with changes made or error message
|
|
414
|
+
"""
|
|
415
|
+
# Resolve and validate path
|
|
416
|
+
abs_file, error = resolve_file_path(file)
|
|
417
|
+
if error:
|
|
418
|
+
return json.dumps(error, indent=2)
|
|
419
|
+
|
|
420
|
+
result = do_move(abs_file, line, column, destination, resources_only=preview)
|
|
421
|
+
result["backend"] = "rope"
|
|
422
|
+
return json.dumps(result, indent=2)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
@mcp.tool()
|
|
426
|
+
def change_signature(
|
|
427
|
+
file: str,
|
|
428
|
+
line: int,
|
|
429
|
+
column: int,
|
|
430
|
+
new_params: Optional[list[str]] = None,
|
|
431
|
+
add_param: Optional[str] = None,
|
|
432
|
+
add_param_default: Optional[str] = None,
|
|
433
|
+
add_param_index: Optional[int] = None,
|
|
434
|
+
remove_param: Optional[str] = None,
|
|
435
|
+
preview: bool = False,
|
|
436
|
+
) -> str:
|
|
437
|
+
"""Change the signature of a function.
|
|
438
|
+
|
|
439
|
+
This will modify files on disk to update the function and all call sites.
|
|
440
|
+
Uses Rope backend for refactoring.
|
|
441
|
+
|
|
442
|
+
Args:
|
|
443
|
+
file: Path to the Python file (absolute or relative to active workspace)
|
|
444
|
+
line: 1-based line number of the function
|
|
445
|
+
column: 1-based column number of the function
|
|
446
|
+
new_params: New parameter order, e.g. ["self", "b", "a"] to reorder
|
|
447
|
+
add_param: Name of parameter to add
|
|
448
|
+
add_param_default: Default value for added parameter
|
|
449
|
+
add_param_index: Index where to insert new param (None = append)
|
|
450
|
+
remove_param: Name of parameter to remove
|
|
451
|
+
preview: If True, only show what would change without applying
|
|
452
|
+
|
|
453
|
+
Returns:
|
|
454
|
+
JSON string with changes made or error message
|
|
455
|
+
|
|
456
|
+
Examples:
|
|
457
|
+
# Reorder: def foo(a, b) -> def foo(b, a)
|
|
458
|
+
change_signature(file, line, col, new_params=["self", "b", "a"])
|
|
459
|
+
|
|
460
|
+
# Add param: def foo(a) -> def foo(a, b=None)
|
|
461
|
+
change_signature(file, line, col, add_param="b", add_param_default="None")
|
|
462
|
+
|
|
463
|
+
# Remove param: def foo(a, b) -> def foo(a)
|
|
464
|
+
change_signature(file, line, col, remove_param="b")
|
|
465
|
+
"""
|
|
466
|
+
# Resolve and validate path
|
|
467
|
+
abs_file, error = resolve_file_path(file)
|
|
468
|
+
if error:
|
|
469
|
+
return json.dumps(error, indent=2)
|
|
470
|
+
|
|
471
|
+
# Build add_param dict if specified
|
|
472
|
+
add_param_dict = None
|
|
473
|
+
if add_param:
|
|
474
|
+
add_param_dict = {
|
|
475
|
+
"name": add_param,
|
|
476
|
+
"default": add_param_default,
|
|
477
|
+
"index": add_param_index,
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
result = do_change_signature(
|
|
481
|
+
abs_file,
|
|
482
|
+
line,
|
|
483
|
+
column,
|
|
484
|
+
new_params=new_params,
|
|
485
|
+
add_param=add_param_dict,
|
|
486
|
+
remove_param=remove_param,
|
|
487
|
+
resources_only=preview,
|
|
488
|
+
)
|
|
489
|
+
result["backend"] = "rope"
|
|
490
|
+
return json.dumps(result, indent=2)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
@mcp.tool()
|
|
494
|
+
def function_signature(file: str, line: int, column: int) -> str:
|
|
495
|
+
"""Get the current signature of a function.
|
|
496
|
+
|
|
497
|
+
Useful for inspecting function parameters before changing the signature.
|
|
498
|
+
|
|
499
|
+
Args:
|
|
500
|
+
file: Path to the Python file (absolute or relative to active workspace)
|
|
501
|
+
line: 1-based line number of the function
|
|
502
|
+
column: 1-based column number of the function
|
|
503
|
+
|
|
504
|
+
Returns:
|
|
505
|
+
JSON string with function signature info
|
|
506
|
+
"""
|
|
507
|
+
# Resolve and validate path
|
|
508
|
+
abs_file, error = resolve_file_path(file)
|
|
509
|
+
if error:
|
|
510
|
+
return json.dumps(error, indent=2)
|
|
511
|
+
|
|
512
|
+
result = get_function_signature(abs_file, line, column)
|
|
513
|
+
result["backend"] = "rope"
|
|
514
|
+
return json.dumps(result, indent=2)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
@mcp.tool()
|
|
518
|
+
def diagnostics(path: str) -> str:
|
|
519
|
+
"""Get type errors and warnings for a Python file.
|
|
520
|
+
|
|
521
|
+
Uses Pyright LSP for type checking.
|
|
522
|
+
|
|
523
|
+
Args:
|
|
524
|
+
path: Path to a Python file (absolute or relative to active workspace)
|
|
525
|
+
|
|
526
|
+
Returns:
|
|
527
|
+
JSON string with diagnostics or error message
|
|
528
|
+
"""
|
|
529
|
+
# Resolve and validate path
|
|
530
|
+
abs_path, error = resolve_file_path(path)
|
|
531
|
+
if error:
|
|
532
|
+
return json.dumps(error, indent=2)
|
|
533
|
+
|
|
534
|
+
try:
|
|
535
|
+
workspace = _find_workspace(abs_path)
|
|
536
|
+
client = get_lsp_client(workspace)
|
|
537
|
+
result = client.get_diagnostics(abs_path)
|
|
538
|
+
|
|
539
|
+
# Check for config file to warn about permissive defaults
|
|
540
|
+
has_config = False
|
|
541
|
+
for cfg in ["pyrightconfig.json", "pyproject.toml"]:
|
|
542
|
+
if os.path.exists(os.path.join(workspace, cfg)):
|
|
543
|
+
has_config = True
|
|
544
|
+
break
|
|
545
|
+
|
|
546
|
+
if not has_config:
|
|
547
|
+
if "summary" not in result:
|
|
548
|
+
result["summary"] = {}
|
|
549
|
+
result["summary"]["note"] = (
|
|
550
|
+
"No 'pyrightconfig.json' or 'pyproject.toml' found. "
|
|
551
|
+
"Using permissive defaults. Create a config file to enable stricter checks (e.g. unused imports)."
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
return json.dumps(result, indent=2)
|
|
555
|
+
except Exception as e:
|
|
556
|
+
return json.dumps({"error": str(e), "backend": "pyright-lsp"}, indent=2)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
@mcp.tool()
|
|
560
|
+
def signature_help(file: str, line: int, column: int) -> str:
|
|
561
|
+
"""Get function signature information at the given position.
|
|
562
|
+
|
|
563
|
+
Uses Pyright backend for accurate signature information.
|
|
564
|
+
|
|
565
|
+
Args:
|
|
566
|
+
file: Path to the Python file (absolute or relative to active workspace)
|
|
567
|
+
line: 1-based line number
|
|
568
|
+
column: 1-based column number
|
|
569
|
+
|
|
570
|
+
Returns:
|
|
571
|
+
JSON string with signature help or error message
|
|
572
|
+
"""
|
|
573
|
+
# Resolve and validate path
|
|
574
|
+
abs_file, error = resolve_file_path(file)
|
|
575
|
+
if error:
|
|
576
|
+
return json.dumps(error, indent=2)
|
|
577
|
+
|
|
578
|
+
try:
|
|
579
|
+
workspace = _find_workspace(abs_file)
|
|
580
|
+
client = get_lsp_client(workspace)
|
|
581
|
+
result = client.signature_help(abs_file, line, column)
|
|
582
|
+
if result:
|
|
583
|
+
result["backend"] = "pyright"
|
|
584
|
+
return json.dumps(result, indent=2)
|
|
585
|
+
return json.dumps(
|
|
586
|
+
{"message": "No signature help available", "backend": "pyright"}, indent=2
|
|
587
|
+
)
|
|
588
|
+
except Exception as e:
|
|
589
|
+
return json.dumps({"error": str(e), "backend": "pyright"}, indent=2)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
@mcp.tool()
|
|
593
|
+
def update_document(file: str, content: str) -> str:
|
|
594
|
+
"""Update file content for incremental analysis without writing to disk.
|
|
595
|
+
|
|
596
|
+
Useful for testing code changes before saving.
|
|
597
|
+
Uses Pyright backend for incremental updates.
|
|
598
|
+
|
|
599
|
+
Args:
|
|
600
|
+
file: Path to the Python file (absolute or relative to active workspace)
|
|
601
|
+
content: New file content
|
|
602
|
+
|
|
603
|
+
Returns:
|
|
604
|
+
JSON string with confirmation
|
|
605
|
+
"""
|
|
606
|
+
# Resolve and validate path
|
|
607
|
+
abs_file, error = resolve_file_path(file)
|
|
608
|
+
if error:
|
|
609
|
+
return json.dumps(error, indent=2)
|
|
610
|
+
|
|
611
|
+
try:
|
|
612
|
+
workspace = _find_workspace(abs_file)
|
|
613
|
+
client = get_lsp_client(workspace)
|
|
614
|
+
client.update_document(abs_file, content)
|
|
615
|
+
return json.dumps(
|
|
616
|
+
{"success": True, "file": abs_file, "backend": "pyright"}, indent=2
|
|
617
|
+
)
|
|
618
|
+
except Exception as e:
|
|
619
|
+
return json.dumps({"error": str(e), "backend": "pyright"}, indent=2)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
@mcp.tool()
|
|
624
|
+
def inlay_hints(file: str) -> str:
|
|
625
|
+
"""Get inlay hints (type annotations, parameter names) for a file.
|
|
626
|
+
|
|
627
|
+
Args:
|
|
628
|
+
file: Path to the Python file
|
|
629
|
+
|
|
630
|
+
Returns:
|
|
631
|
+
JSON string with list of hints
|
|
632
|
+
"""
|
|
633
|
+
# Resolve and validate path
|
|
634
|
+
abs_file, error = resolve_file_path(file)
|
|
635
|
+
if error:
|
|
636
|
+
return json.dumps(error, indent=2)
|
|
637
|
+
|
|
638
|
+
try:
|
|
639
|
+
workspace = _find_workspace(abs_file)
|
|
640
|
+
client = get_lsp_client(workspace)
|
|
641
|
+
|
|
642
|
+
# Read file to get line count
|
|
643
|
+
with open(abs_file, "r", encoding="utf-8") as f:
|
|
644
|
+
lines = f.readlines()
|
|
645
|
+
line_count = len(lines)
|
|
646
|
+
last_col = len(lines[-1]) if lines else 0
|
|
647
|
+
|
|
648
|
+
hints = client.inlay_hint(abs_file, 1, 1, line_count + 1, last_col + 1)
|
|
649
|
+
|
|
650
|
+
return json.dumps({"hints": hints, "count": len(hints)}, indent=2)
|
|
651
|
+
|
|
652
|
+
except Exception as e:
|
|
653
|
+
return json.dumps({"error": str(e), "backend": "pyright"}, indent=2)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
@mcp.tool()
|
|
657
|
+
def code_action(file: str, line: int, column: int) -> str:
|
|
658
|
+
"""Get available code actions (Quick Fixes and Refactorings).
|
|
659
|
+
|
|
660
|
+
Args:
|
|
661
|
+
file: Path to the Python file
|
|
662
|
+
line: 1-based line number
|
|
663
|
+
column: 1-based column number
|
|
664
|
+
|
|
665
|
+
Returns:
|
|
666
|
+
JSON string with list of actions
|
|
667
|
+
"""
|
|
668
|
+
# Resolve and validate path
|
|
669
|
+
abs_file, error = resolve_file_path(file)
|
|
670
|
+
if error:
|
|
671
|
+
return json.dumps(error, indent=2)
|
|
672
|
+
|
|
673
|
+
try:
|
|
674
|
+
workspace = _find_workspace(abs_file)
|
|
675
|
+
client = get_lsp_client(workspace)
|
|
676
|
+
|
|
677
|
+
# 1. Get diagnostics to provide context
|
|
678
|
+
# In a real IDE, we'd pass active diagnostics. Here we fetch them.
|
|
679
|
+
# Note: This might be slow if we do full check.
|
|
680
|
+
# For Pyright, we can pass empty diagnostics context and it might still return some actions,
|
|
681
|
+
# but usually it needs context.
|
|
682
|
+
# Let's try to get diagnostics for this file first.
|
|
683
|
+
# We can't easily get diagnostics without running the full check which is slow.
|
|
684
|
+
# For now, let's pass empty list and see what Pyright gives (e.g. Organize Imports usually works).
|
|
685
|
+
|
|
686
|
+
# Actually, let's try to fetch diagnostics if possible, or just pass context.
|
|
687
|
+
diagnostics = [] # Placeholder
|
|
688
|
+
|
|
689
|
+
actions = client.code_action(abs_file, line, column, line, column, diagnostics)
|
|
690
|
+
|
|
691
|
+
# Format for MCP
|
|
692
|
+
formatted = []
|
|
693
|
+
for action in actions:
|
|
694
|
+
formatted.append({
|
|
695
|
+
"title": action.get("title", "Unknown Action"),
|
|
696
|
+
"kind": action.get("kind", "quickfix"),
|
|
697
|
+
"command": action.get("command"),
|
|
698
|
+
"edit": action.get("edit"),
|
|
699
|
+
# We need to store enough info to run it
|
|
700
|
+
"data": action
|
|
701
|
+
})
|
|
702
|
+
|
|
703
|
+
return json.dumps({"actions": formatted, "count": len(formatted)}, indent=2)
|
|
704
|
+
|
|
705
|
+
except Exception as e:
|
|
706
|
+
return json.dumps({"error": str(e), "backend": "pyright"}, indent=2)
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
@mcp.tool()
|
|
710
|
+
def run_code_action(
|
|
711
|
+
file: str,
|
|
712
|
+
line: int,
|
|
713
|
+
column: int,
|
|
714
|
+
title: str
|
|
715
|
+
) -> str:
|
|
716
|
+
"""Run a specific code action.
|
|
717
|
+
|
|
718
|
+
Args:
|
|
719
|
+
file: Path to the Python file
|
|
720
|
+
line: 1-based line number
|
|
721
|
+
column: 1-based column number
|
|
722
|
+
title: The title of the action to run (must match one from code_action)
|
|
723
|
+
|
|
724
|
+
Returns:
|
|
725
|
+
JSON string with result
|
|
726
|
+
"""
|
|
727
|
+
# Resolve and validate path
|
|
728
|
+
abs_file, error = resolve_file_path(file)
|
|
729
|
+
if error:
|
|
730
|
+
return json.dumps(error, indent=2)
|
|
731
|
+
|
|
732
|
+
try:
|
|
733
|
+
workspace = _find_workspace(abs_file)
|
|
734
|
+
client = get_lsp_client(workspace)
|
|
735
|
+
|
|
736
|
+
# We need to fetch actions again to find the matching one and its edit/command
|
|
737
|
+
actions = client.code_action(abs_file, line, column, line, column, [])
|
|
738
|
+
|
|
739
|
+
target_action = next((a for a in actions if a.get("title") == title), None)
|
|
740
|
+
if not target_action:
|
|
741
|
+
return json.dumps({"error": f"Action '{title}' not found. It may have expired."}, indent=2)
|
|
742
|
+
|
|
743
|
+
# Handle WorkspaceEdit
|
|
744
|
+
if "edit" in target_action:
|
|
745
|
+
_apply_workspace_edit(target_action["edit"])
|
|
746
|
+
return json.dumps({"success": True, "message": "Applied workspace edit"}, indent=2)
|
|
747
|
+
|
|
748
|
+
# Handle Command
|
|
749
|
+
if "command" in target_action:
|
|
750
|
+
# If command has arguments, we might need to execute it via LSP workspace/executeCommand
|
|
751
|
+
cmd = target_action["command"]
|
|
752
|
+
if isinstance(cmd, dict): # Command object
|
|
753
|
+
command_name = cmd["command"]
|
|
754
|
+
arguments = cmd.get("arguments", [])
|
|
755
|
+
|
|
756
|
+
# Execute command via LSP
|
|
757
|
+
# We need to add execute_command to LspClient if not exists
|
|
758
|
+
# client.execute_command(command_name, arguments)
|
|
759
|
+
# For now, let's just say we don't support custom commands yet unless we add that method.
|
|
760
|
+
# Pyright's organize imports is a command.
|
|
761
|
+
return json.dumps({"error": "Command execution not yet supported", "command": command_name}, indent=2)
|
|
762
|
+
|
|
763
|
+
return json.dumps({"success": True, "message": "Action executed (no-op?)"}, indent=2)
|
|
764
|
+
|
|
765
|
+
except Exception as e:
|
|
766
|
+
return json.dumps({"error": str(e), "backend": "pyright"}, indent=2)
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _apply_workspace_edit(edit: dict) -> None:
|
|
770
|
+
"""Apply a WorkspaceEdit to files on disk."""
|
|
771
|
+
changes = edit.get("changes")
|
|
772
|
+
document_changes = edit.get("documentChanges")
|
|
773
|
+
|
|
774
|
+
# Normalize to dict of uri -> edits
|
|
775
|
+
all_edits = {}
|
|
776
|
+
|
|
777
|
+
if changes:
|
|
778
|
+
all_edits.update(changes)
|
|
779
|
+
|
|
780
|
+
if document_changes:
|
|
781
|
+
for change in document_changes:
|
|
782
|
+
# Check if it's TextDocumentEdit (has textDocument and edits)
|
|
783
|
+
if "textDocument" in change and "edits" in change:
|
|
784
|
+
uri = change["textDocument"]["uri"]
|
|
785
|
+
if uri not in all_edits:
|
|
786
|
+
all_edits[uri] = []
|
|
787
|
+
all_edits[uri].extend(change["edits"])
|
|
788
|
+
# TODO: Handle CreateFile, RenameFile, DeleteFile if needed
|
|
789
|
+
|
|
790
|
+
for uri, text_edits in all_edits.items():
|
|
791
|
+
file_path = uri.replace("file://", "")
|
|
792
|
+
if sys.platform == "win32" and file_path.startswith("/"):
|
|
793
|
+
file_path = file_path[1:]
|
|
794
|
+
|
|
795
|
+
if not os.path.exists(file_path):
|
|
796
|
+
continue
|
|
797
|
+
|
|
798
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
799
|
+
content = f.read()
|
|
800
|
+
|
|
801
|
+
# Apply edits in reverse order
|
|
802
|
+
# Sort edits by start position descending
|
|
803
|
+
text_edits.sort(key=lambda e: (e["range"]["start"]["line"], e["range"]["start"]["character"]), reverse=True)
|
|
804
|
+
|
|
805
|
+
client = get_rope_client() # Use rope client for offset conversion helper
|
|
806
|
+
|
|
807
|
+
for text_edit in text_edits:
|
|
808
|
+
start = text_edit["range"]["start"]
|
|
809
|
+
end = text_edit["range"]["end"]
|
|
810
|
+
new_text = text_edit["newText"]
|
|
811
|
+
|
|
812
|
+
start_offset = client.position_to_offset(content, start["line"] + 1, start["character"] + 1)
|
|
813
|
+
end_offset = client.position_to_offset(content, end["line"] + 1, end["character"] + 1)
|
|
814
|
+
|
|
815
|
+
content = content[:start_offset] + new_text + content[end_offset:]
|
|
816
|
+
|
|
817
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
818
|
+
f.write(content)
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
@mcp.tool()
|
|
822
|
+
def search(
|
|
823
|
+
pattern: str,
|
|
824
|
+
path: Optional[str] = None,
|
|
825
|
+
glob: Optional[str] = None,
|
|
826
|
+
case_sensitive: bool = True,
|
|
827
|
+
max_results: int = 50,
|
|
828
|
+
) -> str:
|
|
829
|
+
"""Search for a regex pattern in files using ripgrep.
|
|
830
|
+
|
|
831
|
+
Args:
|
|
832
|
+
pattern: The regex pattern to search for
|
|
833
|
+
path: Directory or file to search in (defaults to current working directory). Can be relative to active workspace.
|
|
834
|
+
glob: Glob pattern to filter files (e.g., "*.py", "**/*.ts")
|
|
835
|
+
case_sensitive: Whether the search is case sensitive
|
|
836
|
+
max_results: Maximum number of results to return
|
|
837
|
+
|
|
838
|
+
Returns:
|
|
839
|
+
JSON string with search results or error message
|
|
840
|
+
"""
|
|
841
|
+
# Resolve and validate path if provided
|
|
842
|
+
search_path = path
|
|
843
|
+
if search_path:
|
|
844
|
+
abs_path, error = resolve_file_path(search_path)
|
|
845
|
+
if error:
|
|
846
|
+
return json.dumps(error, indent=2)
|
|
847
|
+
search_path = abs_path
|
|
848
|
+
|
|
849
|
+
# If no path provided, use active workspace if available
|
|
850
|
+
if not search_path:
|
|
851
|
+
active = get_active_workspace()
|
|
852
|
+
if active:
|
|
853
|
+
search_path = active
|
|
854
|
+
|
|
855
|
+
result = get_search(
|
|
856
|
+
pattern=pattern,
|
|
857
|
+
path=search_path,
|
|
858
|
+
glob=glob,
|
|
859
|
+
case_sensitive=case_sensitive,
|
|
860
|
+
max_results=max_results,
|
|
861
|
+
)
|
|
862
|
+
result["backend"] = "ripgrep"
|
|
863
|
+
return json.dumps(result, indent=2)
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
@mcp.tool()
|
|
867
|
+
def set_backend(
|
|
868
|
+
backend: Literal["rope", "pyright"],
|
|
869
|
+
tool: Optional[str] = None,
|
|
870
|
+
) -> str:
|
|
871
|
+
"""Set the backend for code analysis tools.
|
|
872
|
+
|
|
873
|
+
Args:
|
|
874
|
+
backend: The backend to use ('rope' or 'pyright')
|
|
875
|
+
tool: Optional tool name (hover/definition/references/completions/symbols).
|
|
876
|
+
If not provided, sets the default backend for all shared tools.
|
|
877
|
+
|
|
878
|
+
Returns:
|
|
879
|
+
JSON string with the updated configuration
|
|
880
|
+
"""
|
|
881
|
+
config = get_config()
|
|
882
|
+
|
|
883
|
+
try:
|
|
884
|
+
backend_enum = Backend(backend.lower())
|
|
885
|
+
except ValueError:
|
|
886
|
+
return json.dumps(
|
|
887
|
+
{
|
|
888
|
+
"error": f"Invalid backend: {backend}. Must be 'rope' or 'pyright'.",
|
|
889
|
+
},
|
|
890
|
+
indent=2,
|
|
891
|
+
)
|
|
892
|
+
|
|
893
|
+
if tool:
|
|
894
|
+
if tool not in SHARED_TOOLS:
|
|
895
|
+
return json.dumps(
|
|
896
|
+
{
|
|
897
|
+
"error": f"Invalid tool: {tool}. Must be one of: {', '.join(SHARED_TOOLS)}",
|
|
898
|
+
},
|
|
899
|
+
indent=2,
|
|
900
|
+
)
|
|
901
|
+
config.set_backend(backend_enum, tool)
|
|
902
|
+
return json.dumps(
|
|
903
|
+
{
|
|
904
|
+
"success": True,
|
|
905
|
+
"message": f"Backend for '{tool}' set to '{backend}'",
|
|
906
|
+
"tool": tool,
|
|
907
|
+
"backend": backend,
|
|
908
|
+
},
|
|
909
|
+
indent=2,
|
|
910
|
+
)
|
|
911
|
+
else:
|
|
912
|
+
config.set_all_backends(backend_enum)
|
|
913
|
+
return json.dumps(
|
|
914
|
+
{
|
|
915
|
+
"success": True,
|
|
916
|
+
"message": f"Default backend set to '{backend}' for all shared tools",
|
|
917
|
+
"backend": backend,
|
|
918
|
+
"affected_tools": list(SHARED_TOOLS),
|
|
919
|
+
},
|
|
920
|
+
indent=2,
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
@mcp.tool()
|
|
925
|
+
def set_python_path(
|
|
926
|
+
python_path: str,
|
|
927
|
+
workspace: Optional[str] = None,
|
|
928
|
+
) -> str:
|
|
929
|
+
"""Set the Python interpreter path for code analysis.
|
|
930
|
+
|
|
931
|
+
This affects how Rope resolves imports and analyzes code.
|
|
932
|
+
The path is auto-detected from Pyright config or virtual environments,
|
|
933
|
+
but can be manually overridden using this tool.
|
|
934
|
+
|
|
935
|
+
Args:
|
|
936
|
+
python_path: Absolute path to the Python interpreter
|
|
937
|
+
workspace: Optional workspace to set the path for.
|
|
938
|
+
If not provided, sets the global default.
|
|
939
|
+
|
|
940
|
+
Returns:
|
|
941
|
+
JSON string with success status
|
|
942
|
+
"""
|
|
943
|
+
result = config_set_python_path(python_path, workspace)
|
|
944
|
+
return json.dumps(result, indent=2)
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
@mcp.tool()
|
|
948
|
+
def status() -> str:
|
|
949
|
+
"""Get the status of the MCP server.
|
|
950
|
+
|
|
951
|
+
Returns:
|
|
952
|
+
JSON string with server status information
|
|
953
|
+
"""
|
|
954
|
+
rope_client = get_rope_client()
|
|
955
|
+
rope_status = rope_client.get_status()
|
|
956
|
+
|
|
957
|
+
config = get_config()
|
|
958
|
+
python_status = get_python_path_status()
|
|
959
|
+
|
|
960
|
+
# Get Python paths for active projects
|
|
961
|
+
active_projects = rope_status.get("active_projects", [])
|
|
962
|
+
project_python_paths = {}
|
|
963
|
+
for project in active_projects:
|
|
964
|
+
project_python_paths[project] = rope_client.get_python_path(project)
|
|
965
|
+
|
|
966
|
+
status_info = {
|
|
967
|
+
"server": "python-lsp-mcp",
|
|
968
|
+
"version": __version__,
|
|
969
|
+
"backends": {
|
|
970
|
+
"rope": {
|
|
971
|
+
"available": True,
|
|
972
|
+
"description": "Fast, Python-native code analysis",
|
|
973
|
+
"active_projects": active_projects,
|
|
974
|
+
"project_python_paths": project_python_paths,
|
|
975
|
+
"caching_enabled": rope_status.get("caching_enabled", False),
|
|
976
|
+
"cache_folder": rope_status.get("cache_folder"),
|
|
977
|
+
},
|
|
978
|
+
"pyright": {
|
|
979
|
+
"available": True,
|
|
980
|
+
"description": "Full-featured type checking via LSP",
|
|
981
|
+
},
|
|
982
|
+
"ripgrep": {
|
|
983
|
+
"available": True,
|
|
984
|
+
"description": "Fast regex search",
|
|
985
|
+
},
|
|
986
|
+
},
|
|
987
|
+
"config": {
|
|
988
|
+
"default_backend": config.default_backend.value,
|
|
989
|
+
"shared_tools": list(SHARED_TOOLS),
|
|
990
|
+
"tool_backends": {
|
|
991
|
+
tool: config.get_backend_for(tool).value for tool in SHARED_TOOLS
|
|
992
|
+
},
|
|
993
|
+
"rope_only_tools": ["rename"],
|
|
994
|
+
"pyright_only_tools": ["diagnostics", "signature_help"],
|
|
995
|
+
},
|
|
996
|
+
"python_interpreter": {
|
|
997
|
+
"current": python_status["current_interpreter"],
|
|
998
|
+
"global_override": python_status["global_python_path"],
|
|
999
|
+
"workspace_overrides": python_status["workspace_python_paths"],
|
|
1000
|
+
},
|
|
1001
|
+
}
|
|
1002
|
+
return json.dumps(status_info, indent=2)
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
@mcp.tool()
|
|
1006
|
+
def reload_modules() -> str:
|
|
1007
|
+
"""Reload all tool modules (development only).
|
|
1008
|
+
|
|
1009
|
+
This reloads the Python modules so code changes take effect without
|
|
1010
|
+
restarting the server. Use this during development/debugging.
|
|
1011
|
+
|
|
1012
|
+
Returns:
|
|
1013
|
+
JSON string with reload status
|
|
1014
|
+
"""
|
|
1015
|
+
import importlib
|
|
1016
|
+
from . import tools
|
|
1017
|
+
from .tools import (
|
|
1018
|
+
hover,
|
|
1019
|
+
definition,
|
|
1020
|
+
references,
|
|
1021
|
+
completions,
|
|
1022
|
+
symbols,
|
|
1023
|
+
rename,
|
|
1024
|
+
move,
|
|
1025
|
+
change_signature,
|
|
1026
|
+
diagnostics,
|
|
1027
|
+
search,
|
|
1028
|
+
)
|
|
1029
|
+
from . import rope_client
|
|
1030
|
+
from . import config
|
|
1031
|
+
from .lsp import client as lsp_client
|
|
1032
|
+
|
|
1033
|
+
reloaded = []
|
|
1034
|
+
errors = []
|
|
1035
|
+
|
|
1036
|
+
# Reload in dependency order
|
|
1037
|
+
modules_to_reload = [
|
|
1038
|
+
("config", config),
|
|
1039
|
+
("rope_client", rope_client),
|
|
1040
|
+
("lsp.client", lsp_client),
|
|
1041
|
+
("tools.hover", hover),
|
|
1042
|
+
("tools.definition", definition),
|
|
1043
|
+
("tools.references", references),
|
|
1044
|
+
("tools.completions", completions),
|
|
1045
|
+
("tools.symbols", symbols),
|
|
1046
|
+
("tools.rename", rename),
|
|
1047
|
+
("tools.move", move),
|
|
1048
|
+
("tools.change_signature", change_signature),
|
|
1049
|
+
("tools.diagnostics", diagnostics),
|
|
1050
|
+
("tools.search", search),
|
|
1051
|
+
("tools", tools),
|
|
1052
|
+
]
|
|
1053
|
+
|
|
1054
|
+
for name, module in modules_to_reload:
|
|
1055
|
+
try:
|
|
1056
|
+
importlib.reload(module)
|
|
1057
|
+
reloaded.append(name)
|
|
1058
|
+
except Exception as e:
|
|
1059
|
+
errors.append({"module": name, "error": str(e)})
|
|
1060
|
+
|
|
1061
|
+
# Re-import the functions we use
|
|
1062
|
+
global do_rename, do_move, do_change_signature, get_function_signature
|
|
1063
|
+
global rope_completions, rope_definition, rope_hover, rope_references, rope_symbols
|
|
1064
|
+
global get_diagnostics, get_search
|
|
1065
|
+
|
|
1066
|
+
from .tools import (
|
|
1067
|
+
do_rename,
|
|
1068
|
+
do_move,
|
|
1069
|
+
do_change_signature,
|
|
1070
|
+
get_function_signature,
|
|
1071
|
+
get_completions as rope_completions,
|
|
1072
|
+
get_definition as rope_definition,
|
|
1073
|
+
get_hover as rope_hover,
|
|
1074
|
+
get_references as rope_references,
|
|
1075
|
+
get_symbols as rope_symbols,
|
|
1076
|
+
get_diagnostics,
|
|
1077
|
+
get_search,
|
|
1078
|
+
)
|
|
1079
|
+
|
|
1080
|
+
return json.dumps(
|
|
1081
|
+
{
|
|
1082
|
+
"success": len(errors) == 0,
|
|
1083
|
+
"reloaded": reloaded,
|
|
1084
|
+
"errors": errors if errors else None,
|
|
1085
|
+
},
|
|
1086
|
+
indent=2,
|
|
1087
|
+
)
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def _do_reload():
|
|
1091
|
+
"""Perform module reload."""
|
|
1092
|
+
import importlib
|
|
1093
|
+
from . import tools
|
|
1094
|
+
from .tools import (
|
|
1095
|
+
hover,
|
|
1096
|
+
definition,
|
|
1097
|
+
references,
|
|
1098
|
+
completions,
|
|
1099
|
+
symbols,
|
|
1100
|
+
rename,
|
|
1101
|
+
move,
|
|
1102
|
+
change_signature,
|
|
1103
|
+
diagnostics,
|
|
1104
|
+
search,
|
|
1105
|
+
)
|
|
1106
|
+
from . import rope_client
|
|
1107
|
+
from . import config
|
|
1108
|
+
from .lsp import client as lsp_client
|
|
1109
|
+
|
|
1110
|
+
# Reload in dependency order
|
|
1111
|
+
modules = [
|
|
1112
|
+
config,
|
|
1113
|
+
rope_client,
|
|
1114
|
+
lsp_client,
|
|
1115
|
+
hover,
|
|
1116
|
+
definition,
|
|
1117
|
+
references,
|
|
1118
|
+
completions,
|
|
1119
|
+
symbols,
|
|
1120
|
+
rename,
|
|
1121
|
+
move,
|
|
1122
|
+
change_signature,
|
|
1123
|
+
diagnostics,
|
|
1124
|
+
search,
|
|
1125
|
+
tools,
|
|
1126
|
+
]
|
|
1127
|
+
|
|
1128
|
+
for module in modules:
|
|
1129
|
+
try:
|
|
1130
|
+
importlib.reload(module)
|
|
1131
|
+
except Exception as e:
|
|
1132
|
+
print(f"Error reloading {module.__name__}: {e}", file=sys.stderr)
|
|
1133
|
+
|
|
1134
|
+
# Re-import the functions
|
|
1135
|
+
global do_rename, do_move, do_change_signature, get_function_signature
|
|
1136
|
+
global rope_completions, rope_definition, rope_hover, rope_references, rope_symbols
|
|
1137
|
+
global get_diagnostics, get_search
|
|
1138
|
+
|
|
1139
|
+
from .tools import (
|
|
1140
|
+
do_rename,
|
|
1141
|
+
do_move,
|
|
1142
|
+
do_change_signature,
|
|
1143
|
+
get_function_signature,
|
|
1144
|
+
get_completions as rope_completions,
|
|
1145
|
+
get_definition as rope_definition,
|
|
1146
|
+
get_hover as rope_hover,
|
|
1147
|
+
get_references as rope_references,
|
|
1148
|
+
get_symbols as rope_symbols,
|
|
1149
|
+
get_diagnostics,
|
|
1150
|
+
get_search,
|
|
1151
|
+
)
|
|
1152
|
+
|
|
1153
|
+
print("Modules reloaded", file=sys.stderr)
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
def _start_file_watcher(src_path: str):
|
|
1157
|
+
"""Start watching for file changes and reload modules."""
|
|
1158
|
+
try:
|
|
1159
|
+
from watchdog.observers import Observer
|
|
1160
|
+
from watchdog.events import FileSystemEventHandler, FileModifiedEvent
|
|
1161
|
+
except ImportError:
|
|
1162
|
+
print(
|
|
1163
|
+
"watchdog not installed. Run: uv pip install watchdog",
|
|
1164
|
+
file=sys.stderr,
|
|
1165
|
+
)
|
|
1166
|
+
return None
|
|
1167
|
+
|
|
1168
|
+
class ReloadHandler(FileSystemEventHandler):
|
|
1169
|
+
def __init__(self):
|
|
1170
|
+
self._debounce_timer = None
|
|
1171
|
+
self._lock = threading.Lock()
|
|
1172
|
+
|
|
1173
|
+
def _schedule_reload(self):
|
|
1174
|
+
with self._lock:
|
|
1175
|
+
if self._debounce_timer:
|
|
1176
|
+
self._debounce_timer.cancel()
|
|
1177
|
+
self._debounce_timer = threading.Timer(0.5, self._do_reload)
|
|
1178
|
+
self._debounce_timer.start()
|
|
1179
|
+
|
|
1180
|
+
def _do_reload(self):
|
|
1181
|
+
try:
|
|
1182
|
+
_do_reload()
|
|
1183
|
+
except Exception as e:
|
|
1184
|
+
print(f"Reload error: {e}", file=sys.stderr)
|
|
1185
|
+
|
|
1186
|
+
def on_modified(self, event):
|
|
1187
|
+
if isinstance(event, FileModifiedEvent) and event.src_path.endswith(".py"):
|
|
1188
|
+
print(f"File changed: {event.src_path}", file=sys.stderr)
|
|
1189
|
+
self._schedule_reload()
|
|
1190
|
+
|
|
1191
|
+
observer = Observer()
|
|
1192
|
+
observer.schedule(ReloadHandler(), src_path, recursive=True)
|
|
1193
|
+
observer.start()
|
|
1194
|
+
print(f"Watching for changes in: {src_path}", file=sys.stderr)
|
|
1195
|
+
return observer
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
def main():
|
|
1199
|
+
"""Run the MCP server."""
|
|
1200
|
+
reload_mode = "--reload" in sys.argv or os.environ.get("MCP_RELOAD") == "1"
|
|
1201
|
+
|
|
1202
|
+
observer = None
|
|
1203
|
+
if reload_mode:
|
|
1204
|
+
# Get the source path
|
|
1205
|
+
src_path = os.path.dirname(os.path.abspath(__file__))
|
|
1206
|
+
observer = _start_file_watcher(src_path)
|
|
1207
|
+
|
|
1208
|
+
try:
|
|
1209
|
+
mcp.run()
|
|
1210
|
+
finally:
|
|
1211
|
+
if observer:
|
|
1212
|
+
observer.stop()
|
|
1213
|
+
observer.join()
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
if __name__ == "__main__":
|
|
1217
|
+
main()
|