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:
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)),
|
||||
)
|
||||
|
||||
@@ -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
@@ -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")
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user