""" MCP Bridge - AI 與外部工具橋樑 Phase 3: 企業功能 - ADR-001 MCP 協議採用 核心功能: 1. list_tools(server_name) - 動態獲取 MCP Server 工具清單 2. call_tool(server_name, tool_name, parameters) - 執行工具 資安機制: - Rehydration: 執行前將 [IP_1] 還原為真實值 - 符合 leWOOOgo ActionExecutor 介面 MCP Protocol Spec: https://modelcontextprotocol.io/ """ import logging import re import uuid from dataclasses import dataclass, field from datetime import datetime from enum import Enum from typing import Any import httpx logger = logging.getLogger(__name__) # ==================== Types ==================== class MCPTransport(str, Enum): """MCP 傳輸方式""" STDIO = "stdio" # 標準輸入輸出 (本地程式) HTTP = "http" # HTTP/SSE (遠端服務) WEBSOCKET = "ws" # WebSocket (即時雙向) @dataclass class MCPTool: """MCP 工具定義""" name: str description: str input_schema: dict[str, Any] server_name: str @dataclass class MCPToolResult: """工具執行結果 (符合 ActionResult 介面)""" success: bool execution_id: str output: Any | None = None error: str | None = None duration: float = 0.0 timestamp: datetime = field(default_factory=datetime.utcnow) def to_dict(self) -> dict: return { "success": self.success, "executionId": self.execution_id, "output": self.output, "error": self.error, "duration": self.duration, "timestamp": self.timestamp.isoformat(), } @dataclass class MCPServer: """MCP Server 配置""" name: str transport: MCPTransport endpoint: str # 執行檔路徑 (stdio) 或 URL (http/ws) args: list[str] = field(default_factory=list) env: dict[str, str] = field(default_factory=dict) enabled: bool = True # ==================== Rehydration Engine ==================== class RehydrationEngine: """ 資安標籤還原器 將 Privacy Shield 產生的 [IP_1], [EMAIL_1], [SECRET_1] 等標籤 還原為真實值,以便 MCP Tool 執行 """ # 標籤格式: [TYPE_N] LABEL_PATTERN = re.compile(r'\[(IP|EMAIL|SECRET|CC|PHONE|ID)_(\d+)\]') def unredact( self, data: Any, mapping: dict[str, str], ) -> Any: """ 還原脫敏資料 Args: data: 可能包含脫敏標籤的資料 (str, dict, list) mapping: 原始值 → 標籤 的映射表 (來自 Privacy Shield) Returns: 還原後的資料 """ # 反轉映射: 標籤 → 原始值 reverse_mapping = {v: k for k, v in mapping.items()} return self._recursive_unredact(data, reverse_mapping) def _recursive_unredact( self, data: Any, reverse_mapping: dict[str, str], ) -> Any: """遞迴還原各種資料結構""" if isinstance(data, str): return self._unredact_string(data, reverse_mapping) elif isinstance(data, dict): return { k: self._recursive_unredact(v, reverse_mapping) for k, v in data.items() } elif isinstance(data, list): return [ self._recursive_unredact(item, reverse_mapping) for item in data ] else: return data def _unredact_string( self, text: str, reverse_mapping: dict[str, str], ) -> str: """ 還原字串中的標籤 ⚠️ 重要: 按標籤長度從長到短排序替換 避免 [IP_1] 被先替換而污染 [IP_10] → 結果變成 "192.168.1.1000" """ result = text # 按標籤長度降序排序,確保 [IP_10] 先於 [IP_1] 處理 sorted_labels = sorted( reverse_mapping.items(), key=lambda x: len(x[0]), reverse=True, ) for label, original in sorted_labels: # 使用精準邊界匹配,避免部分替換 result = result.replace(label, original) return result def validate_no_labels(self, data: Any) -> tuple[bool, list[str]]: """ 驗證資料中是否還有未還原的標籤 Returns: (is_clean, remaining_labels) """ remaining = [] self._find_labels(data, remaining) return len(remaining) == 0, remaining def _find_labels(self, data: Any, found: list[str]) -> None: """遞迴搜尋標籤""" if isinstance(data, str): matches = self.LABEL_PATTERN.findall(data) for match in matches: label = f"[{match[0]}_{match[1]}]" if label not in found: found.append(label) elif isinstance(data, dict): for v in data.values(): self._find_labels(v, found) elif isinstance(data, list): for item in data: self._find_labels(item, found) # ==================== MCP Bridge ==================== class MCPBridge: """ MCP 協議橋樑 連接 AI 與外部 MCP Server,實現動態工具調用 符合 leWOOOgo ActionExecutor 介面設計 """ def __init__(self): self.rehydrator = RehydrationEngine() self._servers: dict[str, MCPServer] = {} self._tool_cache: dict[str, list[MCPTool]] = {} self._http_client = httpx.AsyncClient(timeout=30.0) # 註冊 Mock Servers (Phase 3: 先驗證介面) self._register_mock_servers() def _register_mock_servers(self) -> None: """註冊 Mock MCP Servers (開發測試用)""" self._servers["kubernetes"] = MCPServer( name="kubernetes", transport=MCPTransport.HTTP, endpoint="http://localhost:8081/mcp", ) self._servers["filesystem"] = MCPServer( name="filesystem", transport=MCPTransport.STDIO, endpoint="/usr/local/bin/mcp-filesystem", args=["--root", "/tmp"], ) self._servers["database"] = MCPServer( name="database", transport=MCPTransport.HTTP, endpoint="http://localhost:8082/mcp", ) def register_server(self, server: MCPServer) -> None: """註冊 MCP Server""" self._servers[server.name] = server logger.info(f"MCP Server registered: {server.name} ({server.transport.value})") async def list_tools(self, server_name: str) -> list[MCPTool]: """ 動態獲取 MCP Server 工具清單 Args: server_name: MCP Server 名稱 Returns: 可用工具列表 """ if server_name not in self._servers: raise ValueError(f"Unknown MCP Server: {server_name}") # 快取檢查 if server_name in self._tool_cache: return self._tool_cache[server_name] server = self._servers[server_name] tools = await self._fetch_tools(server) self._tool_cache[server_name] = tools return tools async def _fetch_tools(self, server: MCPServer) -> list[MCPTool]: """從 MCP Server 獲取工具清單""" if server.transport == MCPTransport.HTTP: return await self._fetch_tools_http(server) elif server.transport == MCPTransport.STDIO: return await self._fetch_tools_stdio(server) else: raise NotImplementedError(f"Transport not supported: {server.transport}") async def _fetch_tools_http(self, server: MCPServer) -> list[MCPTool]: """HTTP 方式獲取工具 (Mock 實作)""" # Phase 3: Mock 回傳,實際連接待 MCP Server 部署 mock_tools = { "kubernetes": [ MCPTool( name="kubectl_get", description="Get Kubernetes resources", input_schema={ "type": "object", "properties": { "resource": {"type": "string"}, "namespace": {"type": "string"}, "name": {"type": "string"}, }, "required": ["resource"], }, server_name=server.name, ), MCPTool( name="kubectl_delete", description="Delete Kubernetes resources", input_schema={ "type": "object", "properties": { "resource": {"type": "string"}, "namespace": {"type": "string"}, "name": {"type": "string"}, }, "required": ["resource", "name"], }, server_name=server.name, ), MCPTool( name="kubectl_scale", description="Scale Kubernetes deployment", input_schema={ "type": "object", "properties": { "deployment": {"type": "string"}, "namespace": {"type": "string"}, "replicas": {"type": "integer"}, }, "required": ["deployment", "replicas"], }, server_name=server.name, ), ], "database": [ MCPTool( name="query", description="Execute SQL query", input_schema={ "type": "object", "properties": { "sql": {"type": "string"}, "params": {"type": "array"}, }, "required": ["sql"], }, server_name=server.name, ), ], } return mock_tools.get(server.name, []) async def _fetch_tools_stdio(self, server: MCPServer) -> list[MCPTool]: """STDIO 方式獲取工具 (Mock 實作)""" # Phase 3: Mock 回傳 return [ MCPTool( name="read_file", description="Read file contents", input_schema={ "type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"], }, server_name=server.name, ), MCPTool( name="write_file", description="Write file contents", input_schema={ "type": "object", "properties": { "path": {"type": "string"}, "content": {"type": "string"}, }, "required": ["path", "content"], }, server_name=server.name, ), ] # ╔════════════════════════════════════════════════════════════════╗ # ║ ⚠️ SECURITY CRITICAL - DO NOT LOG REHYDRATED PARAMETERS ⚠️ ║ # ║ ║ # ║ After rehydration, `parameters` contains REAL sensitive ║ # ║ data (IPs, emails, secrets). Logging them defeats the ║ # ║ entire purpose of Privacy Shield. ║ # ║ ║ # ║ ALLOWED: logger.info(f"Calling {tool_name}") ║ # ║ FORBIDDEN: logger.info(f"Params: {parameters}") ║ # ╚════════════════════════════════════════════════════════════════╝ async def call_tool( self, server_name: str, tool_name: str, parameters: dict[str, Any], redaction_mapping: dict[str, str] | None = None, ) -> MCPToolResult: """ 執行 MCP 工具 ⚠️ 資安關鍵路徑: 1. Rehydration - 還原脫敏標籤為真實值 2. 驗證 - 確保無殘留標籤 3. 執行 - 調用 MCP Server 4. 結果 - 返回 ActionResult 格式 ⛔ 禁止 logging 任何已 rehydrate 的 parameters! Args: server_name: MCP Server 名稱 tool_name: 工具名稱 parameters: 工具參數 (可能包含脫敏標籤) redaction_mapping: Privacy Shield 映射表 (原始值 → 標籤) Returns: MCPToolResult (符合 ActionResult 介面) """ execution_id = str(uuid.uuid4()) start_time = datetime.utcnow() try: # ======================================== # 1. Rehydration: 還原脫敏標籤 # ======================================== if redaction_mapping: logger.info(f"[{execution_id}] Rehydrating {len(redaction_mapping)} labels") parameters = self.rehydrator.unredact(parameters, redaction_mapping) # ======================================== # 2. 驗證: 確保無殘留標籤 # ======================================== is_clean, remaining = self.rehydrator.validate_no_labels(parameters) if not is_clean: logger.error(f"[{execution_id}] Unrehydrated labels found: {remaining}") return MCPToolResult( success=False, execution_id=execution_id, error=f"Security violation: Unrehydrated labels found: {remaining}", duration=self._calc_duration(start_time), ) # ======================================== # 3. 執行: 調用 MCP Server # ======================================== logger.info(f"[{execution_id}] Calling {server_name}.{tool_name}") if server_name not in self._servers: raise ValueError(f"Unknown MCP Server: {server_name}") server = self._servers[server_name] result = await self._execute_tool(server, tool_name, parameters) # ======================================== # 4. 結果: 返回 ActionResult 格式 # ======================================== return MCPToolResult( success=True, execution_id=execution_id, output=result, duration=self._calc_duration(start_time), ) except Exception as e: logger.error(f"[{execution_id}] Tool execution failed: {e}") return MCPToolResult( success=False, execution_id=execution_id, error=str(e), duration=self._calc_duration(start_time), ) async def _execute_tool( self, server: MCPServer, tool_name: str, parameters: dict[str, Any], ) -> Any: """執行 MCP 工具 (實際調用)""" if server.transport == MCPTransport.HTTP: return await self._execute_http(server, tool_name, parameters) elif server.transport == MCPTransport.STDIO: return await self._execute_stdio(server, tool_name, parameters) else: raise NotImplementedError(f"Transport not supported: {server.transport}") async def _execute_http( self, server: MCPServer, tool_name: str, parameters: dict[str, Any], ) -> Any: """HTTP 方式執行工具 (Mock 實作)""" # Phase 3: Mock 執行,實際連接待 MCP Server 部署 logger.info(f"[MOCK] HTTP call to {server.endpoint}: {tool_name}({parameters})") # 模擬不同工具的回傳 mock_responses = { "kubectl_get": {"items": [{"name": "pod-1"}, {"name": "pod-2"}]}, "kubectl_delete": {"deleted": True, "resource": parameters.get("name")}, "kubectl_scale": {"scaled": True, "replicas": parameters.get("replicas")}, "query": {"rows": [], "affected": 0}, } return mock_responses.get(tool_name, {"status": "ok"}) async def _execute_stdio( self, server: MCPServer, tool_name: str, parameters: dict[str, Any], ) -> Any: """STDIO 方式執行工具 (Mock 實作)""" # Phase 3: Mock 執行 logger.info(f"[MOCK] STDIO call to {server.endpoint}: {tool_name}({parameters})") mock_responses = { "read_file": f"[Mock] Contents of {parameters.get('path')}", "write_file": {"written": True, "path": parameters.get("path")}, } return mock_responses.get(tool_name, {"status": "ok"}) def _calc_duration(self, start_time: datetime) -> float: """計算執行時間 (毫秒)""" return (datetime.utcnow() - start_time).total_seconds() * 1000 # ==================== ActionExecutor 介面對齊 ==================== def get_supported_operations(self) -> list[str]: """取得支援的操作列表 (符合 ActionExecutor 介面)""" operations = [] for server_name, tools in self._tool_cache.items(): for tool in tools: operations.append(f"{server_name}.{tool.name}") return operations async def execute( self, operation: str, parameters: dict[str, Any], redaction_mapping: dict[str, str] | None = None, ) -> MCPToolResult: """ 執行操作 (符合 ActionExecutor.execute 介面) Args: operation: 格式為 "server_name.tool_name" parameters: 工具參數 redaction_mapping: Privacy Shield 映射表 Returns: MCPToolResult """ parts = operation.split(".", 1) if len(parts) != 2: return MCPToolResult( success=False, execution_id=str(uuid.uuid4()), error=f"Invalid operation format: {operation}. Expected: server.tool", ) server_name, tool_name = parts return await self.call_tool(server_name, tool_name, parameters, redaction_mapping) async def close(self) -> None: """關閉連線""" await self._http_client.aclose() # 全域實例 mcp_bridge = MCPBridge()