@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.
Files changed (54) hide show
  1. package/dist/bundled/pyright/dist/index.d.ts +2 -0
  2. package/dist/bundled/pyright/dist/index.js +1620 -0
  3. package/dist/bundled/pyright/dist/index.js.map +26 -0
  4. package/dist/bundled/pyright/dist/lsp/connection.d.ts +71 -0
  5. package/dist/bundled/pyright/dist/lsp/document-manager.d.ts +67 -0
  6. package/dist/bundled/pyright/dist/lsp/index.d.ts +3 -0
  7. package/dist/bundled/pyright/dist/lsp/types.d.ts +55 -0
  8. package/dist/bundled/pyright/dist/lsp-client.d.ts +55 -0
  9. package/dist/bundled/pyright/dist/tools/completions.d.ts +18 -0
  10. package/dist/bundled/pyright/dist/tools/definition.d.ts +16 -0
  11. package/dist/bundled/pyright/dist/tools/diagnostics.d.ts +12 -0
  12. package/dist/bundled/pyright/dist/tools/hover.d.ts +16 -0
  13. package/dist/bundled/pyright/dist/tools/references.d.ts +16 -0
  14. package/dist/bundled/pyright/dist/tools/rename.d.ts +18 -0
  15. package/dist/bundled/pyright/dist/tools/search.d.ts +20 -0
  16. package/dist/bundled/pyright/dist/tools/signature-help.d.ts +16 -0
  17. package/dist/bundled/pyright/dist/tools/status.d.ts +14 -0
  18. package/dist/bundled/pyright/dist/tools/symbols.d.ts +17 -0
  19. package/dist/bundled/pyright/dist/tools/update-document.d.ts +14 -0
  20. package/dist/bundled/pyright/dist/utils/position.d.ts +33 -0
  21. package/dist/bundled/pyright/package.json +54 -0
  22. package/dist/bundled/python/README.md +230 -0
  23. package/dist/bundled/python/pyproject.toml +61 -0
  24. package/dist/bundled/python/src/rope_mcp/__init__.py +3 -0
  25. package/dist/bundled/python/src/rope_mcp/config.py +444 -0
  26. package/dist/bundled/python/src/rope_mcp/lsp/__init__.py +15 -0
  27. package/dist/bundled/python/src/rope_mcp/lsp/client.py +863 -0
  28. package/dist/bundled/python/src/rope_mcp/lsp/types.py +83 -0
  29. package/dist/bundled/python/src/rope_mcp/pyright_client.py +147 -0
  30. package/dist/bundled/python/src/rope_mcp/rope_client.py +198 -0
  31. package/dist/bundled/python/src/rope_mcp/server.py +1217 -0
  32. package/dist/bundled/python/src/rope_mcp/tools/__init__.py +24 -0
  33. package/dist/bundled/python/src/rope_mcp/tools/change_signature.py +184 -0
  34. package/dist/bundled/python/src/rope_mcp/tools/completions.py +84 -0
  35. package/dist/bundled/python/src/rope_mcp/tools/definition.py +51 -0
  36. package/dist/bundled/python/src/rope_mcp/tools/diagnostics.py +18 -0
  37. package/dist/bundled/python/src/rope_mcp/tools/hover.py +49 -0
  38. package/dist/bundled/python/src/rope_mcp/tools/move.py +81 -0
  39. package/dist/bundled/python/src/rope_mcp/tools/references.py +60 -0
  40. package/dist/bundled/python/src/rope_mcp/tools/rename.py +61 -0
  41. package/dist/bundled/python/src/rope_mcp/tools/search.py +128 -0
  42. package/dist/bundled/python/src/rope_mcp/tools/symbols.py +118 -0
  43. package/dist/bundled/python/uv.lock +979 -0
  44. package/dist/bundled/typescript/dist/index.js +29772 -0
  45. package/dist/bundled/typescript/dist/index.js.map +211 -0
  46. package/dist/bundled/typescript/package.json +46 -0
  47. package/dist/bundled/vue/dist/index.d.ts +8 -0
  48. package/dist/bundled/vue/dist/index.js +21176 -0
  49. package/dist/bundled/vue/dist/ts-vue-service.d.ts +67 -0
  50. package/dist/bundled/vue/dist/vue-service.d.ts +160 -0
  51. package/dist/bundled/vue/package.json +45 -0
  52. package/dist/index.js +695 -352
  53. package/dist/index.js.map +6 -6
  54. 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()