feat(mcp): migrate to the mcp 2.x SDK

mcp 2.0.0 implements MCP revision 2026-07-28 and makes three breaking
changes Hermes sits on top of: `mcp.server.fastmcp` is gone, every model
field is renamed to snake_case (camelCase survives only as a
serialization alias, which pydantic does not expose to attribute
access), and the SDK's own HTTP stack moved from `httpx` to `httpx2`.

Bump the pin across the dev/mcp/computer-use extras and port the tree:

- `mcp_serve.py` and `agent/transports/hermes_tools_mcp_server.py` move
  from `FastMCP` to `mcp.server.MCPServer`, which has the same
  decorator/add_tool surface. The hermes-tools server already
  synthesised `__signature__` from Hermes' JSON Schema, which is exactly
  what 2.0's `add_tool` reads.
- SDK model reads go through `mcp_field(obj, snake, camel)`, which reads
  both spellings. A single-spelling read fails *silently* on the other
  generation — empty tool schemas, dropped structured content, tool
  results vanishing from sampling conversations — and `mcp` is an
  optional extra users install at their own version.
- `sdk_httpx()` resolves the httpx flavour from the SDK's own transport
  module, so objects handed to `streamable_http_client`, the `sse_client`
  factory, and the OAuth metadata helpers come from the module the
  installed SDK actually imports.
- HTTP support is gated on either streamable-HTTP entry point, not just
  the deprecated alias 2.0 removed.
- OAuth: `OAuthClientProvider` lost its `timeout` argument (the
  configured `oauth.timeout` now bounds the callback waiter's own poll
  loop, where the browser round-trip was always awaited), and
  `callback_handler` must return `AuthorizationCodeResult` rather than a
  tuple. 2.0 also validates the RFC 9207 `iss` parameter, so the
  callback handler and paste fallback capture it.

`mcp`/`mcp-types` 2.0.0 are inside the 14-day `exclude-newer` window, so
two narrow `exclude-newer-package` entries unblock `uv lock`, annotated
for removal on or after 2026-08-11. `httpx2` needs no exemption: 2.7.0 is
already outside the window and satisfies mcp's floor.

Refs #69931

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
elphamale
2026-08-02 14:03:43 +03:00
committed by Teknium
parent 7a2ad9ae55
commit 11a9dcf567
22 changed files with 564 additions and 163 deletions
+13 -10
View File
@@ -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:
+7 -4
View File
@@ -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 "
+11 -3
View File
@@ -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
+33 -7
View File
@@ -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
+3 -3
View File
@@ -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)
+10 -5
View File
@@ -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:
+4 -4
View File
@@ -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)
+19 -6
View File
@@ -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)
+4 -1
View File
@@ -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"
+7 -2
View File
@@ -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()
+33
View File
@@ -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
+8 -6
View File
@@ -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"
# ---------------------------------------------------------------------------
+10 -2
View File
@@ -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
@@ -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
+14 -2
View File
@@ -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]
+4 -4
View File
@@ -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
+31 -6
View File
@@ -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"):
+2 -1
View File
@@ -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).
+48 -8
View File
@@ -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 = (
"<html><body><h2>Authorization Successful</h2>"
@@ -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)),
)
+14 -3
View File
@@ -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(
+196 -57
View File
@@ -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")
),
}
Generated
+87 -27
View File
@@ -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"