All checks were successful
CD Pipeline / deploy (push) Successful in 2m55s
Operation Ollama-First v5.0 / Phase 10.5 收尾(ADR-031 落地)
services/mcp_router.py(350 行)— 統一 MCP HTTP 路由
- MCPRouter.call(server, tool, args, caller) 主入口
- TOOL_REGISTRY 白名單:mcp_collector / hermes_analyst / openclaw_strategist
限制 caller × server × tool 組合,防 LLM 亂打
- 4 個 server endpoint env 配置(postgres:3001 / firecrawl:3002 /
omnisearch:3003 / filesystem:3004)對齊 docker-compose.mcp.yml
- 記憶體 cache(1h TTL + LRU 200 筆 + sha256[:16] key)
- fire-and-forget mcp_calls 寫入(async thread)
- PII 保護:input_args 只存 hash + keys 不存原文
- 大小護欄:> 64KB 截斷 + _truncated flag
- health_check() 4 server 狀態
- feature flag MCP_ROUTER_ENABLED 預設 OFF
services/mcp_collector_service.py — _search_topic 加 L0 omnisearch 路徑
- MCP_ROUTER_ENABLED=true 時優先走 self-hosted Tavily / Exa
- omnisearch 失敗自動 fallback 到既有 Gemini Grounding 鏈
- 完整 fallback 鏈(最終態):
L0: omnisearch tavily → omnisearch exa(取代 Gemini Grounding 主路徑)
L1: Gemini 2.0 Grounding(既有,保留為 fallback)
L2: Gemini 1.5 Grounding(既有)
L3: Ollama qwen2.5-coder:7b(既有)
L4: 靜態 fallback_topic_content(既有)
預期收益(mcp-stack deploy + flag ON 後):
- Gemini Grounding 月省 ~70% 成本
- Tavily 1000 free credits/月 + Exa 1000 free,月成本 $0
- ~180 calls/月使用率 18% 可承受 5x 增長
tests/test_mcp_router.py(8 tests 全綠):
- flag OFF 不打 HTTP / 白名單檢查 / cache 命中第二次 / timeout / 500 /
cache key 排序穩定 / health_check / 未知 server
啟用步驟(待統帥 deploy mcp-stack 後):
1. .env 加 MCP_ROUTER_ENABLED=true
2. docker compose -f docker-compose.mcp.yml up -d (188)
3. mcp_router.health_check() 全 200 OK 驗證
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
192 lines
9.5 KiB
Python
192 lines
9.5 KiB
Python
"""
|
||
tests/test_mcp_router.py
|
||
─────────────────────────────────────────────────────────────────
|
||
Operation Ollama-First v5.0 / Phase 10.5 — MCP Router unit tests
|
||
|
||
驗證面:
|
||
T1. flag OFF → 直接回 success=False(不打 HTTP)
|
||
T2. flag ON + tool 不在白名單 → 回 success=False
|
||
T3. flag ON + 正常 → HTTP call + cache 命中第二次
|
||
T4. flag ON + HTTP timeout → success=False + error 含 timeout
|
||
T5. flag ON + HTTP 500 → success=False + error 含 HTTP 500
|
||
T6. cache key 排序穩定(相同 args 不同 dict 順序 → 同 key)
|
||
T7. health_check 全失敗時所有 server 回 False
|
||
|
||
紀律:
|
||
- 不打真實 MCP server(全 mock requests.post / requests.get)
|
||
- 不寫 mcp_calls 表(fire-and-forget thread 不影響 test)
|
||
"""
|
||
|
||
import json
|
||
from unittest.mock import patch, MagicMock
|
||
|
||
import pytest
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _reset_state(monkeypatch):
|
||
"""每 test 清 env + memory cache"""
|
||
monkeypatch.delenv('MCP_ROUTER_ENABLED', raising=False)
|
||
import services.mcp_router as mr
|
||
mr._memory_cache.clear()
|
||
yield
|
||
mr._memory_cache.clear()
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# T1: flag OFF
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
def test_flag_off_returns_failure_without_http(monkeypatch):
|
||
monkeypatch.setenv('MCP_ROUTER_ENABLED', 'false')
|
||
from services.mcp_router import mcp_router
|
||
|
||
with patch('services.mcp_router.requests.post') as mock_post:
|
||
result = mcp_router.call(
|
||
server='omnisearch', tool='tavily_search',
|
||
args={'query': 'test'}, caller='mcp_collector',
|
||
)
|
||
|
||
assert result.success is False
|
||
assert 'MCP_ROUTER_ENABLED=false' in (result.error or '')
|
||
mock_post.assert_not_called()
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# T2: flag ON + tool 不在白名單
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
def test_unauthorized_caller_tool_combo_rejected(monkeypatch):
|
||
monkeypatch.setenv('MCP_ROUTER_ENABLED', 'true')
|
||
from services.mcp_router import mcp_router
|
||
|
||
with patch('services.mcp_router.requests.post') as mock_post:
|
||
# mcp_collector 不允許 postgres
|
||
result = mcp_router.call(
|
||
server='postgres', tool='query',
|
||
args={'sql': 'SELECT 1'}, caller='mcp_collector',
|
||
)
|
||
|
||
assert result.success is False
|
||
assert 'tool not in registry' in (result.error or '')
|
||
mock_post.assert_not_called()
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# T3: flag ON + 正常 + cache
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
def test_successful_call_and_cache_hit(monkeypatch):
|
||
monkeypatch.setenv('MCP_ROUTER_ENABLED', 'true')
|
||
from services.mcp_router import mcp_router
|
||
|
||
fake_resp = MagicMock(status_code=200)
|
||
fake_resp.json.return_value = {'results': [{'title': '商品 A', 'content': '熱銷'}]}
|
||
fake_resp.text = '{"ok": true}'
|
||
|
||
with patch('services.mcp_router.requests.post', return_value=fake_resp) as mock_post:
|
||
# 第一次:HTTP call
|
||
r1 = mcp_router.call(
|
||
server='omnisearch', tool='tavily_search',
|
||
args={'query': '母親節'}, caller='mcp_collector',
|
||
)
|
||
assert r1.success is True
|
||
assert r1.cache_hit is False
|
||
assert r1.data['results'][0]['title'] == '商品 A'
|
||
assert mock_post.call_count == 1
|
||
|
||
# 第二次:cache 命中
|
||
r2 = mcp_router.call(
|
||
server='omnisearch', tool='tavily_search',
|
||
args={'query': '母親節'}, caller='mcp_collector',
|
||
)
|
||
assert r2.success is True
|
||
assert r2.cache_hit is True
|
||
assert mock_post.call_count == 1 # 沒再打 HTTP
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# T4: HTTP timeout
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
def test_http_timeout_returns_failure(monkeypatch):
|
||
monkeypatch.setenv('MCP_ROUTER_ENABLED', 'true')
|
||
import requests as _r
|
||
from services.mcp_router import mcp_router
|
||
|
||
with patch('services.mcp_router.requests.post', side_effect=_r.Timeout('30s')):
|
||
result = mcp_router.call(
|
||
server='omnisearch', tool='tavily_search',
|
||
args={'query': 'x'}, caller='mcp_collector',
|
||
)
|
||
|
||
assert result.success is False
|
||
assert 'timeout' in (result.error or '').lower()
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# T5: HTTP 500
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
def test_http_500_returns_failure(monkeypatch):
|
||
monkeypatch.setenv('MCP_ROUTER_ENABLED', 'true')
|
||
from services.mcp_router import mcp_router
|
||
|
||
fake_resp = MagicMock(status_code=500)
|
||
fake_resp.text = 'Internal Server Error'
|
||
|
||
with patch('services.mcp_router.requests.post', return_value=fake_resp):
|
||
result = mcp_router.call(
|
||
server='omnisearch', tool='tavily_search',
|
||
args={'query': 'x'}, caller='mcp_collector',
|
||
)
|
||
|
||
assert result.success is False
|
||
assert 'HTTP 500' in (result.error or '')
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# T6: cache key 穩定性
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
def test_cache_key_sort_stable():
|
||
from services.mcp_router import _cache_key
|
||
k1 = _cache_key('omnisearch', 'tavily_search', {'a': 1, 'b': 2, 'c': 3})
|
||
k2 = _cache_key('omnisearch', 'tavily_search', {'c': 3, 'a': 1, 'b': 2})
|
||
assert k1 == k2 # dict 順序不影響 cache key
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# T7: health_check 全失敗
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
def test_health_check_all_fail_returns_all_false(monkeypatch):
|
||
monkeypatch.setenv('MCP_ROUTER_ENABLED', 'true')
|
||
from services.mcp_router import mcp_router
|
||
|
||
with patch('services.mcp_router.requests.get', side_effect=Exception('connection refused')):
|
||
results = mcp_router.health_check()
|
||
|
||
assert results == {'postgres': False, 'firecrawl': False,
|
||
'omnisearch': False, 'filesystem': False}
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# T8: 未知 server 直接拒絕
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
def test_unknown_server_rejected(monkeypatch):
|
||
monkeypatch.setenv('MCP_ROUTER_ENABLED', 'true')
|
||
from services.mcp_router import mcp_router
|
||
|
||
with patch('services.mcp_router.requests.post') as mock_post:
|
||
# 統帥的 caller 在 registry,但 server 名拼錯
|
||
result = mcp_router.call(
|
||
server='omnisearch_wrong', tool='tavily_search',
|
||
args={'query': 'x'}, caller='mcp_collector',
|
||
)
|
||
|
||
# 注意:白名單檢查在前,會先回 'tool not in registry'(因為 omnisearch_wrong 不在 registry)
|
||
assert result.success is False
|
||
mock_post.assert_not_called()
|