Files
awoooi/apps/api/src/plugins/mcp/mcp_bridge.py
OG T 196d269b92 feat: add all application source code
- 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>
2026-03-22 18:57:44 +08:00

544 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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()