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.
- package/LICENSE +21 -0
- package/README.md +213 -0
- package/bin/pylance-mcp.js +68 -0
- package/mcp_server/__init__.py +13 -0
- package/mcp_server/__pycache__/__init__.cpython-312.pyc +0 -0
- package/mcp_server/__pycache__/__init__.cpython-313.pyc +0 -0
- package/mcp_server/__pycache__/__init__.cpython-314.pyc +0 -0
- package/mcp_server/__pycache__/ai_features.cpython-313.pyc +0 -0
- package/mcp_server/__pycache__/api_routes.cpython-313.pyc +0 -0
- package/mcp_server/__pycache__/auth.cpython-313.pyc +0 -0
- package/mcp_server/__pycache__/cloud_sync.cpython-313.pyc +0 -0
- package/mcp_server/__pycache__/logging_db.cpython-312.pyc +0 -0
- package/mcp_server/__pycache__/logging_db.cpython-313.pyc +0 -0
- package/mcp_server/__pycache__/pylance_bridge.cpython-312.pyc +0 -0
- package/mcp_server/__pycache__/pylance_bridge.cpython-313.pyc +0 -0
- package/mcp_server/__pycache__/pylance_bridge.cpython-314.pyc +0 -0
- package/mcp_server/__pycache__/resources.cpython-312.pyc +0 -0
- package/mcp_server/__pycache__/resources.cpython-313.pyc +0 -0
- package/mcp_server/__pycache__/tools.cpython-312.pyc +0 -0
- package/mcp_server/__pycache__/tools.cpython-313.pyc +0 -0
- package/mcp_server/__pycache__/tracing.cpython-313.pyc +0 -0
- package/mcp_server/ai_features.py +274 -0
- package/mcp_server/api_routes.py +429 -0
- package/mcp_server/auth.py +275 -0
- package/mcp_server/cloud_sync.py +427 -0
- package/mcp_server/logging_db.py +403 -0
- package/mcp_server/pylance_bridge.py +579 -0
- package/mcp_server/resources.py +174 -0
- package/mcp_server/tools.py +642 -0
- package/mcp_server/tracing.py +84 -0
- package/package.json +53 -0
- package/requirements.txt +29 -0
- package/scripts/check-python.js +57 -0
- 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()
|