pylance-mcp-server 1.0.0

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/LICENSE +21 -0
  2. package/README.md +213 -0
  3. package/bin/pylance-mcp.js +68 -0
  4. package/mcp_server/__init__.py +13 -0
  5. package/mcp_server/__pycache__/__init__.cpython-312.pyc +0 -0
  6. package/mcp_server/__pycache__/__init__.cpython-313.pyc +0 -0
  7. package/mcp_server/__pycache__/__init__.cpython-314.pyc +0 -0
  8. package/mcp_server/__pycache__/ai_features.cpython-313.pyc +0 -0
  9. package/mcp_server/__pycache__/api_routes.cpython-313.pyc +0 -0
  10. package/mcp_server/__pycache__/auth.cpython-313.pyc +0 -0
  11. package/mcp_server/__pycache__/cloud_sync.cpython-313.pyc +0 -0
  12. package/mcp_server/__pycache__/logging_db.cpython-312.pyc +0 -0
  13. package/mcp_server/__pycache__/logging_db.cpython-313.pyc +0 -0
  14. package/mcp_server/__pycache__/pylance_bridge.cpython-312.pyc +0 -0
  15. package/mcp_server/__pycache__/pylance_bridge.cpython-313.pyc +0 -0
  16. package/mcp_server/__pycache__/pylance_bridge.cpython-314.pyc +0 -0
  17. package/mcp_server/__pycache__/resources.cpython-312.pyc +0 -0
  18. package/mcp_server/__pycache__/resources.cpython-313.pyc +0 -0
  19. package/mcp_server/__pycache__/tools.cpython-312.pyc +0 -0
  20. package/mcp_server/__pycache__/tools.cpython-313.pyc +0 -0
  21. package/mcp_server/__pycache__/tracing.cpython-313.pyc +0 -0
  22. package/mcp_server/ai_features.py +274 -0
  23. package/mcp_server/api_routes.py +429 -0
  24. package/mcp_server/auth.py +275 -0
  25. package/mcp_server/cloud_sync.py +427 -0
  26. package/mcp_server/logging_db.py +403 -0
  27. package/mcp_server/pylance_bridge.py +579 -0
  28. package/mcp_server/resources.py +174 -0
  29. package/mcp_server/tools.py +642 -0
  30. package/mcp_server/tracing.py +84 -0
  31. package/package.json +53 -0
  32. package/requirements.txt +29 -0
  33. package/scripts/check-python.js +57 -0
  34. package/server.py +1228 -0
@@ -0,0 +1,579 @@
1
+ """
2
+ Pylance Bridge Module
3
+
4
+ Spawns and manages communication with the Pyright language server via subprocess.
5
+ Implements LSP JSON-RPC protocol over stdio.
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ import logging
11
+ import os
12
+ import subprocess
13
+ import sys
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional
16
+ import threading
17
+ import queue
18
+
19
+ from opentelemetry import trace
20
+
21
+ logger = logging.getLogger(__name__)
22
+ tracer = trace.get_tracer(__name__)
23
+
24
+
25
+ class PylanceBridge:
26
+ """Manages a Pyright language server subprocess and handles LSP communication."""
27
+
28
+ def __init__(self, workspace_root: str):
29
+ """
30
+ Initialize the Pylance bridge.
31
+
32
+ Args:
33
+ workspace_root: Absolute path to the workspace root directory
34
+ """
35
+ self.workspace_root = Path(workspace_root).resolve()
36
+ self.process: Optional[subprocess.Popen] = None
37
+ self.message_id = 0
38
+ self.pending_responses: Dict[int, queue.Queue] = {}
39
+ self.reader_thread: Optional[threading.Thread] = None
40
+ self.running = False
41
+ self.initialized = False
42
+ self._lock = threading.Lock()
43
+
44
+ # Detect Python virtual environment
45
+ self.python_path = self._detect_python_venv()
46
+ if self.python_path:
47
+ logger.info(f"Detected Python environment: {self.python_path}")
48
+ else:
49
+ logger.warning("No virtual environment detected, using system Python")
50
+
51
+ def _detect_python_venv(self) -> Optional[str]:
52
+ """
53
+ Detect and return the Python executable path from virtual environment.
54
+ If no venv exists, creates one automatically.
55
+
56
+ Checks in order:
57
+ 1. .venv/Scripts/python.exe (Windows) or .venv/bin/python (Unix)
58
+ 2. venv/Scripts/python.exe (Windows) or venv/bin/python (Unix)
59
+ 3. env/Scripts/python.exe (Windows) or env/bin/python (Unix)
60
+ 4. .env/Scripts/python.exe (Windows) or .env/bin/python (Unix)
61
+ 5. If none found, creates .venv automatically
62
+ 6. System Python (fallback if creation fails)
63
+
64
+ Returns:
65
+ Path to Python executable or None if not found
66
+ """
67
+ venv_names = ['.venv', 'venv', 'env', '.env']
68
+
69
+ if sys.platform == 'win32':
70
+ python_names = ['python.exe', 'python3.exe']
71
+ venv_subdir = 'Scripts'
72
+ else:
73
+ python_names = ['python3', 'python']
74
+ venv_subdir = 'bin'
75
+
76
+ # Check for existing virtual environments in workspace
77
+ for venv_name in venv_names:
78
+ venv_path = self.workspace_root / venv_name
79
+ if venv_path.exists() and venv_path.is_dir():
80
+ for python_name in python_names:
81
+ python_exe = venv_path / venv_subdir / python_name
82
+ if python_exe.exists():
83
+ logger.info(f"Found existing virtual environment: {venv_path}")
84
+ return str(python_exe.resolve())
85
+
86
+ # No venv found - create one automatically
87
+ logger.info("No virtual environment found. Creating .venv automatically...")
88
+ created_python = self._create_venv()
89
+ if created_python:
90
+ return created_python
91
+
92
+ # Fallback to system Python
93
+ for python_name in python_names:
94
+ try:
95
+ result = subprocess.run(
96
+ ['which' if sys.platform != 'win32' else 'where', python_name],
97
+ capture_output=True,
98
+ text=True,
99
+ timeout=5
100
+ )
101
+ if result.returncode == 0 and result.stdout.strip():
102
+ python_path = result.stdout.strip().split('\n')[0]
103
+ logger.info(f"Using system Python: {python_path}")
104
+ return python_path
105
+ except Exception as e:
106
+ logger.debug(f"Error finding {python_name}: {e}")
107
+
108
+ return None
109
+
110
+ def _create_venv(self) -> Optional[str]:
111
+ """
112
+ Create a new virtual environment in .venv directory.
113
+
114
+ Returns:
115
+ Path to created Python executable, or None if creation failed
116
+ """
117
+ venv_path = self.workspace_root / '.venv'
118
+
119
+ # Find system Python to create venv
120
+ if sys.platform == 'win32':
121
+ python_names = ['python', 'python3', 'py']
122
+ else:
123
+ python_names = ['python3', 'python']
124
+
125
+ system_python = None
126
+ for python_name in python_names:
127
+ try:
128
+ result = subprocess.run(
129
+ ['which' if sys.platform != 'win32' else 'where', python_name],
130
+ capture_output=True,
131
+ text=True,
132
+ timeout=5
133
+ )
134
+ if result.returncode == 0 and result.stdout.strip():
135
+ system_python = result.stdout.strip().split('\n')[0]
136
+ break
137
+ except Exception as e:
138
+ logger.debug(f"Error finding {python_name}: {e}")
139
+
140
+ if not system_python:
141
+ logger.error("Cannot create venv: No Python executable found")
142
+ return None
143
+
144
+ logger.info(f"Creating virtual environment at {venv_path} using {system_python}")
145
+
146
+ try:
147
+ # Create venv using: python -m venv .venv
148
+ result = subprocess.run(
149
+ [system_python, '-m', 'venv', str(venv_path)],
150
+ capture_output=True,
151
+ text=True,
152
+ timeout=60,
153
+ cwd=str(self.workspace_root)
154
+ )
155
+
156
+ if result.returncode != 0:
157
+ logger.error(f"Failed to create venv: {result.stderr}")
158
+ return None
159
+
160
+ # Verify venv was created
161
+ if sys.platform == 'win32':
162
+ python_exe = venv_path / 'Scripts' / 'python.exe'
163
+ else:
164
+ python_exe = venv_path / 'bin' / 'python3'
165
+ if not python_exe.exists():
166
+ python_exe = venv_path / 'bin' / 'python'
167
+
168
+ if python_exe.exists():
169
+ logger.info(f"✓ Successfully created virtual environment: {venv_path}")
170
+
171
+ # Install common packages if requirements.txt exists
172
+ requirements_file = self.workspace_root / 'requirements.txt'
173
+ if requirements_file.exists():
174
+ logger.info("Found requirements.txt, installing dependencies...")
175
+ self._install_requirements(str(python_exe), str(requirements_file))
176
+ else:
177
+ logger.info("No requirements.txt found. Virtual environment created with no packages.")
178
+
179
+ return str(python_exe.resolve())
180
+ else:
181
+ logger.error(f"Venv created but Python executable not found at {python_exe}")
182
+ return None
183
+
184
+ except subprocess.TimeoutExpired:
185
+ logger.error("Timeout creating virtual environment (>60s)")
186
+ return None
187
+ except Exception as e:
188
+ logger.error(f"Error creating virtual environment: {e}")
189
+ return None
190
+
191
+ def _install_requirements(self, python_exe: str, requirements_file: str) -> None:
192
+ """
193
+ Install packages from requirements.txt into the virtual environment.
194
+
195
+ Args:
196
+ python_exe: Path to venv Python executable
197
+ requirements_file: Path to requirements.txt
198
+ """
199
+ try:
200
+ logger.info(f"Installing packages from {requirements_file}...")
201
+
202
+ # Use pip from the venv: python -m pip install -r requirements.txt
203
+ result = subprocess.run(
204
+ [python_exe, '-m', 'pip', 'install', '-r', requirements_file],
205
+ capture_output=True,
206
+ text=True,
207
+ timeout=300, # 5 minutes timeout for package installation
208
+ cwd=str(self.workspace_root)
209
+ )
210
+
211
+ if result.returncode == 0:
212
+ logger.info("✓ Successfully installed dependencies from requirements.txt")
213
+ else:
214
+ logger.warning(f"Failed to install some dependencies: {result.stderr}")
215
+
216
+ except subprocess.TimeoutExpired:
217
+ logger.warning("Package installation timed out (>5 minutes). Some packages may not be installed.")
218
+ except Exception as e:
219
+ logger.warning(f"Error installing requirements: {e}")
220
+
221
+
222
+ def _create_pyrightconfig(self) -> None:
223
+ """
224
+ Create or update pyrightconfig.json to use detected Python environment.
225
+ """
226
+ config_path = self.workspace_root / 'pyrightconfig.json'
227
+
228
+ config = {}
229
+ if config_path.exists():
230
+ try:
231
+ with open(config_path, 'r', encoding='utf-8') as f:
232
+ config = json.load(f)
233
+ except Exception as e:
234
+ logger.warning(f"Failed to read existing pyrightconfig.json: {e}")
235
+
236
+ # Update Python path if detected
237
+ if self.python_path:
238
+ config['pythonPath'] = self.python_path
239
+
240
+ # Set other useful defaults
241
+ if 'include' not in config:
242
+ config['include'] = ['.']
243
+
244
+ if 'exclude' not in config:
245
+ config['exclude'] = [
246
+ '**/node_modules',
247
+ '**/__pycache__',
248
+ '.git',
249
+ '.venv',
250
+ 'venv',
251
+ 'env',
252
+ '.env'
253
+ ]
254
+
255
+ # Write back to file
256
+ try:
257
+ with open(config_path, 'w', encoding='utf-8') as f:
258
+ json.dump(config, f, indent=2)
259
+ logger.info(f"Created/updated pyrightconfig.json with Python path: {self.python_path}")
260
+ except Exception as e:
261
+ logger.error(f"Failed to write pyrightconfig.json: {e}")
262
+
263
+ def start(self) -> None:
264
+ """Start the Pyright language server subprocess."""
265
+ if self.process and self.process.poll() is None:
266
+ logger.info("Pyright server already running")
267
+ return
268
+
269
+ # Create/update pyrightconfig.json with detected Python path
270
+ self._create_pyrightconfig()
271
+
272
+ # Find npx or pyright executable
273
+ npx_cmd = self._find_npx()
274
+ if not npx_cmd:
275
+ raise RuntimeError(
276
+ "npx not found. Please install Node.js: https://nodejs.org/"
277
+ )
278
+
279
+ # Start the language server
280
+ cmd = [npx_cmd, "pyright-langserver", "--stdio"]
281
+ logger.info(f"Starting Pyright: {' '.join(cmd)}")
282
+
283
+ try:
284
+ self.process = subprocess.Popen(
285
+ cmd,
286
+ stdin=subprocess.PIPE,
287
+ stdout=subprocess.PIPE,
288
+ stderr=subprocess.PIPE,
289
+ cwd=str(self.workspace_root),
290
+ text=False, # Use binary mode for proper encoding
291
+ )
292
+ except Exception as e:
293
+ raise RuntimeError(f"Failed to start Pyright: {e}")
294
+
295
+ self.running = True
296
+ self.reader_thread = threading.Thread(target=self._read_messages, daemon=True)
297
+ self.reader_thread.start()
298
+
299
+ # Initialize the language server
300
+ self._initialize()
301
+ logger.info("Pyright language server started successfully")
302
+
303
+ def stop(self) -> None:
304
+ """Stop the Pyright language server subprocess."""
305
+ self.running = False
306
+ if self.process:
307
+ try:
308
+ self.send_request("shutdown", {})
309
+ self.send_notification("exit", {})
310
+ self.process.wait(timeout=5)
311
+ except Exception as e:
312
+ logger.warning(f"Error during shutdown: {e}")
313
+ self.process.kill()
314
+ finally:
315
+ self.process = None
316
+ self.initialized = False
317
+ logger.info("Pyright language server stopped")
318
+
319
+ def _find_npx(self) -> Optional[str]:
320
+ """Find the npx executable."""
321
+ # Check if npx is in PATH
322
+ if sys.platform == "win32":
323
+ npx_names = ["npx.cmd", "npx.exe", "npx"]
324
+ else:
325
+ npx_names = ["npx"]
326
+
327
+ for npx_name in npx_names:
328
+ try:
329
+ result = subprocess.run(
330
+ ["where" if sys.platform == "win32" else "which", npx_name],
331
+ capture_output=True,
332
+ text=True,
333
+ )
334
+ if result.returncode == 0:
335
+ return npx_name
336
+ except Exception:
337
+ pass
338
+
339
+ # Check common locations
340
+ if sys.platform == "win32":
341
+ common_paths = [
342
+ os.path.expandvars(r"%ProgramFiles%\nodejs"),
343
+ os.path.expandvars(r"%ProgramFiles(x86)%\nodejs"),
344
+ os.path.expandvars(r"%APPDATA%\npm"),
345
+ ]
346
+ else:
347
+ common_paths = [
348
+ "/usr/local/bin",
349
+ "/usr/bin",
350
+ os.path.expanduser("~/.npm-global/bin"),
351
+ ]
352
+
353
+ for path in common_paths:
354
+ for npx_name in npx_names:
355
+ npx_path = os.path.join(path, npx_name)
356
+ if os.path.exists(npx_path):
357
+ return npx_path
358
+
359
+ return None
360
+
361
+ def _initialize(self) -> None:
362
+ """Send the initialize request to the language server."""
363
+ init_params = {
364
+ "processId": os.getpid(),
365
+ "rootUri": self.workspace_root.as_uri(),
366
+ "capabilities": {
367
+ "textDocument": {
368
+ "completion": {"completionItem": {"snippetSupport": True}},
369
+ "hover": {"contentFormat": ["markdown", "plaintext"]},
370
+ "definition": {"linkSupport": True},
371
+ "references": {},
372
+ "rename": {"prepareSupport": True},
373
+ "diagnostic": {},
374
+ "formatting": {},
375
+ },
376
+ "workspace": {
377
+ "applyEdit": True,
378
+ "workspaceEdit": {"documentChanges": True},
379
+ },
380
+ },
381
+ "workspaceFolders": [
382
+ {"uri": self.workspace_root.as_uri(), "name": self.workspace_root.name}
383
+ ],
384
+ }
385
+
386
+ # Add Python path to initialization options if detected
387
+ if self.python_path:
388
+ init_params["initializationOptions"] = {
389
+ "pythonPath": self.python_path,
390
+ "python": {
391
+ "pythonPath": self.python_path
392
+ }
393
+ }
394
+
395
+ response = self.send_request("initialize", init_params)
396
+ if "error" in response:
397
+ raise RuntimeError(f"Failed to initialize: {response['error']}")
398
+
399
+ self.send_notification("initialized", {})
400
+
401
+ # Send workspace configuration with Python path
402
+ if self.python_path:
403
+ self.send_notification("workspace/didChangeConfiguration", {
404
+ "settings": {
405
+ "python": {
406
+ "pythonPath": self.python_path
407
+ }
408
+ }
409
+ })
410
+
411
+ self.initialized = True
412
+ logger.info("Pyright language server initialized")
413
+
414
+ def _read_messages(self) -> None:
415
+ """Read messages from the language server stdout."""
416
+ buffer = b""
417
+ while self.running and self.process:
418
+ try:
419
+ chunk = self.process.stdout.read(1024)
420
+ if not chunk:
421
+ break
422
+
423
+ buffer += chunk
424
+
425
+ while b"\r\n\r\n" in buffer:
426
+ header_end = buffer.index(b"\r\n\r\n")
427
+ header = buffer[:header_end].decode("utf-8")
428
+ buffer = buffer[header_end + 4 :]
429
+
430
+ # Parse Content-Length
431
+ content_length = 0
432
+ for line in header.split("\r\n"):
433
+ if line.startswith("Content-Length:"):
434
+ content_length = int(line.split(":")[1].strip())
435
+ break
436
+
437
+ # Read the message body
438
+ while len(buffer) < content_length:
439
+ chunk = self.process.stdout.read(content_length - len(buffer))
440
+ if not chunk:
441
+ break
442
+ buffer += chunk
443
+
444
+ message_body = buffer[:content_length]
445
+ buffer = buffer[content_length:]
446
+
447
+ try:
448
+ message = json.loads(message_body.decode("utf-8"))
449
+ self._handle_message(message)
450
+ except json.JSONDecodeError as e:
451
+ logger.error(f"Failed to decode message: {e}")
452
+
453
+ except Exception as e:
454
+ logger.error(f"Error reading messages: {e}")
455
+ break
456
+
457
+ def _handle_message(self, message: Dict[str, Any]) -> None:
458
+ """Handle incoming message from the language server."""
459
+ if "id" in message:
460
+ # This is a response to a request
461
+ msg_id = message["id"]
462
+ if msg_id in self.pending_responses:
463
+ self.pending_responses[msg_id].put(message)
464
+ else:
465
+ # This is a notification - log it
466
+ logger.debug(f"Received notification: {message.get('method')}")
467
+
468
+ def send_notification(self, method: str, params: Dict[str, Any]) -> None:
469
+ """Send a notification to the language server."""
470
+ message = {"jsonrpc": "2.0", "method": method, "params": params}
471
+ self._send_message(message)
472
+
473
+ def send_request(self, method: str, params: Dict[str, Any], timeout: int = 30) -> Dict[str, Any]:
474
+ """
475
+ Send a request to the language server and wait for response.
476
+
477
+ Args:
478
+ method: LSP method name
479
+ params: Request parameters
480
+ timeout: Timeout in seconds
481
+
482
+ Returns:
483
+ Response dictionary
484
+ """
485
+ with tracer.start_as_current_span(f"lsp.{method}") as span:
486
+ span.set_attribute("lsp.method", method)
487
+ span.set_attribute("lsp.timeout", timeout)
488
+
489
+ with self._lock:
490
+ self.message_id += 1
491
+ msg_id = self.message_id
492
+
493
+ response_queue: queue.Queue = queue.Queue()
494
+ self.pending_responses[msg_id] = response_queue
495
+
496
+ message = {"jsonrpc": "2.0", "id": msg_id, "method": method, "params": params}
497
+ self._send_message(message)
498
+
499
+ try:
500
+ response = response_queue.get(timeout=timeout)
501
+ span.set_attribute("lsp.success", True)
502
+ return response
503
+ except queue.Empty:
504
+ span.set_attribute("lsp.success", False)
505
+ span.set_attribute("lsp.error", "timeout")
506
+ raise TimeoutError(f"Request {method} timed out after {timeout}s")
507
+ finally:
508
+ self.pending_responses.pop(msg_id, None)
509
+
510
+ def _send_message(self, message: Dict[str, Any]) -> None:
511
+ """Send a message to the language server."""
512
+ if not self.process or not self.process.stdin:
513
+ raise RuntimeError("Language server not running")
514
+
515
+ content = json.dumps(message).encode("utf-8")
516
+ header = f"Content-Length: {len(content)}\r\n\r\n".encode("utf-8")
517
+
518
+ try:
519
+ self.process.stdin.write(header + content)
520
+ self.process.stdin.flush()
521
+ except Exception as e:
522
+ raise RuntimeError(f"Failed to send message: {e}")
523
+
524
+ def _file_uri(self, file_path: str) -> str:
525
+ """Convert file path to URI."""
526
+ path = Path(file_path)
527
+ if not path.is_absolute():
528
+ path = (self.workspace_root / path).resolve()
529
+ return path.as_uri()
530
+
531
+ def _validate_path(self, file_path: str) -> Path:
532
+ """Validate that file path is within workspace root."""
533
+ path = Path(file_path)
534
+ if not path.is_absolute():
535
+ path = (self.workspace_root / path).resolve()
536
+ else:
537
+ path = path.resolve()
538
+
539
+ try:
540
+ path.relative_to(self.workspace_root)
541
+ except ValueError:
542
+ raise ValueError(f"File path {file_path} is outside workspace root")
543
+
544
+ return path
545
+
546
+ def open_document(self, file_path: str, content: str) -> None:
547
+ """Open a document in the language server."""
548
+ with tracer.start_as_current_span("lsp.open_document") as span:
549
+ span.set_attribute("file_path", file_path)
550
+ span.set_attribute("content_length", len(content))
551
+
552
+ uri = self._file_uri(file_path)
553
+ params = {
554
+ "textDocument": {
555
+ "uri": uri,
556
+ "languageId": "python",
557
+ "version": 1,
558
+ "text": content,
559
+ }
560
+ }
561
+ self.send_notification("textDocument/didOpen", params)
562
+
563
+ def close_document(self, file_path: str) -> None:
564
+ """Close a document in the language server."""
565
+ with tracer.start_as_current_span("lsp.close_document") as span:
566
+ span.set_attribute("file_path", file_path)
567
+
568
+ uri = self._file_uri(file_path)
569
+ params = {"textDocument": {"uri": uri}}
570
+ self.send_notification("textDocument/didClose", params)
571
+
572
+ def __enter__(self) -> "PylanceBridge":
573
+ """Context manager entry."""
574
+ self.start()
575
+ return self
576
+
577
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
578
+ """Context manager exit."""
579
+ self.stop()