diff --git a/agent/transports/hermes_tools_mcp_server.py b/agent/transports/hermes_tools_mcp_server.py
index 5595c48d84..e3fcec6258 100644
--- a/agent/transports/hermes_tools_mcp_server.py
+++ b/agent/transports/hermes_tools_mcp_server.py
@@ -152,11 +152,13 @@ EXPOSED_TOOLS: tuple[str, ...] = (
def _build_server() -> Any:
- """Create the FastMCP server with Hermes tools attached. Lazy imports
+ """Create the MCP server with Hermes tools attached. Lazy imports
so the module can be imported without the mcp package installed
(we degrade to a clear error only when actually run)."""
try:
- from mcp.server.fastmcp import FastMCP
+ # mcp 2.0 removed `mcp.server.fastmcp`; `mcp.server.MCPServer` is the
+ # same decorator/add_tool surface under the new name.
+ from mcp.server import MCPServer
except ImportError as exc: # pragma: no cover - install hint
raise ImportError(
f"hermes-tools MCP server requires the 'mcp' package: {exc}"
@@ -168,7 +170,7 @@ def _build_server() -> Any:
handle_function_call,
)
- mcp = FastMCP(
+ mcp = MCPServer(
"hermes-tools",
instructions=(
"Hermes Agent's tool surface, exposed for use inside a Codex "
@@ -200,11 +202,12 @@ def _build_server() -> Any:
description = spec.get("description") or f"Hermes {name} tool"
params_schema = spec.get("parameters") or {"type": "object", "properties": {}}
- # FastMCP wants a Python callable. Build a closure that takes the
- # arguments dict, dispatches via handle_function_call, and returns
- # the result string. We use add_tool() for full control over the
- # input schema (FastMCP's @tool() decorator inspects type hints,
- # which we can't get from a JSON schema at runtime).
+ # The SDK wants a Python callable and derives the input schema from
+ # its signature — there is no inputSchema parameter on either the
+ # decorator or add_tool(). So build a closure that takes the arguments
+ # dict, dispatches via handle_function_call, returns the result
+ # string, and carries a __signature__ synthesized from the Hermes
+ # JSON Schema (see _signature_from_schema) for the SDK to read.
def _make_handler(tool_name: str, schema: dict | None):
sig, annots = _signature_from_schema(schema)
@@ -269,8 +272,8 @@ def main(argv: Optional[list[str]] = None) -> int:
sys.stderr.write(f"hermes-tools MCP server cannot start: {exc}\n")
return 2
- # FastMCP runs with stdio transport by default when launched as a
- # subprocess.
+ # MCPServer.run() defaults to stdio transport, which is what codex
+ # spawns us on.
try:
server.run()
except KeyboardInterrupt:
diff --git a/mcp_serve.py b/mcp_serve.py
index 76fcf800b8..a81bda733b 100644
--- a/mcp_serve.py
+++ b/mcp_serve.py
@@ -47,13 +47,16 @@ logger = logging.getLogger("hermes.mcp_serve")
# Lazy MCP SDK import
# ---------------------------------------------------------------------------
+# mcp 2.0 removed `mcp.server.fastmcp`; its decorator-driven server is now
+# `mcp.server.MCPServer` with the same `@server.tool()` / `run_stdio_async()`
+# surface (docstring -> tool description, signature -> input schema).
_MCP_SERVER_AVAILABLE = False
try:
- from mcp.server.fastmcp import FastMCP
+ from mcp.server import MCPServer
_MCP_SERVER_AVAILABLE = True
except ImportError:
- FastMCP = None # type: ignore[assignment,misc]
+ MCPServer = None # type: ignore[assignment,misc]
# ---------------------------------------------------------------------------
@@ -617,7 +620,7 @@ class EventBridge:
# MCP Server
# ---------------------------------------------------------------------------
-def create_mcp_server(event_bridge: Optional[EventBridge] = None) -> "FastMCP":
+def create_mcp_server(event_bridge: Optional[EventBridge] = None) -> "MCPServer":
"""Create and return the Hermes MCP server with all tools registered."""
if not _MCP_SERVER_AVAILABLE:
raise ImportError(
@@ -625,7 +628,7 @@ def create_mcp_server(event_bridge: Optional[EventBridge] = None) -> "FastMCP":
f"Install with: {sys.executable} -m pip install 'mcp'"
)
- mcp = FastMCP(
+ mcp = MCPServer(
"hermes",
instructions=(
"Hermes Agent messaging bridge. Use these tools to interact with "
diff --git a/pyproject.toml b/pyproject.toml
index 1191bd2c59..1c92ef69f6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -181,7 +181,7 @@ modal = ["modal==1.3.4"]
daytona = ["daytona==0.155.0"]
vercel = ["vercel==0.7.2"]
hindsight = ["hindsight-client==0.6.1"]
-dev = ["debugpy==1.8.20", "pytest==9.1.1", "pytest-asyncio==1.3.0", "mcp==1.28.1", "starlette==1.3.1", "ty==0.0.21", "ruff==0.15.10", "setuptools==83.0.0"] # starlette: CVE-2026-48710; setuptools: 83 (torch >=2.13 requires setuptools 83)
+dev = ["debugpy==1.8.20", "pytest==9.1.1", "pytest-asyncio==1.3.0", "mcp==2.0.0", "httpx2==2.7.0", "starlette==1.3.1", "ty==0.0.21", "ruff==0.15.10", "setuptools==83.0.0"] # starlette: CVE-2026-48710; setuptools: 83 (torch >=2.13 requires setuptools 83)
messaging = ["python-telegram-bot[webhooks]==22.8", "discord.py[voice]==2.7.1", "aiohttp==3.14.3", "brotlicffi==1.2.0.1", "slack-bolt==1.30.0", "slack-sdk==3.43.0", "qrcode==7.4.2"] # aiohttp 3.14.3: prior CVEs + GHSA-cq5v-8q36-5273/GHSA-mfx4-hv73-q22v/GHSA-mq44-7p77-q5h7
cron = [] # croniter is now a core dependency; this extra kept for back-compat
slack = ["slack-bolt==1.30.0", "slack-sdk==3.43.0", "aiohttp==3.14.3"]
@@ -245,7 +245,15 @@ pty = []
# `request.url` can be bypassed. We pin a patched Starlette directly in every
# extra that exposes a Starlette-backed server surface so pip/uv can't resolve
# a vulnerable pre-1.0.1 transitive. Bump in lockstep with uv.lock.
-mcp = ["mcp==1.28.1", "starlette==1.3.1"] # starlette: CVE-2026-48710
+#
+# mcp 2.0.0 implements MCP revision 2026-07-28 and moved its own HTTP stack
+# from `httpx` to `httpx2`. httpx2 arrives transitively, but tools/mcp_tool.py
+# and tools/mcp_oauth_manager.py import it by name to build the client objects
+# they hand to the SDK, so it is pinned here explicitly rather than left to
+# resolution. Hermes' own `httpx[socks]==0.28.1` in [dependencies] is
+# unaffected — the two distributions install side by side under different
+# module names.
+mcp = ["mcp==2.0.0", "httpx2==2.7.0", "starlette==1.3.1"] # starlette: CVE-2026-48710
# Backwards-compatible no-op alias. Relay is a core dependency on supported
# wheel targets and intentionally unavailable on other platforms.
nemo-relay = []
@@ -256,7 +264,7 @@ teams = ["microsoft-teams-apps==2.0.13.4", "aiohttp==3.14.3"] # aiohttp 3.14.3:
# The cua-driver binary itself is installed via `hermes tools` post-setup
# (curl install script); this extra just pins the MCP client used to talk
# to it, which is already provided by the `mcp` extra.
-computer-use = ["mcp==1.28.1", "starlette==1.3.1"] # starlette: CVE-2026-48710
+computer-use = ["mcp==2.0.0", "httpx2==2.7.0", "starlette==1.3.1"] # starlette: CVE-2026-48710
acp = ["agent-client-protocol==0.9.0"]
# mistral: Voxtral STT + TTS. Pinned to an exact verified-clean version.
# The `mistralai` PyPI project was quarantined 2026-05-12 after the malicious
diff --git a/tests/test_mcp_serve.py b/tests/test_mcp_serve.py
index d17aa4754e..e31838111f 100644
--- a/tests/test_mcp_serve.py
+++ b/tests/test_mcp_serve.py
@@ -4,7 +4,7 @@ Tests for mcp_serve — Hermes MCP server.
Three layers of tests:
1. Unit tests — helpers, content extraction, attachment parsing
2. EventBridge tests — queue mechanics, cursors, waiters, concurrency
-3. End-to-end tests — call actual MCP tools through FastMCP's tool manager
+3. End-to-end tests — call actual MCP tools through the MCPServer's public API
with real session data in SQLite and sessions.json
"""
@@ -228,7 +228,9 @@ class _FakeToolManager:
return list(self._tools.values())
-class _FakeFastMCP:
+class _FakeMCPServer:
+ """Stand-in for ``mcp.server.MCPServer`` (``FastMCP`` before mcp 2.0)."""
+
def __init__(self, *args, **kwargs):
self._tool_manager = _FakeToolManager()
@@ -239,6 +241,17 @@ class _FakeFastMCP:
return decorator
+ async def call_tool(self, name, args=None):
+ """Dispatch straight to the handler, with no schema validation.
+
+ Mirrors ``MCPServer.call_tool``'s name so ``_run_tool`` works against
+ either server, but deliberately skips the SDK's pydantic coercion:
+ the parameter-coercion tests exist to prove the handlers' own
+ ``_coerce_int`` guards hold when a client sends a wrongly-typed value,
+ which the real server would reject before the handler ever ran.
+ """
+ return await self._tool_manager.call_tool(name, args)
+
@pytest.fixture
def fake_mcp_server(populated_sessions_dir, mock_session_db, monkeypatch):
@@ -248,7 +261,7 @@ def fake_mcp_server(populated_sessions_dir, mock_session_db, monkeypatch):
monkeypatch.setattr(mcp_serve, "_get_session_db", lambda: mock_session_db)
monkeypatch.setattr(mcp_serve, "_load_channel_directory", lambda: {})
monkeypatch.setattr(mcp_serve, "_MCP_SERVER_AVAILABLE", True)
- monkeypatch.setattr(mcp_serve, "FastMCP", _FakeFastMCP)
+ monkeypatch.setattr(mcp_serve, "MCPServer", _FakeMCPServer)
bridge = mcp_serve.EventBridge()
server = mcp_serve.create_mcp_server(event_bridge=bridge)
@@ -505,7 +518,7 @@ class TestEventBridge:
# ---------------------------------------------------------------------------
-# 3. END-TO-END TESTS — call MCP tools through FastMCP server
+# 3. END-TO-END TESTS — call MCP tools through the MCP server
# ---------------------------------------------------------------------------
@pytest.fixture
@@ -523,11 +536,24 @@ def mcp_server_e2e(populated_sessions_dir, mock_session_db, monkeypatch):
def _run_tool(server, name, args=None):
- """Call an MCP tool through FastMCP's tool manager and return parsed JSON."""
+ """Call an MCP tool through the server's public API and return parsed JSON.
+
+ Goes through ``MCPServer.call_tool`` rather than the private
+ ``_tool_manager`` the FastMCP-era version reached into: mcp 2.0's
+ ``ToolManager.call_tool`` gained a required ``context`` argument, and the
+ public method is what an actual MCP client exercises anyway. It returns a
+ ``CallToolResult``, so unwrap the text content block our tools produce.
+ """
result = asyncio.get_event_loop().run_until_complete(
- server._tool_manager.call_tool(name, args or {})
+ server.call_tool(name, args or {})
)
- return json.loads(result) if isinstance(result, str) else result
+ if isinstance(result, str): # FastMCP-era shape
+ return json.loads(result)
+ text = "".join(
+ block.text for block in (getattr(result, "content", None) or [])
+ if getattr(block, "text", None)
+ )
+ return json.loads(text) if text else result
@pytest.fixture
diff --git a/tests/tools/test_computer_use.py b/tests/tools/test_computer_use.py
index 1da5b0666f..a717086f9b 100644
--- a/tests/tools/test_computer_use.py
+++ b/tests/tools/test_computer_use.py
@@ -1861,11 +1861,11 @@ class TestImageMimeTypePropagation:
image_part = MagicMock()
image_part.type = "image"
image_part.data = "iVBORw0K..."
- image_part.mimeType = "image/png"
+ image_part.mime_type = "image/png"
result = MagicMock()
- result.isError = False
- result.structuredContent = None
+ result.is_error = False
+ result.structured_content = None
result.content = [image_part]
out = _extract_tool_result(result)
diff --git a/tests/tools/test_mcp_capability_gating.py b/tests/tools/test_mcp_capability_gating.py
index a0fef278fe..5facbd24e4 100644
--- a/tests/tools/test_mcp_capability_gating.py
+++ b/tests/tools/test_mcp_capability_gating.py
@@ -4,7 +4,7 @@ Prompt-only / resource-only MCP servers do not implement the ``tools/*``
request family. Per the MCP spec, ``InitializeResult.capabilities.tools``
is non-None iff the server supports it. Before the capability gate, Hermes
always called ``tools/list`` during discovery, which raised
-``McpError(-32601 Method not found)`` against such servers, so a prompt-only
+``MCPError(-32601 Method not found)`` against such servers, so a prompt-only
server could never stay connected. Discovery/refresh remain capability-gated.
The keepalive probe uses ``ping`` (MCP base-protocol liveness) for every
@@ -186,10 +186,15 @@ class TestKeepaliveInterval:
def _mcp_error(code, message="boom"):
- """Build a real McpError carrying a JSON-RPC error code."""
- from mcp.shared.exceptions import McpError
- from mcp.types import ErrorData
- return McpError(ErrorData(code=code, message=message))
+ """Build a real MCPError carrying a JSON-RPC error code.
+
+ mcp 2.0 renamed ``McpError`` to ``MCPError`` and replaced its
+ ``ErrorData`` positional with flat ``code`` / ``message`` arguments. The
+ ``.error.code`` attribute ``_is_method_not_found_error`` inspects survives
+ unchanged, which is the point of the structural check.
+ """
+ from mcp.shared.exceptions import MCPError
+ return MCPError(code=code, message=message)
class TestMethodNotFoundDetection:
diff --git a/tests/tools/test_mcp_circuit_breaker.py b/tests/tools/test_mcp_circuit_breaker.py
index 357589d066..44b003c2a7 100644
--- a/tests/tools/test_mcp_circuit_breaker.py
+++ b/tests/tools/test_mcp_circuit_breaker.py
@@ -116,11 +116,11 @@ def test_circuit_breaker_half_opens_after_cooldown(monkeypatch, tmp_path):
async def _call_tool_success(*a, **kw):
call_count["n"] += 1
result = MagicMock()
- result.isError = False
+ result.is_error = False
block = MagicMock()
block.text = "ok"
result.content = [block]
- result.structuredContent = None
+ result.structured_content = None
return result
_install_stub_server(mcp_tool, "srv", _call_tool_success)
@@ -279,11 +279,11 @@ def test_half_open_dead_session_recovers_after_reconnect(monkeypatch, tmp_path):
async def _call_tool_success(*a, **kw):
result = MagicMock()
- result.isError = False
+ result.is_error = False
block = MagicMock()
block.text = "ok"
result.content = [block]
- result.structuredContent = None
+ result.structured_content = None
return result
server = _install_stub_server(mcp_tool, "srv", _call_tool_success)
diff --git a/tests/tools/test_mcp_client_cert.py b/tests/tools/test_mcp_client_cert.py
index 4483d97f07..dc23d840be 100644
--- a/tests/tools/test_mcp_client_cert.py
+++ b/tests/tools/test_mcp_client_cert.py
@@ -20,6 +20,20 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
+def _patch_sdk_async_client(dummy):
+ """Patch ``AsyncClient`` on whichever httpx module the MCP SDK uses.
+
+ mcp 2.0 moved the SDK's HTTP stack to ``httpx2``, so patching
+ ``httpx.AsyncClient`` no longer intercepts the client Hermes builds for
+ the SDK. Resolve the module the same way production does, via
+ ``tools.mcp_tool.sdk_httpx``, so these tests follow the SDK rather than
+ hardcoding a distribution name.
+ """
+ from tools.mcp_tool import sdk_httpx
+
+ return patch.object(sdk_httpx(), "AsyncClient", dummy)
+
+
# ---------------------------------------------------------------------------
# _resolve_client_cert helper
# ---------------------------------------------------------------------------
@@ -123,7 +137,7 @@ class TestHTTPClientCert:
async def _drive():
with patch("tools.mcp_tool._MCP_HTTP_AVAILABLE", True), \
patch("tools.mcp_tool._MCP_NEW_HTTP", True), \
- patch("httpx.AsyncClient", DummyAsyncClient), \
+ _patch_sdk_async_client(DummyAsyncClient), \
patch("tools.mcp_tool.streamable_http_client",
return_value=DummyTransportCtx()), \
patch("tools.mcp_tool.ClientSession", DummySession), \
@@ -271,9 +285,9 @@ class TestSSEClientCert:
def __init__(self, **kwargs):
captured_client_kwargs.update(kwargs)
- import httpx
- with patch.object(httpx, "AsyncClient", DummyAsyncClient):
- factory(headers={"x": "y"}, timeout=httpx.Timeout(30.0), auth=None)
+ from tools.mcp_tool import sdk_httpx
+ with _patch_sdk_async_client(DummyAsyncClient):
+ factory(headers={"x": "y"}, timeout=sdk_httpx().Timeout(30.0), auth=None)
assert captured_client_kwargs["cert"] == str(cert)
assert captured_client_kwargs["verify"] is True
@@ -318,8 +332,7 @@ class TestSSEClientCert:
def __init__(self, **kwargs):
captured_client_kwargs.update(kwargs)
- import httpx
- with patch.object(httpx, "AsyncClient", DummyAsyncClient):
+ with _patch_sdk_async_client(DummyAsyncClient):
factory(headers=None, timeout=None, auth=None)
assert captured_client_kwargs["verify"] == str(ca_bundle)
diff --git a/tests/tools/test_mcp_dashboard_oauth.py b/tests/tools/test_mcp_dashboard_oauth.py
index 9fdd38798e..198a2f0977 100644
--- a/tests/tools/test_mcp_dashboard_oauth.py
+++ b/tests/tools/test_mcp_dashboard_oauth.py
@@ -92,7 +92,10 @@ def test_mcp_oauth_helpers_use_dashboard_flow_without_loopback_port():
)
)
flow.deliver_callback(code="code-4", state="state-4", error=None)
- assert asyncio.run(_make_callback_waiter(0)()) == ("code-4", "state-4")
+ # mcp 2.0's callback_handler contract returns an
+ # AuthorizationCodeResult, not the legacy (code, state) tuple.
+ result = asyncio.run(_make_callback_waiter(0)())
+ assert (result.code, result.state) == ("code-4", "state-4")
assert flow.authorization_url == "https://idp.example/authorize?state=state-4"
diff --git a/tests/tools/test_mcp_dynamic_discovery.py b/tests/tools/test_mcp_dynamic_discovery.py
index 78ba1abacd..d0d7083aa0 100644
--- a/tests/tools/test_mcp_dynamic_discovery.py
+++ b/tests/tools/test_mcp_dynamic_discovery.py
@@ -95,9 +95,14 @@ class TestMessageHandler:
# reaching into asyncio.create_task internals.
with patch.object(MCPServerTask, "_schedule_tools_refresh") as mock_schedule:
handler = server._make_message_handler()
- notification = ServerNotification(
- root=ToolListChangedNotification(method="notifications/tools/list_changed")
+ 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()
diff --git a/tests/tools/test_mcp_elicitation.py b/tests/tools/test_mcp_elicitation.py
index b104eb4adf..a7506f41ab 100644
--- a/tests/tools/test_mcp_elicitation.py
+++ b/tests/tools/test_mcp_elicitation.py
@@ -86,6 +86,39 @@ class TestElicitationHandlerFormMode:
assert handler.metrics["declined"] == 0
+ def test_schema_read_from_real_sdk_params_reaches_the_summary(self):
+ """The requested schema must be read off the *real* SDK model.
+
+ Every other test here builds a duck-typed ``SimpleNamespace``, which
+ cannot catch a field rename in the SDK — and 2.0 renamed this field
+ (``requestedSchema`` -> ``requested_schema``). Pinning one case to the
+ actual model is what proves the elicitation path still reads the
+ schema after the migration, rather than silently summarising an empty
+ one.
+ """
+ from mcp.types import ElicitRequestFormParams
+
+ params = ElicitRequestFormParams(
+ message="authorize a payment of $0.50",
+ requested_schema={
+ "type": "object",
+ "properties": {"card_number": {"type": "string"}},
+ },
+ )
+ handler = ElicitationHandler("pay", {"timeout": 5})
+ captured: dict = {}
+
+ def _capture(*args, **kwargs):
+ captured["description"] = kwargs.get("description") or (
+ args[1] if len(args) > 1 else ""
+ )
+ return "decline"
+
+ with patch("tools.approval.request_elicitation_consent", _capture):
+ asyncio.run(handler(context=None, params=params))
+
+ assert "card_number" in (captured.get("description") or ""), captured
+
def test_cancel_propagates_through(self):
"""request_elicitation_consent returns 'cancel' when the gateway
wait times out (resolved=False). The handler should propagate
diff --git a/tests/tools/test_mcp_oauth.py b/tests/tools/test_mcp_oauth.py
index 5576a51ea3..dae8c51917 100644
--- a/tests/tools/test_mcp_oauth.py
+++ b/tests/tools/test_mcp_oauth.py
@@ -508,9 +508,11 @@ class TestCallbackPortReservation:
).start()
return await asyncio.wait_for(task, timeout=20)
- code, state = asyncio.run(drive())
- assert code == "abc123"
- assert state == "xyz"
+ # mcp 2.0's callback_handler contract returns an
+ # AuthorizationCodeResult, not the legacy (code, state) tuple.
+ result = asyncio.run(drive())
+ assert result.code == "abc123"
+ assert result.state == "xyz"
# Reservation was consumed by adoption.
assert port not in mod._reserved_sockets
@@ -549,13 +551,13 @@ class TestCallbackPortReservation:
return await asyncio.wait_for(task, timeout=20)
try:
- code, state = asyncio.run(drive())
+ result = asyncio.run(drive())
finally:
leftover = mod._reserved_sockets.pop(port_b, None)
if leftover is not None:
leftover.close()
- assert code == "flowA"
- assert state == "sA"
+ assert result.code == "flowA"
+ assert result.state == "sA"
# ---------------------------------------------------------------------------
diff --git a/tests/tools/test_mcp_oauth_bidirectional.py b/tests/tools/test_mcp_oauth_bidirectional.py
index 37ca409bbc..ea5eccbc2c 100644
--- a/tests/tools/test_mcp_oauth_bidirectional.py
+++ b/tests/tools/test_mcp_oauth_bidirectional.py
@@ -43,7 +43,11 @@ async def test_hermes_provider_forwards_asend_values(tmp_path, monkeypatch):
``oauth2.py:505``. With the correct bridge, a 200 response finishes the
flow cleanly (``StopAsyncIteration``).
"""
- import httpx
+ # The SDK's httpx flavour (httpx2 on mcp >= 2.0): the provider is an
+ # Auth subclass from that module and its auth_flow only accepts its own
+ # Request/Response types.
+ from tools.mcp_tool import sdk_httpx
+ httpx = sdk_httpx()
from mcp.shared.auth import OAuthClientMetadata, OAuthToken
from pydantic import AnyUrl
@@ -125,7 +129,11 @@ async def test_hermes_provider_forwards_401_triggers_refresh(tmp_path, monkeypat
bridge, the 401 is routed into the SDK's ``response.status_code == 401``
branch which begins discovery (yielding a metadata-discovery request).
"""
- import httpx
+ # The SDK's httpx flavour (httpx2 on mcp >= 2.0): the provider is an
+ # Auth subclass from that module and its auth_flow only accepts its own
+ # Request/Response types.
+ from tools.mcp_tool import sdk_httpx
+ httpx = sdk_httpx()
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
from pydantic import AnyUrl
diff --git a/tests/tools/test_mcp_oauth_cold_load_expiry.py b/tests/tools/test_mcp_oauth_cold_load_expiry.py
index c8cef38972..6e59a59c0c 100644
--- a/tests/tools/test_mcp_oauth_cold_load_expiry.py
+++ b/tests/tools/test_mcp_oauth_cold_load_expiry.py
@@ -310,7 +310,11 @@ async def test_initialize_prefetches_oauth_metadata_when_missing(
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
- import httpx
+ # The SDK's httpx flavour (httpx2 on mcp >= 2.0). _prefetch_oauth_metadata
+ # builds its client from the same module, so the MockTransport and the
+ # patched AsyncClient below have to come from there too.
+ from tools.mcp_tool import sdk_httpx
+ httpx = sdk_httpx()
from mcp.shared.auth import (
OAuthClientInformationFull,
OAuthClientMetadata,
@@ -380,7 +384,7 @@ async def test_initialize_prefetches_oauth_metadata_when_missing(
# Patch the AsyncClient constructor used by _prefetch_oauth_metadata so
# it uses our mock transport instead of the real network.
- import httpx as real_httpx
+ real_httpx = httpx
original_async_client = real_httpx.AsyncClient
diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py
index 1f92de0a1b..bca9dfb4a3 100644
--- a/tests/tools/test_mcp_tool.py
+++ b/tests/tools/test_mcp_tool.py
@@ -20,6 +20,18 @@ import pytest
# Helpers
# ---------------------------------------------------------------------------
+def _stop_reason(result):
+ """Read a sampling result's stop reason across the mcp 1.x -> 2.x rename.
+
+ ``CreateMessageResult.stopReason`` became ``.stop_reason`` in mcp 2.0
+ (camelCase survives only as the serialization alias, which pydantic does
+ not expose to attribute access).
+ """
+ from tools.mcp_tool import mcp_field
+
+ return mcp_field(result, "stop_reason", "stopReason")
+
+
def _make_mcp_tool(name="read_file", description="Read a file", input_schema=None):
"""Create a fake MCP Tool object matching the SDK interface."""
tool = SimpleNamespace()
@@ -1940,7 +1952,7 @@ class TestSamplingCallbackText:
assert result.content.text == "Hello from LLM"
assert result.model == "test-model"
assert result.role == "assistant"
- assert result.stopReason == "endTurn"
+ assert _stop_reason(result) == "endTurn"
def test_server_tools_with_object_schema_are_normalized(self):
"""Server-provided tools should gain empty properties for object schemas."""
@@ -1990,7 +2002,7 @@ class TestSamplingCallbackToolUse:
result = asyncio.run(self.handler(None, params))
assert isinstance(result, CreateMessageResultWithTools)
- assert result.stopReason == "toolUse"
+ assert _stop_reason(result) == "toolUse"
assert result.model == "test-model"
assert len(result.content) == 1
tc = result.content[0]
diff --git a/tests/tools/test_mcp_tool_session_expired.py b/tests/tools/test_mcp_tool_session_expired.py
index 5004d4346c..e73945c44e 100644
--- a/tests/tools/test_mcp_tool_session_expired.py
+++ b/tests/tools/test_mcp_tool_session_expired.py
@@ -157,9 +157,9 @@ def test_call_tool_handler_rebuilds_configured_server_transport(
if call_count["n"] == 1:
raise ClosedResourceError
result = MagicMock()
- result.isError = False
+ result.is_error = False
result.content = [MagicMock(type="text", text="reconnected")]
- result.structuredContent = None
+ result.structured_content = None
return result
class _LifecycleTask(MCPServerTask):
@@ -247,9 +247,9 @@ def test_session_expired_retry_waits_for_new_session(monkeypatch, tmp_path):
async def _new_call(*a, **kw):
result = MagicMock()
- result.isError = False
+ result.is_error = False
result.content = [MagicMock(type="text", text="bank ok")]
- result.structuredContent = None
+ result.structured_content = None
return result
new_session.call_tool = _new_call
diff --git a/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py
index 09e58772f2..bd93ea2cea 100644
--- a/tools/computer_use/cua_backend.py
+++ b/tools/computer_use/cua_backend.py
@@ -65,6 +65,29 @@ from tools.computer_use.browser_route import CuaTypedBrowserRoute
logger = logging.getLogger(__name__)
+_MISSING = object()
+
+
+def _mcp_field(obj, snake: str, camel: str, default=None):
+ """Read an MCP model field across the 1.x -> 2.x field rename.
+
+ mcp 2.0 renamed model fields to snake_case, keeping camelCase only as a
+ serialization alias that pydantic does not expose to attribute access. A
+ plain ``getattr(result, "isError", False)`` therefore reads False for
+ *every* result on 2.x — a denied or failed cua-driver call would be
+ treated as a success. Reading both spellings keeps this correct on either
+ SDK generation.
+
+ Deliberately duplicated from ``tools.mcp_tool.mcp_field`` rather than
+ imported: computer_use talks to cua-driver over its own stdio client and
+ does not otherwise load the (much larger) config-driven MCP client module.
+ """
+ value = getattr(obj, snake, _MISSING)
+ if value is not _MISSING:
+ return value
+ value = getattr(obj, camel, _MISSING)
+ return default if value is _MISSING else value
+
def _action_result_from(
name: str,
@@ -1672,7 +1695,7 @@ class _CuaDriverSession:
}
else:
self._capabilities[tool_name] = set()
- schema = getattr(tool, "inputSchema", None)
+ schema = _mcp_field(tool, "input_schema", "inputSchema")
if schema is None:
schema = (getattr(tool, "model_extra", None) or {}).get(
"inputSchema"
@@ -1985,7 +2008,7 @@ class _CuaDriverSession:
On macOS the ``cua-driver mcp`` bridge forwards calls to the CuaDriver
daemon over a non-blocking unix socket. Heavier ops (notably
``get_window_state``, which walks the AX tree and captures a PNG) can
- come back as an ``McpError`` carrying ``Resource temporarily
+ come back as an ``MCPError`` carrying ``Resource temporarily
unavailable (os error 35)`` — POSIX EAGAIN — when the socket buffer is
momentarily full. This is transient by definition: the same call
succeeds when retried after a short pause (which is why spaced-out
@@ -2296,8 +2319,10 @@ def _extract_tool_result(mcp_result: Any) -> Dict[str, Any]:
image_mime_types: List[str] = []
# Use identity, not truthiness: unittest mocks and proxy objects commonly
# synthesize truthy attributes that were never present in the real result.
- is_error = getattr(mcp_result, "isError", False) is True
- structured: Optional[Dict] = getattr(mcp_result, "structuredContent", None) or None
+ is_error = _mcp_field(mcp_result, "is_error", "isError", False) is True
+ structured: Optional[Dict] = (
+ _mcp_field(mcp_result, "structured_content", "structuredContent") or None
+ )
text_chunks: List[str] = []
for part in getattr(mcp_result, "content", []) or []:
ptype = getattr(part, "type", None)
@@ -2307,7 +2332,7 @@ def _extract_tool_result(mcp_result: Any) -> Dict[str, Any]:
b64 = getattr(part, "data", None)
if b64:
images.append(b64)
- mime = getattr(part, "mimeType", None) or ""
+ mime = _mcp_field(part, "mime_type", "mimeType") or ""
image_mime_types.append(mime)
if text_chunks:
joined = "\n".join(t for t in text_chunks if t)
@@ -3078,7 +3103,7 @@ class CuaDriverBackend(ComputerUseBackend):
# 0x0 capture. Detect "no screenshot AND no parseable tree" and
# force a one-shot CLI-transport re-fetch, which talks to the daemon
# over a different socket and returns the full result. This is
- # distinct from the EAGAIN McpError path (handled in call_tool);
+ # distinct from the EAGAIN MCPError path (handled in call_tool);
# here the MCP call "succeeded" but gave us nothing usable.
def _gws_is_empty(out: Dict[str, Any]) -> bool:
if out.get("images"):
diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py
index 88cf6e78b5..3887d3a257 100644
--- a/tools/lazy_deps.py
+++ b/tools/lazy_deps.py
@@ -302,7 +302,8 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = {
# `[all]`; lazy-installing here covers lean / partial / broken-extra
# installs so computer_use never dead-ends on `No module named 'mcp'`.
"tool.computer_use": (
- "mcp==1.28.1",
+ "mcp==2.0.0",
+ "httpx2==2.7.0", # mcp 2.x HTTP stack — keep in sync with pyproject [computer-use]
"starlette==1.3.1", # CVE-2026-48710 — keep in sync with pyproject [computer-use]
),
# HF Agent Trace Viewer upload (hermes trace upload / /upload-trace).
diff --git a/tools/mcp_oauth.py b/tools/mcp_oauth.py
index 112c2254e4..51fc169ca8 100644
--- a/tools/mcp_oauth.py
+++ b/tools/mcp_oauth.py
@@ -669,6 +669,22 @@ class HermesTokenStorage:
# ---------------------------------------------------------------------------
+def _authorization_code_result(code: str, state: "str | None", iss: "str | None" = None):
+ """Package redirect parameters in the shape the installed SDK expects.
+
+ mcp 2.0 changed ``callback_handler``'s contract from a
+ ``tuple[str, str | None]`` to an ``AuthorizationCodeResult`` model, and the
+ SDK now reads ``result.state`` / ``result.iss`` off it — a tuple raises
+ ``AttributeError`` mid-flow. Fall back to the tuple when the model is
+ absent so the handler still satisfies an older SDK.
+ """
+ try:
+ from mcp.shared.auth import AuthorizationCodeResult
+ except ImportError: # mcp < 2.0
+ return code, state
+ return AuthorizationCodeResult(code=code, state=state, iss=iss)
+
+
def _make_callback_handler() -> tuple[type, dict]:
"""Create a per-flow callback HTTP handler class with its own result dict.
@@ -677,7 +693,9 @@ def _make_callback_handler() -> tuple[type, dict]:
OAuth redirect arrives. Each call returns a fresh pair so concurrent
flows don't stomp on each other.
"""
- result: dict[str, Any] = {"auth_code": None, "state": None, "error": None}
+ result: dict[str, Any] = {
+ "auth_code": None, "state": None, "error": None, "iss": None,
+ }
class _Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802
@@ -685,10 +703,17 @@ def _make_callback_handler() -> tuple[type, dict]:
code = params.get("code", [None])[0]
state = params.get("state", [None])[0]
error = params.get("error", [None])[0]
+ # RFC 9207 authorization-response issuer. mcp 2.0 validates it
+ # against the discovered metadata and *rejects* a response that
+ # omits it when the authorization server advertised
+ # `authorization_response_iss_parameter_supported`, so dropping it
+ # here would break login against those providers.
+ iss = params.get("iss", [None])[0]
result["auth_code"] = code
result["state"] = state
result["error"] = error
+ result["iss"] = iss
body = (
"
Authorization Successful
"
@@ -831,9 +856,14 @@ async def _wait_for_callback() -> tuple[str, str | None]:
return await _make_callback_waiter(_oauth_port)()
-def _make_callback_waiter(port: int):
+def _make_callback_waiter(port: int, timeout: float = 300.0):
"""Return a callback waiter bound to a single OAuth flow's port.
+ ``timeout`` bounds how long the waiter polls for the redirect. It used to
+ be passed to ``OAuthClientProvider(timeout=...)`` as well, but mcp 2.0
+ dropped that constructor argument — the wait happens here, so this is now
+ the only place the configured ``oauth.timeout`` takes effect.
+
Closing over the port (instead of reading the module-level
``_oauth_port``) keeps concurrent OAuth flows isolated: flow A's waiter
listens on flow A's port even when flow B's ``_configure_callback_port``
@@ -850,12 +880,15 @@ def _make_callback_waiter(port: int):
to complete the browser auth), or in non-interactive contexts.
"""
- async def _wait() -> tuple[str, str | None]:
+ async def _wait():
from tools.mcp_dashboard_oauth import get_dashboard_oauth_flow
dashboard_flow = get_dashboard_oauth_flow()
if dashboard_flow is not None:
- return await dashboard_flow.wait_for_callback()
+ # The dashboard flow still speaks the legacy tuple; normalize it
+ # here so both callback sources hand the SDK one shape.
+ dash_code, dash_state = await dashboard_flow.wait_for_callback()
+ return _authorization_code_result(dash_code, dash_state)
# Reject before binding the callback listener in non-interactive
# contexts. Reaching here means the SDK entered the authorization-code
@@ -932,7 +965,6 @@ def _make_callback_waiter(port: int):
)
paste_thread.start()
- timeout = 300.0
poll_interval = 0.5
elapsed = 0.0
try:
@@ -954,7 +986,9 @@ def _make_callback_waiter(port: int):
"Ensure you completed the browser authorization flow."
)
- return result["auth_code"], result["state"]
+ return _authorization_code_result(
+ result["auth_code"], result["state"], result.get("iss")
+ )
return _wait
@@ -1024,6 +1058,7 @@ def _paste_callback_reader(result: dict) -> None:
code = params.get("code", [None])[0]
state = params.get("state", [None])[0]
error = params.get("error", [None])[0]
+ iss = params.get("iss", [None])[0] # RFC 9207 — see _make_callback_handler
if not code and not error:
print(
@@ -1039,6 +1074,7 @@ def _paste_callback_reader(result: dict) -> None:
result["auth_code"] = code
result["state"] = state
result["error"] = error
+ result["iss"] = iss
if code:
print(" Got authorization code from paste — completing flow.", file=sys.stderr)
@@ -1530,7 +1566,9 @@ def build_oauth_auth(
redirect_handler = _make_redirect_handler(
resolved_port, redirect_uri=cfg.get("redirect_uri") or None
)
- callback_handler = _make_callback_waiter(resolved_port)
+ callback_handler = _make_callback_waiter(
+ resolved_port, timeout=float(cfg.get("timeout", 300))
+ )
provider_class = _get_hermes_oauth_provider_class()
if provider_class is None:
@@ -1545,6 +1583,8 @@ def build_oauth_auth(
client_metadata=client_metadata,
storage=storage,
redirect_handler=redirect_handler,
+ # mcp 2.0 removed the provider's own `timeout` argument; the configured
+ # `oauth.timeout` is applied inside the callback waiter above, which is
+ # where the browser round-trip is actually awaited.
callback_handler=callback_handler,
- timeout=float(cfg.get("timeout", 300)),
)
diff --git a/tools/mcp_oauth_manager.py b/tools/mcp_oauth_manager.py
index e2ff9f0a86..636c2a8412 100644
--- a/tools/mcp_oauth_manager.py
+++ b/tools/mcp_oauth_manager.py
@@ -307,7 +307,14 @@ def _make_hermes_provider_class() -> Optional[type]:
builders and response handlers so we track whatever the SDK
version we're pinned to expects.
"""
- import httpx # local import: httpx is an MCP SDK dependency
+ # The SDK's httpx flavour, not Hermes' — mcp 2.0 builds on httpx2,
+ # and `create_oauth_metadata_request` below returns one of *its*
+ # Request objects, which only its own AsyncClient can send. See
+ # tools.mcp_tool.sdk_httpx.
+ from tools.mcp_tool import sdk_httpx
+ httpx = sdk_httpx()
+ if httpx is None: # pragma: no cover — SDK import would have failed
+ return
from mcp.client.auth.utils import (
build_oauth_authorization_server_metadata_discovery_urls,
build_protected_resource_metadata_discovery_urls,
@@ -648,7 +655,12 @@ class MCPOAuthManager:
resolved_port = cfg.get("_resolved_port", 0)
redirect_handler = _make_redirect_handler(resolved_port)
- callback_handler = _make_callback_waiter(resolved_port)
+ # mcp 2.0 removed OAuthClientProvider's `timeout` argument, so the
+ # configured `oauth.timeout` now bounds the callback waiter's own poll
+ # loop instead — that is where the browser round-trip is awaited.
+ callback_handler = _make_callback_waiter(
+ resolved_port, timeout=float(cfg.get("timeout", 300))
+ )
return _HERMES_PROVIDER_CLS(
server_name=server_name,
@@ -658,7 +670,6 @@ class MCPOAuthManager:
storage=storage,
redirect_handler=redirect_handler,
callback_handler=callback_handler,
- timeout=float(cfg.get("timeout", 300)),
)
def remove(
diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py
index 3c2a5b4d9e..82d9313404 100644
--- a/tools/mcp_tool.py
+++ b/tools/mcp_tool.py
@@ -209,6 +209,8 @@ def _write_stderr_log_header(server_name: str) -> None:
_MCP_AVAILABLE = False
_MCP_HTTP_AVAILABLE = False
+_MCP_NEW_HTTP = False
+_MCP_LEGACY_HTTP = False
_MCP_SAMPLING_TYPES = False
_MCP_NOTIFICATION_TYPES = False
_MCP_ELICITATION_TYPES = False
@@ -278,7 +280,7 @@ def _ensure_mcp_sdk() -> bool:
global _MCP_SDK_IMPORT_ATTEMPTED, _MCP_AVAILABLE, _MCP_HTTP_AVAILABLE
global _MCP_SAMPLING_TYPES, _MCP_NOTIFICATION_TYPES, _MCP_ELICITATION_TYPES
global _MCP_MESSAGE_HANDLER_SUPPORTED, _MCP_LOGGING_CALLBACK_SUPPORTED
- global _MCP_NEW_HTTP, LATEST_PROTOCOL_VERSION, sse_client
+ global _MCP_NEW_HTTP, _MCP_LEGACY_HTTP, LATEST_PROTOCOL_VERSION, sse_client
global ClientSession, StdioServerParameters, stdio_client
global streamablehttp_client, streamable_http_client
global CreateMessageResult, CreateMessageResultWithTools, ErrorData
@@ -298,11 +300,6 @@ def _ensure_mcp_sdk() -> bool:
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
_MCP_AVAILABLE = True
- try:
- from mcp.client.streamable_http import streamablehttp_client
- _MCP_HTTP_AVAILABLE = True
- except ImportError:
- _MCP_HTTP_AVAILABLE = False
# Prefer the non-deprecated API (mcp >= 1.24.0); fall back to the
# deprecated wrapper for older SDK versions.
try:
@@ -310,6 +307,17 @@ def _ensure_mcp_sdk() -> bool:
_MCP_NEW_HTTP = True
except ImportError:
_MCP_NEW_HTTP = False
+ try:
+ from mcp.client.streamable_http import streamablehttp_client
+ _MCP_LEGACY_HTTP = True
+ except ImportError:
+ _MCP_LEGACY_HTTP = False
+ # HTTP support requires EITHER entry point. mcp 2.0 dropped the
+ # deprecated `streamablehttp_client` alias, so gating on that name
+ # alone made _run_http raise ImportError for every HTTP and SSE
+ # server on 2.x before it could reach the `streamable_http_client`
+ # path that does work.
+ _MCP_HTTP_AVAILABLE = _MCP_NEW_HTTP or _MCP_LEGACY_HTTP
try:
from mcp.types import LATEST_PROTOCOL_VERSION
except ImportError:
@@ -374,6 +382,72 @@ def _ensure_mcp_sdk() -> bool:
return _MCP_AVAILABLE
+_SDK_HTTPX_MOD = None
+
+
+def sdk_httpx():
+ """Return the httpx module the *installed* MCP SDK is built against.
+
+ mcp 2.0 moved its HTTP transports and OAuth stack from ``httpx`` to
+ ``httpx2`` — a separate distribution with the same public API, importable
+ side by side with Hermes' own pinned ``httpx``. Every object that crosses
+ the SDK boundary has to come from the module the SDK itself imports:
+ the ``AsyncClient`` handed to ``streamable_http_client``, the client the
+ ``sse_client`` factory returns, the ``Request`` built by the SDK's OAuth
+ metadata helpers, and the exception classes those raise. Mixing the two
+ fails at the transport layer rather than at import, so resolve it from the
+ SDK's own transport module instead of inferring it from a version number.
+
+ Returns ``None`` only when neither module is importable, which also means
+ the SDK import above failed and no caller here can run.
+ """
+ global _SDK_HTTPX_MOD
+ if _SDK_HTTPX_MOD is not None:
+ return _SDK_HTTPX_MOD
+ try:
+ from mcp.client import streamable_http as _transport
+ _SDK_HTTPX_MOD = getattr(_transport, "httpx2", None) or getattr(
+ _transport, "httpx", None
+ )
+ except ImportError:
+ _SDK_HTTPX_MOD = None
+ if _SDK_HTTPX_MOD is None:
+ # SDK transport module unavailable (or it stopped importing the
+ # module under a predictable name). Fall back to whichever is
+ # present, newest first.
+ try:
+ import httpx2 as _fallback
+ except ImportError:
+ try:
+ import httpx as _fallback # type: ignore[no-redef]
+ except ImportError:
+ return None
+ _SDK_HTTPX_MOD = _fallback
+ return _SDK_HTTPX_MOD
+
+
+_MISSING = object()
+
+
+def mcp_field(obj, snake: str, camel: str, default=None):
+ """Read an MCP model field across the 1.x -> 2.x field rename.
+
+ mcp 2.0 renamed every model field to snake_case and kept the camelCase
+ spelling only as a *serialization* alias — pydantic aliases do not apply
+ to attribute access, so ``getattr(result, "isError", False)`` returns the
+ default on 2.x rather than raising. That turns a rename into silent wrong
+ behaviour: failed tool calls read as successful, tool schemas read as
+ empty, paginated lists stop after page one. Asking for both spellings
+ keeps the read correct on either SDK generation, which matters because
+ ``mcp`` is an optional extra users can install at their own version.
+ """
+ value = getattr(obj, snake, _MISSING)
+ if value is not _MISSING:
+ return value
+ value = getattr(obj, camel, _MISSING)
+ return default if value is _MISSING else value
+
+
def _check_message_handler_support() -> bool:
"""Check if ClientSession accepts ``message_handler`` kwarg.
@@ -643,7 +717,7 @@ def _is_method_not_found_error(exc: BaseException) -> bool:
``ping`` is an *optional* MCP utility (spec: "optional ping mechanism").
A server that doesn't implement it answers a ping with -32601 rather than
- an empty result. Structurally inspect ``McpError.error.code`` first, then
+ an empty result. Structurally inspect ``MCPError.error.code`` first, then
fall back to a substring match so detection survives SDK version drift and
servers that surface the condition as a plain message.
@@ -654,7 +728,7 @@ def _is_method_not_found_error(exc: BaseException) -> bool:
server is one such case (#50028). Without matching that phrasing the
ping→list_tools fallback never latches and the keepalive reconnect-loops.
"""
- # Structural: mcp.shared.exceptions.McpError carries ErrorData.code.
+ # Structural: mcp.shared.exceptions.MCPError carries ErrorData.code.
err = getattr(exc, "error", None)
code = getattr(err, "code", None)
if code == _JSONRPC_METHOD_NOT_FOUND:
@@ -769,7 +843,7 @@ async def _paginate_full_list(list_method, items_attr: str, server_name: str):
for _ in range(_MCP_LIST_MAX_PAGES):
result = await (list_method(cursor=cursor) if cursor else list_method())
items.extend(getattr(result, items_attr, None) or [])
- cursor = getattr(result, "nextCursor", None)
+ cursor = mcp_field(result, "next_cursor", "nextCursor")
# Per the MCP spec the cursor is an opaque string; anything else
# (including mock objects in tests) means "no more pages".
if not isinstance(cursor, str) or not cursor:
@@ -939,7 +1013,7 @@ def _cache_mcp_image_block(block) -> str:
import base64
data = getattr(block, "data", None)
- mime_type = getattr(block, "mimeType", None)
+ mime_type = mcp_field(block, "mime_type", "mimeType")
normalized_mime = str(mime_type or "").split(";", 1)[0].strip().lower()
if data is None or not normalized_mime.startswith("image/"):
return ""
@@ -1027,7 +1101,7 @@ def _cache_mcp_audio_block(block) -> str:
import base64
data = getattr(block, "data", None)
- mime_type = str(getattr(block, "mimeType", None) or "").split(";", 1)[0].strip().lower()
+ mime_type = str(mcp_field(block, "mime_type", "mimeType") or "").split(";", 1)[0].strip().lower()
if data is None or not mime_type.startswith("audio/"):
return ""
if len(data) > _MCP_RESOURCE_MAX_B64_CHARS:
@@ -1081,7 +1155,7 @@ def _render_mcp_resource_block(block, server_name: str = "") -> str:
if not uri:
return ""
name = getattr(block, "name", "") or ""
- mime = getattr(block, "mimeType", "") or ""
+ mime = mcp_field(block, "mime_type", "mimeType", "") or ""
details = f"uri={uri}"
if name:
details += f", name={name}"
@@ -1109,7 +1183,7 @@ def _render_mcp_resource_block(block, server_name: str = "") -> str:
import base64
uri = str(getattr(resource, "uri", "") or "")
- mime = str(getattr(resource, "mimeType", "") or "")
+ mime = str(mcp_field(resource, "mime_type", "mimeType", "") or "")
if len(blob) > _MCP_RESOURCE_MAX_B64_CHARS:
return f"[MCP embedded resource too large to cache: ~{len(blob) * 3 // 4} bytes, uri={uri}]"
try:
@@ -1630,22 +1704,40 @@ class SamplingHandler:
with ``isinstance`` on real SDK types when available, falling back
to duck-typing via ``hasattr`` for compatibility.
"""
+ # The presence of a tool-use id is the discriminator for a tool
+ # *result* block, so it has to be read under both spellings (see
+ # mcp_field) — on mcp 2.x a bare ``hasattr(b, "toolUseId")`` is False
+ # for every block, which silently drops tool results out of the
+ # conversation and pushes them down the "unsupported block type" path
+ # below.
+ def _tool_use_id(block):
+ return mcp_field(block, "tool_use_id", "toolUseId", _MISSING)
+
+ def _is_tool_use(block):
+ return hasattr(block, "name") and hasattr(block, "input")
+
messages: List[dict] = []
for msg in params.messages:
blocks = msg.content_as_list if hasattr(msg, "content_as_list") else (
msg.content if isinstance(msg.content, list) else [msg.content]
)
- # Separate blocks by kind
- tool_results = [b for b in blocks if hasattr(b, "toolUseId")]
- tool_uses = [b for b in blocks if hasattr(b, "name") and hasattr(b, "input") and not hasattr(b, "toolUseId")]
- content_blocks = [b for b in blocks if not hasattr(b, "toolUseId") and not (hasattr(b, "name") and hasattr(b, "input"))]
+ # Separate blocks by kind.
+ tool_results = [b for b in blocks if _tool_use_id(b) is not _MISSING]
+ tool_uses = [
+ b for b in blocks
+ if _is_tool_use(b) and _tool_use_id(b) is _MISSING
+ ]
+ content_blocks = [
+ b for b in blocks
+ if _tool_use_id(b) is _MISSING and not _is_tool_use(b)
+ ]
# Emit tool result messages (role: tool)
for tr in tool_results:
messages.append({
"role": "tool",
- "tool_call_id": tr.toolUseId,
+ "tool_call_id": _tool_use_id(tr),
"content": self._extract_tool_result_text(tr),
})
@@ -1674,12 +1766,15 @@ class SamplingHandler:
else:
parts = []
for block in content_blocks:
+ block_mime = mcp_field(
+ block, "mime_type", "mimeType", _MISSING
+ )
if hasattr(block, "text"):
parts.append({"type": "text", "text": block.text})
- elif hasattr(block, "data") and hasattr(block, "mimeType"):
+ elif hasattr(block, "data") and block_mime is not _MISSING:
parts.append({
"type": "image_url",
- "image_url": {"url": f"data:{block.mimeType};base64,{block.data}"},
+ "image_url": {"url": f"data:{block_mime};base64,{block.data}"},
})
else:
logger.warning(
@@ -1811,7 +1906,9 @@ class SamplingHandler:
)
# Resolve model
- model = self._resolve_model(getattr(params, "modelPreferences", None))
+ model = self._resolve_model(
+ mcp_field(params, "model_preferences", "modelPreferences")
+ )
# Get auxiliary LLM client via centralized router
from agent.auxiliary_client import call_llm
@@ -1832,11 +1929,15 @@ class SamplingHandler:
# Convert messages
messages = self._convert_messages(params)
- if hasattr(params, "systemPrompt") and params.systemPrompt:
- messages.insert(0, {"role": "system", "content": params.systemPrompt})
+ system_prompt = mcp_field(params, "system_prompt", "systemPrompt")
+ if system_prompt:
+ messages.insert(0, {"role": "system", "content": system_prompt})
# Build LLM call kwargs
- max_tokens = min(params.maxTokens, self.max_tokens_cap)
+ max_tokens = min(
+ mcp_field(params, "max_tokens", "maxTokens", self.max_tokens_cap),
+ self.max_tokens_cap,
+ )
call_temperature = None
if hasattr(params, "temperature") and params.temperature is not None:
call_temperature = params.temperature
@@ -1852,7 +1953,7 @@ class SamplingHandler:
"name": getattr(t, "name", ""),
"description": getattr(t, "description", "") or "",
"parameters": _normalize_mcp_input_schema(
- getattr(t, "inputSchema", None)
+ mcp_field(t, "input_schema", "inputSchema")
),
},
}
@@ -2215,7 +2316,7 @@ class MCPServerTask:
Per the MCP spec, ``InitializeResult.capabilities.tools`` is non-None
iff the server implements the ``tools/*`` request family. Prompt-only
or resource-only servers omit it, and calling ``tools/list`` against
- them raises ``McpError(-32601 Method not found)`` — which previously
+ them raises ``MCPError(-32601 Method not found)`` — which previously
killed the connection during discovery and made every keepalive fail.
(Ported from anomalyco/opencode#31271.)
@@ -2341,7 +2442,15 @@ class MCPServerTask:
logger.debug("MCP message handler (%s): exception: %s", self.name, message)
return
if _MCP_NOTIFICATION_TYPES and isinstance(message, ServerNotification):
- match message.root:
+ # mcp 2.0 turned ServerNotification from a RootModel into
+ # a plain union of the concrete notification types, so the
+ # payload IS the message instead of living under ``.root``.
+ # ``isinstance`` accepts a union, so the guard above still
+ # holds on both generations; only the unwrap changes.
+ # Without this, ``message.root`` raises AttributeError into
+ # the catch-all below and tools/list_changed refreshes stop
+ # firing silently.
+ match getattr(message, "root", message):
case ToolListChangedNotification():
logger.info(
"MCP server '%s': received tools/list_changed notification",
@@ -2384,7 +2493,7 @@ class MCPServerTask:
if not self._advertises_tools():
# A server that doesn't implement tools/* should never send
# tools/list_changed, but guard anyway — calling tools/list
- # would raise McpError(-32601).
+ # would raise MCPError(-32601).
return
async with self._refresh_lock:
@@ -3149,7 +3258,9 @@ class MCPServerTask:
# defaults (follow_redirects=True) and adds our TLS settings.
# The SDK calls the factory with (headers, auth, timeout); we
# forward all of those and layer verify/cert on top.
- import httpx as _httpx_mod
+ # The client MUST come from the SDK's own httpx module
+ # (httpx2 on mcp >= 2.0) — see sdk_httpx().
+ _httpx_mod = sdk_httpx()
_cert_for_factory = client_cert
_verify_for_factory = ssl_verify
@@ -3208,9 +3319,12 @@ class MCPServerTask:
return reason
if _MCP_NEW_HTTP:
- # New API (mcp >= 1.24.0): build an explicit httpx.AsyncClient
- # matching the SDK's own create_mcp_http_client defaults.
- import httpx
+ # New API (mcp >= 1.24.0): build an explicit AsyncClient matching
+ # the SDK's own create_mcp_http_client defaults. It has to come
+ # from the SDK's httpx module (httpx2 on mcp >= 2.0), because the
+ # SDK sends its own Request objects through this client — see
+ # sdk_httpx().
+ httpx = sdk_httpx()
_original_url = httpx.URL(url)
@@ -3317,7 +3431,7 @@ class MCPServerTask:
"""Discover tools from the connected session.
Capability-gated: prompt-only / resource-only MCP servers don't
- implement ``tools/list``, and calling it raises ``McpError(-32601)``,
+ implement ``tools/list``, and calling it raises ``MCPError(-32601)``,
which previously aborted the connection — those servers could never
stay connected for their prompts/resources. Skip the call when the
server doesn't advertise the ``tools`` capability.
@@ -4255,6 +4369,32 @@ def _signal_reconnect_and_wait(
# Cached tuple of auth-related exception types. Lazy so this module
# imports cleanly when the MCP SDK OAuth module is missing.
_AUTH_ERROR_TYPES: tuple = ()
+_HTTP_STATUS_ERROR_TYPES: Optional[tuple] = None
+
+
+def _http_status_error_types() -> tuple:
+ """``HTTPStatusError`` classes that can reach us, from both httpx flavours.
+
+ A 401 can be raised either by the MCP SDK's own HTTP stack (``httpx2`` on
+ mcp >= 2.0) or by Hermes' pinned ``httpx``, and the two define unrelated
+ exception classes. Both go in the tuple so ``isinstance`` covers whichever
+ layer raised.
+ """
+ global _HTTP_STATUS_ERROR_TYPES
+ if _HTTP_STATUS_ERROR_TYPES is not None:
+ return _HTTP_STATUS_ERROR_TYPES
+ found: list = []
+ sdk_mod = sdk_httpx()
+ if sdk_mod is not None:
+ found.append(sdk_mod.HTTPStatusError)
+ try:
+ import httpx
+ if httpx.HTTPStatusError not in found:
+ found.append(httpx.HTTPStatusError)
+ except ImportError:
+ pass
+ _HTTP_STATUS_ERROR_TYPES = tuple(found)
+ return _HTTP_STATUS_ERROR_TYPES
def _get_auth_error_types() -> tuple:
@@ -4267,8 +4407,8 @@ def _get_auth_error_types() -> tuple:
optional import for forward/backward compatibility.
- ``tools.mcp_oauth.OAuthNonInteractiveError`` — raised by our callback
handler when no user is present to complete a browser flow.
- - ``httpx.HTTPStatusError`` — caller must additionally check
- ``status_code == 401`` via :func:`_is_auth_error`.
+ - ``HTTPStatusError`` from both httpx flavours — caller must
+ additionally check ``status_code == 401`` via :func:`_is_auth_error`.
"""
global _AUTH_ERROR_TYPES
if _AUTH_ERROR_TYPES:
@@ -4290,11 +4430,7 @@ def _get_auth_error_types() -> tuple:
types.append(OAuthNonInteractiveError)
except ImportError:
pass
- try:
- import httpx
- types.append(httpx.HTTPStatusError)
- except ImportError:
- pass
+ types.extend(_http_status_error_types())
_AUTH_ERROR_TYPES = tuple(types)
return _AUTH_ERROR_TYPES
@@ -4302,19 +4438,16 @@ def _get_auth_error_types() -> tuple:
def _is_auth_error(exc: BaseException) -> bool:
"""Return True if ``exc`` indicates an MCP OAuth failure.
- ``httpx.HTTPStatusError`` is only treated as auth-related when the
- response status code is 401. Other HTTP errors fall through to the
- generic error path in the tool handlers.
+ ``HTTPStatusError`` is only treated as auth-related when the response
+ status code is 401. Other HTTP errors fall through to the generic error
+ path in the tool handlers.
"""
types = _get_auth_error_types()
if not types or not isinstance(exc, types):
return False
- try:
- import httpx
- if isinstance(exc, httpx.HTTPStatusError):
- return getattr(exc.response, "status_code", None) == 401
- except ImportError:
- pass
+ status_error_types = _http_status_error_types()
+ if status_error_types and isinstance(exc, status_error_types):
+ return getattr(exc.response, "status_code", None) == 401
return True
@@ -5472,8 +5605,9 @@ def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float):
_mark_proven = getattr(server, "_mark_session_proven", None)
if _mark_proven is not None:
_mark_proven()
- # MCP CallToolResult has .content (list of content blocks) and .isError
- if result.isError:
+ # MCP CallToolResult has .content (list of content blocks) and
+ # .is_error (.isError before mcp 2.0)
+ if mcp_field(result, "is_error", "isError", False):
error_text = ""
for block in (result.content or []):
if getattr(block, "text", None):
@@ -5554,8 +5688,8 @@ def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float):
# host/protocol plumbing, not model-facing data. Unprefixed and
# vendor-namespaced keys (`com.example.mcp/...`) pass through —
# their semantics belong to the server.
- structured = getattr(result, "structuredContent", None)
- meta = _strip_reserved_meta_keys(getattr(result, "meta", None))
+ structured = mcp_field(result, "structured_content", "structuredContent")
+ meta = _strip_reserved_meta_keys(mcp_field(result, "meta", "meta"))
if structured is not None or meta is not None:
payload: Dict[str, Any] = {}
if text_result:
@@ -5650,8 +5784,11 @@ def _make_list_resources_handler(server_name: str, tool_timeout: float):
entry["name"] = r.name
if hasattr(r, "description") and r.description:
entry["description"] = r.description
- if hasattr(r, "mimeType") and r.mimeType:
- entry["mimeType"] = r.mimeType
+ # Key stays camelCase — this dict is the tool's own JSON
+ # output shape, not an SDK model.
+ _mime = mcp_field(r, "mime_type", "mimeType")
+ if _mime:
+ entry["mimeType"] = _mime
resources.append(entry)
return json.dumps({"resources": resources}, ensure_ascii=False)
@@ -6083,7 +6220,7 @@ def _convert_mcp_schema(server_name: str, mcp_tool) -> dict:
Args:
server_name: The logical server name for prefixing.
mcp_tool: An MCP ``Tool`` object with ``.name``, ``.description``,
- and ``.inputSchema``.
+ and ``.input_schema`` (``.inputSchema`` before mcp 2.0).
Returns:
A dict suitable for ``registry.register(schema=...)``.
@@ -6094,7 +6231,9 @@ def _convert_mcp_schema(server_name: str, mcp_tool) -> dict:
"description": strip_unicode_tags(
mcp_tool.description or f"MCP tool {mcp_tool.name} from {server_name}"
),
- "parameters": _normalize_mcp_input_schema(getattr(mcp_tool, "inputSchema", None)),
+ "parameters": _normalize_mcp_input_schema(
+ mcp_field(mcp_tool, "input_schema", "inputSchema")
+ ),
}
diff --git a/uv.lock b/uv.lock
index f08ed7efa3..7ed7c468ec 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1556,15 +1556,15 @@ wheels = [
[[package]]
name = "h2"
-version = "4.4.1"
+version = "4.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "hpack" },
{ name = "hyperframe" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281, upload-time = "2026-08-03T11:45:09.509Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/7e/22/e85faf23bd72a92d1921e37d674ca56eb298a3c8be31fdecef0ff2b3aaac/h2-4.4.1-py3-none-any.whl", hash = "sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6", size = 62636, upload-time = "2026-08-03T11:44:59.164Z" },
+ { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" },
]
[[package]]
@@ -1619,6 +1619,7 @@ all = [
{ name = "google-auth-httplib2" },
{ name = "google-auth-oauthlib" },
{ name = "httplib2" },
+ { name = "httpx2" },
{ name = "mcp" },
{ name = "pyasn1" },
{ name = "python-multipart" },
@@ -1636,6 +1637,7 @@ bedrock = [
{ name = "boto3" },
]
computer-use = [
+ { name = "httpx2" },
{ name = "mcp" },
{ name = "starlette" },
]
@@ -1644,6 +1646,7 @@ daytona = [
]
dev = [
{ name = "debugpy" },
+ { name = "httpx2" },
{ name = "mcp" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
@@ -1698,6 +1701,7 @@ matrix = [
{ name = "mautrix", extra = ["encryption"] },
]
mcp = [
+ { name = "httpx2" },
{ name = "mcp" },
{ name = "starlette" },
]
@@ -1744,6 +1748,7 @@ teams = [
termux = [
{ name = "agent-client-protocol" },
{ name = "honcho-ai" },
+ { name = "httpx2" },
{ name = "mcp" },
{ name = "python-telegram-bot", extra = ["webhooks"] },
{ name = "starlette" },
@@ -1758,6 +1763,7 @@ termux-all = [
{ name = "google-auth-oauthlib" },
{ name = "honcho-ai" },
{ name = "httplib2" },
+ { name = "httpx2" },
{ name = "mcp" },
{ name = "pyasn1" },
{ name = "python-multipart" },
@@ -1866,13 +1872,16 @@ requires-dist = [
{ name = "honcho-ai", marker = "extra == 'honcho'", specifier = "==2.2.0" },
{ name = "httplib2", marker = "extra == 'google'", specifier = "==0.32.0" },
{ name = "httpx", extras = ["socks"], specifier = "==0.28.1" },
+ { name = "httpx2", marker = "extra == 'computer-use'", specifier = "==2.7.0" },
+ { name = "httpx2", marker = "extra == 'dev'", specifier = "==2.7.0" },
+ { name = "httpx2", marker = "extra == 'mcp'", specifier = "==2.7.0" },
{ name = "jinja2", specifier = "==3.1.6" },
{ name = "lark-oapi", marker = "extra == 'feishu'", specifier = "==1.6.8" },
{ name = "markdown", specifier = "==3.10.2" },
{ name = "mautrix", extras = ["encryption"], marker = "extra == 'matrix'", specifier = "==0.21.1" },
- { name = "mcp", marker = "extra == 'computer-use'", specifier = "==1.28.1" },
- { name = "mcp", marker = "extra == 'dev'", specifier = "==1.28.1" },
- { name = "mcp", marker = "extra == 'mcp'", specifier = "==1.28.1" },
+ { name = "mcp", marker = "extra == 'computer-use'", specifier = "==2.0.0" },
+ { name = "mcp", marker = "extra == 'dev'", specifier = "==2.0.0" },
+ { name = "mcp", marker = "extra == 'mcp'", specifier = "==2.0.0" },
{ name = "mem0ai", marker = "extra == 'mem0'", specifier = "==2.0.10" },
{ name = "microsoft-teams-apps", marker = "extra == 'teams'", specifier = "==2.0.13.4" },
{ name = "mistralai", marker = "extra == 'mistral'", specifier = "==2.4.8" },
@@ -1988,11 +1997,11 @@ wheels = [
[[package]]
name = "hpack"
-version = "4.2.0"
+version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" },
+ { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" },
]
[[package]]
@@ -2008,6 +2017,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
+[[package]]
+name = "httpcore2"
+version = "2.7.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "h11" },
+ { name = "truststore" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d5/fe/6a3f9f1a8bb8733326140737446aaf72fddb8b54b8f202302f5c84960613/httpcore2-2.7.0.tar.gz", hash = "sha256:6dc0fedf329a52a990930a5579edfebaea81118ea700ea0dd7de2b5e5be49efc", size = 65593, upload-time = "2026-07-14T20:40:01.111Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6f/6c/62e2e279e63fc4f7a5ee841ef13175a8bbc613f258e9dcc186e9de803a42/httpcore2-2.7.0-py3-none-any.whl", hash = "sha256:1452f589fe23f55b44546cd884294c41a29330af902bc0b71a761fd52d18f92b", size = 81506, upload-time = "2026-07-14T20:39:58.053Z" },
+]
+
[[package]]
name = "httplib2"
version = "0.32.0"
@@ -2081,6 +2103,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
]
+[[package]]
+name = "httpx2"
+version = "2.7.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "httpcore2" },
+ { name = "idna" },
+ { name = "truststore" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a3/4a/129b2e21b90ac2985d3928d96792bccc39bc6dfe796c5eee2d8ec06d4105/httpx2-2.7.0.tar.gz", hash = "sha256:8b30709aed5c8465b0dd3b95c09ce301c8f79e7e7a2d00ab0af551e0d0375b07", size = 94487, upload-time = "2026-07-14T20:40:02.318Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1d/b8/c341bba6411bdfda786020343c47a75ef472f6085caf82391b142b1a3ad9/httpx2-2.7.0-py3-none-any.whl", hash = "sha256:ed2a2719c696789e09493bd8e2bec3d8bd925cc6e26b68389ec25ade132f7bf4", size = 90234, upload-time = "2026-07-14T20:39:59.531Z" },
+]
+
[[package]]
name = "huggingface-hub"
version = "1.24.0"
@@ -2112,11 +2150,11 @@ wheels = [
[[package]]
name = "idna"
-version = "3.15"
+version = "3.18"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
]
[[package]]
@@ -2387,15 +2425,15 @@ encryption = [
[[package]]
name = "mcp"
-version = "1.28.1"
+version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
- { name = "httpx" },
- { name = "httpx-sse" },
+ { name = "httpx2" },
{ name = "jsonschema" },
+ { name = "mcp-types" },
+ { name = "opentelemetry-api" },
{ name = "pydantic" },
- { name = "pydantic-settings" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "python-multipart" },
{ name = "pywin32", marker = "sys_platform == 'win32'" },
@@ -2405,9 +2443,22 @@ dependencies = [
{ name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/74/33/32d4dff2c95bb5d897c3ef4c83649a08996b17b58f0a326d2495d4c81179/mcp-2.0.0.tar.gz", hash = "sha256:0f440e735c13ece8bb19bc62cf0b86f4313448432fbb77d35e14034f4e050728", size = 1662284, upload-time = "2026-07-28T13:45:32.346Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" },
+ { url = "https://files.pythonhosted.org/packages/67/72/7d7897418912c1d12e87556630dfb7bf0eac71160e9bef8b447960804ee3/mcp-2.0.0-py3-none-any.whl", hash = "sha256:1cb4c75d2d2c7b8c1d756355e5d82a39f2822cc7f13e22a2051d7ca3592349d6", size = 349980, upload-time = "2026-07-28T13:45:28.853Z" },
+]
+
+[[package]]
+name = "mcp-types"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pydantic" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bb/56/9b8e1c152f61f6c6b07c4b5896c88c7d0ae90bac6ee6306f852fcc5c1eb0/mcp_types-2.0.0.tar.gz", hash = "sha256:d7d939b9285c9961ae8866ba75ef85da34d12bafe276efbf4eb6a131786d8379", size = 66632, upload-time = "2026-07-28T13:45:33.804Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f5/4c/c78d78c3d52b0ac594ad7cc8ef5972adfe070e3597a8a4c6ce0cd39196ea/mcp_types-2.0.0-py3-none-any.whl", hash = "sha256:6b2de797ca2797f568b79529e1b25948e34de511bcc0bd82fef1039a6d1b8eb0", size = 69649, upload-time = "2026-07-28T13:45:30.713Z" },
]
[[package]]
@@ -2698,17 +2749,17 @@ wheels = [
[[package]]
name = "nemo-relay"
-version = "0.7.1"
+version = "0.7.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f3/c8/4a28f9071d5de59c0ddfbea78be15eea12b13a88141bae05a2f26c1018c4/nemo_relay-0.7.1.tar.gz", hash = "sha256:0570c1a07863441e593a74a39e0523ee2bf221739fc7fcaef4bc0c79dcfd929f", size = 1293194, upload-time = "2026-08-07T03:18:11.724Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/58/81/a7a545ac3a2f8c670d261c89df599aa8fbf49d8be45fd1f52efb36b489eb/nemo_relay-0.7.2.tar.gz", hash = "sha256:828d9f6c7d7e4e42276bb7192bd44202c761e0c76fa4943d84e051b5a99028e5", size = 1295616, upload-time = "2026-08-08T01:54:00.953Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/48/2f/c1966abc74cd212ae404fa25a777729f4c545fdeb9641c44dcc396cd6d09/nemo_relay-0.7.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:a26c493b8a6f0e3e49960ba9922015d7ac77ce0ad7d13679136ec553b13d52e7", size = 9245494, upload-time = "2026-08-07T03:17:40.99Z" },
- { url = "https://files.pythonhosted.org/packages/d2/0a/cb3e37f8c2255cdd74661ea530c8206de2b776d6a5131d396bc0d00d2cbd/nemo_relay-0.7.1-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7db593b09e4c78a62cb979ce07ea1773443a18df4d1fc96eba165174c11f927e", size = 8453689, upload-time = "2026-08-07T03:17:43.5Z" },
- { url = "https://files.pythonhosted.org/packages/27/c7/3ed76753be128da9921607b088d436476af1bc387da4bb55008d5a891025/nemo_relay-0.7.1-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:711ffc75947cadcbdd49ea36430258cd3ac8ec9d9d1a4293c245d813c9bac23b", size = 8953162, upload-time = "2026-08-07T03:17:45.387Z" },
- { url = "https://files.pythonhosted.org/packages/09/46/d95930e40eba65b3a126d280dc661d2fd2325e7053df5113299d630aa097/nemo_relay-0.7.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c912268461fa9ef9ae34bcd2516047d52c4ae5d7313eb7ca80e6ce311b9762ac", size = 10321407, upload-time = "2026-08-07T03:17:47.333Z" },
- { url = "https://files.pythonhosted.org/packages/84/dc/b74dfdab16172e64a0a730d5c793a564feca50957ca4e983531a7e5f288f/nemo_relay-0.7.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2de8ee3f9eaee87fc7f17c897fb12502c5405f2f2a01ef89b9c474ff34597019", size = 10703761, upload-time = "2026-08-07T03:17:49.169Z" },
- { url = "https://files.pythonhosted.org/packages/fa/ce/7e2eb5197763f457a08191ec5d9313399b38e6068d4c22f887e547d86597/nemo_relay-0.7.1-cp311-abi3-win_amd64.whl", hash = "sha256:67888eec2378e598a370faf1f7f92b3d457dc117c558bdd6d81a9e8939840971", size = 8802474, upload-time = "2026-08-07T03:17:51.513Z" },
- { url = "https://files.pythonhosted.org/packages/ba/f9/7840d53cecc9c8ae92ffe63c8834fdd04cec8f53c0c9bd9483546e9a9c0a/nemo_relay-0.7.1-cp311-abi3-win_arm64.whl", hash = "sha256:d4e1f56e325e7f503447c9f5961690fced5a6e6196cfaa96b54f49416143fe69", size = 8437915, upload-time = "2026-08-07T03:17:53.516Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/cd/f50440257f01bc5ab3d668331c90e06cf4edcc84dc7dc582d322ad05b622/nemo_relay-0.7.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:e7c7977f0903793cc34c5542bf2b2e44d107def8a5ae9f1b28f06dd61ddec4ed", size = 9246341, upload-time = "2026-08-08T01:53:19.832Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/9f/4041446dd134218799a34b5b5fad3a62d3e1d0a6c322ba2ca4b896ba1393/nemo_relay-0.7.2-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4ae77c1f3d58eabda264e82ffaca54548df80caede7dd6af8cbd8f72b4a82ed", size = 8454070, upload-time = "2026-08-08T01:53:22.524Z" },
+ { url = "https://files.pythonhosted.org/packages/11/83/90230c2e9fae1aee39f768d4a9ef57e9f2716bcaed1a5923cce8b526c66b/nemo_relay-0.7.2-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ce7103aec546766649c182619d16aa6ad07439e4d0ebd16d95c5004afb3e56a", size = 8954377, upload-time = "2026-08-08T01:53:25.267Z" },
+ { url = "https://files.pythonhosted.org/packages/71/e7/463fa461d0801146fec6a00cbc02e8961b30089d65ba170f9dfa9e6e3dcd/nemo_relay-0.7.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2e7d0c2629ade7313aaed71d2272dca96a2fafad0248d0f87cf40a7720b252a0", size = 10322132, upload-time = "2026-08-08T01:53:27.991Z" },
+ { url = "https://files.pythonhosted.org/packages/32/8c/e20ec9c52bd1edd953157aaf24d0d9f9ab8afcbf108fc2356f398e252da8/nemo_relay-0.7.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b841c92395d7686c7f233036008294b9d362af1ec5123ab0babbfd11cbb04054", size = 10704141, upload-time = "2026-08-08T01:53:30.453Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/c1/92a73961ea759b433b1f897b225662d499123cb962b48dc8ece19f610a09/nemo_relay-0.7.2-cp311-abi3-win_amd64.whl", hash = "sha256:0cdcc5e09d6d62d5c1d385dc62c9233eb714a25f36a09da81e5b9731e3c67903", size = 8803938, upload-time = "2026-08-08T01:53:33.437Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/ec/2de114dab437431173988b9b11f46e8d377e12d57e1b4903258f3e03c2df/nemo_relay-0.7.2-cp311-abi3-win_arm64.whl", hash = "sha256:ca5f66e617311f836a10d96f120f3f32a99b4267d65048453b31951de3419a9d", size = 8438997, upload-time = "2026-08-08T01:53:36.12Z" },
]
[[package]]
@@ -4432,6 +4483,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
]
+[[package]]
+name = "truststore"
+version = "0.10.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
+]
+
[[package]]
name = "ty"
version = "0.0.21"