""" Unified MCP Handler Central handler for all MCP tool execution. Used by: - Direct API calls (/api/mcp/tools/{name}/execute) - Claude chat (inline tool execution) - SSE streaming connections This module ensures all MCP requests go through a single code path for consistent logging, error handling, and security. """ import json import asyncio from typing import Dict, Any, List, Optional, Tuple from datetime import datetime from dataclasses import dataclass, asdict import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent)) from storage.database import get_connection from .config import mcp_config, integration_config from .context.project_context import get_context_manager, ProjectContext from .tools.project_tools import PROJECT_TOOLS, ProjectTools from .integrations.figma import FIGMA_TOOLS, FigmaTools from .integrations.jira import JIRA_TOOLS, JiraTools from .integrations.confluence import CONFLUENCE_TOOLS, ConfluenceTools from .integrations.base import CircuitBreakerOpen @dataclass class ToolResult: """Result of a tool execution""" tool_name: str success: bool result: Any error: Optional[str] = None duration_ms: int = 0 timestamp: str = None def __post_init__(self): if not self.timestamp: self.timestamp = datetime.now().isoformat() def to_dict(self) -> Dict[str, Any]: return asdict(self) @dataclass class MCPContext: """Context for MCP operations""" project_id: str user_id: Optional[int] = None session_id: Optional[str] = None class MCPHandler: """ Unified MCP tool handler. Provides: - Tool discovery (list all available tools) - Tool execution with proper context - Integration management - Logging and metrics """ def __init__(self): self.context_manager = get_context_manager() self._tool_registry: Dict[str, Dict[str, Any]] = {} self._initialize_tools() def _initialize_tools(self): """Initialize tool registry with all available tools""" # Register base project tools for tool in PROJECT_TOOLS: self._tool_registry[tool.name] = { "tool": tool, "category": "project", "requires_integration": False } # Register Figma tools for tool in FIGMA_TOOLS: self._tool_registry[tool.name] = { "tool": tool, "category": "figma", "requires_integration": True, "integration_type": "figma" } # Register Jira tools for tool in JIRA_TOOLS: self._tool_registry[tool.name] = { "tool": tool, "category": "jira", "requires_integration": True, "integration_type": "jira" } # Register Confluence tools for tool in CONFLUENCE_TOOLS: self._tool_registry[tool.name] = { "tool": tool, "category": "confluence", "requires_integration": True, "integration_type": "confluence" } def list_tools(self, include_details: bool = False) -> Dict[str, Any]: """ List all available MCP tools. Args: include_details: Include full tool schemas Returns: Tool listing by category """ tools_by_category = {} for name, info in self._tool_registry.items(): category = info["category"] if category not in tools_by_category: tools_by_category[category] = [] tool_info = { "name": name, "description": info["tool"].description, "requires_integration": info.get("requires_integration", False) } if include_details: tool_info["input_schema"] = info["tool"].inputSchema tools_by_category[category].append(tool_info) return { "tools": tools_by_category, "total_count": len(self._tool_registry) } def get_tool_info(self, tool_name: str) -> Optional[Dict[str, Any]]: """Get information about a specific tool""" if tool_name not in self._tool_registry: return None info = self._tool_registry[tool_name] return { "name": tool_name, "description": info["tool"].description, "category": info["category"], "input_schema": info["tool"].inputSchema, "requires_integration": info.get("requires_integration", False), "integration_type": info.get("integration_type") } async def execute_tool( self, tool_name: str, arguments: Dict[str, Any], context: MCPContext ) -> ToolResult: """ Execute an MCP tool. Args: tool_name: Name of the tool to execute arguments: Tool arguments context: MCP context (project_id, user_id) Returns: ToolResult with success/failure and data """ start_time = datetime.now() # Check if tool exists if tool_name not in self._tool_registry: return ToolResult( tool_name=tool_name, success=False, result=None, error=f"Unknown tool: {tool_name}" ) tool_info = self._tool_registry[tool_name] category = tool_info["category"] try: # Execute based on category if category == "project": result = await self._execute_project_tool(tool_name, arguments, context) elif category == "figma": result = await self._execute_figma_tool(tool_name, arguments, context) elif category == "jira": result = await self._execute_jira_tool(tool_name, arguments, context) elif category == "confluence": result = await self._execute_confluence_tool(tool_name, arguments, context) else: result = {"error": f"Unknown tool category: {category}"} # Check for error in result success = "error" not in result error = result.get("error") if not success else None # Calculate duration duration_ms = int((datetime.now() - start_time).total_seconds() * 1000) # Log execution await self._log_tool_usage( tool_name=tool_name, category=category, project_id=context.project_id, user_id=context.user_id, success=success, duration_ms=duration_ms, error=error ) return ToolResult( tool_name=tool_name, success=success, result=result if success else None, error=error, duration_ms=duration_ms ) except CircuitBreakerOpen as e: duration_ms = int((datetime.now() - start_time).total_seconds() * 1000) return ToolResult( tool_name=tool_name, success=False, result=None, error=str(e), duration_ms=duration_ms ) except Exception as e: duration_ms = int((datetime.now() - start_time).total_seconds() * 1000) await self._log_tool_usage( tool_name=tool_name, category=category, project_id=context.project_id, user_id=context.user_id, success=False, duration_ms=duration_ms, error=str(e) ) return ToolResult( tool_name=tool_name, success=False, result=None, error=str(e), duration_ms=duration_ms ) async def _execute_project_tool( self, tool_name: str, arguments: Dict[str, Any], context: MCPContext ) -> Dict[str, Any]: """Execute a project tool""" # Ensure project_id is set if "project_id" not in arguments: arguments["project_id"] = context.project_id project_tools = ProjectTools(context.user_id) return await project_tools.execute_tool(tool_name, arguments) async def _execute_figma_tool( self, tool_name: str, arguments: Dict[str, Any], context: MCPContext ) -> Dict[str, Any]: """Execute a Figma tool""" # Get Figma config config = await self._get_integration_config("figma", context) if not config: # Try global config if integration_config.FIGMA_TOKEN: config = {"api_token": integration_config.FIGMA_TOKEN} else: return {"error": "Figma not configured. Please add Figma API token."} figma_tools = FigmaTools(config) return await figma_tools.execute_tool(tool_name, arguments) async def _execute_jira_tool( self, tool_name: str, arguments: Dict[str, Any], context: MCPContext ) -> Dict[str, Any]: """Execute a Jira tool""" config = await self._get_integration_config("jira", context) if not config: return {"error": "Jira not configured. Please configure Jira integration."} jira_tools = JiraTools(config) return await jira_tools.execute_tool(tool_name, arguments) async def _execute_confluence_tool( self, tool_name: str, arguments: Dict[str, Any], context: MCPContext ) -> Dict[str, Any]: """Execute a Confluence tool""" config = await self._get_integration_config("confluence", context) if not config: return {"error": "Confluence not configured. Please configure Confluence integration."} confluence_tools = ConfluenceTools(config) return await confluence_tools.execute_tool(tool_name, arguments) async def _get_integration_config( self, integration_type: str, context: MCPContext ) -> Optional[Dict[str, Any]]: """Get decrypted integration config for user/project""" if not context.user_id or not context.project_id: return None loop = asyncio.get_event_loop() def get_config(): try: with get_connection() as conn: row = conn.execute( """ SELECT config FROM project_integrations WHERE project_id = ? AND user_id = ? AND integration_type = ? AND enabled = 1 """, (context.project_id, context.user_id, integration_type) ).fetchone() if not row: return None encrypted_config = row["config"] # Decrypt cipher = mcp_config.get_cipher() if cipher: try: decrypted = cipher.decrypt(encrypted_config.encode()).decode() return json.loads(decrypted) except: pass # Try parsing as plain JSON try: return json.loads(encrypted_config) except: return None except: return None return await loop.run_in_executor(None, get_config) async def _log_tool_usage( self, tool_name: str, category: str, project_id: str, user_id: Optional[int], success: bool, duration_ms: int, error: Optional[str] = None ): """Log tool execution to database""" loop = asyncio.get_event_loop() def log(): try: with get_connection() as conn: conn.execute( """ INSERT INTO mcp_tool_usage (project_id, user_id, tool_name, tool_category, duration_ms, success, error_message) VALUES (?, ?, ?, ?, ?, ?, ?) """, (project_id, user_id, tool_name, category, duration_ms, success, error) ) except: pass # Don't fail on logging errors await loop.run_in_executor(None, log) async def get_project_context( self, project_id: str, user_id: Optional[int] = None ) -> Optional[ProjectContext]: """Get project context for Claude system prompt""" return await self.context_manager.get_context(project_id, user_id) def get_tools_for_claude(self) -> List[Dict[str, Any]]: """ Get tools formatted for Claude's tool_use feature. Returns: List of tools in Anthropic's tool format """ tools = [] for name, info in self._tool_registry.items(): tools.append({ "name": name, "description": info["tool"].description, "input_schema": info["tool"].inputSchema }) return tools # Singleton instance _mcp_handler: Optional[MCPHandler] = None def get_mcp_handler() -> MCPHandler: """Get singleton MCP handler instance""" global _mcp_handler if _mcp_handler is None: _mcp_handler = MCPHandler() return _mcp_handler