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>
This commit is contained in:
OG T
2026-03-22 18:57:44 +08:00
parent a840bf975b
commit 196d269b92
245 changed files with 42207 additions and 6 deletions

View File

@@ -0,0 +1,543 @@
"""
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()