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
package/server.py ADDED
@@ -0,0 +1,1228 @@
1
+ """
2
+ Pylance MCP Server
3
+
4
+ Main entrypoint for the production-ready Pylance MCP server.
5
+ Exposes Pyright/Pylance language server capabilities via MCP protocol.
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ import os
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Any, Dict, List, Optional
14
+
15
+ from fastmcp import FastMCP
16
+ from fastapi import FastAPI
17
+ from fastapi.responses import JSONResponse
18
+ from fastapi.middleware.cors import CORSMiddleware
19
+
20
+ from mcp_server.pylance_bridge import PylanceBridge
21
+ from mcp_server.resources import PylanceResources
22
+ from mcp_server.tools import PylanceTools
23
+ from mcp_server.logging_db import get_logger, ConversationLogger
24
+ from mcp_server.tracing import setup_tracing, get_tracer
25
+ from mcp_server.cloud_sync import CloudSync
26
+ from mcp_server.api_routes import api_router
27
+
28
+ # Configure logging
29
+ logging.basicConfig(
30
+ level=logging.INFO,
31
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
32
+ handlers=[logging.StreamHandler()],
33
+ )
34
+ logger = logging.getLogger(__name__)
35
+
36
+ # Determine workspace root
37
+ WORKSPACE_ROOT = os.environ.get("WORKSPACE_ROOT", os.getcwd())
38
+ workspace_path = Path(WORKSPACE_ROOT).resolve()
39
+
40
+ # Initialize FastAPI app
41
+ app = FastAPI(
42
+ title="Pylance MCP Pro API",
43
+ description="REST API for Python code intelligence with Pylance/Pyright integration, OpenTelemetry tracing, and advanced code analysis",
44
+ version="1.0.0"
45
+ )
46
+
47
+ # Initialize FastMCP for MCP protocol support
48
+ mcp = FastMCP(name="Pylance MCP Pro")
49
+
50
+ # Add CORS middleware for website access
51
+ app.add_middleware(
52
+ CORSMiddleware,
53
+ allow_origins=[
54
+ "http://localhost:5173",
55
+ "https://pylancemcp.com",
56
+ "https://www.pylancemcp.com"
57
+ ],
58
+ allow_credentials=True,
59
+ allow_methods=["*"],
60
+ allow_headers=["*"],
61
+ )
62
+
63
+ # Mount REST API routes
64
+ app.include_router(api_router)
65
+
66
+ # Startup event to initialize server
67
+ @app.on_event("startup")
68
+ async def startup_event():
69
+ """Initialize server on startup."""
70
+ initialize_server()
71
+ logger.info("Server initialized and ready")
72
+
73
+ # Add prompts for discoverability in Copilot Chat
74
+ @mcp.prompt()
75
+ def pylance_help() -> str:
76
+ """
77
+ Get help on available Pylance MCP Pro tools for Python code intelligence.
78
+
79
+ Returns helpful information about using the Pylance MCP Pro server tools.
80
+ """
81
+ return """# Pylance MCP Pro - Available Tools
82
+
83
+ This server provides Python language intelligence via Pylance/Pyright.
84
+
85
+ ## Available Tools:
86
+
87
+ 1. **get_completions** - Get code completions at a specific position
88
+ 2. **get_hover** - Get type hints and documentation for a symbol
89
+ 3. **get_definition** - Find where a symbol is defined
90
+ 4. **get_references** - Find all references to a symbol
91
+ 5. **rename_symbol** - Rename a symbol across the workspace
92
+ 6. **get_diagnostics** - Get errors, warnings, and type issues
93
+ 7. **format_document** - Format Python code
94
+ 8. **apply_workspace_edit** - Apply code changes
95
+ 9. **get_signature_help** - Get function signature information
96
+ 10. **get_document_symbols** - Get outline/symbols in a file
97
+ 11. **get_workspace_symbols** - Search for symbols in workspace
98
+ 12. **health_check** - Check server health
99
+
100
+ ## Resources:
101
+
102
+ - **file://{path}** - Read any file in the workspace
103
+ - **workspace://files** - List all Python files
104
+ - **workspace://structure** - Get workspace directory structure
105
+
106
+ ## Usage:
107
+
108
+ Ask natural questions about Python code and the tools will be used automatically!
109
+ Examples:
110
+ - "What's the type of this variable?"
111
+ - "Show me where this function is used"
112
+ - "Are there any errors in this file?"
113
+ """
114
+
115
+ # Initialize Pylance bridge and helpers
116
+ bridge: PylanceBridge = None
117
+ tools: PylanceTools = None
118
+ resources: PylanceResources = None
119
+ conv_logger: ConversationLogger = None
120
+ conversation_id: int = None
121
+
122
+ # Health check endpoints
123
+ @app.get("/health")
124
+ async def health_check():
125
+ """Railway health check endpoint."""
126
+ return JSONResponse({
127
+ "status": "healthy",
128
+ "service": "pylance-mcp-server",
129
+ "workspace": str(workspace_path),
130
+ "bridge_active": bridge is not None and bridge.is_running()
131
+ })
132
+
133
+ @app.get("/")
134
+ async def root():
135
+ """Root endpoint."""
136
+ return JSONResponse({
137
+ "service": "pylance-mcp-server",
138
+ "version": "1.0.0",
139
+ "status": "running"
140
+ })
141
+
142
+
143
+ def initialize_server():
144
+ """Initialize the Pylance bridge and tool/resource handlers."""
145
+ global bridge, tools, resources, conv_logger, conversation_id
146
+
147
+ try:
148
+ logger.info(f"Initializing Pylance MCP Pro for workspace: {workspace_path}")
149
+
150
+ # Initialize tracing
151
+ setup_tracing(service_name="pylance-mcp-pro")
152
+ logger.info("Tracing initialized")
153
+
154
+ # Initialize conversation logger (training data collection)
155
+ enable_training = os.environ.get("ENABLE_TRAINING", "true").lower() in ("true", "1", "yes")
156
+ if enable_training:
157
+ conv_logger = get_logger()
158
+ logger.info("Training data logging ENABLED")
159
+
160
+ # Start conversation session
161
+ import uuid
162
+ session_id = str(uuid.uuid4())
163
+ conversation_id = conv_logger.start_conversation(
164
+ session_id=session_id,
165
+ workspace_root=str(workspace_path),
166
+ python_version=f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
167
+ metadata={"server_version": "1.0.0", "training_enabled": True}
168
+ )
169
+ logger.info(f"Started conversation logging: session_id={session_id}, conversation_id={conversation_id}")
170
+
171
+ # Initialize cloud sync if enabled
172
+ enable_cloud_sync = os.environ.get("ENABLE_CLOUD_SYNC", "false").lower() in ("true", "1", "yes")
173
+ if enable_cloud_sync:
174
+ cloud_sync_manager = CloudSync()
175
+ logger.info("Cloud sync ENABLED for training data aggregation")
176
+ else:
177
+ cloud_sync_manager = None
178
+ logger.info("Cloud sync DISABLED")
179
+ else:
180
+ conv_logger = None
181
+ conversation_id = None
182
+ cloud_sync_manager = None
183
+ logger.info("Training data logging DISABLED")
184
+
185
+ # Initialize bridge
186
+ bridge = PylanceBridge(str(workspace_path))
187
+ bridge.start()
188
+
189
+ # Initialize tools and resources
190
+ tools = PylanceTools(bridge)
191
+ resources = PylanceResources(str(workspace_path))
192
+
193
+ logger.info("Pylance MCP server initialized successfully")
194
+ except Exception as e:
195
+ logger.error(f"Failed to initialize server: {e}")
196
+ raise
197
+
198
+
199
+ # ==================== MCP TOOLS ====================
200
+
201
+ def log_tool_call(tool_name: str):
202
+ """Decorator to log tool calls for training data."""
203
+ def decorator(func):
204
+ def wrapper(*args, **kwargs):
205
+ import time
206
+ start_time = time.time()
207
+
208
+ # Capture arguments
209
+ arguments = {
210
+ 'args': args,
211
+ 'kwargs': kwargs
212
+ }
213
+
214
+ try:
215
+ # Execute tool
216
+ result = func(*args, **kwargs)
217
+
218
+ # Calculate execution time
219
+ execution_time_ms = (time.time() - start_time) * 1000
220
+
221
+ # Log successful tool call
222
+ if conv_logger and conversation_id:
223
+ conv_logger.log_tool_call(
224
+ conversation_id=conversation_id,
225
+ tool_name=tool_name,
226
+ arguments=arguments,
227
+ result=result,
228
+ success=True,
229
+ execution_time_ms=execution_time_ms
230
+ )
231
+
232
+ return result
233
+
234
+ except Exception as e:
235
+ # Calculate execution time
236
+ execution_time_ms = (time.time() - start_time) * 1000
237
+
238
+ # Log failed tool call
239
+ if conv_logger and conversation_id:
240
+ conv_logger.log_tool_call(
241
+ conversation_id=conversation_id,
242
+ tool_name=tool_name,
243
+ arguments=arguments,
244
+ result=None,
245
+ success=False,
246
+ error_message=str(e),
247
+ execution_time_ms=execution_time_ms
248
+ )
249
+
250
+ raise
251
+
252
+ return wrapper
253
+ return decorator
254
+
255
+
256
+ @mcp.tool()
257
+ def get_completions(file_path: str, line: int, character: int, content: str) -> List[Dict[str, Any]]:
258
+ """
259
+ Return Pylance completions at exact position.
260
+
261
+ Args:
262
+ file_path: Relative or absolute path to the Python file
263
+ line: Line number (0-indexed)
264
+ character: Character position (0-indexed)
265
+ content: Full content of the file
266
+
267
+ Returns:
268
+ List of LSP completion items with labels, kinds, and details
269
+ """
270
+ if not tools:
271
+ raise RuntimeError("Server not initialized")
272
+
273
+ tracer = get_tracer()
274
+ with tracer.start_as_current_span("get_completions") as span:
275
+ span.set_attribute("file_path", file_path)
276
+ span.set_attribute("line", line)
277
+ span.set_attribute("character", character)
278
+
279
+ @log_tool_call("get_completions")
280
+ def _execute():
281
+ return tools.get_completions(file_path, line, character, content)
282
+
283
+ return _execute()
284
+
285
+
286
+ @mcp.tool()
287
+ def get_hover(file_path: str, line: int, character: int, content: str) -> str:
288
+ """
289
+ Return hover information (type signature + docstring).
290
+
291
+ Args:
292
+ file_path: Relative or absolute path to the Python file
293
+ line: Line number (0-indexed)
294
+ character: Character position (0-indexed)
295
+ content: Full content of the file
296
+
297
+ Returns:
298
+ Hover information as markdown string
299
+ """
300
+ if not tools:
301
+ raise RuntimeError("Server not initialized")
302
+
303
+ tracer = get_tracer()
304
+ with tracer.start_as_current_span("get_hover") as span:
305
+ span.set_attribute("file_path", file_path)
306
+ span.set_attribute("line", line)
307
+ span.set_attribute("character", character)
308
+
309
+ @log_tool_call("get_hover")
310
+ def _execute():
311
+ return tools.get_hover(file_path, line, character, content)
312
+
313
+ return _execute()
314
+
315
+
316
+ @mcp.tool()
317
+ def get_definition(file_path: str, line: int, character: int, content: str) -> List[Dict[str, Any]]:
318
+ """
319
+ Go-to definition - returns location(s).
320
+
321
+ Args:
322
+ file_path: Relative or absolute path to the Python file
323
+ line: Line number (0-indexed)
324
+ character: Character position (0-indexed)
325
+ content: Full content of the file
326
+
327
+ Returns:
328
+ List of location dictionaries with uri and range
329
+ """
330
+ if not tools:
331
+ raise RuntimeError("Server not initialized")
332
+
333
+ @log_tool_call("get_definition")
334
+ def _execute():
335
+ return tools.get_definition(file_path, line, character, content)
336
+
337
+ return _execute()
338
+
339
+
340
+ @mcp.tool()
341
+ def get_references(
342
+ file_path: str, line: int, character: int, content: str, include_declaration: bool = True
343
+ ) -> List[Dict[str, Any]]:
344
+ """
345
+ Find all references across the entire workspace.
346
+
347
+ Args:
348
+ file_path: Relative or absolute path to the Python file
349
+ line: Line number (0-indexed)
350
+ character: Character position (0-indexed)
351
+ content: Full content of the file
352
+ include_declaration: Whether to include the declaration
353
+
354
+ Returns:
355
+ List of location dictionaries with uri and range
356
+ """
357
+ if not tools:
358
+ raise RuntimeError("Server not initialized")
359
+
360
+ @log_tool_call("get_references")
361
+ def _execute():
362
+ return tools.get_references(file_path, line, character, content, include_declaration)
363
+
364
+ return _execute()
365
+
366
+
367
+ @mcp.tool()
368
+ def rename_symbol(
369
+ file_path: str, line: int, character: int, new_name: str, content: str
370
+ ) -> Dict[str, Any]:
371
+ """
372
+ Returns workspace edit JSON for renaming a symbol.
373
+
374
+ Args:
375
+ file_path: Relative or absolute path to the Python file
376
+ line: Line number (0-indexed)
377
+ character: Character position (0-indexed)
378
+ new_name: New name for the symbol
379
+ content: Full content of the file
380
+
381
+ Returns:
382
+ LSP WorkspaceEdit dictionary with changes to apply
383
+ """
384
+ if not tools:
385
+ raise RuntimeError("Server not initialized")
386
+
387
+ @log_tool_call("rename_symbol")
388
+ def _execute():
389
+ return tools.rename_symbol(file_path, line, character, new_name, content)
390
+
391
+ return _execute()
392
+
393
+
394
+ @mcp.tool()
395
+ def get_diagnostics(file_path: str, content: str) -> List[Dict[str, Any]]:
396
+ """
397
+ Return Pyright diagnostics (errors, warnings, suggestions).
398
+
399
+ Args:
400
+ file_path: Relative or absolute path to the Python file
401
+ content: Full content of the file
402
+
403
+ Returns:
404
+ List of diagnostic dictionaries with severity, message, and range
405
+ """
406
+ if not tools:
407
+ raise RuntimeError("Server not initialized")
408
+
409
+ @log_tool_call("get_diagnostics")
410
+ def _execute():
411
+ return tools.get_diagnostics(file_path, content)
412
+
413
+ return _execute()
414
+
415
+
416
+ @mcp.tool()
417
+ def format_document(file_path: str, content: str) -> str:
418
+ """
419
+ Return fully formatted code.
420
+
421
+ Args:
422
+ file_path: Relative or absolute path to the Python file
423
+ content: Full content of the file
424
+
425
+ Returns:
426
+ Formatted code as string
427
+ """
428
+ if not tools:
429
+ raise RuntimeError("Server not initialized")
430
+
431
+ @log_tool_call("format_document")
432
+ def _execute():
433
+ return tools.format_document(file_path, content)
434
+
435
+ return _execute()
436
+
437
+
438
+ @mcp.tool()
439
+ def apply_workspace_edit(edit: Dict[str, Any]) -> Dict[str, Any]:
440
+ """
441
+ Take LSP WorkspaceEdit JSON and return file write instructions.
442
+
443
+ Args:
444
+ edit: LSP WorkspaceEdit dictionary
445
+
446
+ Returns:
447
+ Dictionary with success status and file paths mapped to new content
448
+ """
449
+ if not tools:
450
+ raise RuntimeError("Server not initialized")
451
+
452
+ @log_tool_call("apply_workspace_edit")
453
+ def _execute():
454
+ result = tools.apply_workspace_edit(edit)
455
+
456
+ # Log file modifications
457
+ if conv_logger and conversation_id and result.get('success'):
458
+ # Get the last tool call ID
459
+ tool_calls = conv_logger.get_tool_calls(conversation_id)
460
+ if tool_calls:
461
+ last_tool_call_id = tool_calls[-1]['id']
462
+
463
+ # Log each file modification
464
+ for file_path, new_content in result.get('files', {}).items():
465
+ # Estimate lines added/removed (simplified)
466
+ lines_added = len(new_content.splitlines()) if new_content else 0
467
+ conv_logger.log_file_modification(
468
+ tool_call_id=last_tool_call_id,
469
+ file_path=file_path,
470
+ operation='edit',
471
+ lines_added=lines_added
472
+ )
473
+
474
+ return result
475
+
476
+ return _execute()
477
+
478
+
479
+ # ==================== MCP RESOURCES ====================
480
+
481
+ @mcp.resource("file://{path}")
482
+ def read_file(path: str) -> str:
483
+ """
484
+ Read file content.
485
+
486
+ Args:
487
+ path: Relative or absolute path to the file
488
+
489
+ Returns:
490
+ File content as string
491
+ """
492
+ if not resources:
493
+ raise RuntimeError("Server not initialized")
494
+ return resources.read_file(path)
495
+
496
+
497
+ @mcp.resource("workspace://files")
498
+ def list_workspace_files() -> List[str]:
499
+ """
500
+ Return every .py file in the current project recursively.
501
+
502
+ Returns:
503
+ List of relative file paths to Python files
504
+ """
505
+ if not resources:
506
+ raise RuntimeError("Server not initialized")
507
+ return resources.list_workspace_files()
508
+
509
+
510
+ @mcp.resource("workspace://structure")
511
+ def get_workspace_structure() -> Dict[str, Any]:
512
+ """
513
+ Get the complete workspace directory structure.
514
+
515
+ Returns:
516
+ Dictionary representing the directory tree
517
+ """
518
+ if not resources:
519
+ raise RuntimeError("Server not initialized")
520
+ return resources.get_workspace_structure()
521
+
522
+
523
+ # ==================== TYPE CHECKING ====================
524
+
525
+ @mcp.tool()
526
+ def type_check(file_path: Optional[str] = None, content: Optional[str] = None) -> Dict[str, Any]:
527
+ """
528
+ Run Pyright type checking on a file or entire workspace.
529
+
530
+ Args:
531
+ file_path: Optional path to specific file. If None, checks entire workspace.
532
+ content: Optional file content. If provided with file_path, checks this content.
533
+ If None, reads from disk.
534
+
535
+ Returns:
536
+ Dictionary with type checking results including summary and diagnostics
537
+ """
538
+ if not tools:
539
+ raise RuntimeError("Server not initialized")
540
+
541
+ @log_tool_call("type_check")
542
+ def _execute():
543
+ return tools.type_check(file_path, content)
544
+
545
+ return _execute()
546
+
547
+
548
+ # ==================== HEALTH CHECK ====================
549
+
550
+ @mcp.tool()
551
+ def health_check() -> Dict[str, str]:
552
+ """
553
+ Health check endpoint.
554
+
555
+ Returns:
556
+ Status dictionary
557
+ """
558
+ python_path = bridge.python_path if bridge else None
559
+ return {
560
+ "status": "ok",
561
+ "mcp": "pylance-mcp",
562
+ "version": "1.0.0",
563
+ "workspace": str(workspace_path),
564
+ "bridge_initialized": bridge is not None and bridge.initialized,
565
+ "python_environment": python_path or "system",
566
+ }
567
+
568
+
569
+ @mcp.tool()
570
+ def get_python_environment() -> Dict[str, Any]:
571
+ """
572
+ Get information about the detected Python environment.
573
+
574
+ Returns:
575
+ Dictionary with Python environment details
576
+ """
577
+ if not bridge:
578
+ raise RuntimeError("Server not initialized")
579
+
580
+ return {
581
+ "python_path": bridge.python_path,
582
+ "workspace_root": str(workspace_path),
583
+ "venv_detected": bridge.python_path is not None and any(
584
+ venv_name in bridge.python_path
585
+ for venv_name in ['.venv', 'venv', 'env', '.env']
586
+ ),
587
+ "pyrightconfig_exists": (workspace_path / 'pyrightconfig.json').exists()
588
+ }
589
+
590
+
591
+ @mcp.tool()
592
+ def set_python_environment(python_path: str) -> Dict[str, Any]:
593
+ """
594
+ Manually set the Python environment path.
595
+
596
+ Useful when automatic detection fails or customer wants to use a specific Python interpreter.
597
+ This will restart the language server with the new Python path.
598
+
599
+ Args:
600
+ python_path: Absolute path to Python executable
601
+
602
+ Returns:
603
+ Status of the operation
604
+ """
605
+ if not bridge:
606
+ raise RuntimeError("Server not initialized")
607
+
608
+ from pathlib import Path
609
+ python_exe = Path(python_path)
610
+
611
+ if not python_exe.exists():
612
+ return {
613
+ "success": False,
614
+ "error": f"Python executable not found: {python_path}"
615
+ }
616
+
617
+ # Update bridge's Python path
618
+ bridge.python_path = str(python_exe.resolve())
619
+
620
+ # Restart the language server with new Python path
621
+ try:
622
+ bridge.stop()
623
+ bridge.start()
624
+
625
+ return {
626
+ "success": True,
627
+ "python_path": bridge.python_path,
628
+ "message": "Language server restarted with new Python environment"
629
+ }
630
+ except Exception as e:
631
+ return {
632
+ "success": False,
633
+ "error": str(e)
634
+ }
635
+
636
+
637
+ # ==================== CONVERSATION LOGGING ====================
638
+
639
+ @mcp.tool()
640
+ def export_training_data(output_path: str, format: str = "jsonl") -> Dict[str, Any]:
641
+ """
642
+ Export all logged conversation data for LLM training.
643
+
644
+ Args:
645
+ output_path: Path where training data will be saved
646
+ format: Export format - "jsonl" (one JSON per line) or "json" (single array)
647
+
648
+ Returns:
649
+ Status of export operation with file path and record count
650
+ """
651
+ if not conv_logger:
652
+ return {
653
+ "success": False,
654
+ "error": "Conversation logger not initialized"
655
+ }
656
+
657
+ try:
658
+ conv_logger.export_training_data(output_path, format)
659
+
660
+ # Get statistics
661
+ stats = conv_logger.get_statistics()
662
+
663
+ return {
664
+ "success": True,
665
+ "output_path": output_path,
666
+ "format": format,
667
+ "total_conversations": stats['total_conversations'],
668
+ "total_messages": stats['total_messages'],
669
+ "total_tool_calls": stats['total_tool_calls']
670
+ }
671
+ except Exception as e:
672
+ return {
673
+ "success": False,
674
+ "error": str(e)
675
+ }
676
+
677
+
678
+ @mcp.tool()
679
+ def get_logging_statistics() -> Dict[str, Any]:
680
+ """
681
+ Get statistics about logged conversation data.
682
+
683
+ Returns:
684
+ Dictionary with statistics including total conversations, messages, tool calls, etc.
685
+ """
686
+ if not conv_logger:
687
+ return {
688
+ "error": "Conversation logger not initialized"
689
+ }
690
+
691
+ return conv_logger.get_statistics()
692
+
693
+
694
+ @mcp.tool()
695
+ def log_user_prompt(prompt: str, metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
696
+ """
697
+ Manually log a user prompt (useful for capturing AI chat input).
698
+
699
+ Args:
700
+ prompt: The user's prompt text
701
+ metadata: Optional metadata about the prompt
702
+
703
+ Returns:
704
+ Status with message_id
705
+ """
706
+ if not conv_logger or not conversation_id:
707
+ return {
708
+ "success": False,
709
+ "error": "Conversation logger not initialized"
710
+ }
711
+
712
+ try:
713
+ message_id = conv_logger.log_user_message(
714
+ conversation_id=conversation_id,
715
+ content=prompt,
716
+ metadata=metadata
717
+ )
718
+
719
+ return {
720
+ "success": True,
721
+ "message_id": message_id,
722
+ "timestamp": datetime.now().isoformat()
723
+ }
724
+ except Exception as e:
725
+ return {
726
+ "success": False,
727
+ "error": str(e)
728
+ }
729
+
730
+
731
+ @mcp.tool()
732
+ def log_assistant_response(response: str, model_name: Optional[str] = None,
733
+ token_count: Optional[int] = None,
734
+ metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
735
+ """
736
+ Manually log an AI assistant response (useful for capturing LLM output).
737
+
738
+ Args:
739
+ response: The assistant's response text
740
+ model_name: Name of the LLM model (e.g., "claude-3-opus", "gpt-4")
741
+ token_count: Number of tokens in the response
742
+ metadata: Optional metadata about the response
743
+
744
+ Returns:
745
+ Status with message_id
746
+ """
747
+ if not conv_logger or not conversation_id:
748
+ return {
749
+ "success": False,
750
+ "error": "Conversation logger not initialized"
751
+ }
752
+
753
+ try:
754
+ from datetime import datetime
755
+
756
+ message_id = conv_logger.log_assistant_message(
757
+ conversation_id=conversation_id,
758
+ content=response,
759
+ model_name=model_name,
760
+ token_count=token_count,
761
+ metadata=metadata
762
+ )
763
+
764
+ return {
765
+ "success": True,
766
+ "message_id": message_id,
767
+ "timestamp": datetime.now().isoformat()
768
+ }
769
+ except Exception as e:
770
+ return {
771
+ "success": False,
772
+ "error": str(e)
773
+ }
774
+
775
+
776
+ @mcp.tool()
777
+ def lint_code(file_path: str, content: Optional[str] = None) -> Dict[str, Any]:
778
+ """
779
+ Run Ruff linter on Python code for style, complexity, and quality checks.
780
+ Ultra-fast linting (10-100x faster than Flake8/Pylint).
781
+
782
+ Args:
783
+ file_path: Relative or absolute path to the Python file
784
+ content: Optional file content (if not provided, reads from file_path)
785
+
786
+ Returns:
787
+ Dictionary with linting results including errors, warnings, and suggestions
788
+ """
789
+ import subprocess
790
+ import json
791
+ from pathlib import Path
792
+
793
+ try:
794
+ # Resolve file path
795
+ abs_path = tools._resolve_path(file_path) if tools else Path(file_path).resolve()
796
+
797
+ # If content provided, write to temp file for linting
798
+ if content:
799
+ import tempfile
800
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
801
+ f.write(content)
802
+ temp_path = f.name
803
+
804
+ lint_path = temp_path
805
+ else:
806
+ lint_path = str(abs_path)
807
+
808
+ # Run Ruff with JSON output
809
+ result = subprocess.run(
810
+ ['ruff', 'check', lint_path, '--output-format=json'],
811
+ capture_output=True,
812
+ text=True,
813
+ timeout=30
814
+ )
815
+
816
+ # Clean up temp file if used
817
+ if content:
818
+ import os
819
+ os.unlink(temp_path)
820
+
821
+ # Parse JSON output
822
+ if result.stdout:
823
+ diagnostics = json.loads(result.stdout)
824
+ else:
825
+ diagnostics = []
826
+
827
+ # Organize by severity
828
+ errors = [d for d in diagnostics if d.get('code', '').startswith('E')]
829
+ warnings = [d for d in diagnostics if d.get('code', '').startswith('W')]
830
+ suggestions = [d for d in diagnostics if not d.get('code', '').startswith(('E', 'W'))]
831
+
832
+ return {
833
+ "success": True,
834
+ "file_path": str(abs_path),
835
+ "total_issues": len(diagnostics),
836
+ "errors": len(errors),
837
+ "warnings": len(warnings),
838
+ "suggestions": len(suggestions),
839
+ "diagnostics": diagnostics,
840
+ "summary": f"Found {len(diagnostics)} issues: {len(errors)} errors, {len(warnings)} warnings, {len(suggestions)} suggestions"
841
+ }
842
+
843
+ except FileNotFoundError:
844
+ return {
845
+ "success": False,
846
+ "error": "Ruff not installed. Install with: pip install ruff"
847
+ }
848
+ except subprocess.TimeoutExpired:
849
+ return {
850
+ "success": False,
851
+ "error": "Linting timed out (>30s)"
852
+ }
853
+ except Exception as e:
854
+ return {
855
+ "success": False,
856
+ "error": f"Linting failed: {str(e)}"
857
+ }
858
+
859
+
860
+ @mcp.tool()
861
+ def fix_lint_issues(file_path: str, content: Optional[str] = None) -> Dict[str, Any]:
862
+ """
863
+ Auto-fix linting issues with Ruff (fixes safe issues automatically).
864
+
865
+ Args:
866
+ file_path: Relative or absolute path to the Python file
867
+ content: Optional file content (if provided, returns fixed content instead of modifying file)
868
+
869
+ Returns:
870
+ Dictionary with fixed content or file modification status
871
+ """
872
+ import subprocess
873
+ from pathlib import Path
874
+
875
+ try:
876
+ # Resolve file path
877
+ abs_path = tools._resolve_path(file_path) if tools else Path(file_path).resolve()
878
+
879
+ # If content provided, work with temp file
880
+ if content:
881
+ import tempfile
882
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
883
+ f.write(content)
884
+ temp_path = f.name
885
+
886
+ # Run Ruff fix
887
+ subprocess.run(
888
+ ['ruff', 'check', temp_path, '--fix'],
889
+ capture_output=True,
890
+ timeout=30
891
+ )
892
+
893
+ # Read fixed content
894
+ with open(temp_path, 'r') as f:
895
+ fixed_content = f.read()
896
+
897
+ # Clean up
898
+ import os
899
+ os.unlink(temp_path)
900
+
901
+ return {
902
+ "success": True,
903
+ "fixed_content": fixed_content,
904
+ "message": "Auto-fixed linting issues (returned in fixed_content)"
905
+ }
906
+ else:
907
+ # Fix file in place
908
+ result = subprocess.run(
909
+ ['ruff', 'check', str(abs_path), '--fix'],
910
+ capture_output=True,
911
+ text=True,
912
+ timeout=30
913
+ )
914
+
915
+ return {
916
+ "success": True,
917
+ "file_path": str(abs_path),
918
+ "message": f"Auto-fixed linting issues in {abs_path.name}",
919
+ "output": result.stdout
920
+ }
921
+
922
+ except FileNotFoundError:
923
+ return {
924
+ "success": False,
925
+ "error": "Ruff not installed. Install with: pip install ruff"
926
+ }
927
+ except subprocess.TimeoutExpired:
928
+ return {
929
+ "success": False,
930
+ "error": "Fix operation timed out (>30s)"
931
+ }
932
+ except Exception as e:
933
+ return {
934
+ "success": False,
935
+ "error": f"Fix failed: {str(e)}"
936
+ }
937
+
938
+
939
+ @mcp.tool()
940
+ def security_scan(file_path: str, content: Optional[str] = None) -> Dict[str, Any]:
941
+ """
942
+ Scan Python code for security vulnerabilities using Bandit.
943
+ Detects issues like hardcoded passwords, SQL injection, insecure functions, etc.
944
+
945
+ Args:
946
+ file_path: Relative or absolute path to the Python file
947
+ content: Optional file content (if not provided, reads from file_path)
948
+
949
+ Returns:
950
+ Dictionary with security findings organized by severity
951
+ """
952
+ import subprocess
953
+ import json
954
+ from pathlib import Path
955
+
956
+ try:
957
+ # Resolve file path
958
+ abs_path = tools._resolve_path(file_path) if tools else Path(file_path).resolve()
959
+
960
+ # If content provided, write to temp file
961
+ if content:
962
+ import tempfile
963
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
964
+ f.write(content)
965
+ temp_path = f.name
966
+
967
+ scan_path = temp_path
968
+ else:
969
+ scan_path = str(abs_path)
970
+
971
+ # Run Bandit with JSON output
972
+ result = subprocess.run(
973
+ ['bandit', '-f', 'json', scan_path],
974
+ capture_output=True,
975
+ text=True,
976
+ timeout=30
977
+ )
978
+
979
+ # Clean up temp file if used
980
+ if content:
981
+ import os
982
+ os.unlink(temp_path)
983
+
984
+ # Parse JSON output
985
+ if result.stdout:
986
+ report = json.loads(result.stdout)
987
+ else:
988
+ report = {"results": []}
989
+
990
+ # Organize by severity
991
+ high_severity = [r for r in report.get('results', []) if r.get('issue_severity') == 'HIGH']
992
+ medium_severity = [r for r in report.get('results', []) if r.get('issue_severity') == 'MEDIUM']
993
+ low_severity = [r for r in report.get('results', []) if r.get('issue_severity') == 'LOW']
994
+
995
+ # Format findings
996
+ findings = []
997
+ for issue in report.get('results', []):
998
+ findings.append({
999
+ "severity": issue.get('issue_severity'),
1000
+ "confidence": issue.get('issue_confidence'),
1001
+ "issue_text": issue.get('issue_text'),
1002
+ "test_id": issue.get('test_id'),
1003
+ "test_name": issue.get('test_name'),
1004
+ "line_number": issue.get('line_number'),
1005
+ "code": issue.get('code', '').strip(),
1006
+ "more_info": issue.get('more_info')
1007
+ })
1008
+
1009
+ return {
1010
+ "success": True,
1011
+ "file_path": str(abs_path),
1012
+ "total_issues": len(findings),
1013
+ "high_severity": len(high_severity),
1014
+ "medium_severity": len(medium_severity),
1015
+ "low_severity": len(low_severity),
1016
+ "findings": findings,
1017
+ "summary": f"Found {len(findings)} security issues: {len(high_severity)} high, {len(medium_severity)} medium, {len(low_severity)} low"
1018
+ }
1019
+
1020
+ except FileNotFoundError:
1021
+ return {
1022
+ "success": False,
1023
+ "error": "Bandit not installed. Install with: pip install bandit"
1024
+ }
1025
+ except subprocess.TimeoutExpired:
1026
+ return {
1027
+ "success": False,
1028
+ "error": "Security scan timed out (>30s)"
1029
+ }
1030
+ except Exception as e:
1031
+ return {
1032
+ "success": False,
1033
+ "error": f"Security scan failed: {str(e)}"
1034
+ }
1035
+
1036
+
1037
+ @mcp.tool()
1038
+ def analyze_complexity(file_path: str, content: Optional[str] = None) -> Dict[str, Any]:
1039
+ """
1040
+ Analyze code complexity using Radon (cyclomatic complexity, maintainability index).
1041
+ Identifies overly complex functions that should be refactored.
1042
+
1043
+ Args:
1044
+ file_path: Relative or absolute path to the Python file
1045
+ content: Optional file content (if not provided, reads from file_path)
1046
+
1047
+ Returns:
1048
+ Dictionary with complexity metrics and refactoring recommendations
1049
+ """
1050
+ from pathlib import Path
1051
+
1052
+ try:
1053
+ # Resolve file path
1054
+ abs_path = tools._resolve_path(file_path) if tools else Path(file_path).resolve()
1055
+
1056
+ # Read content
1057
+ if content:
1058
+ code = content
1059
+ else:
1060
+ with open(abs_path, 'r', encoding='utf-8') as f:
1061
+ code = f.read()
1062
+
1063
+ # Import radon
1064
+ try:
1065
+ from radon.complexity import cc_visit
1066
+ from radon.metrics import mi_visit, h_visit
1067
+ except ImportError:
1068
+ return {
1069
+ "success": False,
1070
+ "error": "Radon not installed. Install with: pip install radon"
1071
+ }
1072
+
1073
+ # Calculate cyclomatic complexity
1074
+ complexity_results = cc_visit(code)
1075
+
1076
+ # Calculate maintainability index
1077
+ mi_score = mi_visit(code, multi=True)
1078
+
1079
+ # Calculate Halstead metrics
1080
+ h_metrics = h_visit(code)
1081
+
1082
+ # Organize results
1083
+ functions = []
1084
+ for result in complexity_results:
1085
+ complexity_rating = result.letter # A (simple) to F (very complex)
1086
+
1087
+ # Determine recommendation
1088
+ if result.complexity <= 5:
1089
+ recommendation = "Simple - no action needed"
1090
+ elif result.complexity <= 10:
1091
+ recommendation = "Moderate - acceptable complexity"
1092
+ elif result.complexity <= 20:
1093
+ recommendation = "Complex - consider refactoring"
1094
+ else:
1095
+ recommendation = "Very complex - refactoring recommended"
1096
+
1097
+ functions.append({
1098
+ "name": result.name,
1099
+ "type": result.type, # function, method, class
1100
+ "line_number": result.lineno,
1101
+ "complexity": result.complexity,
1102
+ "complexity_rating": complexity_rating,
1103
+ "recommendation": recommendation
1104
+ })
1105
+
1106
+ # Sort by complexity (highest first)
1107
+ functions.sort(key=lambda x: x['complexity'], reverse=True)
1108
+
1109
+ # Maintainability index interpretation
1110
+ if mi_score >= 20:
1111
+ maintainability = "Excellent - highly maintainable"
1112
+ elif mi_score >= 10:
1113
+ maintainability = "Good - moderately maintainable"
1114
+ elif mi_score >= 0:
1115
+ maintainability = "Fair - difficult to maintain"
1116
+ else:
1117
+ maintainability = "Poor - very difficult to maintain"
1118
+
1119
+ # Count complex functions
1120
+ complex_count = sum(1 for f in functions if f['complexity'] > 10)
1121
+ very_complex_count = sum(1 for f in functions if f['complexity'] > 20)
1122
+
1123
+ return {
1124
+ "success": True,
1125
+ "file_path": str(abs_path),
1126
+ "total_functions": len(functions),
1127
+ "complex_functions": complex_count,
1128
+ "very_complex_functions": very_complex_count,
1129
+ "maintainability_index": round(mi_score, 2),
1130
+ "maintainability_rating": maintainability,
1131
+ "average_complexity": round(sum(f['complexity'] for f in functions) / len(functions), 2) if functions else 0,
1132
+ "max_complexity": max((f['complexity'] for f in functions), default=0),
1133
+ "functions": functions,
1134
+ "halstead_metrics": {
1135
+ "total_operators": h_metrics.total.h1 if h_metrics.total else 0,
1136
+ "total_operands": h_metrics.total.h2 if h_metrics.total else 0,
1137
+ "vocabulary": h_metrics.total.vocabulary if h_metrics.total else 0,
1138
+ "difficulty": round(h_metrics.total.difficulty, 2) if h_metrics.total and h_metrics.total.difficulty else 0
1139
+ },
1140
+ "summary": f"{len(functions)} functions analyzed: {very_complex_count} very complex, {complex_count} complex. Maintainability: {maintainability}"
1141
+ }
1142
+
1143
+ except Exception as e:
1144
+ return {
1145
+ "success": False,
1146
+ "error": f"Complexity analysis failed: {str(e)}"
1147
+ }
1148
+
1149
+
1150
+ # ==================== MAIN ====================
1151
+
1152
+ def main():
1153
+ """Main entrypoint for the server."""
1154
+ try:
1155
+ # Initialize server
1156
+ initialize_server()
1157
+
1158
+ # Run the FastMCP server
1159
+ port = int(os.environ.get("PORT", 8080))
1160
+ host = os.environ.get("HOST", "0.0.0.0")
1161
+
1162
+ logger.info(f"Starting Pylance MCP Pro on {host}:{port}")
1163
+ mcp.run(transport="stdio")
1164
+
1165
+ except KeyboardInterrupt:
1166
+ logger.info("Server stopped by user")
1167
+ except Exception as e:
1168
+ logger.error(f"Server error: {e}", exc_info=True)
1169
+ sys.exit(1)
1170
+ finally:
1171
+ # End conversation logging
1172
+ if conv_logger and conversation_id:
1173
+ conv_logger.end_conversation(conversation_id)
1174
+ logger.info(f"Ended conversation logging: conversation_id={conversation_id}")
1175
+
1176
+ # Stop language server bridge
1177
+ if bridge:
1178
+ bridge.stop()
1179
+
1180
+
1181
+ if __name__ == "__main__":
1182
+ # Handle --test flag for installation testing
1183
+ if len(sys.argv) > 1 and sys.argv[1] == '--test':
1184
+ print("๐Ÿงช Testing Pylance MCP Server dependencies...")
1185
+ try:
1186
+ # Test FastMCP import
1187
+ from fastmcp import FastMCP
1188
+ print("โœ… FastMCP: OK")
1189
+
1190
+ # Test Pylance bridge import
1191
+ from mcp_server.pylance_bridge import PylanceBridge
1192
+ print("โœ… PylanceBridge: OK")
1193
+
1194
+ # Test if we can find Pyright
1195
+ import subprocess
1196
+ try:
1197
+ result = subprocess.run(['pyright', '--version'],
1198
+ capture_output=True,
1199
+ text=True,
1200
+ timeout=5)
1201
+ if result.returncode == 0:
1202
+ version = result.stdout.strip()
1203
+ print(f"โœ… Pyright: {version}")
1204
+ else:
1205
+ print("โš ๏ธ Pyright not found (will auto-install on first run)")
1206
+ except FileNotFoundError:
1207
+ print("โš ๏ธ Pyright not found (will auto-install on first run)")
1208
+ except subprocess.TimeoutExpired:
1209
+ print("โš ๏ธ Pyright check timeout")
1210
+
1211
+ # Test workspace detection
1212
+ workspace_test = Path(os.environ.get("WORKSPACE_ROOT", os.getcwd()))
1213
+ print(f"โœ… Workspace: {workspace_test}")
1214
+
1215
+ print()
1216
+ print("โœ… All dependency tests passed!")
1217
+ sys.exit(0)
1218
+
1219
+ except ImportError as e:
1220
+ print(f"โŒ Missing dependency: {e}")
1221
+ print(" Run: pip install -r requirements.txt")
1222
+ sys.exit(1)
1223
+ except Exception as e:
1224
+ print(f"โŒ Test failed: {e}")
1225
+ sys.exit(1)
1226
+
1227
+ # Normal server startup
1228
+ main()