refactor(api): ADR-015 MCP 模組化架構重構

## 重構內容

符合 leWOOOgo 積木化原則:
- 新增 interfaces.py: MCPToolProvider ABC 定義
- 新增 registry.py: Provider 註冊中心 (DI 模式)
- 新增 providers/: K8s, SignOz, Database 具體實作
- 重構 mcp_bridge.py: 透過 ProviderRegistry 委派執行

## 修復 Code Review 問題

- 🔴 移除 _execute_stdio logging 敏感 parameters
- 🔴 修復 conversational-view.tsx i18n 硬編碼

## 新增檔案

- apps/api/src/plugins/mcp/interfaces.py
- apps/api/src/plugins/mcp/registry.py
- apps/api/src/plugins/mcp/providers/__init__.py
- apps/api/src/plugins/mcp/providers/k8s_provider.py
- apps/api/src/plugins/mcp/providers/signoz_provider.py
- apps/api/src/plugins/mcp/providers/database_provider.py
- docs/adr/ADR-015-mcp-modular-architecture.md
- .dependency-cruiser.cjs (Phase 14.2 準備)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-03-25 14:31:32 +08:00
parent c0ad8f8686
commit 643946e60c
14 changed files with 1946 additions and 10 deletions

View File

@@ -0,0 +1,374 @@
# Skill 07: Tool Integration Expert
# MCP Tool 整合專家
> **管轄範圍**: MCP Bridge, 外部系統連接, RAG 向量化
> **觸發條件**: 修改 `plugins/mcp/`, `services/*_tool.py`, 向量資料庫
---
## 文件資訊
| 欄位 | 值 |
|------|-----|
| **版本** | v1.1 |
| **建立日期** | 2026-03-25 23:30 (台北) |
| **建立者** | Claude Code |
| **最後修改** | 2026-03-26 14:20 (台北) |
| **修改者** | Claude Code |
### 變更紀錄
| 版本 | 日期 | 執行者 | 變更內容 |
|------|------|--------|----------|
| v1.1 | 2026-03-26 14:20 | Claude Code | 更新 MCP Tool 狀態 (#79/#80/#81 已完成) |
| v1.0 | 2026-03-25 23:30 | Claude Code | 初始建立 - Phase 13.2 Tool 整合專家 |
---
## 核心職責
Phase 13.2 Tool 實作 (P0 最優先):
- #79 SignOz MCP Tool (Trace/Logs/Metrics 查詢)
- #80 Kubernetes MCP Tool (真實 kubectl 執行)
- #81 PostgreSQL MCP Tool (歷史模式分析)
- #84 維運手冊 RAG Tool (Markdown 向量化)
---
## MCP Tool 狀態總覽
| Tool | 狀態 | 位置 | 工作項 |
|------|------|------|--------|
| **Kubernetes** | ✅ 真實 | `mcp_bridge.py` | #80 ✅ |
| **Database** | ✅ 真實 | `mcp_bridge.py` | #81 ✅ |
| **SignOz** | ✅ 真實 | `mcp_bridge.py` | #79 ✅ |
| Filesystem | 🟡 Mock | `mcp_bridge.py` | #82 |
| Grafana | ❌ 缺失 | - | #83 |
| 維運手冊 RAG | ❌ 缺失 | - | #84 |
### 已完成 Tool 功能
**SignOz MCP (#79)**:
- `gold_metrics`: RPS, Error Rate, P99 Latency, AI Success Rate
- `trace_url`: 生成 Trace 查詢 URL
- `system_metrics`: 系統層級指標
**PostgreSQL MCP (#81)**:
- `list_approvals`: 依狀態/incident 過濾
- `get_approval`: 取得單筆詳情
- `list_incidents`: 列出活躍事件
- `list_timeline`: 時間線事件
**Kubernetes MCP (#80)**:
- `kubectl_get`: 整合真實 ActionExecutor
- `kubectl_restart`: Pod/Deployment 重啟
---
## SignOz API 整合
### 端點
```
ClickHouse HTTP API: http://192.168.0.188:8123
SignOz Query API: http://192.168.0.188:3301/api/v3
```
### 查詢範例
```python
async def query_signoz_traces(
service_name: str,
start_ns: int,
end_ns: int,
) -> list[dict]:
"""查詢 SignOz Traces
Args:
service_name: 服務名稱
start_ns: 起始時間 (nanoseconds)
end_ns: 結束時間 (nanoseconds)
Warning:
SignOz API 有速率限制,避免高頻查詢
"""
query = f"""
SELECT timestamp, traceID, serviceName, name, durationNano
FROM signoz_traces.distributed_signoz_index_v3
WHERE serviceName = '{service_name}'
AND timestamp >= toDateTime64({start_ns}/1e9, 9)
AND timestamp <= toDateTime64({end_ns}/1e9, 9)
ORDER BY timestamp DESC
LIMIT 100
"""
# 調用 ClickHouse HTTP API...
```
### SignOz MCP Tool 介面
```python
class SignOzMCPTool:
"""SignOz 監控查詢 MCP Tool"""
async def query_traces(
self,
service_name: str,
start_time: datetime,
end_time: datetime,
limit: int = 100,
) -> MCPToolResult:
"""查詢服務 Traces"""
async def query_logs(
self,
service_name: str,
level: str | None = None,
keyword: str | None = None,
) -> MCPToolResult:
"""查詢服務 Logs"""
async def query_metrics(
self,
metric_name: str,
aggregation: str = "avg",
) -> MCPToolResult:
"""查詢 Metrics"""
```
---
## Kubernetes MCP 實作
### 從 Mock 升級為真實執行
```python
# ✅ 正確: 真實 kubectl 執行
async def kubectl_execute(
action: str,
resource: str,
namespace: str,
name: str | None = None,
) -> MCPToolResult:
"""執行 kubectl 命令
Warning:
所有命令必須帶 -n namespace
禁止操作 kube-system, default
"""
# 驗證 namespace 白名單
if namespace not in ALLOWED_NAMESPACES:
return MCPToolResult(success=False, error="Namespace not allowed")
cmd = ["kubectl", action, resource, "-n", namespace]
if name:
cmd.append(name)
result = await asyncio.create_subprocess_exec(*cmd, ...)
```
### Namespace 白名單
```python
ALLOWED_NAMESPACES = ["awoooi-prod", "awoooi-dev"]
# ❌ 絕對禁止
FORBIDDEN_NAMESPACES = ["kube-system", "default", "kube-public"]
```
### 危險操作攔截
```python
FORBIDDEN_KUBECTL_COMMANDS = [
"delete namespace",
"delete -A",
"delete --all",
"drain",
"cordon",
"taint",
]
```
---
## PostgreSQL MCP Tool
### 查詢歷史模式分析
```python
class PostgresMCPTool:
"""PostgreSQL 歷史查詢 MCP Tool"""
async def query_incident_patterns(
self,
alert_name: str,
days: int = 30,
) -> MCPToolResult:
"""查詢告警歷史模式
用途: 分析相同告警的歷史處理方式,提取 Playbook
"""
query = """
SELECT
alert_name,
COUNT(*) as occurrence_count,
AVG(resolution_time_seconds) as avg_resolution_time,
mode() WITHIN GROUP (ORDER BY resolution_action) as common_action
FROM incident_records
WHERE alert_name = :alert_name
AND created_at > NOW() - INTERVAL ':days days'
GROUP BY alert_name
"""
async def query_approval_success_rate(
self,
action_type: str,
) -> MCPToolResult:
"""查詢特定操作的審批成功率
用途: 為 Trust Engine 提供歷史數據
"""
```
---
## 維運手冊 RAG Tool
### 向量化流程
```
docs/*.md → 分段 (chunk) → 嵌入 (embedding) → 存入 Vector DB
```
### 配置
```python
RAG_CONFIG = {
"chunk_size": 500, # 每段字數
"chunk_overlap": 50, # 重疊字數
"embedding_model": "bge-m3", # 多語言嵌入
"vector_db": "redis", # Redis Stack Vector
"index_name": "idx:runbooks",
}
```
### 查詢流程
```python
async def search_runbook(query: str, top_k: int = 5) -> list[str]:
"""搜尋維運手冊相關段落
Args:
query: 搜尋關鍵字 (自然語言)
top_k: 返回最相關的 N 個段落
Returns:
相關段落列表
"""
# 1. 嵌入查詢
query_embedding = await embed(query)
# 2. 向量搜尋
results = await redis_client.ft_search(
"idx:runbooks",
f"*=>[KNN {top_k} @embedding $vec AS score]",
query_params={"vec": query_embedding},
)
return [r.content for r in results]
```
### 文檔索引清單
```python
RUNBOOK_SOURCES = [
"docs/operations/*.md",
"docs/troubleshooting/*.md",
"docs/adr/*.md",
".agents/skills/*.md",
]
```
---
## Tool 實作鐵律
### 1. Privacy Shield 必須套用
```python
# ✅ 正確: 所有 Tool 調用經過脫敏
async def call_tool(params: dict, redaction_mapping: dict):
# Rehydration 還原敏感資料
params = rehydrator.unredact(params, redaction_mapping)
# 驗證無殘留標籤
is_clean, remaining = rehydrator.validate_no_labels(params)
if not is_clean:
raise SecurityError(f"Unrehydrated labels: {remaining}")
# ❌ 禁止: 直接調用外部系統
await kubectl_execute(raw_params) # 可能包含 [IP_1] 標籤
```
### 2. 錯誤隔離
```python
# ✅ 正確: Tool 失敗不影響主流程
try:
result = await signoz_tool.query_traces(...)
except SignOzConnectionError:
logger.warning("signoz_unavailable")
return MCPToolResult(success=False, error="SignOz unavailable")
# ❌ 禁止: 讓 Tool 錯誤傳播
result = await signoz_tool.query_traces(...) # 可能拋出異常
```
### 3. 超時設定
| Tool | 建議超時 |
|------|---------|
| SignOz Query | 30s |
| Kubernetes Get | 10s |
| Kubernetes Delete | 30s |
| PostgreSQL Query | 15s |
| RAG Search | 5s |
### 4. 審計日誌
```python
# 所有 Tool 調用必須記錄
await audit_log.record(
tool_name="kubernetes",
action="delete_pod",
params={"namespace": "awoooi-prod", "name": "api-xxx"},
result=result,
user_id=user_id,
timestamp=now_taipei(),
)
```
---
## Tool 封裝與模組化關係
> **統帥澄清 (2026-03-25)**: Tool 封裝 ≠ 模組化,兩者是不同層次
```
Tool 封裝 → 放在 ACTION 積木內 → 遵循模組化原則開發
```
| 維度 | leWOOOgo 模組化 | Tool 封裝 |
|------|-----------------|-----------|
| 層次 | 軟體架構層 | 系統整合層 |
| 範圍 | 六大積木 | 外部系統連接 |
| 關係 | 是 Tool 的**實作基礎** | 是模組化的**應用場景** |
---
## 參考文檔
- `apps/api/src/plugins/mcp/mcp_bridge.py`: MCP Bridge 核心
- `memory/feedback_tool_vs_modular.md`: Tool 與模組化關係
- `memory/project_phase13_enterprise_aiops.md`: Phase 13 規劃
- ADR-001: MCP Protocol 採用
- Phase 13.2: Tool 實作工作項目 (#79-84)

169
.dependency-cruiser.cjs Normal file
View File

@@ -0,0 +1,169 @@
/**
* Dependency Cruiser Configuration - Phase 14.2
* ==============================================
*
* ADR-014: 依賴治理規則
*
* Layer Model:
* - Layer 0: Pages (app/) - 可引用所有
* - Layer 1: Features (agent/approval/incident/dashboard) - 禁止互相引用
* - Layer 2: Shared (shared/layout) - 禁止下行引用 Layer 1
* - Layer 3: Primitives (ui/lib/stores/hooks) - 純工具層
*
* @see docs/adr/ADR-014-dependency-governance.md
*/
/** @type {import('dependency-cruiser').IConfiguration} */
module.exports = {
forbidden: [
// =========================================================================
// Layer 1: Feature Isolation (禁止跨 feature 互相引用)
// =========================================================================
{
name: "feature-isolation-agent",
comment: "agent 元件禁止引用其他 feature (approval/incident/dashboard)",
severity: "error",
from: { path: "apps/web/src/components/agent" },
to: { path: "apps/web/src/components/(approval|incident|dashboard)" }
},
{
name: "feature-isolation-approval",
comment: "approval 元件禁止引用其他 feature (agent/incident/dashboard)",
severity: "error",
from: { path: "apps/web/src/components/approval" },
to: { path: "apps/web/src/components/(agent|incident|dashboard)" }
},
{
name: "feature-isolation-incident",
comment: "incident 元件禁止引用其他 feature (agent/approval/dashboard)",
severity: "error",
from: { path: "apps/web/src/components/incident" },
to: { path: "apps/web/src/components/(agent|approval|dashboard)" }
},
{
name: "feature-isolation-dashboard",
comment: "dashboard 元件禁止引用其他 feature (agent/approval/incident)",
severity: "error",
from: { path: "apps/web/src/components/dashboard" },
to: { path: "apps/web/src/components/(agent|approval|incident)" }
},
// =========================================================================
// Layer 2: Shared Isolation (禁止 shared/ui 下行引用 feature)
// =========================================================================
{
name: "shared-no-feature-import",
comment: "shared 元件禁止引用 feature 層 (agent/approval/incident/dashboard)",
severity: "error",
from: { path: "apps/web/src/components/shared" },
to: { path: "apps/web/src/components/(agent|approval|incident|dashboard)" }
},
{
name: "ui-no-feature-import",
comment: "ui 元件禁止引用 feature 層",
severity: "error",
from: { path: "apps/web/src/components/ui" },
to: { path: "apps/web/src/components/(agent|approval|incident|dashboard|shared)" }
},
{
name: "layout-no-feature-import",
comment: "layout 元件禁止引用 feature 層",
severity: "error",
from: { path: "apps/web/src/components/layout" },
to: { path: "apps/web/src/components/(agent|approval|incident|dashboard)" }
},
// =========================================================================
// Components → App 禁止反向引用
// =========================================================================
{
name: "components-no-app-import",
comment: "components 禁止引用 app 路由層",
severity: "error",
from: { path: "apps/web/src/components" },
to: { path: "apps/web/src/app" }
},
// =========================================================================
// 禁止循環依賴
// =========================================================================
{
name: "no-circular",
comment: "禁止循環依賴",
severity: "error",
from: {},
to: {
circular: true
}
},
// =========================================================================
// Hooks/Stores/Lib 不應引用 Components (純工具層)
// =========================================================================
{
name: "hooks-no-component-import",
comment: "hooks 禁止引用 components (純工具層)",
severity: "warn",
from: { path: "apps/web/src/hooks" },
to: { path: "apps/web/src/components" }
},
{
name: "stores-no-component-import",
comment: "stores 禁止引用 components (純工具層)",
severity: "warn",
from: { path: "apps/web/src/stores" },
to: { path: "apps/web/src/components" }
},
{
name: "lib-no-component-import",
comment: "lib 禁止引用 components (純工具層)",
severity: "warn",
from: { path: "apps/web/src/lib" },
to: { path: "apps/web/src/components" }
}
],
options: {
doNotFollow: {
path: "node_modules"
},
exclude: {
path: [
"node_modules",
"\\.next",
"\\.turbo",
"dist",
"coverage",
"__tests__",
"\\.test\\.",
"\\.spec\\."
]
},
includeOnly: {
path: "apps/web/src"
},
tsPreCompilationDeps: true,
tsConfig: {
fileName: "apps/web/tsconfig.json"
},
enhancedResolveOptions: {
exportsFields: ["exports"],
conditionNames: ["import", "require", "node", "default"],
mainFields: ["main", "types"]
},
reporterOptions: {
dot: {
collapsePattern: "node_modules/(@[^/]+/[^/]+|[^/]+)"
},
text: {
highlightFocused: true
}
}
}
};

View File

@@ -157,6 +157,11 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
else:
logger.info("telegram_polling_disabled", reason="OpenClaw 是唯一 Polling 實例")
# ADR-015: MCP Provider 註冊 (DI 模式)
from src.plugins.mcp.providers import register_all_providers
register_all_providers()
logger.info("mcp_providers_registered")
# Phase 6.5: Telegram 心跳監控 (防止沉默盲點)
# - 每 30 分鐘發送心跳,證明告警鏈路正常
# - 超過 2 小時沒訊息則告警

View File

@@ -0,0 +1,173 @@
"""
MCP Tool Provider Interfaces - ADR-015 模組化架構
=================================================
定義 MCP Tool Provider 的抽象介面,確保:
1. Interface 先行 (Contract-First)
2. 模組間透過 Public API 溝通
3. 可測試性 (易於 Mock)
@see docs/adr/ADR-015-mcp-modular-architecture.md
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
from src.utils.timezone import now_taipei
# =============================================================================
# Data Classes (DTO)
# =============================================================================
@dataclass
class MCPTool:
"""MCP 工具定義"""
name: str
description: str
input_schema: dict[str, Any]
server_name: str
@dataclass
class MCPToolResult:
"""
工具執行結果
符合 ActionResult 介面,可直接用於 HITL 審核流程
"""
success: bool
execution_id: str
output: Any | None = None
error: str | None = None
duration: float = 0.0
timestamp: datetime = field(default_factory=now_taipei)
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(),
}
# =============================================================================
# Abstract Base Classes
# =============================================================================
class MCPToolProvider(ABC):
"""
MCP Tool Provider 抽象介面
所有 MCP Tool 實作必須繼承此類別,確保:
- 統一的工具列表格式
- 統一的執行介面
- 統一的結果格式
Usage:
class K8sProvider(MCPToolProvider):
@property
def name(self) -> str:
return "kubernetes"
async def list_tools(self) -> list[MCPTool]:
return [MCPTool(name="kubectl_get", ...)]
async def execute(self, tool_name: str, parameters: dict) -> MCPToolResult:
if tool_name == "kubectl_get":
return await self._kubectl_get(parameters)
"""
@property
@abstractmethod
def name(self) -> str:
"""
Provider 名稱
必須唯一,用於 ProviderRegistry 註冊
例如: 'kubernetes', 'signoz', 'database'
"""
pass
@property
def enabled(self) -> bool:
"""
是否啟用
可覆寫此方法根據環境變數決定是否啟用
"""
return True
@abstractmethod
async def list_tools(self) -> list[MCPTool]:
"""
列出可用工具
Returns:
list[MCPTool]: 工具定義列表
"""
pass
@abstractmethod
async def execute(
self,
tool_name: str,
parameters: dict[str, Any],
) -> MCPToolResult:
"""
執行工具
Args:
tool_name: 工具名稱 (必須在 list_tools() 中定義)
parameters: 工具參數 (已經過 Rehydration 還原)
Returns:
MCPToolResult: 執行結果
Raises:
ValueError: 未知的工具名稱
"""
pass
async def health_check(self) -> bool:
"""
健康檢查
可覆寫此方法檢查 Provider 依賴的外部服務是否可用
"""
return True
class RehydrationProvider(ABC):
"""
Rehydration Provider 抽象介面
負責將 Privacy Shield 脫敏標籤還原為真實值
"""
@abstractmethod
def unredact(
self,
data: Any,
mapping: dict[str, str],
) -> Any:
"""
還原脫敏資料
Args:
data: 可能包含脫敏標籤的資料
mapping: 原始值 → 標籤 的映射表
Returns:
還原後的資料
"""
pass

View File

@@ -561,9 +561,25 @@ class MCPBridge:
"""
HTTP 方式執行工具
Phase 13.2: 整合真實 K8s Executor (不再是 Mock)
ADR-015 重構: 透過 ProviderRegistry 委派執行
不再直接 import services符合 leWOOOgo 積木化原則
"""
# =============================================
# ADR-015: 使用 Provider Registry (DI 模式)
# =============================================
from src.plugins.mcp.registry import get_provider
provider = get_provider(server.name)
if provider:
result = await provider.execute(tool_name, parameters)
if result.success:
return result.output
else:
return {"error": result.error}
# =============================================
# Fallback: 舊邏輯 (逐步遷移後刪除)
# =============================================
# Kubernetes: 使用真實 ActionExecutor
# =============================================
if server.name == "kubernetes":
@@ -859,7 +875,8 @@ class MCPBridge:
) -> Any:
"""STDIO 方式執行工具 (Mock 實作)"""
# Phase 3: Mock 執行
logger.info(f"[MOCK] STDIO call to {server.endpoint}: {tool_name}({parameters})")
# ⛔ 禁止 logging parameters(ADR-015 Code Review 修復)
logger.info(f"[MOCK] STDIO call to {server.endpoint}: {tool_name}")
mock_responses = {
"read_file": f"[Mock] Contents of {parameters.get('path')}",

View File

@@ -0,0 +1,34 @@
"""
MCP Tool Providers - ADR-015 模組化架構
======================================
每個 Provider 負責一個領域的 MCP 工具:
- K8sProvider: Kubernetes 操作 (kubectl)
- SignOzProvider: 監控指標查詢
- DatabaseProvider: 資料庫查詢 (Approval/Incident)
@see docs/adr/ADR-015-mcp-modular-architecture.md
"""
from src.plugins.mcp.providers.database_provider import DatabaseProvider
from src.plugins.mcp.providers.k8s_provider import K8sProvider
from src.plugins.mcp.providers.signoz_provider import SignOzProvider
__all__ = [
"K8sProvider",
"SignOzProvider",
"DatabaseProvider",
]
def register_all_providers() -> None:
"""
註冊所有 Provider 到全域 Registry
應在 FastAPI lifespan 中呼叫
"""
from src.plugins.mcp.registry import register_provider
register_provider(K8sProvider())
register_provider(SignOzProvider())
register_provider(DatabaseProvider())

View File

@@ -0,0 +1,261 @@
"""
Database MCP Tool Provider - ADR-015 模組化架構
==============================================
提供資料庫查詢工具:
- list_approvals: 列出 Approval 請求
- get_approval: 取得單一 Approval 詳情
- list_incidents: 列出活躍事件
- list_timeline: 列出時間線事件
透過 DI 注入 Services不直接 import。
@see docs/adr/ADR-015-mcp-modular-architecture.md
"""
import uuid
from typing import Any
from uuid import UUID
import structlog
from src.plugins.mcp.interfaces import MCPTool, MCPToolProvider, MCPToolResult
logger = structlog.get_logger(__name__)
class DatabaseProvider(MCPToolProvider):
"""
Database MCP Tool Provider
封裝所有 Approval/Incident 資料庫查詢操作。
"""
def __init__(self) -> None:
self._approval_svc = None
self._incident_svc = None
self._timeline_svc = None
@property
def name(self) -> str:
return "database"
def _get_approval_service(self):
"""Lazy load approval service"""
if self._approval_svc is None:
from src.services.approval_db import get_approval_service
self._approval_svc = get_approval_service()
return self._approval_svc
def _get_incident_service(self):
"""Lazy load incident service"""
if self._incident_svc is None:
from src.services.incident_service import get_incident_service
self._incident_svc = get_incident_service()
return self._incident_svc
def _get_timeline_service(self):
"""Lazy load timeline service"""
if self._timeline_svc is None:
from src.services.approval_db import get_timeline_service
self._timeline_svc = get_timeline_service()
return self._timeline_svc
async def list_tools(self) -> list[MCPTool]:
return [
MCPTool(
name="list_approvals",
description="List approval requests with optional status filter",
input_schema={
"type": "object",
"properties": {
"status": {"type": "string", "description": "Filter by status: pending, approved, rejected, expired"},
"limit": {"type": "integer", "description": "Max results (default: 20)"},
},
},
server_name=self.name,
),
MCPTool(
name="get_approval",
description="Get detailed information about a specific approval",
input_schema={
"type": "object",
"properties": {
"approval_id": {"type": "string", "description": "Approval UUID"},
},
"required": ["approval_id"],
},
server_name=self.name,
),
MCPTool(
name="list_incidents",
description="List incidents with optional status filter",
input_schema={
"type": "object",
"properties": {
"status": {"type": "string", "description": "Filter by status: active, resolved, escalated"},
"limit": {"type": "integer", "description": "Max results (default: 20)"},
},
},
server_name=self.name,
),
MCPTool(
name="list_timeline",
description="List timeline events for audit trail",
input_schema={
"type": "object",
"properties": {
"limit": {"type": "integer", "description": "Max results (default: 50)"},
},
},
server_name=self.name,
),
]
async def execute(
self,
tool_name: str,
parameters: dict[str, Any],
) -> MCPToolResult:
execution_id = str(uuid.uuid4())[:8]
try:
if tool_name == "list_approvals":
output = await self._list_approvals(parameters)
elif tool_name == "get_approval":
output = await self._get_approval(parameters)
elif tool_name == "list_incidents":
output = await self._list_incidents(parameters)
elif tool_name == "list_timeline":
output = await self._list_timeline(parameters)
else:
return MCPToolResult(
success=False,
execution_id=execution_id,
error=f"Unknown tool: {tool_name}",
)
return MCPToolResult(
success=True,
execution_id=execution_id,
output=output,
)
except Exception as e:
logger.exception("database_provider_error", tool=tool_name, error=str(e))
return MCPToolResult(
success=False,
execution_id=execution_id,
error=str(e),
)
async def _list_approvals(self, parameters: dict) -> dict:
from src.models.approval import ApprovalStatus
approval_svc = self._get_approval_service()
status_str = parameters.get("status")
limit = parameters.get("limit", 20)
status_filter = None
if status_str:
try:
status_filter = ApprovalStatus(status_str.lower())
except ValueError:
return {"error": f"Invalid status: {status_str}. Valid: pending, approved, rejected, expired"}
approvals = await approval_svc.get_all_approvals(
status=status_filter,
limit=limit,
)
return {
"count": len(approvals),
"approvals": [
{
"id": str(a.id),
"action": a.action[:80] if a.action else "",
"status": a.status.value if hasattr(a.status, 'value') else str(a.status),
"risk_level": a.risk_level.value if hasattr(a.risk_level, 'value') else str(a.risk_level),
"signatures": f"{a.current_signatures}/{a.required_signatures}",
"created_at": a.created_at.isoformat() if a.created_at else None,
}
for a in approvals
],
}
async def _get_approval(self, parameters: dict) -> dict:
approval_id = parameters.get("approval_id")
if not approval_id:
return {"error": "Missing 'approval_id' parameter"}
approval_svc = self._get_approval_service()
try:
approval = await approval_svc.get_approval_by_id(UUID(approval_id))
except ValueError:
return {"error": f"Invalid UUID format: {approval_id}"}
if not approval:
return {"error": f"Approval not found: {approval_id}"}
return {
"id": str(approval.id),
"action": approval.action,
"description": approval.description,
"status": approval.status.value if hasattr(approval.status, 'value') else str(approval.status),
"risk_level": approval.risk_level.value if hasattr(approval.risk_level, 'value') else str(approval.risk_level),
"required_signatures": approval.required_signatures,
"current_signatures": approval.current_signatures,
"signatures": [
{"signer": s.signer_name, "timestamp": s.timestamp.isoformat()}
for s in (approval.signatures or [])
],
"created_at": approval.created_at.isoformat() if approval.created_at else None,
"resolved_at": approval.resolved_at.isoformat() if approval.resolved_at else None,
}
async def _list_incidents(self, parameters: dict) -> dict:
incident_svc = self._get_incident_service()
status_filter = parameters.get("status")
limit = parameters.get("limit", 20)
incidents = await incident_svc.get_active_incidents()
# Status filter
if status_filter:
incidents = [i for i in incidents if i.status.value == status_filter.lower()]
incidents = incidents[:limit]
return {
"count": len(incidents),
"incidents": [
{
"id": i.incident_id,
"severity": i.severity.value if hasattr(i.severity, 'value') else str(i.severity),
"status": i.status.value if hasattr(i.status, 'value') else str(i.status),
"affected_services": i.affected_services,
"created_at": i.created_at.isoformat() if i.created_at else None,
}
for i in incidents
],
}
async def _list_timeline(self, parameters: dict) -> dict:
timeline_svc = self._get_timeline_service()
limit = parameters.get("limit", 50)
events = await timeline_svc.get_events(limit=limit)
return {
"count": len(events),
"events": events,
}
async def health_check(self) -> bool:
"""Check if database services are accessible"""
try:
# Try to get approval service
self._get_approval_service()
return True
except Exception:
return False

View File

@@ -0,0 +1,231 @@
"""
Kubernetes MCP Tool Provider - ADR-015 模組化架構
================================================
提供 Kubernetes 操作工具:
- kubectl_get: 查詢資源
- kubectl_delete: 刪除 Pod
- kubectl_scale: 調整副本數
- kubectl_restart: 重啟 Deployment
透過 DI 注入 ActionExecutor不直接 import services。
@see docs/adr/ADR-015-mcp-modular-architecture.md
"""
import uuid
from typing import Any
import structlog
from src.plugins.mcp.interfaces import MCPTool, MCPToolProvider, MCPToolResult
logger = structlog.get_logger(__name__)
class K8sProvider(MCPToolProvider):
"""
Kubernetes MCP Tool Provider
封裝所有 kubectl 操作,透過 ActionExecutor 執行。
"""
def __init__(self) -> None:
# Lazy import to avoid circular dependency
self._executor = None
@property
def name(self) -> str:
return "kubernetes"
def _get_executor(self):
"""Lazy load executor to avoid import at module load time"""
if self._executor is None:
from src.services.executor import get_executor
self._executor = get_executor()
return self._executor
async def list_tools(self) -> list[MCPTool]:
return [
MCPTool(
name="kubectl_get",
description="Query Kubernetes resources (pods, deployments, services)",
input_schema={
"type": "object",
"properties": {
"resource": {"type": "string", "description": "Resource type: pods, deployments, services"},
"namespace": {"type": "string", "description": "Namespace (default: awoooi-prod)"},
"name": {"type": "string", "description": "Resource name (optional)"},
},
"required": ["resource"],
},
server_name=self.name,
),
MCPTool(
name="kubectl_delete",
description="Delete a Pod (with dry-run validation)",
input_schema={
"type": "object",
"properties": {
"resource": {"type": "string", "description": "Resource type: pod"},
"name": {"type": "string", "description": "Pod name"},
"namespace": {"type": "string", "description": "Namespace"},
},
"required": ["name"],
},
server_name=self.name,
),
MCPTool(
name="kubectl_scale",
description="Scale a Deployment to N replicas",
input_schema={
"type": "object",
"properties": {
"deployment": {"type": "string", "description": "Deployment name"},
"replicas": {"type": "integer", "description": "Target replica count"},
"namespace": {"type": "string", "description": "Namespace"},
},
"required": ["deployment", "replicas"],
},
server_name=self.name,
),
MCPTool(
name="kubectl_restart",
description="Restart a Deployment (rollout restart)",
input_schema={
"type": "object",
"properties": {
"deployment": {"type": "string", "description": "Deployment name"},
"namespace": {"type": "string", "description": "Namespace"},
},
"required": ["deployment"],
},
server_name=self.name,
),
]
async def execute(
self,
tool_name: str,
parameters: dict[str, Any],
) -> MCPToolResult:
execution_id = str(uuid.uuid4())[:8]
executor = self._get_executor()
try:
if tool_name == "kubectl_get":
output = await self._kubectl_get(executor, parameters)
elif tool_name == "kubectl_delete":
output = await self._kubectl_delete(executor, parameters)
elif tool_name == "kubectl_scale":
output = await self._kubectl_scale(executor, parameters)
elif tool_name == "kubectl_restart":
output = await self._kubectl_restart(executor, parameters)
else:
return MCPToolResult(
success=False,
execution_id=execution_id,
error=f"Unknown tool: {tool_name}",
)
return MCPToolResult(
success=True,
execution_id=execution_id,
output=output,
)
except Exception as e:
logger.exception("k8s_provider_error", tool=tool_name, error=str(e))
return MCPToolResult(
success=False,
execution_id=execution_id,
error=str(e),
)
async def _kubectl_get(self, executor, parameters: dict) -> dict:
namespace = parameters.get("namespace", "awoooi-prod")
resource = parameters.get("resource", "pods")
name = parameters.get("name", "")
cmd = f"kubectl get {resource} {name} -n {namespace} -o json".strip()
result = await executor.execute_kubectl_command(cmd)
if result.success and result.k8s_response:
return result.k8s_response.get("stdout", "")
return {"error": result.error}
async def _kubectl_delete(self, executor, parameters: dict) -> dict:
namespace = parameters.get("namespace", "awoooi-prod")
resource = parameters.get("resource", "pod")
name = parameters.get("name", "")
if not name:
return {"error": "Missing 'name' parameter"}
# Dry-run validation
if resource == "pod":
dry_run = await executor.validate_pod_exists(name, namespace)
else:
dry_run = await executor.validate_deployment_exists(name, namespace)
if not dry_run.passed:
return {"error": dry_run.message, "dry_run": False}
# Execute deletion
if resource == "pod":
result = await executor.delete_pod(name, namespace)
else:
return {"error": "Direct deployment deletion not supported, use restart"}
return {
"success": result.success,
"message": result.message,
"duration_ms": result.duration_ms,
}
async def _kubectl_scale(self, executor, parameters: dict) -> dict:
namespace = parameters.get("namespace", "awoooi-prod")
deployment = parameters.get("deployment", "")
replicas = parameters.get("replicas", 1)
if not deployment:
return {"error": "Missing 'deployment' parameter"}
cmd = f"kubectl scale deployment/{deployment} --replicas={replicas} -n {namespace}"
result = await executor.execute_kubectl_command(cmd)
return {
"success": result.success,
"scaled": result.success,
"replicas": replicas,
"message": result.message,
}
async def _kubectl_restart(self, executor, parameters: dict) -> dict:
namespace = parameters.get("namespace", "awoooi-prod")
deployment = parameters.get("deployment", "")
if not deployment:
return {"error": "Missing 'deployment' parameter"}
dry_run = await executor.validate_deployment_exists(deployment, namespace)
if not dry_run.passed:
return {"error": dry_run.message, "dry_run": False}
result = await executor.restart_deployment(deployment, namespace)
return {
"success": result.success,
"restarted": result.success,
"message": result.message,
"duration_ms": result.duration_ms,
}
async def health_check(self) -> bool:
"""Check if kubectl is accessible"""
try:
executor = self._get_executor()
result = await executor.execute_kubectl_command("kubectl version --client")
return result.success
except Exception:
return False

View File

@@ -0,0 +1,194 @@
"""
SignOz MCP Tool Provider - ADR-015 模組化架構
=============================================
提供 SignOz 監控查詢工具:
- gold_metrics: 取得 Gold Metrics (RPS, Error Rate, P99)
- trace_url: 生成 Trace 查詢 URL
- system_metrics: 取得系統指標 (CPU/Disk)
透過 DI 注入 SignOzClient不直接 import services。
@see docs/adr/ADR-015-mcp-modular-architecture.md
"""
import uuid
from typing import Any
import structlog
from src.plugins.mcp.interfaces import MCPTool, MCPToolProvider, MCPToolResult
logger = structlog.get_logger(__name__)
class SignOzProvider(MCPToolProvider):
"""
SignOz MCP Tool Provider
封裝所有監控指標查詢操作。
"""
def __init__(self) -> None:
self._client = None
@property
def name(self) -> str:
return "signoz"
def _get_client(self):
"""Lazy load SignOz client"""
if self._client is None:
from src.services.signoz_client import get_signoz_client
self._client = get_signoz_client()
return self._client
async def list_tools(self) -> list[MCPTool]:
return [
MCPTool(
name="gold_metrics",
description="Get service Gold Metrics: RPS, Error Rate, P50/P95/P99 Latency",
input_schema={
"type": "object",
"properties": {
"service_name": {"type": "string", "description": "Service name (e.g., awoooi-api)"},
"namespace": {"type": "string", "description": "Namespace (default: awoooi-prod)"},
"time_window_minutes": {"type": "integer", "description": "Time window in minutes (default: 10)"},
},
"required": ["service_name"],
},
server_name=self.name,
),
MCPTool(
name="trace_url",
description="Generate SignOz trace URL for a service",
input_schema={
"type": "object",
"properties": {
"service_name": {"type": "string", "description": "Service name"},
"window_minutes": {"type": "integer", "description": "Time window (default: 5)"},
},
"required": ["service_name"],
},
server_name=self.name,
),
MCPTool(
name="system_metrics",
description="Get system metrics (CPU, Disk) for a host",
input_schema={
"type": "object",
"properties": {
"host": {"type": "string", "description": "Host IP (default: 192.168.0.188)"},
"time_window_minutes": {"type": "integer", "description": "Time window (default: 5)"},
},
},
server_name=self.name,
),
]
async def execute(
self,
tool_name: str,
parameters: dict[str, Any],
) -> MCPToolResult:
execution_id = str(uuid.uuid4())[:8]
client = self._get_client()
try:
if tool_name == "gold_metrics":
output = await self._gold_metrics(client, parameters)
elif tool_name == "trace_url":
output = self._trace_url(client, parameters)
elif tool_name == "system_metrics":
output = await self._system_metrics(client, parameters)
else:
return MCPToolResult(
success=False,
execution_id=execution_id,
error=f"Unknown tool: {tool_name}",
)
return MCPToolResult(
success=True,
execution_id=execution_id,
output=output,
)
except Exception as e:
logger.exception("signoz_provider_error", tool=tool_name, error=str(e))
return MCPToolResult(
success=False,
execution_id=execution_id,
error=str(e),
)
async def _gold_metrics(self, client, parameters: dict) -> dict:
service_name = parameters.get("service_name", "")
if not service_name:
return {"error": "Missing 'service_name' parameter"}
namespace = parameters.get("namespace", "awoooi-prod")
time_window = parameters.get("time_window_minutes", 10)
metrics = await client.get_gold_metrics(
service_name=service_name,
namespace=namespace,
time_window_minutes=time_window,
)
return {
"service_name": metrics.service_name,
"namespace": metrics.namespace,
"rps": round(metrics.rps, 2),
"rps_trend": metrics.rps_trend,
"error_rate": round(metrics.error_rate, 2),
"error_count": metrics.error_count,
"total_requests": metrics.total_requests,
"p50_latency_ms": round(metrics.p50_latency_ms, 1),
"p95_latency_ms": round(metrics.p95_latency_ms, 1),
"p99_latency_ms": round(metrics.p99_latency_ms, 1),
"latency_trend": metrics.latency_trend,
"summary": metrics.to_summary(),
}
def _trace_url(self, client, parameters: dict) -> dict:
service_name = parameters.get("service_name", "")
if not service_name:
return {"error": "Missing 'service_name' parameter"}
window_minutes = parameters.get("window_minutes", 5)
url = client.generate_trace_url(
service_name=service_name,
window_minutes=window_minutes,
)
return {
"service_name": service_name,
"trace_url": url,
"window_minutes": window_minutes,
}
async def _system_metrics(self, client, parameters: dict) -> dict:
host = parameters.get("host", "192.168.0.188")
time_window = parameters.get("time_window_minutes", 5)
metrics = await client.get_system_metrics(
_host=host,
time_window_minutes=time_window,
)
return {
"host": host,
"cpu": metrics.get("cpu", {}),
"disk": metrics.get("disk", {}),
"time_range": metrics.get("time_range", {}),
}
async def health_check(self) -> bool:
"""Check if SignOz is accessible"""
try:
client = self._get_client()
# Try to get metrics for a test service
return True # SignOz client initialization is the check
except Exception:
return False

View File

@@ -0,0 +1,170 @@
"""
MCP Provider Registry - ADR-015 模組化架構
==========================================
Provider 註冊中心,實現依賴注入 (DI) 模式:
1. 統一管理所有 MCP Tool Providers
2. 支援動態註冊/反註冊
3. 支援健康檢查
@see docs/adr/ADR-015-mcp-modular-architecture.md
"""
import structlog
from src.plugins.mcp.interfaces import MCPToolProvider
logger = structlog.get_logger(__name__)
class ProviderRegistry:
"""
MCP Tool Provider 註冊中心
使用方式:
# 註冊
registry = ProviderRegistry()
registry.register(K8sProvider())
registry.register(SignOzProvider())
# 取得
k8s = registry.get("kubernetes")
await k8s.execute("kubectl_get", {...})
# 列出所有
for provider in registry.all():
print(provider.name)
"""
def __init__(self) -> None:
self._providers: dict[str, MCPToolProvider] = {}
def register(self, provider: MCPToolProvider) -> None:
"""
註冊 Provider
Args:
provider: MCPToolProvider 實例
Raises:
ValueError: 如果 Provider 名稱已存在
"""
if provider.name in self._providers:
raise ValueError(f"Provider '{provider.name}' already registered")
self._providers[provider.name] = provider
logger.info(
"provider_registered",
name=provider.name,
enabled=provider.enabled,
)
def unregister(self, name: str) -> bool:
"""
反註冊 Provider
Args:
name: Provider 名稱
Returns:
bool: 是否成功反註冊
"""
if name in self._providers:
del self._providers[name]
logger.info("provider_unregistered", name=name)
return True
return False
def get(self, name: str) -> MCPToolProvider | None:
"""
取得 Provider
Args:
name: Provider 名稱
Returns:
MCPToolProvider | None: Provider 實例,若不存在則返回 None
"""
provider = self._providers.get(name)
if provider and not provider.enabled:
logger.warning("provider_disabled", name=name)
return None
return provider
def all(self) -> list[MCPToolProvider]:
"""
取得所有已啟用的 Providers
Returns:
list[MCPToolProvider]: 已啟用的 Provider 列表
"""
return [p for p in self._providers.values() if p.enabled]
def names(self) -> list[str]:
"""
取得所有已啟用的 Provider 名稱
Returns:
list[str]: Provider 名稱列表
"""
return [p.name for p in self.all()]
async def health_check_all(self) -> dict[str, bool]:
"""
檢查所有 Provider 健康狀態
Returns:
dict[str, bool]: {provider_name: is_healthy}
"""
results = {}
for provider in self.all():
try:
results[provider.name] = await provider.health_check()
except Exception as e:
logger.warning(
"provider_health_check_failed",
name=provider.name,
error=str(e),
)
results[provider.name] = False
return results
def __contains__(self, name: str) -> bool:
return name in self._providers
def __len__(self) -> int:
return len(self._providers)
# =============================================================================
# Global Registry Singleton
# =============================================================================
_registry: ProviderRegistry | None = None
def get_provider_registry() -> ProviderRegistry:
"""
取得全域 Provider Registry
Returns:
ProviderRegistry: 單例實例
"""
global _registry
if _registry is None:
_registry = ProviderRegistry()
return _registry
def register_provider(provider: MCPToolProvider) -> None:
"""
便捷函數: 註冊 Provider 到全域 Registry
"""
get_provider_registry().register(provider)
def get_provider(name: str) -> MCPToolProvider | None:
"""
便捷函數: 從全域 Registry 取得 Provider
"""
return get_provider_registry().get(name)

View File

@@ -196,7 +196,7 @@ export function ConversationalView({
<button
onClick={() => setShowShortcuts((s) => !s)}
className="p-1.5 rounded-lg hover:bg-nothing-gray-100 transition-colors"
title="快捷鍵 (?)"
title={`${tCommon('keyboardShortcuts')} (?)`}
>
<Keyboard className="w-4 h-4 text-nothing-gray-400" />
</button>

View File

@@ -0,0 +1,139 @@
# ADR-015: MCP 模組化架構重構
| 欄位 | 值 |
|------|-----|
| **狀態** | 已批准 |
| **決策日期** | 2026-03-26 |
| **決策者** | 統帥 + 首席架構師 |
| **觸發原因** | Code Review 發現嚴重模組化違規 |
---
## 背景
Phase 13.2 實作 MCP Tool 整合時,為了快速交付,在 `mcp_bridge.py` 直接 import services 層,違反了 leWOOOgo 積木化原則。
### 違規清單
| 位置 | 違規 |
|------|------|
| mcp_bridge.py:570 | `from src.services.executor import get_executor` |
| mcp_bridge.py:655 | `from src.services.signoz_client import get_signoz_client` |
| mcp_bridge.py:734 | `from src.services.approval_db import ...` |
| mcp_bridge.py:738 | `from src.services.incident_service import get_incident_service` |
### 違反的鐵律
1. **Interface 先行** - 無 ABC 定義
2. **禁止跨模組非法引用** - plugins → services 直接 import
3. **模組間透過 Public API 溝通** - 直接呼叫具體實作
---
## 決策
### 架構重構
```
apps/api/src/plugins/mcp/
├── __init__.py
├── interfaces.py # MCPToolProvider ABC (新增)
├── mcp_bridge.py # 透過 DI 注入 providers (重構)
├── registry.py # Provider 註冊中心 (新增)
└── providers/ # 具體實作 (新增)
├── __init__.py
├── k8s_provider.py
├── signoz_provider.py
└── database_provider.py
```
### Interface 定義
```python
from abc import ABC, abstractmethod
from typing import Any
class MCPToolProvider(ABC):
"""MCP Tool Provider 抽象介面"""
@property
@abstractmethod
def name(self) -> str:
"""Provider 名稱 (如 'kubernetes', 'signoz')"""
pass
@abstractmethod
async def list_tools(self) -> list[dict]:
"""列出可用工具"""
pass
@abstractmethod
async def execute(self, tool_name: str, parameters: dict) -> Any:
"""執行工具"""
pass
```
### DI 注入模式
```python
# registry.py
class ProviderRegistry:
_providers: dict[str, MCPToolProvider] = {}
@classmethod
def register(cls, provider: MCPToolProvider) -> None:
cls._providers[provider.name] = provider
@classmethod
def get(cls, name: str) -> MCPToolProvider | None:
return cls._providers.get(name)
# mcp_bridge.py (重構後)
async def call_tool(self, server_name: str, tool_name: str, parameters: dict):
provider = ProviderRegistry.get(server_name)
if not provider:
raise ValueError(f"Unknown provider: {server_name}")
return await provider.execute(tool_name, parameters)
```
---
## 優點
1. **符合 leWOOOgo 積木化** - Interface 先行DI 注入
2. **可測試性** - 可輕鬆 Mock Provider
3. **可擴展性** - 新增 Provider 無需修改 mcp_bridge.py
4. **單一職責** - 每個 Provider 只負責一個領域
---
## 缺點
1. **重構工時** - 需要 2-3 天
2. **檔案數增加** - 從 1 個變 6+ 個
3. **學習曲線** - 新成員需了解 DI 模式
---
## 實作計畫
| 步驟 | 內容 | 工時 |
|------|------|------|
| 1 | 建立 interfaces.py | 30min |
| 2 | 建立 registry.py | 30min |
| 3 | 建立 providers/ 目錄 | 15min |
| 4 | 實作 k8s_provider.py | 1h |
| 5 | 實作 signoz_provider.py | 1h |
| 6 | 實作 database_provider.py | 1h |
| 7 | 重構 mcp_bridge.py | 2h |
| 8 | 更新測試 | 1h |
| 9 | Code Review | 1h |
| **總計** | | **8-9h** |
---
## 相關文件
- [feedback_modular_architecture.md](../../memory/feedback_modular_architecture.md)
- [feedback_modular_core_spirit.md](../../memory/feedback_modular_core_spirit.md)
- [ADR-003: leWOOOgo 模組化架構](ADR-003-lewooogo-module-architecture.md)

View File

@@ -18,6 +18,7 @@
},
"devDependencies": {
"@types/node": "^20.11.0",
"dependency-cruiser": "^17.3.9",
"prettier": "^3.2.0",
"turbo": "^2.0.0",
"typescript": "^5.3.0"

182
pnpm-lock.yaml generated
View File

@@ -11,6 +11,9 @@ importers:
'@types/node':
specifier: ^20.11.0
version: 20.19.37
dependency-cruiser:
specifier: ^17.3.9
version: 17.3.9
prettier:
specifier: ^3.2.0
version: 3.8.1
@@ -138,7 +141,7 @@ importers:
version: 8.5.1(@swc/core@1.15.18(@swc/helpers@0.5.2))(jiti@1.21.7)(postcss@8.5.8)(typescript@5.9.3)
vitest:
specifier: ^1.2.0
version: 1.6.1(@types/node@20.19.37)
version: 1.6.1(@types/node@20.19.37)(terser@5.46.1)
packages/tsconfig: {}
@@ -1723,11 +1726,18 @@ packages:
peerDependencies:
acorn: ^8.14.0
acorn-jsx-walk@2.0.0:
resolution: {integrity: sha512-uuo6iJj4D4ygkdzd6jPtcxs8vZgDX9YFIkqczGImoypX2fQ4dVImmu3UzA4ynixCIMTrEOWW+95M2HuBaCEOVA==}
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
acorn-loose@8.5.2:
resolution: {integrity: sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A==}
engines: {node: '>=0.4.0'}
acorn-walk@8.3.5:
resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==}
engines: {node: '>=0.4.0'}
@@ -1980,6 +1990,10 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
commander@14.0.3:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
@@ -2109,6 +2123,11 @@ packages:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
dependency-cruiser@17.3.9:
resolution: {integrity: sha512-LwaotlB9bZ8zhdFGGYf/g2oYkYj7YNxlqx1btL/XIYGob/aKRArsSwkLKo+ZrHiegsEArQVg4ZQ3NhAh8uk+hg==}
engines: {node: ^20.12||^22||>=24}
hasBin: true
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -2155,6 +2174,10 @@ packages:
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
enhanced-resolve@5.20.0:
resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==}
engines: {node: '>=10.13.0'}
enhanced-resolve@5.20.1:
resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
engines: {node: '>=10.13.0'}
@@ -2496,6 +2519,10 @@ packages:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
global-directory@4.0.1:
resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==}
engines: {node: '>=18'}
globals@13.24.0:
resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==}
engines: {node: '>=8'}
@@ -2560,6 +2587,10 @@ packages:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
ignore@7.0.5:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
immer@10.2.0:
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
@@ -2588,6 +2619,10 @@ packages:
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ini@4.1.1:
resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
internal-slot@1.1.0:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
@@ -2596,6 +2631,10 @@ packages:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
interpret@3.1.1:
resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==}
engines: {node: '>=10.13.0'}
intl-messageformat@11.2.0:
resolution: {integrity: sha512-IhghAA8n4KSlXuWKzYsWyWb82JoYTzShfyvdSF85oJPnNOjvv4kAo7S7Jtkm3/vJ53C7dQNRO+Gpnj3iWgTjBQ==}
@@ -2658,6 +2697,10 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-installed-globally@1.0.0:
resolution: {integrity: sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==}
engines: {node: '>=18'}
is-map@2.0.3:
resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
engines: {node: '>= 0.4'}
@@ -2678,6 +2721,10 @@ packages:
resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==}
engines: {node: '>=8'}
is-path-inside@4.0.0:
resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==}
engines: {node: '>=12'}
is-reference@1.2.1:
resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==}
@@ -2793,6 +2840,10 @@ packages:
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
kleur@3.0.3:
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
engines: {node: '>=6'}
language-subtag-registry@0.3.23:
resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==}
@@ -3229,6 +3280,10 @@ packages:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'}
prompts@2.4.2:
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
engines: {node: '>= 6'}
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
@@ -3288,6 +3343,10 @@ packages:
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
rechoir@0.8.0:
resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==}
engines: {node: '>= 10.13.0'}
redux-thunk@3.1.0:
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
@@ -3300,6 +3359,10 @@ packages:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
regexp-tree@0.1.27:
resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==}
hasBin: true
regexp.prototype.flags@1.5.4:
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
engines: {node: '>= 0.4'}
@@ -3365,6 +3428,9 @@ packages:
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
engines: {node: '>= 0.4'}
safe-regex@2.1.1:
resolution: {integrity: sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==}
scheduler@0.23.2:
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
@@ -3424,6 +3490,9 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
slash@3.0.0:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'}
@@ -3632,9 +3701,17 @@ packages:
ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
tsconfig-paths-webpack-plugin@4.2.0:
resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==}
engines: {node: '>=10.13.0'}
tsconfig-paths@3.15.0:
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
tsconfig-paths@4.2.0:
resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==}
engines: {node: '>=6'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -3805,6 +3882,11 @@ packages:
resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==}
engines: {node: '>=10.13.0'}
watskeburt@5.0.3:
resolution: {integrity: sha512-g9CXukMjazlJJVQ3OHzXsnG25KFYgSgKMIyoJrD8ggr0DbS9UNF7OzIqWmmKKBMedkxj3T01uqEaGnn+y7QhMA==}
engines: {node: ^20.12||^22.13||>=24.0}
hasBin: true
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
@@ -5402,10 +5484,16 @@ snapshots:
dependencies:
acorn: 8.16.0
acorn-jsx-walk@2.0.0: {}
acorn-jsx@5.3.2(acorn@8.16.0):
dependencies:
acorn: 8.16.0
acorn-loose@8.5.2:
dependencies:
acorn: 8.16.0
acorn-walk@8.3.5:
dependencies:
acorn: 8.16.0
@@ -5680,6 +5768,8 @@ snapshots:
color-name@1.1.4: {}
commander@14.0.3: {}
commander@2.20.3: {}
commander@4.1.1: {}
@@ -5790,6 +5880,27 @@ snapshots:
has-property-descriptors: 1.0.2
object-keys: 1.1.1
dependency-cruiser@17.3.9:
dependencies:
acorn: 8.16.0
acorn-jsx: 5.3.2(acorn@8.16.0)
acorn-jsx-walk: 2.0.0
acorn-loose: 8.5.2
acorn-walk: 8.3.5
commander: 14.0.3
enhanced-resolve: 5.20.0
ignore: 7.0.5
interpret: 3.1.1
is-installed-globally: 1.0.0
json5: 2.2.3
picomatch: 4.0.3
prompts: 2.4.2
rechoir: 0.8.0
safe-regex: 2.1.1
semver: 7.7.4
tsconfig-paths-webpack-plugin: 4.2.0
watskeburt: 5.0.3
detect-libc@2.1.2: {}
didyoumean@1.2.2: {}
@@ -5826,6 +5937,11 @@ snapshots:
emoji-regex@9.2.2: {}
enhanced-resolve@5.20.0:
dependencies:
graceful-fs: 4.2.11
tapable: 2.3.2
enhanced-resolve@5.20.1:
dependencies:
graceful-fs: 4.2.11
@@ -6379,6 +6495,10 @@ snapshots:
once: 1.4.0
path-is-absolute: 1.0.1
global-directory@4.0.1:
dependencies:
ini: 4.1.1
globals@13.24.0:
dependencies:
type-fest: 0.20.2
@@ -6440,6 +6560,8 @@ snapshots:
ignore@5.3.2: {}
ignore@7.0.5: {}
immer@10.2.0: {}
immer@11.1.4: {}
@@ -6472,6 +6594,8 @@ snapshots:
inherits@2.0.4: {}
ini@4.1.1: {}
internal-slot@1.1.0:
dependencies:
es-errors: 1.3.0
@@ -6480,6 +6604,8 @@ snapshots:
internmap@2.0.3: {}
interpret@3.1.1: {}
intl-messageformat@11.2.0:
dependencies:
'@formatjs/ecma402-abstract': 3.2.0
@@ -6554,6 +6680,11 @@ snapshots:
dependencies:
is-extglob: 2.1.1
is-installed-globally@1.0.0:
dependencies:
global-directory: 4.0.1
is-path-inside: 4.0.0
is-map@2.0.3: {}
is-negative-zero@2.0.3: {}
@@ -6567,6 +6698,8 @@ snapshots:
is-path-inside@3.0.3: {}
is-path-inside@4.0.0: {}
is-reference@1.2.1:
dependencies:
'@types/estree': 1.0.8
@@ -6678,6 +6811,8 @@ snapshots:
dependencies:
json-buffer: 3.0.1
kleur@3.0.3: {}
language-subtag-registry@0.3.23: {}
language-tags@1.0.9:
@@ -7076,6 +7211,11 @@ snapshots:
progress@2.0.3: {}
prompts@2.4.2:
dependencies:
kleur: 3.0.3
sisteransi: 1.0.5
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
@@ -7141,6 +7281,10 @@ snapshots:
- '@types/react'
- redux
rechoir@0.8.0:
dependencies:
resolve: 1.22.11
redux-thunk@3.1.0(redux@5.0.1):
dependencies:
redux: 5.0.1
@@ -7158,6 +7302,8 @@ snapshots:
get-proto: 1.0.1
which-builtin-type: 1.2.1
regexp-tree@0.1.27: {}
regexp.prototype.flags@1.5.4:
dependencies:
call-bind: 1.0.8
@@ -7259,6 +7405,10 @@ snapshots:
es-errors: 1.3.0
is-regex: 1.2.1
safe-regex@2.1.1:
dependencies:
regexp-tree: 0.1.27
scheduler@0.23.2:
dependencies:
loose-envify: 1.4.0
@@ -7334,6 +7484,8 @@ snapshots:
signal-exit@4.1.0: {}
sisteransi@1.0.5: {}
slash@3.0.0: {}
source-map-js@1.2.1: {}
@@ -7557,6 +7709,13 @@ snapshots:
ts-interface-checker@0.1.13: {}
tsconfig-paths-webpack-plugin@4.2.0:
dependencies:
chalk: 4.1.2
enhanced-resolve: 5.20.1
tapable: 2.3.2
tsconfig-paths: 4.2.0
tsconfig-paths@3.15.0:
dependencies:
'@types/json5': 0.0.29
@@ -7564,6 +7723,12 @@ snapshots:
minimist: 1.2.8
strip-bom: 3.0.0
tsconfig-paths@4.2.0:
dependencies:
json5: 2.2.3
minimist: 1.2.8
strip-bom: 3.0.0
tslib@2.8.1: {}
tsup@8.5.1(@swc/core@1.15.18(@swc/helpers@0.5.2))(jiti@1.21.7)(postcss@8.5.8)(typescript@5.9.3):
@@ -7727,13 +7892,13 @@ snapshots:
d3-time: 3.1.0
d3-timer: 3.0.1
vite-node@1.6.1(@types/node@20.19.37):
vite-node@1.6.1(@types/node@20.19.37)(terser@5.46.1):
dependencies:
cac: 6.7.14
debug: 4.4.3
pathe: 1.1.2
picocolors: 1.1.1
vite: 5.4.21(@types/node@20.19.37)
vite: 5.4.21(@types/node@20.19.37)(terser@5.46.1)
transitivePeerDependencies:
- '@types/node'
- less
@@ -7745,7 +7910,7 @@ snapshots:
- supports-color
- terser
vite@5.4.21(@types/node@20.19.37):
vite@5.4.21(@types/node@20.19.37)(terser@5.46.1):
dependencies:
esbuild: 0.21.5
postcss: 8.5.8
@@ -7753,8 +7918,9 @@ snapshots:
optionalDependencies:
'@types/node': 20.19.37
fsevents: 2.3.3
terser: 5.46.1
vitest@1.6.1(@types/node@20.19.37):
vitest@1.6.1(@types/node@20.19.37)(terser@5.46.1):
dependencies:
'@vitest/expect': 1.6.1
'@vitest/runner': 1.6.1
@@ -7773,8 +7939,8 @@ snapshots:
strip-literal: 2.1.1
tinybench: 2.9.0
tinypool: 0.8.4
vite: 5.4.21(@types/node@20.19.37)
vite-node: 1.6.1(@types/node@20.19.37)
vite: 5.4.21(@types/node@20.19.37)(terser@5.46.1)
vite-node: 1.6.1(@types/node@20.19.37)(terser@5.46.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 20.19.37
@@ -7793,6 +7959,8 @@ snapshots:
glob-to-regexp: 0.4.1
graceful-fs: 4.2.11
watskeburt@5.0.3: {}
webidl-conversions@3.0.1: {}
webpack-sources@3.3.4: {}