- apps/api: FastAPI backend with Dockerfile - apps/web: Next.js frontend with Dockerfile - apps/sensor: Signal collection agent - packages: shared packages Co-Authored-By: Claude <noreply@anthropic.com>
544 lines
18 KiB
Python
544 lines
18 KiB
Python
"""
|
||
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()
|