From 018e949726ba6f99d2d7ebe59eb9bb26ba046094 Mon Sep 17 00:00:00 2001 From: Jonathan Springer Date: Mon, 12 Oct 2026 11:03:11 +0100 Subject: [PATCH] feat(transports): forward sessionless MCP 2026-07-28 traffic alongside handshake eras The Streamable HTTP adapter required an initialize handshake and a server-minted session before forwarding any request, which made sessionless 2026-07-28 traffic impossible. The adapter now auto-detects the protocol era per connection: - Modern era (2026-07-28): a server/discover request or a params._meta io.modelcontextprotocol/protocolVersion field switches the adapter to sessionless forwarding - no initialize, no session headers. Per request it mints the spec-required MCP-Protocol-Version, Mcp-Method, and Mcp-Name headers (base64 sentinel encoding for non-header-safe values). Error statuses carrying JSON-RPC bodies (404 -32601, 400 unsupported version) are relayed to the gateway as ordinary responses, so handshake-era session-expiry recovery (SessionExpiredError, re-registration) can never fire on modern connections. A mcp-session-id on a modern response is ignored per spec. - Handshake era (<= 2025-11-25): unchanged - initialize capture, session headers, 404 retry/re-registration. SSE stays handshake-era only (legacy transport by spec); stdio is era-agnostic passthrough. Test harness: bump the integration extra to fastmcp>=4,<5 (FastMCP 4 / MCP SDK v2 serve both eras per connection, auto-detected). Integration suite now covers handshake 2025-06-18 and 2025-11-25 over stdio/SSE/ streamable-http, plus live sessionless 2026-07-28 round-trips (server/discover, tools/call, tools/list, resources/read, prompts/get) and modern-era 404 error relay. Unit tests cover era detection, header minting/encoding, and era-aware error handling. Signed-off-by: Jonathan Springer --- USER_README.md | 21 +++ pyproject.toml | 2 +- .../transports/streamablehttp_adapter.py | 121 +++++++++++-- tests/integration/conftest.py | 81 +++++---- tests/integration/helpers.py | 27 ++- .../integration/test_adapters_integration.py | 10 +- tests/integration/test_modern_era.py | 106 ++++++++++++ ...cp_reverse_proxy_streamablehttp_adapter.py | 161 +++++++++++++++++- uv.lock | 116 ++++++++++--- 9 files changed, 573 insertions(+), 72 deletions(-) create mode 100644 tests/integration/test_modern_era.py diff --git a/USER_README.md b/USER_README.md index 8feaba0..113ef18 100644 --- a/USER_README.md +++ b/USER_README.md @@ -9,6 +9,7 @@ The MCP Reverse Proxy is a standalone component that enables you to connect loca ### Key Features - **Multi-Transport Support**: Connect to MCP servers via stdio, HTTP/2 streaming, or SSE +- **Dual Protocol Eras**: Forwards both handshake-era (≀ 2025-11-25) and sessionless 2026-07-28 MCP traffic - **WebSocket Gateway**: Persistent connection to remote gateways - **Automatic Reconnection**: Built-in retry logic with exponential backoff - **Health Monitoring**: Active health checks with automatic recovery @@ -604,6 +605,26 @@ mcp-reverse-proxy --local-stdio "uvx mcp-server-git" \ --log-level INFO ``` +## MCP Protocol Eras + +The proxy forwards MCP traffic of both protocol eras, auto-detected per connection: + +- **Handshake era (2025-11-25 and earlier)**: the gateway drives the classic + `initialize` handshake; the Streamable HTTP adapter tracks the negotiated + `mcp-session-id` / `mcp-protocol-version` headers and performs + session-expiry recovery (re-registration) when the server expires a session. +- **Modern era (2026-07-28, sessionless)**: no handshake and no sessions. The + gateway opens with `server/discover` (or any request carrying + `_meta["io.modelcontextprotocol/protocolVersion"]`), and the adapter mints + the spec-required HTTP headers per request: `MCP-Protocol-Version`, + `Mcp-Method`, and `Mcp-Name` (for `tools/call`, `resources/read`, + `prompts/get`; non-ASCII values use the `=?base64?...?=` sentinel encoding). + Error statuses with JSON-RPC bodies (e.g. 404 `-32601` method not found) are + relayed to the gateway as ordinary responses, not treated as session expiry. + +Era support by server-side transport: stdio and Streamable HTTP carry both +eras; SSE is a handshake-era (legacy) transport by specification. + ## Architecture The reverse proxy uses a transport abstraction pattern: diff --git a/pyproject.toml b/pyproject.toml index 53d31bc..9b75c5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,7 @@ dev = [ "black>=25.1.0" ] integration = [ - "fastmcp>=3.4.7,<4", + "fastmcp>=4,<5", "uvicorn>=0.30.0" ] diff --git a/src/mcp_reverse_proxy/transports/streamablehttp_adapter.py b/src/mcp_reverse_proxy/transports/streamablehttp_adapter.py index 2a5c32e..960ab7f 100644 --- a/src/mcp_reverse_proxy/transports/streamablehttp_adapter.py +++ b/src/mcp_reverse_proxy/transports/streamablehttp_adapter.py @@ -14,6 +14,7 @@ # Standard import asyncio +import base64 import json import ssl from collections.abc import Awaitable, Callable @@ -37,12 +38,62 @@ class SessionExpiredError(Exception): logging_service = LoggingService() LOGGER = logging_service.get_logger("mcp_reverse_proxy.transports.streamablehttp_adapter") +# Modern (sessionless, 2026-07-28) protocol markers +MODERN_PROTOCOL_META_KEY = "io.modelcontextprotocol/protocolVersion" +DISCOVER_METHOD = "server/discover" +# Methods whose params carry a target name mirrored into the Mcp-Name header +NAMED_METHOD_PARAMS = {"tools/call": "name", "resources/read": "uri", "prompts/get": "name"} +BASE64_SENTINEL_PREFIX = "=?base64?" +BASE64_SENTINEL_SUFFIX = "?=" + + +def extract_modern_protocol_version(msg_data: dict[str, object]) -> str | None: + """Return the protocol version declared in a message's ``_meta``, if any. + + Modern-era (2026-07-28) clients carry the negotiated version in every + request's ``params._meta`` rather than negotiating it via an initialize + handshake. A top-level ``_meta`` is tolerated as a fallback. + """ + params = msg_data.get("params") + meta = params.get("_meta") if isinstance(params, dict) else None + if not isinstance(meta, dict): + meta = msg_data.get("_meta") + if isinstance(meta, dict): + version = meta.get(MODERN_PROTOCOL_META_KEY) + if isinstance(version, str) and version: + return version + return None + + +def encode_header_value(value: str) -> str: + """Encode an Mcp-Name header value per the 2026-07-28 value-encoding rules. + + Header-safe values (visible ASCII, space, tab; no leading/trailing + whitespace; not matching the sentinel pattern) pass through verbatim; + anything else is Base64-encoded with the ``=?base64?...?=`` sentinel. + """ + header_safe = ( + value == value.strip(" \t") + and all(c in " \t" or 0x21 <= ord(c) <= 0x7E for c in value) + and not (value.startswith(BASE64_SENTINEL_PREFIX) and value.endswith(BASE64_SENTINEL_SUFFIX)) + ) + if header_safe: + return value + encoded = base64.b64encode(value.encode("utf-8")).decode("ascii") + return f"{BASE64_SENTINEL_PREFIX}{encoded}{BASE64_SENTINEL_SUFFIX}" + class StreamableHttpAdapter(McpServerTransport): """Transport adapter for Streamable HTTP MCP servers. Communicates with MCP servers via HTTP/2 streaming instead of stdio. The server can be local or remote - this adapter connects via HTTP. + + Supports both MCP protocol eras on the HTTP leg: the session-based + handshake eras (initialize + mcp-session-id, through 2025-11-25) and + the sessionless 2026-07-28 era (server/discover, per-request + MCP-Protocol-Version header, no sessions). The era is detected from the + first messages forwarded and is fixed for the adapter's lifetime. """ def __init__( @@ -73,6 +124,9 @@ def __init__( # Session management (MCP protocol requirement) self._session_id: str | None = None self._protocol_version: str | None = None + # True once modern-era (2026-07-28) traffic is seen: no initialize + # handshake, no session headers; version/routing headers minted per request + self._sessionless = False # Authentication headers from gateway self._auth_headers: dict[str, str] = {} @@ -153,23 +207,46 @@ async def stop(self) -> None: self._session_id = None self._message_endpoint = None self._protocol_version = None + self._sessionless = False LOGGER.info("HTTP connection closed and session state cleared") async def send(self, message: str) -> None: """Send a message to the MCP server via HTTP POST and handle inline response. + Forwards handshake-era (initialize + session) and modern-era + (2026-07-28, sessionless) traffic; the era is auto-detected from the + message and fixed for the adapter's lifetime. + Args: message: JSON-RPC message to send to the MCP server. Raises: RuntimeError: If not connected to MCP server or HTTP request fails. - SessionExpiredError: If MCP server session has expired (404) and re-registration is needed. + SessionExpiredError: Handshake era only - if the MCP server session + has expired (404) and re-registration is needed. Modern-era + error statuses carry JSON-RPC bodies and are relayed, not raised. """ if not self._connected or not self._client: raise RuntimeError("Not connected to MCP server") LOGGER.debug(f"β†’ HTTP: {message[:200]}...") + try: + msg_data = json.loads(message) + except Exception: + msg_data = {} + + # Era detection: a modern-era (2026-07-28) message carries the protocol + # version in _meta, or opens with server/discover instead of initialize. + # Once a handshake session exists the era is fixed; the two never mix. + meta_version = extract_modern_protocol_version(msg_data) + if not self._session_id and (meta_version or msg_data.get("method") == DISCOVER_METHOD): + if not self._sessionless: + LOGGER.info("Modern (sessionless) MCP protocol era detected - forwarding without initialize handshake") + self._sessionless = True + if meta_version and self._sessionless: + self._protocol_version = meta_version + headers = { "Content-Type": "application/json", "Accept": "application/json, text/event-stream", @@ -180,20 +257,30 @@ async def send(self, message: str) -> None: LOGGER.info(f"Using authentication headers from gateway: {list(self._auth_headers.keys())}") headers.update(self._auth_headers) - # IMPORTANT: Only add session headers AFTER we have received them from the server - # The first request (initialize) should NOT include session headers - let the server create the session - # Subsequent requests must include BOTH session ID and protocol version - if self._session_id and self._protocol_version: + if self._sessionless: + # 2026-07-28 (sessionless): no session headers. Mint the headers the + # spec requires on every POST: MCP-Protocol-Version (matching the + # body _meta), Mcp-Method, and Mcp-Name for named operations. + if self._protocol_version: + headers["MCP-Protocol-Version"] = self._protocol_version + method = msg_data.get("method") + if isinstance(method, str) and method: + headers["Mcp-Method"] = method + name_param = NAMED_METHOD_PARAMS.get(method) + params = msg_data.get("params") + name = params.get(name_param) if name_param and isinstance(params, dict) else None + if isinstance(name, str) and name: + headers["Mcp-Name"] = encode_header_value(name) + elif self._session_id and self._protocol_version: + # IMPORTANT: Only add session headers AFTER we have received them from the server + # The first request (initialize) should NOT include session headers - let the server create the session + # Subsequent requests must include BOTH session ID and protocol version headers["mcp-session-id"] = self._session_id headers["mcp-protocol-version"] = self._protocol_version LOGGER.debug(f"Including session headers: session_id={self._session_id}, protocol={self._protocol_version}") else: # No session - check if this is an initialize request - try: - msg_data = json.loads(message) - is_initialize = msg_data.get("method") == "initialize" - except Exception: - is_initialize = False + is_initialize = msg_data.get("method") == "initialize" if not is_initialize: # Non-initialize request without a session - this should not happen @@ -209,11 +296,21 @@ async def send(self, message: str) -> None: content=message, headers=headers, ) - response.raise_for_status() + if self._sessionless: + # Modern era: error statuses (404 method not found, 400 + # unsupported protocol version) carry JSON-RPC error bodies the + # gateway must receive - relay them instead of raising. Only a + # bodyless error is a transport failure. Session-expiry + # recovery (SessionExpiredError) is a handshake-era concept and + # must never fire here. + if response.status_code >= 400 and not response.content: + raise RuntimeError(f"MCP server returned HTTP {response.status_code} with no error body") + else: + response.raise_for_status() # Extract session ID from response headers (first request) session_id = response.headers.get("mcp-session-id") - if session_id and not self._session_id: + if session_id and not self._session_id and not self._sessionless: self._session_id = session_id LOGGER.info(f"Received session ID: {self._session_id}") LOGGER.info( diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 7fdfe01..8d24105 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -13,6 +13,7 @@ import asyncio import sys from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from pathlib import Path # Third-Party @@ -30,6 +31,33 @@ SERVER_STARTUP_TIMEOUT = 10.0 +@asynccontextmanager +async def _running_companion_http(kind: str) -> AsyncIterator[str]: + """Run the companion server over an HTTP transport; yield its base URL.""" + # Third-Party (integration extra, imported lazily so unit-only installs can collect this file) + import uvicorn + + from tests.integration.companion_server import create_app + + config = uvicorn.Config(create_app(kind), host="127.0.0.1", port=0, log_level="warning") + server = uvicorn.Server(config) + serve_task = asyncio.create_task(server.serve()) + + deadline = asyncio.get_running_loop().time() + SERVER_STARTUP_TIMEOUT + while not server.started: + if asyncio.get_running_loop().time() > deadline: + serve_task.cancel() + pytest.fail(f"companion server ({kind}) failed to start within {SERVER_STARTUP_TIMEOUT}s") + await asyncio.sleep(0.05) + + port = server.servers[0].sockets[0].getsockname()[1] + try: + yield f"http://127.0.0.1:{port}" + finally: + server.should_exit = True + await serve_task + + @pytest.fixture def companion_stdio_command() -> str: """Command line that launches the companion server over stdio.""" @@ -40,40 +68,35 @@ def companion_stdio_command() -> str: async def mcp_transport(request: pytest.FixtureRequest, companion_stdio_command: str) -> AsyncIterator[McpServerTransport]: """Start each MCP-server-side transport against the live companion server.""" kind: str = request.param - server = None - serve_task = None if kind == "stdio": adapter: McpServerTransport = StdioAdapter(companion_stdio_command) - else: - # Third-Party (integration extra, imported lazily so unit-only installs can collect this file) - import uvicorn - - from tests.integration.companion_server import create_app - - config = uvicorn.Config(create_app(kind), host="127.0.0.1", port=0, log_level="warning") - server = uvicorn.Server(config) - serve_task = asyncio.create_task(server.serve()) - - deadline = asyncio.get_running_loop().time() + SERVER_STARTUP_TIMEOUT - while not server.started: - if asyncio.get_running_loop().time() > deadline: - serve_task.cancel() - pytest.fail(f"companion server ({kind}) failed to start within {SERVER_STARTUP_TIMEOUT}s") - await asyncio.sleep(0.05) - - port = server.servers[0].sockets[0].getsockname()[1] - base_url = f"http://127.0.0.1:{port}" + await adapter.start() + try: + yield adapter + finally: + await adapter.stop() + return + + async with _running_companion_http(kind) as base_url: adapter = SseAdapter(f"{base_url}/sse") if kind == "sse" else StreamableHttpAdapter(f"{base_url}/mcp") + await adapter.start() + try: + yield adapter + finally: + await adapter.stop() - await adapter.start() - try: - yield adapter - finally: - await adapter.stop() - if server is not None and serve_task is not None: - server.should_exit = True - await serve_task + +@pytest.fixture +async def streamable_http_transport() -> AsyncIterator[StreamableHttpAdapter]: + """Companion server over Streamable HTTP only (for protocol-era tests).""" + async with _running_companion_http("streamable-http") as base_url: + adapter = StreamableHttpAdapter(f"{base_url}/mcp") + await adapter.start() + try: + yield adapter + finally: + await adapter.stop() @pytest.fixture diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index c02acba..71a5e77 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -14,11 +14,28 @@ DEFAULT_TIMEOUT = 15.0 -INITIALIZE_PARAMS: dict[str, Any] = { - "protocolVersion": "2025-06-18", - "capabilities": {}, - "clientInfo": {"name": "mcp-reverse-proxy-integration-tests", "version": "0.1.0"}, -} +# Handshake-era protocol versions exercised against the companion server. +# FastMCP 4 negotiates the era per connection, so every version a gateway +# may legitimately offer must round-trip through the proxy unchanged. +HANDSHAKE_PROTOCOL_VERSIONS = ("2025-06-18", "2025-11-25") + +# Modern (sessionless) era: no initialize handshake; capability discovery via +# server/discover and the protocol version carried in each request's _meta. +MODERN_PROTOCOL_VERSION = "2026-07-28" +MODERN_PROTOCOL_META_KEY = "io.modelcontextprotocol/protocolVersion" + + +def initialize_params(protocol_version: str) -> dict[str, Any]: + """Build initialize params for a handshake-era protocol version.""" + return { + "protocolVersion": protocol_version, + "capabilities": {}, + "clientInfo": {"name": "mcp-reverse-proxy-integration-tests", "version": "0.1.0"}, + } + + +# Default initialize params, pinned to the newest handshake era. +INITIALIZE_PARAMS: dict[str, Any] = initialize_params(HANDSHAKE_PROTOCOL_VERSIONS[-1]) INITIALIZED_NOTIFICATION = json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) diff --git a/tests/integration/test_adapters_integration.py b/tests/integration/test_adapters_integration.py index 50340ea..638c435 100644 --- a/tests/integration/test_adapters_integration.py +++ b/tests/integration/test_adapters_integration.py @@ -15,9 +15,10 @@ # First-Party from mcp_reverse_proxy.base import McpServerTransport from tests.integration.helpers import ( - INITIALIZE_PARAMS, + HANDSHAKE_PROTOCOL_VERSIONS, INITIALIZED_NOTIFICATION, MessageCollector, + initialize_params, rpc_request, wait_until_ready, ) @@ -26,12 +27,15 @@ pytestmark = pytest.mark.integration -async def test_full_mcp_session_round_trip(mcp_transport: McpServerTransport, collector: MessageCollector) -> None: +@pytest.mark.parametrize("protocol_version", HANDSHAKE_PROTOCOL_VERSIONS) +async def test_full_mcp_session_round_trip( + mcp_transport: McpServerTransport, collector: MessageCollector, protocol_version: str +) -> None: """Drive a complete MCP session over the parametrized transport.""" await wait_until_ready(mcp_transport) # initialize - await mcp_transport.send(rpc_request(1, "initialize", INITIALIZE_PARAMS)) + await mcp_transport.send(rpc_request(1, "initialize", initialize_params(protocol_version))) response = await collector.response_for(1) assert response["result"]["serverInfo"]["name"] == "reverse-proxy-companion" diff --git a/tests/integration/test_modern_era.py b/tests/integration/test_modern_era.py new file mode 100644 index 0000000..820b443 --- /dev/null +++ b/tests/integration/test_modern_era.py @@ -0,0 +1,106 @@ +"""Integration tests: sessionless 2026-07-28 (modern era) MCP traffic. + +Drives the Streamable HTTP adapter against the live FastMCP 4 companion +server without an initialize handshake: ``server/discover`` for capability +discovery, then ordinary method calls carrying the protocol version in +``params._meta`` (the modern-era convention). Also verifies that a +modern-era 404 (unknown method) is relayed to the gateway as a JSON-RPC +error response rather than triggering the handshake-era session-expiry +recovery. +""" + +# Future +from __future__ import annotations + +# Standard +from typing import Any + +# Third-Party +import pytest + +# First-Party +from mcp_reverse_proxy.transports.streamablehttp_adapter import StreamableHttpAdapter +from tests.integration.helpers import ( + MODERN_PROTOCOL_META_KEY, + MODERN_PROTOCOL_VERSION, + MessageCollector, + rpc_request, +) + +pytest.importorskip("fastmcp", reason="requires the 'integration' extra (pip install -e '.[integration]')") +pytestmark = pytest.mark.integration + + +def modern_request(request_id: int, method: str, params: dict[str, Any] | None = None) -> str: + """Serialize a modern-era JSON-RPC request carrying the protocol version in params._meta.""" + params = dict(params or {}) + params["_meta"] = { + MODERN_PROTOCOL_META_KEY: MODERN_PROTOCOL_VERSION, + "io.modelcontextprotocol/clientInfo": {"name": "mcp-reverse-proxy-integration-tests", "version": "0.1.0"}, + "io.modelcontextprotocol/clientCapabilities": {}, + } + return rpc_request(request_id, method, params) + + +@pytest.fixture +def modern_collector(streamable_http_transport: StreamableHttpAdapter) -> MessageCollector: + """Attach a MessageCollector to the sessionless transport.""" + message_collector = MessageCollector() + streamable_http_transport.add_message_handler(message_collector) + return message_collector + + +async def test_modern_era_sessionless_round_trip( + streamable_http_transport: StreamableHttpAdapter, modern_collector: MessageCollector +) -> None: + """Drive a full sessionless MCP exchange: discover, then calls, with no initialize.""" + adapter = streamable_http_transport + + # server/discover: capability discovery replaces the initialize handshake + await adapter.send(modern_request(1, "server/discover")) + response = await modern_collector.response_for(1) + result = response["result"] + assert MODERN_PROTOCOL_VERSION in result["supportedVersions"] + assert result["_meta"]["io.modelcontextprotocol/serverInfo"]["name"] == "reverse-proxy-companion" + assert "tools" in result["capabilities"] + + # The modern era must not mint a session + assert adapter._sessionless is True + assert adapter._session_id is None + + # tools/call + await adapter.send(modern_request(2, "tools/call", {"name": "echo", "arguments": {"message": "hello-modern"}})) + response = await modern_collector.response_for(2) + assert any("hello-modern" in item.get("text", "") for item in response["result"]["content"]) + + # tools/list + await adapter.send(modern_request(3, "tools/list")) + response = await modern_collector.response_for(3) + tool_names = {tool["name"] for tool in response["result"]["tools"]} + assert {"echo", "add"} <= tool_names + + # resources/read exercises the Mcp-Name: header path + await adapter.send(modern_request(4, "resources/read", {"uri": "companion://info"})) + response = await modern_collector.response_for(4) + assert any("reverse-proxy-companion" in item.get("text", "") for item in response["result"]["contents"]) + + # prompts/get exercises the Mcp-Name: header path + await adapter.send(modern_request(5, "prompts/get", {"name": "greet", "arguments": {"name": "proxy"}})) + response = await modern_collector.response_for(5) + assert "proxy" in str(response["result"]) + + +async def test_modern_era_unknown_method_relays_jsonrpc_error( + streamable_http_transport: StreamableHttpAdapter, modern_collector: MessageCollector +) -> None: + """A modern-era unknown method returns 404 + -32601, relayed without session-expiry recovery.""" + adapter = streamable_http_transport + + await adapter.send(modern_request(1, "bogus/method")) + response = await modern_collector.response_for(1) + + assert response["error"]["code"] == -32601 + # The failure must not be mistaken for handshake-era session expiry: + # sessionless state is intact and no re-registration was triggered. + assert adapter._sessionless is True + assert adapter._protocol_version == MODERN_PROTOCOL_VERSION diff --git a/tests/test_mcp_reverse_proxy_streamablehttp_adapter.py b/tests/test_mcp_reverse_proxy_streamablehttp_adapter.py index 7074612..d27724a 100644 --- a/tests/test_mcp_reverse_proxy_streamablehttp_adapter.py +++ b/tests/test_mcp_reverse_proxy_streamablehttp_adapter.py @@ -11,6 +11,7 @@ # Standard import asyncio +import json import ssl from unittest.mock import AsyncMock, Mock, call @@ -19,7 +20,12 @@ import pytest # First-Party -from mcp_reverse_proxy.transports.streamablehttp_adapter import SessionExpiredError, StreamableHttpAdapter +from mcp_reverse_proxy.transports.streamablehttp_adapter import ( + SessionExpiredError, + StreamableHttpAdapter, + encode_header_value, + extract_modern_protocol_version, +) @pytest.mark.asyncio @@ -107,6 +113,7 @@ async def test_stop_cancels_task_closes_client_and_clears_session_state() -> Non adapter._session_id = "session-1" adapter._message_endpoint = "http://server.example/mcp" adapter._protocol_version = "2025-11-05" + adapter._sessionless = True adapter._receive_task = asyncio.create_task(asyncio.sleep(10)) adapter._client = AsyncMock() @@ -116,6 +123,7 @@ async def test_stop_cancels_task_closes_client_and_clears_session_state() -> Non assert adapter._session_id is None assert adapter._message_endpoint is None assert adapter._protocol_version is None + assert adapter._sessionless is False assert adapter._client is None @@ -402,3 +410,154 @@ async def fake_sleep(_delay: float) -> None: await adapter._receive_stream() assert call_count == 1 + + +def _modern_request(method: str, params: dict | None = None, request_id: int = 1) -> str: + """Build a modern-era (2026-07-28) JSON-RPC request carrying the version in params._meta.""" + params = dict(params or {}) + params["_meta"] = {"io.modelcontextprotocol/protocolVersion": "2026-07-28"} + return json.dumps({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params}) + + +def _mock_response(status_code: int = 200, body: str = '{"jsonrpc":"2.0","id":1,"result":{}}', headers: dict | None = None) -> Mock: + """Build a mock httpx response for adapter send tests.""" + response = Mock() + response.headers = headers or {} + response.status_code = status_code + response.content = body.encode() + response.text = body + response.raise_for_status = Mock() + return response + + +def test_extract_modern_protocol_version_reads_params_meta_and_fallbacks() -> None: + """The version lives in params._meta per spec; top-level _meta is a tolerated fallback.""" + assert extract_modern_protocol_version({"params": {"_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28"}}}) == "2026-07-28" + assert extract_modern_protocol_version({"_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28"}}) == "2026-07-28" + assert extract_modern_protocol_version({"params": {"name": "echo"}}) is None + assert extract_modern_protocol_version({"params": None}) is None + assert extract_modern_protocol_version({}) is None + + +def test_encode_header_value_passes_safe_values_and_encodes_unsafe() -> None: + """Header-safe ASCII passes through; everything else takes the =?base64?...?= sentinel.""" + assert encode_header_value("get_weather") == "get_weather" + assert encode_header_value("companion://info") == "companion://info" + + encoded_unicode = encode_header_value("Hello, δΈ–η•Œ") + assert encoded_unicode == "=?base64?SGVsbG8sIOS4lueVjA==?=" + + # Leading/trailing whitespace and sentinel-pattern values must be encoded + assert encode_header_value(" padded ") == "=?base64?IHBhZGRlZCA=?=" + assert encode_header_value("=?base64?literal?=") == "=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?=" + + # Control characters are not header-safe + assert encode_header_value("line1\nline2") == "=?base64?bGluZTEKbGluZTI=?=" + + +@pytest.mark.asyncio +async def test_send_modern_era_mints_version_and_routing_headers() -> None: + """Sessionless send must mint MCP-Protocol-Version, Mcp-Method, and Mcp-Name per request.""" + adapter = StreamableHttpAdapter("http://server.example/mcp") + adapter._connected = True + adapter._client = AsyncMock() + handler = AsyncMock() + adapter.add_message_handler(handler) + adapter._client.post = AsyncMock(return_value=_mock_response()) + + await adapter.send(_modern_request("tools/call", {"name": "echo", "arguments": {"message": "hi"}})) + + assert adapter._sessionless is True + assert adapter._protocol_version == "2026-07-28" + headers = adapter._client.post.await_args.kwargs["headers"] + assert headers["MCP-Protocol-Version"] == "2026-07-28" + assert headers["Mcp-Method"] == "tools/call" + assert headers["Mcp-Name"] == "echo" + assert "mcp-session-id" not in headers + assert "mcp-protocol-version" not in headers + handler.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_send_server_discover_without_meta_enters_sessionless_mode() -> None: + """server/discover opens the modern era even without _meta; unknown version means no version header.""" + adapter = StreamableHttpAdapter("http://server.example/mcp") + adapter._connected = True + adapter._client = AsyncMock() + adapter._client.post = AsyncMock(return_value=_mock_response()) + + await adapter.send('{"jsonrpc":"2.0","id":1,"method":"server/discover"}') + + assert adapter._sessionless is True + headers = adapter._client.post.await_args.kwargs["headers"] + assert headers["Mcp-Method"] == "server/discover" + assert "MCP-Protocol-Version" not in headers + + +@pytest.mark.asyncio +async def test_send_sessionless_relays_404_jsonrpc_error_without_session_expiry() -> None: + """Modern-era 404 (method not found) is relayed as a response, never as SessionExpiredError.""" + adapter = StreamableHttpAdapter("http://server.example/mcp") + adapter._connected = True + adapter._client = AsyncMock() + handler = AsyncMock() + adapter.add_message_handler(handler) + + error_body = '{"jsonrpc":"2.0","id":9,"error":{"code":-32601,"message":"Method not found"}}' + adapter._client.post = AsyncMock(return_value=_mock_response(status_code=404, body=error_body)) + + await adapter.send(_modern_request("bogus/method", request_id=9)) + + handler.assert_awaited_once_with(error_body) + assert adapter._sessionless is True + assert adapter._protocol_version == "2026-07-28" + + +@pytest.mark.asyncio +async def test_send_sessionless_raises_on_bodyless_error_response() -> None: + """A modern-era error status with no body is a transport failure, not a relayable response.""" + adapter = StreamableHttpAdapter("http://server.example/mcp") + adapter._connected = True + adapter._client = AsyncMock() + + response = _mock_response(status_code=500) + response.content = b"" + adapter._client.post = AsyncMock(return_value=response) + + with pytest.raises(RuntimeError, match="HTTP 500"): + await adapter.send(_modern_request("tools/list")) + + +@pytest.mark.asyncio +async def test_send_with_existing_session_ignores_meta_and_keeps_handshake_era() -> None: + """A _meta version on a message must not flip an established handshake session to sessionless.""" + adapter = StreamableHttpAdapter("http://server.example/mcp") + adapter._connected = True + adapter._client = AsyncMock() + adapter._session_id = "session-1" + adapter._protocol_version = "2025-11-25" + adapter._client.post = AsyncMock(return_value=_mock_response()) + + await adapter.send(_modern_request("tools/call", {"name": "echo"})) + + assert adapter._sessionless is False + headers = adapter._client.post.await_args.kwargs["headers"] + assert headers["mcp-session-id"] == "session-1" + assert headers["mcp-protocol-version"] == "2025-11-25" + assert "Mcp-Method" not in headers + + +@pytest.mark.asyncio +async def test_send_sessionless_ignores_server_minted_session_id() -> None: + """A session id on a modern-era response must not flip the adapter into handshake mode.""" + adapter = StreamableHttpAdapter("http://server.example/mcp") + adapter._connected = True + adapter._client = AsyncMock() + adapter._client.post = AsyncMock(return_value=_mock_response(headers={"mcp-session-id": "rogue-session"})) + + await adapter.send(_modern_request("tools/list")) + await adapter.send(_modern_request("tools/list", request_id=2)) + + assert adapter._session_id is None + for post_call in adapter._client.post.await_args_list: + assert "mcp-session-id" not in post_call.kwargs["headers"] diff --git a/uv.lock b/uv.lock index 0b0ea42..96aa407 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.15'", @@ -142,11 +142,29 @@ wheels = [ name = "beartype" version = "0.22.9" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "(python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version < '3.12' and sys_platform == 'emscripten')", +] sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, ] +[[package]] +name = "beartype" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", +] +sdist = { url = "https://files.pythonhosted.org/packages/79/34/c0cfdaee9c796c8381439fed0fcab62810753b8b0adb1ec88fa7f41eaa5d/beartype-0.23.1.tar.gz", hash = "sha256:8b805f246b32931c74f2b321ea0939fe5bb2ff43c12a3eff19cc4c854b832e1d", size = 1993714, upload-time = "2026-10-11T05:36:55.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/cb/2f6aa982e2860c1156830db3da7f823b71784ed7d533ca289ea9b6c6347a/beartype-0.23.1-py3-none-any.whl", hash = "sha256:4461b4dc57e3fdd6c8a8464b22cf118002eea107c33e9fef651c69068ab3cce3", size = 1582101, upload-time = "2026-10-11T05:36:52.431Z" }, +] + [[package]] name = "black" version = "26.5.1" @@ -588,21 +606,22 @@ wheels = [ [[package]] name = "fastmcp" -version = "3.4.7" +version = "4.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastmcp-slim", extra = ["client", "server"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/62/dd/fd444d94ae7afdaf5b6dd168799d34023f576b405872d6a27d5686a9d1f4/fastmcp-3.4.7.tar.gz", hash = "sha256:43117aca886f5ee2f6a569bba91cef02b59c339aad04ba29950ff18d251c822a", size = 28808982, upload-time = "2026-08-10T21:17:55.045Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/c4/fe4e24af2cb03f4701f45634873d87557c2b5d283d287412345345ad0ede/fastmcp-4.1.0.tar.gz", hash = "sha256:7a8bf4e58cc6c2f3a8552b5a7a17ba1e4682b6fd4cafb7f461920a66dbd2499f", size = 42686511, upload-time = "2026-10-08T22:57:05.652Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ac/14/6d950459cc831fa17fe2d1797926b6eb2d2f2af50f830e62d0c098cc1ec8/fastmcp-3.4.7-py3-none-any.whl", hash = "sha256:e4e7698cb4af5bc667b1901685261fa2f3526dc73d243a461fca42500c8dbe56", size = 8016, upload-time = "2026-08-10T21:17:51.391Z" }, + { url = "https://files.pythonhosted.org/packages/5c/81/77610de5d79187455ec4a4eafdc17942365f1bc4e5850633e50159e9afdc/fastmcp-4.1.0-py3-none-any.whl", hash = "sha256:cf19fd603315b48ef1829c8579431e77793feafdcbed51f0dc5607af10a24e9b", size = 8154, upload-time = "2026-10-08T22:57:02.531Z" }, ] [[package]] name = "fastmcp-slim" -version = "3.4.7" +version = "4.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "mcp-types" }, { name = "platformdirs" }, { name = "pydantic", extra = ["email"] }, { name = "pydantic-settings" }, @@ -610,16 +629,17 @@ dependencies = [ { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/12/ac/7924e803368d0758ee4d6b1259066550df78f58f0f9f8bfebd5a123e957d/fastmcp_slim-3.4.7.tar.gz", hash = "sha256:06b32a358320a7dc2b2ee040ba89ea55ddc20763dff2949f384f7974b13b5d8f", size = 594357, upload-time = "2026-08-10T21:17:28.723Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/88/85aafec43ec9940a360d75f2d8e9517eda01ac38b8b1610b0e9e125d61d3/fastmcp_slim-4.1.0.tar.gz", hash = "sha256:390b2b430195e0cdd0327aff80fe5dceb862b2421baf71de6d4eec92f80f99f8", size = 734647, upload-time = "2026-10-08T22:56:40.642Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/97/e0e53642cd029a9a7635ae9c548f9f2cc995af5914e487b3df795664e4be/fastmcp_slim-3.4.7-py3-none-any.whl", hash = "sha256:6c931a0089705f3f2935428ef9b2bc74ad94140adc64aab84d116d103e694b3a", size = 769370, upload-time = "2026-08-10T21:17:27.227Z" }, + { url = "https://files.pythonhosted.org/packages/54/6a/d61e1541f4c65872146a576c1379f6f32013fd8c636bcaf0f8e70a04e533/fastmcp_slim-4.1.0-py3-none-any.whl", hash = "sha256:6edb48a78e508ef5ac691bf2a68f3e5000403773655d859eceea1068b894df4a", size = 911585, upload-time = "2026-10-08T22:56:39.136Z" }, ] [package.optional-dependencies] client = [ { name = "authlib" }, + { name = "beartype", version = "0.23.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" }, { name = "exceptiongroup" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "mcp" }, { name = "opentelemetry-api" }, { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, @@ -627,10 +647,11 @@ client = [ ] server = [ { name = "authlib" }, + { name = "beartype", version = "0.23.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" }, { name = "cyclopts" }, { name = "exceptiongroup" }, { name = "griffelib" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "joserfc" }, { name = "jsonref" }, { name = "jsonschema-path" }, @@ -702,6 +723,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.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -723,12 +757,29 @@ http2 = [ ] [[package]] -name = "httpx-sse" -version = "0.4.3" +name = "httpx2" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, ] [[package]] @@ -1026,15 +1077,15 @@ wheels = [ [[package]] name = "mcp" -version = "1.30.0" +version = "2.3.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'" }, @@ -1044,9 +1095,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/93/0142dc84a666daf8ad51a34268f34c12fd6fda4f3810c4be2504eecc8212/mcp-1.30.0.tar.gz", hash = "sha256:445414625fce5c295faa505bb11bacece661ab6f4028d57c935db57820b7a3e4", size = 680511, upload-time = "2026-09-07T14:34:15.845Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/8d/e0d339616f4810e9051d4aba6887afab289ab1f81875fe908b606cdfd0e3/mcp-2.3.0.tar.gz", hash = "sha256:8b147a50441cf059dc88c684e0aeed3687f0aa0f39c6cde7b90330effd2b34d8", size = 4171955, upload-time = "2026-10-02T22:06:56.091Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/f4/e58bc33317c92a0203664daaf00bf6f41166cc0149e5d6870a03f7cd004a/mcp-1.30.0-py3-none-any.whl", hash = "sha256:666edb5009503e1047c9d60346a756f94b261f05cc2625f23d41c728ffc484d0", size = 234581, upload-time = "2026-09-07T14:34:14.266Z" }, + { url = "https://files.pythonhosted.org/packages/20/d7/4a2878f128df729db3ca04e71bfce26405cc2e0c9ebe19c592feeb4c61b9/mcp-2.3.0-py3-none-any.whl", hash = "sha256:dd0c44c089d16453e8ae31a3877a0054d7a2314caaa81f5e0541b9b1734b2377", size = 368664, upload-time = "2026-10-02T22:06:52.119Z" }, ] [[package]] @@ -1079,7 +1130,7 @@ integration = [ [package.metadata] requires-dist = [ { name = "black", marker = "extra == 'dev'", specifier = ">=25.1.0" }, - { name = "fastmcp", marker = "extra == 'integration'", specifier = ">=3.4.7,<4" }, + { name = "fastmcp", marker = "extra == 'integration'", specifier = ">=4,<5" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", extras = ["http2"], specifier = ">=0.28.1" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.14.1" }, @@ -1096,6 +1147,19 @@ requires-dist = [ ] provides-extras = ["dev", "integration"] +[[package]] +name = "mcp-types" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/2d/7c251e34207f6c51000312fc8839111ac45cfe02023f90b44e7f1051dd8e/mcp_types-2.3.0.tar.gz", hash = "sha256:d1e46549edb35ee19a94940fcee6d1addd7e589ab7ea92dda83f5d84781fc362", size = 65898, upload-time = "2026-10-02T22:06:57.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/48/32583066b155bb5d4507e7887dc88ee23118cc4de8e2a12870c49719e2a8/mcp_types-2.3.0-py3-none-any.whl", hash = "sha256:968efdbdaedfab06adae40d378a34395f1090c5921d4be3c9cde283aaf76d91d", size = 69108, upload-time = "2026-10-02T22:06:54.099Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -1320,7 +1384,8 @@ name = "py-key-value-aio" version = "0.4.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "beartype" }, + { name = "beartype", version = "0.22.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" }, + { name = "beartype", version = "0.23.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fb/e2/d689d922894a7ecde73b6daeaf9b13dab5aae06fe6aaaf7514722644d382/py_key_value_aio-0.4.5.tar.gz", hash = "sha256:c6563a2c6abe5da5e20f4f9e875c2a9b425a2244a54fadbf46cf140a9eea45d7", size = 107547, upload-time = "2026-05-27T16:37:08.107Z" } @@ -1988,6 +2053,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[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 = "types-pyyaml" version = "6.0.12.20260815"