From 11a9dcf56743c2e4a0866b6bd5fbae2dfa5d32ff Mon Sep 17 00:00:00 2001 From: elphamale Date: Sun, 2 Aug 2026 14:03:43 +0300 Subject: [PATCH] feat(mcp): migrate to the mcp 2.x SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- agent/transports/hermes_tools_mcp_server.py | 23 +- mcp_serve.py | 11 +- pyproject.toml | 14 +- tests/test_mcp_serve.py | 40 ++- tests/tools/test_computer_use.py | 6 +- tests/tools/test_mcp_capability_gating.py | 15 +- tests/tools/test_mcp_circuit_breaker.py | 8 +- tests/tools/test_mcp_client_cert.py | 25 +- tests/tools/test_mcp_dashboard_oauth.py | 5 +- tests/tools/test_mcp_dynamic_discovery.py | 9 +- tests/tools/test_mcp_elicitation.py | 33 +++ tests/tools/test_mcp_oauth.py | 14 +- tests/tools/test_mcp_oauth_bidirectional.py | 12 +- .../tools/test_mcp_oauth_cold_load_expiry.py | 8 +- tests/tools/test_mcp_tool.py | 16 +- tests/tools/test_mcp_tool_session_expired.py | 8 +- tools/computer_use/cua_backend.py | 37 ++- tools/lazy_deps.py | 3 +- tools/mcp_oauth.py | 56 +++- tools/mcp_oauth_manager.py | 17 +- tools/mcp_tool.py | 253 ++++++++++++++---- uv.lock | 114 ++++++-- 22 files changed, 564 insertions(+), 163 deletions(-) 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"