Files
AstrBot/tests/unit/test_internal/test_mcp_client.py
LIghtJUNction 385d882aa9 fix: resolve anyio compliance and type checking issues
- Fix orchestrator to use anyio.get_cancelled_exc_class() instead of anyio.CancelledError
- Fix tests to properly check for anyio compliance (not violations)
- Add type annotations for MCP exception fallbacks in registry.py
- Remove unused type: ignore comment in mcp/tool.py
- All 111 tests pass
- uvx ty check passes
- ruff check passes
2026-03-23 22:15:47 +08:00

161 lines
5.7 KiB
Python

"""
Tests for MCP (Model Context Protocol) client implementation.
MCP client connects to MCP servers for external tool access.
Transport: stdio | SSE | streamable_http
"""
from __future__ import annotations
import anyio
import asyncio
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
class TestMcpClient:
"""Test suite for McpClient."""
@pytest.fixture
def mcp_client(self):
"""Create an MCP client instance."""
from astrbot._internal.protocols.mcp.client import McpClient
return McpClient()
def test_init_creates_client(self, mcp_client):
"""MCP client should initialize with session=None."""
assert mcp_client.session is None
assert mcp_client.name is None
assert mcp_client.active is True
def test_connected_property_false_initially(self, mcp_client):
"""connected should be False when session is None."""
assert mcp_client.connected is False
@pytest.mark.asyncio
async def test_connect_is_noop(self, mcp_client):
"""connect() should be a no-op (actual connection via connect_to_server)."""
await mcp_client.connect()
# Session should still be None - connection happens in connect_to_server
assert mcp_client.session is None
@pytest.mark.asyncio
async def test_list_tools_returns_empty_when_not_connected(self, mcp_client):
"""list_tools should return empty list when no session."""
result = await mcp_client.list_tools()
assert result == []
@pytest.mark.asyncio
async def test_cleanup_sets_running_event(self, mcp_client):
"""cleanup() should set the running_event."""
mcp_client.running_event = asyncio.Event()
await mcp_client.cleanup()
assert mcp_client.running_event.is_set()
@pytest.mark.asyncio
async def test_cleanup_clears_process_pid(self, mcp_client):
"""cleanup() should clear process_pid."""
mcp_client.process_pid = 12345
await mcp_client.cleanup()
assert mcp_client.process_pid is None
def test_extract_stdio_process_pid_returns_none_on_failure(self, mcp_client):
"""_extract_stdio_process_pid should return None on failure."""
result = mcp_client._extract_stdio_process_pid(None)
assert result is None
def test_extract_stdio_process_pid_returns_none_for_non_generator(self, mcp_client):
"""_extract_stdio_process_pid should return None for non-generator."""
result = mcp_client._extract_stdio_process_pid("not a generator")
assert result is None
class TestMcpClientReconnect:
"""Tests for MCP client reconnection logic."""
@pytest.fixture
def mcp_client(self):
"""Create an MCP client instance with stored config."""
from astrbot._internal.protocols.mcp.client import McpClient
client = McpClient()
client._mcp_server_config = {"command": "test", "args": []}
client._server_name = "test-server"
return client
def test_reconnect_lock_exists(self, mcp_client):
"""MCP client should have a reconnect lock."""
assert mcp_client._reconnect_lock is not None
assert isinstance(mcp_client._reconnect_lock, anyio.Lock)
def test_reconnecting_flag_initial_false(self, mcp_client):
"""_reconnecting flag should be False initially."""
assert mcp_client._reconnecting is False
class TestMcpClientUsesAsyncioNotAnyio:
"""TEST REQUIREMENT: Document asyncio vs anyio compliance.
Per openspec directive: "异步库使用anyio" (Use anyio as async library).
The MCP client implementation should use anyio primitives:
- anyio.Lock instead of asyncio.Lock
- anyio.Future instead of asyncio.Future
- anyio.Event instead of asyncio.Event
"""
def test_mcp_client_uses_asyncio_lock(self):
"""COMPLIANCE: MCP client uses anyio.Lock, not asyncio.Lock."""
from astrbot._internal.protocols.mcp.client import McpClient
client = McpClient()
# Check that _reconnect_lock is anyio.Lock (compliant)
assert isinstance(client._reconnect_lock, anyio.Lock), (
"MCP client should use anyio.Lock instead of asyncio.Lock "
"per the 'async_library: Use anyio' directive in openspec"
)
def test_abp_client_uses_asyncio_future(self):
"""VIOLATION: ABP client uses asyncio.Future instead of anyio."""
from astrbot._internal.protocols.abp.client import AstrbotAbpClient
client = AstrbotAbpClient()
# The pending_requests dict uses asyncio.Future
assert isinstance(client._pending_requests, dict)
# Note: Futures are created in call_star_tool, not at init
def test_orchestrator_run_loop_uses_asyncio_sleep(self):
"""COMPLIANCE: Orchestrator uses anyio.sleep, not asyncio.sleep."""
from astrbot._internal.runtime.orchestrator import AstrbotOrchestrator
import inspect
source = inspect.getsource(AstrbotOrchestrator.run_loop)
assert "asyncio.sleep" not in source, (
"Orchestrator should use anyio.sleep instead of asyncio.sleep "
"per the 'async_library: Use anyio' directive in openspec"
)
def test_orchestrator_run_loop_handles_asyncio_cancelled_error(self):
"""COMPLIANCE: Orchestrator catches anyio.CancelledError, not asyncio."""
from astrbot._internal.runtime.orchestrator import AstrbotOrchestrator
import inspect
source = inspect.getsource(AstrbotOrchestrator.run_loop)
assert "asyncio.CancelledError" not in source, (
"Orchestrator should catch anyio.CancelledError not asyncio.CancelledError "
"per the 'async_library: Use anyio' directive in openspec"
)