@treedy/lsp-mcp 0.1.8 → 0.1.10

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 (34) hide show
  1. package/dist/bundled/python/src/rope_mcp/config.py +50 -14
  2. package/dist/bundled/python/src/rope_mcp/lsp/client.py +243 -4
  3. package/dist/bundled/python/src/rope_mcp/lsp/types.py +1 -0
  4. package/dist/bundled/python/src/rope_mcp/server.py +331 -77
  5. package/dist/bundled/python/src/rope_mcp/tools/__init__.py +0 -2
  6. package/dist/bundled/typescript/dist/index.js +6129 -5891
  7. package/dist/bundled/typescript/dist/index.js.map +5 -5
  8. package/dist/bundled/vue/dist/index.js +136 -71
  9. package/dist/bundled/vue/dist/vue-service.d.ts +16 -0
  10. package/dist/index.js +595 -328
  11. package/dist/index.js.map +7 -7
  12. package/package.json +1 -1
  13. package/dist/bundled/python/src/rope_mcp/__pycache__/__init__.cpython-312.pyc +0 -0
  14. package/dist/bundled/python/src/rope_mcp/__pycache__/__init__.cpython-313.pyc +0 -0
  15. package/dist/bundled/python/src/rope_mcp/__pycache__/config.cpython-312.pyc +0 -0
  16. package/dist/bundled/python/src/rope_mcp/__pycache__/config.cpython-313.pyc +0 -0
  17. package/dist/bundled/python/src/rope_mcp/__pycache__/pyright_client.cpython-313.pyc +0 -0
  18. package/dist/bundled/python/src/rope_mcp/__pycache__/rope_client.cpython-313.pyc +0 -0
  19. package/dist/bundled/python/src/rope_mcp/__pycache__/server.cpython-312.pyc +0 -0
  20. package/dist/bundled/python/src/rope_mcp/__pycache__/server.cpython-313.pyc +0 -0
  21. package/dist/bundled/python/src/rope_mcp/lsp/__pycache__/__init__.cpython-313.pyc +0 -0
  22. package/dist/bundled/python/src/rope_mcp/lsp/__pycache__/client.cpython-313.pyc +0 -0
  23. package/dist/bundled/python/src/rope_mcp/lsp/__pycache__/types.cpython-313.pyc +0 -0
  24. package/dist/bundled/python/src/rope_mcp/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  25. package/dist/bundled/python/src/rope_mcp/tools/__pycache__/change_signature.cpython-313.pyc +0 -0
  26. package/dist/bundled/python/src/rope_mcp/tools/__pycache__/completions.cpython-313.pyc +0 -0
  27. package/dist/bundled/python/src/rope_mcp/tools/__pycache__/definition.cpython-313.pyc +0 -0
  28. package/dist/bundled/python/src/rope_mcp/tools/__pycache__/diagnostics.cpython-313.pyc +0 -0
  29. package/dist/bundled/python/src/rope_mcp/tools/__pycache__/hover.cpython-313.pyc +0 -0
  30. package/dist/bundled/python/src/rope_mcp/tools/__pycache__/move.cpython-313.pyc +0 -0
  31. package/dist/bundled/python/src/rope_mcp/tools/__pycache__/references.cpython-313.pyc +0 -0
  32. package/dist/bundled/python/src/rope_mcp/tools/__pycache__/rename.cpython-313.pyc +0 -0
  33. package/dist/bundled/python/src/rope_mcp/tools/__pycache__/search.cpython-313.pyc +0 -0
  34. package/dist/bundled/python/src/rope_mcp/tools/__pycache__/symbols.cpython-313.pyc +0 -0
@@ -22,10 +22,10 @@ class Backend(Enum):
22
22
 
23
23
 
24
24
  # Tools that support both backends
25
- SHARED_TOOLS = {"hover", "definition", "references", "completions", "symbols"}
25
+ SHARED_TOOLS = {"hover", "definition", "references", "completions", "symbols", "rename"}
26
26
 
27
27
  # Tools exclusive to each backend
28
- ROPE_ONLY_TOOLS = {"rename"} # Rope has better refactoring
28
+ ROPE_ONLY_TOOLS = set() # Rope has better refactoring (move, change_signature)
29
29
  PYRIGHT_ONLY_TOOLS = {"diagnostics", "signature_help"} # Pyright exclusive
30
30
 
31
31
  # Environment variable prefix (supports both old and new names)
@@ -37,7 +37,9 @@ class ServerConfig:
37
37
  """Configuration for the MCP server."""
38
38
 
39
39
  # Default backend for shared features
40
- default_backend: Backend = Backend.ROPE
40
+ # Pyright is generally more robust for read operations as it doesn't require
41
+ # perfect environment setup (like running imports).
42
+ default_backend: Backend = Backend.PYRIGHT
41
43
 
42
44
  # Per-tool backend overrides (None = use default)
43
45
  tool_backends: dict[str, Backend] = field(default_factory=dict)
@@ -47,7 +49,7 @@ class ServerConfig:
47
49
  """Create config from environment variables.
48
50
 
49
51
  Environment variables (PYTHON_LSP_MCP_ or ROPE_MCP_ prefix):
50
- *_BACKEND: Default backend (rope/pyright), default: rope
52
+ *_BACKEND: Default backend (rope/pyright), default: pyright
51
53
  *_HOVER_BACKEND: Backend for hover (rope/pyright)
52
54
  *_DEFINITION_BACKEND: Backend for definition
53
55
  *_REFERENCES_BACKEND: Backend for references
@@ -63,9 +65,9 @@ class ServerConfig:
63
65
  return val
64
66
  return None
65
67
 
66
- default = get_env("BACKEND") or "rope"
68
+ default = get_env("BACKEND") or "pyright"
67
69
  default_backend = (
68
- Backend(default) if default in ["rope", "pyright"] else Backend.ROPE
70
+ Backend(default) if default in ["rope", "pyright"] else Backend.PYRIGHT
69
71
  )
70
72
 
71
73
  tool_backends = {}
@@ -328,20 +330,43 @@ def is_file_in_workspace(file_path: str) -> bool:
328
330
  return abs_file.startswith(_active_workspace)
329
331
 
330
332
 
331
- def validate_file_workspace(file_path: str) -> Optional[dict]:
332
- """Validate that a file is within the active workspace.
333
+ def resolve_file_path(file_path: str) -> tuple[Optional[str], Optional[dict]]:
334
+ """Resolve a file path and validate it against the active workspace.
335
+
336
+ Supports:
337
+ 1. Absolute paths (must be within active workspace)
338
+ 2. Relative paths (resolved against active workspace)
333
339
 
334
340
  Args:
335
- file_path: Path to the file
341
+ file_path: Path to the file (absolute or relative)
336
342
 
337
343
  Returns:
338
- Error dict if file is outside workspace, None otherwise.
344
+ Tuple of (absolute_path, error_dict).
345
+ If success, absolute_path is set and error_dict is None.
346
+ If failure, absolute_path is None and error_dict contains the error.
339
347
  """
340
- if not is_file_in_workspace(file_path):
341
- return {
348
+ path_obj = Path(file_path)
349
+
350
+ # Handle relative paths
351
+ if not path_obj.is_absolute():
352
+ if _active_workspace:
353
+ abs_path = os.path.join(_active_workspace, file_path)
354
+ else:
355
+ # If no workspace set, we can't resolve relative paths reliably
356
+ # But for backward compatibility with "no workspace" mode, we might assume cwd
357
+ abs_path = os.path.abspath(file_path)
358
+ else:
359
+ abs_path = str(path_obj)
360
+
361
+ # Normalize path
362
+ abs_path = os.path.abspath(abs_path)
363
+
364
+ # Validate against workspace
365
+ if _active_workspace and not abs_path.startswith(_active_workspace):
366
+ return None, {
342
367
  "error": "Context Mismatch",
343
368
  "message": (
344
- f"The file '{file_path}' is outside the active workspace '{_active_workspace}'.\n\n"
369
+ f"The file '{file_path}' resolves to '{abs_path}', which is outside the active workspace '{_active_workspace}'.\n\n"
345
370
  "Current Logic:\n"
346
371
  "1. I only analyze files from the active project to ensure accuracy and save resources.\n"
347
372
  "2. You must explicitly switch the workspace if you want to work on a different project.\n\n"
@@ -349,8 +374,19 @@ def validate_file_workspace(file_path: str) -> Optional[dict]:
349
374
  "Please call 'switch_workspace(path=\"...\")' with the new project root before retrying."
350
375
  ),
351
376
  "current_workspace": _active_workspace,
377
+ "resolved_path": abs_path
352
378
  }
353
- return None
379
+
380
+ return abs_path, None
381
+
382
+
383
+ def validate_file_workspace(file_path: str) -> Optional[dict]:
384
+ """Validate that a file is within the active workspace.
385
+
386
+ Deprecated: Use resolve_file_path instead.
387
+ """
388
+ _, error = resolve_file_path(file_path)
389
+ return error
354
390
 
355
391
 
356
392
  def set_python_path(python_path: str, workspace: Optional[str] = None) -> dict:
@@ -83,16 +83,154 @@ class LspClient:
83
83
  message = json.loads(content.decode())
84
84
 
85
85
  # Handle response
86
- if "id" in message and message["id"] in self._pending_requests:
87
- req_id = message["id"]
88
- self._responses[req_id] = message
89
- self._pending_requests[req_id].set()
86
+ if "id" in message:
87
+ if "method" in message:
88
+ # Server Request
89
+ self._handle_server_request(message)
90
+ elif message["id"] in self._pending_requests:
91
+ # Server Response to our request
92
+ req_id = message["id"]
93
+ self._responses[req_id] = message
94
+ self._pending_requests[req_id].set()
95
+ elif "method" in message:
96
+ # Server Notification
97
+ self._handle_server_notification(message)
90
98
 
91
99
  except Exception:
92
100
  if self._running:
93
101
  continue
94
102
  break
95
103
 
104
+ def _handle_server_notification(self, message: dict) -> None:
105
+ """Handle a notification from the server."""
106
+ method = message.get("method")
107
+ params = message.get("params", {})
108
+
109
+ if method == "textDocument/publishDiagnostics":
110
+ uri = params.get("uri")
111
+ diagnostics = params.get("diagnostics", [])
112
+
113
+ # Normalize URI to match our internal map
114
+ # Pyright might send "file:///path" while we stored "file:///path"
115
+ # But we need to check if it's in our _documents
116
+ if uri in self._documents:
117
+ self._documents[uri].diagnostics = diagnostics
118
+ # Signal update if someone is waiting
119
+ if hasattr(self._documents[uri], "update_event"):
120
+ self._documents[uri].update_event.set()
121
+ else:
122
+ # Try decoding uri if keys don't match exactly
123
+ path = self._uri_to_path(uri)
124
+ uri_key = self._path_to_uri(path)
125
+ if uri_key in self._documents:
126
+ self._documents[uri_key].diagnostics = diagnostics
127
+ if hasattr(self._documents[uri_key], "update_event"):
128
+ self._documents[uri_key].update_event.set()
129
+
130
+ def get_diagnostics(self, file_path: str, timeout: float = 3.0) -> dict:
131
+ """Get diagnostics for a file."""
132
+ # Force re-open to trigger fresh diagnostics
133
+ # This ensures we get the latest state including any config changes
134
+ if self._path_to_uri(file_path) in self._documents:
135
+ self.close_document(file_path)
136
+
137
+ # Create event before opening
138
+ uri = self._path_to_uri(file_path)
139
+
140
+ # We need to access _documents but it's created in open_document
141
+ # So we can't attach event yet.
142
+
143
+ self.open_document(file_path)
144
+ doc = self._documents.get(uri)
145
+ if not doc:
146
+ return {"error": "Failed to open document"}
147
+
148
+ doc.update_event = threading.Event()
149
+
150
+ # Wait for diagnostics
151
+ doc.update_event.wait(timeout)
152
+
153
+ diags = doc.diagnostics or []
154
+
155
+ formatted = []
156
+ for d in diags:
157
+ formatted.append({
158
+ "file": file_path,
159
+ "line": d["range"]["start"]["line"] + 1,
160
+ "column": d["range"]["start"]["character"] + 1,
161
+ "end_line": d["range"]["end"]["line"] + 1,
162
+ "end_column": d["range"]["end"]["character"] + 1,
163
+ "severity": self._severity_to_string(d.get("severity", 1)),
164
+ "message": d["message"],
165
+ "code": d.get("code")
166
+ })
167
+
168
+ return {
169
+ "diagnostics": formatted,
170
+ "summary": {
171
+ "files_analyzed": 1,
172
+ "errors": len([d for d in formatted if d["severity"] == "error"]),
173
+ "warnings": len([d for d in formatted if d["severity"] == "warning"]),
174
+ "informations": len([d for d in formatted if d["severity"] == "information"]),
175
+ },
176
+ "backend": "pyright-lsp"
177
+ }
178
+
179
+ def _severity_to_string(self, severity: int) -> str:
180
+ mapping = {1: "error", 2: "warning", 3: "information", 4: "hint"}
181
+ return mapping.get(severity, "error")
182
+
183
+ def _handle_server_request(self, message: dict) -> None:
184
+ """Handle a request from the server."""
185
+ method = message.get("method")
186
+ req_id = message.get("id")
187
+
188
+ if method == "workspace/configuration":
189
+ # Pyright asking for config
190
+ items = message.get("params", {}).get("items", [])
191
+ result = []
192
+ analysis_config = {
193
+ "typeCheckingMode": "basic",
194
+ "reportUnusedImport": "warning",
195
+ "reportUnusedVariable": "warning",
196
+ "autoImportCompletions": True,
197
+ "autoSearchPaths": True,
198
+ "useLibraryCodeForTypes": True,
199
+ "diagnosticMode": "workspace"
200
+ }
201
+
202
+ for item in items:
203
+ section = item.get("section")
204
+ if section == "python.analysis":
205
+ result.append(analysis_config)
206
+ elif section == "python":
207
+ result.append({"analysis": analysis_config})
208
+ else:
209
+ # Return analysis config for everything to be safe
210
+ result.append(analysis_config)
211
+
212
+ self._send_response(req_id, result)
213
+
214
+ elif method == "client/registerCapability":
215
+ # Just acknowledge
216
+ self._send_response(req_id, None)
217
+
218
+ def _send_response(self, req_id: Any, result: Any) -> None:
219
+ """Send a JSON-RPC response."""
220
+ if not self._process or not self._process.stdin:
221
+ return
222
+
223
+ message = {"jsonrpc": "2.0", "id": req_id, "result": result}
224
+ content = json.dumps(message)
225
+ header = f"Content-Length: {len(content)}\r\n\r\n"
226
+
227
+ try:
228
+ with self._lock: # Protect stdin write
229
+ self._process.stdin.write(header.encode() + content.encode())
230
+ self._process.stdin.flush()
231
+ except Exception:
232
+ pass
233
+
96
234
  def _send_message(self, message: dict) -> None:
97
235
  """Send a JSON-RPC message to the server."""
98
236
  if not self._process or not self._process.stdin:
@@ -170,6 +308,22 @@ class LspClient:
170
308
  "definition": {"linkSupport": True},
171
309
  "references": {},
172
310
  "rename": {"prepareSupport": True},
311
+ "codeAction": {
312
+ "codeActionLiteralSupport": {
313
+ "codeActionKind": {
314
+ "valueSet": [
315
+ "quickfix",
316
+ "refactor",
317
+ "refactor.extract",
318
+ "refactor.inline",
319
+ "refactor.rewrite",
320
+ "source",
321
+ "source.organizeImports",
322
+ ]
323
+ }
324
+ }
325
+ },
326
+ "inlayHint": {"dynamicRegistration": False},
173
327
  "documentSymbol": {"hierarchicalDocumentSymbolSupport": True},
174
328
  "publishDiagnostics": {},
175
329
  },
@@ -181,6 +335,19 @@ class LspClient:
181
335
  "name": Path(self.workspace_root).name,
182
336
  }
183
337
  ],
338
+ "initializationOptions": {
339
+ "python": {
340
+ "analysis": {
341
+ # Default to basic mode but enable useful hints
342
+ "typeCheckingMode": "basic",
343
+ "reportUnusedImport": "warning",
344
+ "reportUnusedVariable": "warning",
345
+ "autoImportCompletions": True,
346
+ "autoSearchPaths": True,
347
+ "useLibraryCodeForTypes": True
348
+ }
349
+ }
350
+ }
184
351
  }
185
352
 
186
353
  self._send_request("initialize", init_params)
@@ -431,6 +598,78 @@ class LspClient:
431
598
  )
432
599
  return completions
433
600
 
601
+ def inlay_hint(
602
+ self,
603
+ file_path: str,
604
+ start_line: int,
605
+ start_col: int,
606
+ end_line: int,
607
+ end_col: int,
608
+ ) -> list[dict]:
609
+ """Get inlay hints for a range."""
610
+ self.open_document(file_path)
611
+ uri = self._path_to_uri(file_path)
612
+
613
+ params = {
614
+ "textDocument": {"uri": uri},
615
+ "range": {
616
+ "start": {"line": start_line - 1, "character": start_col - 1},
617
+ "end": {"line": end_line - 1, "character": end_col - 1},
618
+ },
619
+ }
620
+
621
+ result = self._send_request("textDocument/inlayHint", params)
622
+
623
+ if not result:
624
+ return []
625
+
626
+ return result
627
+
628
+ def code_action(
629
+ self,
630
+ file_path: str,
631
+ start_line: int,
632
+ start_col: int,
633
+ end_line: int,
634
+ end_col: int,
635
+ diagnostics: list = None,
636
+ ) -> list[dict]:
637
+ """Get available code actions."""
638
+ self.open_document(file_path)
639
+ uri = self._path_to_uri(file_path)
640
+
641
+ params = {
642
+ "textDocument": {"uri": uri},
643
+ "range": {
644
+ "start": {"line": start_line - 1, "character": start_col - 1},
645
+ "end": {"line": end_line - 1, "character": end_col - 1},
646
+ },
647
+ "context": {"diagnostics": diagnostics or []},
648
+ }
649
+
650
+ result = self._send_request("textDocument/codeAction", params)
651
+
652
+ if not result:
653
+ return []
654
+
655
+ return result
656
+
657
+ def rename(self, file_path: str, line: int, column: int, new_name: str) -> Optional[dict]:
658
+ """Rename symbol at position."""
659
+ self.open_document(file_path)
660
+ uri = self._path_to_uri(file_path)
661
+
662
+ result = self._send_request(
663
+ "textDocument/rename",
664
+ {
665
+ "textDocument": {"uri": uri},
666
+ "position": {"line": line - 1, "character": column - 1},
667
+ "newName": new_name,
668
+ },
669
+ )
670
+
671
+ return result
672
+
434
673
  def document_symbols(self, file_path: str) -> list[dict]:
435
674
  """Get document symbols."""
436
675
  self.open_document(file_path)
@@ -80,3 +80,4 @@ class DocumentState:
80
80
  version: int
81
81
  content: str
82
82
  language_id: str = "python"
83
+ diagnostics: list = None