diff --git a/services/mcp_router.py b/services/mcp_router.py index 9e93d4d..7d8c229 100644 --- a/services/mcp_router.py +++ b/services/mcp_router.py @@ -152,6 +152,7 @@ class MCPResult: cost_usd: float = 0.0 error: Optional[str] = None output_size: int = 0 + status: Optional[str] = None # ───────────────────────────────────────────────────────────────────────────── @@ -196,7 +197,7 @@ def _async_write_mcp_call( 'args': json.dumps(args_redacted), 'osz': result.output_size, 'dur': result.duration_ms, - 'status': 'ok' if result.success else 'error', + 'status': result.status or ('ok' if result.success else 'error'), 'err': (result.error or '')[:4000] if result.error else None, 'cost': result.cost_usd, 'cache': result.cache_hit, @@ -260,6 +261,7 @@ class MCPRouter: success=True, server=server, tool=tool, data=cached, cache_hit=True, duration_ms=0, output_size=len(json.dumps(cached, ensure_ascii=False)), + status='cache_only', ) _async_write_mcp_call(caller, server, tool, args, result, request_id) return result @@ -274,10 +276,12 @@ class MCPRouter: duration_ms = int((time.monotonic() - t0) * 1000) if resp.status_code != 200: + status = 'rate_limited' if resp.status_code == 429 else 'error' result = MCPResult( success=False, server=server, tool=tool, duration_ms=duration_ms, error=f'HTTP {resp.status_code}: {resp.text[:200]}', + status=status, ) _async_write_mcp_call(caller, server, tool, args, result, request_id) return result @@ -299,6 +303,7 @@ class MCPRouter: success=True, server=server, tool=tool, data=data, cache_hit=False, duration_ms=duration_ms, output_size=output_size, + status='ok', ) _async_write_mcp_call(caller, server, tool, args, result, request_id) return result @@ -308,6 +313,7 @@ class MCPRouter: result = MCPResult( success=False, server=server, tool=tool, duration_ms=duration_ms, error=f'timeout ({request_timeout}s)', + status='timeout', ) _async_write_mcp_call(caller, server, tool, args, result, request_id) return result @@ -318,6 +324,7 @@ class MCPRouter: success=False, server=server, tool=tool, duration_ms=duration_ms, error=f'{type(exc).__name__}: {str(exc)[:300]}', + status='error', ) _async_write_mcp_call(caller, server, tool, args, result, request_id) return result diff --git a/tests/test_mcp_router.py b/tests/test_mcp_router.py index cd0b482..253d56c 100644 --- a/tests/test_mcp_router.py +++ b/tests/test_mcp_router.py @@ -103,8 +103,16 @@ def test_filesystem_registry_is_read_only(monkeypatch): def test_successful_call_and_cache_hit(monkeypatch): monkeypatch.setenv('MCP_ROUTER_ENABLED', 'true') + import services.mcp_router as mr from services.mcp_router import mcp_router + written_statuses = [] + monkeypatch.setattr( + mr, + '_async_write_mcp_call', + lambda caller, server, tool, args, result, request_id=None: written_statuses.append(result.status), + ) + fake_resp = MagicMock(status_code=200) fake_resp.json.return_value = {'results': [{'title': '商品 A', 'content': '熱銷'}]} fake_resp.text = '{"ok": true}' @@ -117,6 +125,7 @@ def test_successful_call_and_cache_hit(monkeypatch): ) assert r1.success is True assert r1.cache_hit is False + assert r1.status == 'ok' assert r1.data['results'][0]['title'] == '商品 A' assert mock_post.call_count == 1 @@ -127,7 +136,9 @@ def test_successful_call_and_cache_hit(monkeypatch): ) assert r2.success is True assert r2.cache_hit is True + assert r2.status == 'cache_only' assert mock_post.call_count == 1 # 沒再打 HTTP + assert written_statuses == ['ok', 'cache_only'] # ═══════════════════════════════════════════════════════════════════════════ @@ -146,6 +157,7 @@ def test_http_timeout_returns_failure(monkeypatch): ) assert result.success is False + assert result.status == 'timeout' assert 'timeout' in (result.error or '').lower() @@ -167,9 +179,28 @@ def test_http_500_returns_failure(monkeypatch): ) assert result.success is False + assert result.status == 'error' assert 'HTTP 500' in (result.error or '') +def test_http_429_records_rate_limited_status(monkeypatch): + monkeypatch.setenv('MCP_ROUTER_ENABLED', 'true') + from services.mcp_router import mcp_router + + fake_resp = MagicMock(status_code=429) + fake_resp.text = 'Too Many Requests' + + 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 result.status == 'rate_limited' + assert 'HTTP 429' in (result.error or '') + + # ═══════════════════════════════════════════════════════════════════════════ # T6: cache key 穩定性 # ═══════════════════════════════════════════════════════════════════════════