@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.
- package/dist/bundled/python/src/rope_mcp/config.py +50 -14
- package/dist/bundled/python/src/rope_mcp/lsp/client.py +243 -4
- package/dist/bundled/python/src/rope_mcp/lsp/types.py +1 -0
- package/dist/bundled/python/src/rope_mcp/server.py +331 -77
- package/dist/bundled/python/src/rope_mcp/tools/__init__.py +0 -2
- package/dist/bundled/typescript/dist/index.js +6129 -5891
- package/dist/bundled/typescript/dist/index.js.map +5 -5
- package/dist/bundled/vue/dist/index.js +136 -71
- package/dist/bundled/vue/dist/vue-service.d.ts +16 -0
- package/dist/index.js +595 -328
- package/dist/index.js.map +7 -7
- package/package.json +1 -1
- package/dist/bundled/python/src/rope_mcp/__pycache__/__init__.cpython-312.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/__pycache__/config.cpython-312.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/__pycache__/config.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/__pycache__/pyright_client.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/__pycache__/rope_client.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/__pycache__/server.cpython-312.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/__pycache__/server.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/lsp/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/lsp/__pycache__/client.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/lsp/__pycache__/types.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/tools/__pycache__/change_signature.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/tools/__pycache__/completions.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/tools/__pycache__/definition.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/tools/__pycache__/diagnostics.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/tools/__pycache__/hover.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/tools/__pycache__/move.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/tools/__pycache__/references.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/tools/__pycache__/rename.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/tools/__pycache__/search.cpython-313.pyc +0 -0
- 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 =
|
|
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
|
-
|
|
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:
|
|
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 "
|
|
68
|
+
default = get_env("BACKEND") or "pyright"
|
|
67
69
|
default_backend = (
|
|
68
|
-
Backend(default) if default in ["rope", "pyright"] else Backend.
|
|
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
|
|
332
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
341
|
-
|
|
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
|
-
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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)
|