11a9dcf567
mcp 2.0.0 implements MCP revision 2026-07-28 and makes three breaking changes Hermes sits on top of: `mcp.server.fastmcp` is gone, every model field is renamed to snake_case (camelCase survives only as a serialization alias, which pydantic does not expose to attribute access), and the SDK's own HTTP stack moved from `httpx` to `httpx2`. Bump the pin across the dev/mcp/computer-use extras and port the tree: - `mcp_serve.py` and `agent/transports/hermes_tools_mcp_server.py` move from `FastMCP` to `mcp.server.MCPServer`, which has the same decorator/add_tool surface. The hermes-tools server already synthesised `__signature__` from Hermes' JSON Schema, which is exactly what 2.0's `add_tool` reads. - SDK model reads go through `mcp_field(obj, snake, camel)`, which reads both spellings. A single-spelling read fails *silently* on the other generation — empty tool schemas, dropped structured content, tool results vanishing from sampling conversations — and `mcp` is an optional extra users install at their own version. - `sdk_httpx()` resolves the httpx flavour from the SDK's own transport module, so objects handed to `streamable_http_client`, the `sse_client` factory, and the OAuth metadata helpers come from the module the installed SDK actually imports. - HTTP support is gated on either streamable-HTTP entry point, not just the deprecated alias 2.0 removed. - OAuth: `OAuthClientProvider` lost its `timeout` argument (the configured `oauth.timeout` now bounds the callback waiter's own poll loop, where the browser round-trip was always awaited), and `callback_handler` must return `AuthorizationCodeResult` rather than a tuple. 2.0 also validates the RFC 9207 `iss` parameter, so the callback handler and paste fallback capture it. `mcp`/`mcp-types` 2.0.0 are inside the 14-day `exclude-newer` window, so two narrow `exclude-newer-package` entries unblock `uv lock`, annotated for removal on or after 2026-08-11. `httpx2` needs no exemption: 2.7.0 is already outside the window and satisfies mcp's floor. Refs #69931 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
135 lines
5.4 KiB
Python
135 lines
5.4 KiB
Python
"""Tests for MCP dynamic tool discovery (notifications/tools/list_changed)."""
|
|
|
|
import asyncio
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from tools.mcp_tool import MCPServerTask, _register_server_tools
|
|
from tools.registry import ToolRegistry
|
|
|
|
|
|
def _make_mcp_tool(name: str, desc: str = ""):
|
|
return SimpleNamespace(name=name, description=desc, inputSchema=None)
|
|
|
|
|
|
class TestRegisterServerTools:
|
|
"""Tests for the extracted _register_server_tools helper."""
|
|
|
|
@pytest.fixture
|
|
def mock_registry(self):
|
|
return ToolRegistry()
|
|
|
|
def test_exposes_live_server_aliases(self, mock_registry):
|
|
"""Registered MCP tools are reachable via live raw-server aliases."""
|
|
server = MCPServerTask("my_srv")
|
|
server._tools = [_make_mcp_tool("my_tool", "desc")]
|
|
server.session = MagicMock()
|
|
from toolsets import resolve_toolset, validate_toolset
|
|
|
|
with patch("tools.registry.registry", mock_registry):
|
|
registered = _register_server_tools("my_srv", server, {})
|
|
assert "mcp__my_srv__my_tool" in registered
|
|
assert "mcp__my_srv__my_tool" in mock_registry.get_all_tool_names()
|
|
assert validate_toolset("my_srv") is True
|
|
assert "mcp__my_srv__my_tool" in resolve_toolset("my_srv")
|
|
|
|
|
|
class TestRefreshTools:
|
|
"""Tests for MCPServerTask._refresh_tools nuke-and-repave cycle."""
|
|
|
|
@pytest.fixture
|
|
def mock_registry(self):
|
|
return ToolRegistry()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nuke_and_repave(self, mock_registry):
|
|
"""Old tools are removed and new tools registered on refresh."""
|
|
server = MCPServerTask("live_srv")
|
|
server._refresh_lock = asyncio.Lock()
|
|
server._config = {}
|
|
from toolsets import resolve_toolset
|
|
|
|
# Seed initial state: one old tool registered
|
|
mock_registry.register(
|
|
name="mcp__live_srv__old_tool", toolset="mcp-live_srv", schema={},
|
|
handler=lambda x: x, check_fn=lambda: True, is_async=False,
|
|
description="", emoji="",
|
|
)
|
|
server._registered_tool_names = ["mcp__live_srv__old_tool"]
|
|
|
|
# New tool list from server
|
|
new_tool = _make_mcp_tool("new_tool", "new behavior")
|
|
server.session = SimpleNamespace(
|
|
list_tools=AsyncMock(
|
|
return_value=SimpleNamespace(tools=[new_tool])
|
|
)
|
|
)
|
|
|
|
with patch("tools.registry.registry", mock_registry):
|
|
await server._refresh_tools()
|
|
assert "mcp__live_srv__old_tool" not in mock_registry.get_all_tool_names()
|
|
assert "mcp__live_srv__old_tool" not in resolve_toolset("live_srv")
|
|
assert "mcp__live_srv__new_tool" in mock_registry.get_all_tool_names()
|
|
assert "mcp__live_srv__new_tool" in resolve_toolset("live_srv")
|
|
assert server._registered_tool_names == ["mcp__live_srv__new_tool"]
|
|
|
|
|
|
class TestMessageHandler:
|
|
"""Tests for MCPServerTask._make_message_handler dispatch."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatches_tool_list_changed(self):
|
|
from tools.mcp_tool import _MCP_NOTIFICATION_TYPES
|
|
if not _MCP_NOTIFICATION_TYPES:
|
|
pytest.skip("MCP SDK ToolListChangedNotification not available")
|
|
|
|
from mcp.types import ServerNotification, ToolListChangedNotification
|
|
|
|
server = MCPServerTask("notif_srv")
|
|
# Product now schedules the refresh as a background task (see
|
|
# _schedule_tools_refresh in mcp_tool.py ~L918) rather than awaiting
|
|
# it directly, to avoid wedging the stdio JSON-RPC stream. Patch at
|
|
# the scheduler seam so we can still assert dispatch happened without
|
|
# reaching into asyncio.create_task internals.
|
|
with patch.object(MCPServerTask, "_schedule_tools_refresh") as mock_schedule:
|
|
handler = server._make_message_handler()
|
|
notification = ToolListChangedNotification(
|
|
method="notifications/tools/list_changed"
|
|
)
|
|
if hasattr(ServerNotification, "model_validate"):
|
|
# mcp < 2.0 wrapped notifications in a RootModel; 2.0 made
|
|
# ServerNotification a plain union of the concrete types, which
|
|
# has no constructor to wrap with.
|
|
notification = ServerNotification(root=notification)
|
|
await handler(notification)
|
|
mock_schedule.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ignores_exceptions_and_other_messages(self):
|
|
server = MCPServerTask("notif_srv")
|
|
with patch.object(MCPServerTask, "_schedule_tools_refresh") as mock_schedule:
|
|
handler = server._make_message_handler()
|
|
# Exceptions should not trigger refresh
|
|
await handler(RuntimeError("connection dead"))
|
|
# Unknown message types should not trigger refresh
|
|
await handler({"jsonrpc": "2.0", "result": "ok"})
|
|
mock_schedule.assert_not_called()
|
|
|
|
|
|
class TestDeregister:
|
|
"""Tests for ToolRegistry.deregister."""
|
|
|
|
def test_removes_tool(self):
|
|
reg = ToolRegistry()
|
|
reg.register(name="foo", toolset="ts1", schema={}, handler=lambda x: x)
|
|
assert "foo" in reg.get_all_tool_names()
|
|
reg.deregister("foo")
|
|
assert "foo" not in reg.get_all_tool_names()
|
|
|
|
|
|
def test_noop_for_unknown_tool(self):
|
|
reg = ToolRegistry()
|
|
reg.deregister("nonexistent") # Should not raise
|