Files
ewoooc/tests/test_mcp_router.py
OoO c1fd913a35
All checks were successful
CD Pipeline / deploy (push) Successful in 2m55s
feat(p10.5): MCP Router 統一介面 + mcp_collector 接 omnisearch L0
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>
2026-05-04 09:34:21 +08:00

192 lines
9.5 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.
"""
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()