From 576b041fd0fd789744cb326175878bd92fae084b Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Wed, 13 May 2026 19:59:23 +0530 Subject: [PATCH 01/20] feat: Add support for SEP-2575 --- .../toolbox-core/src/toolbox_core/client.py | 10 + .../toolbox_core/mcp_transport/__init__.py | 2 + .../mcp_transport/v20260618/mcp.py | 285 ++++++++++++++++++ .../toolbox-core/src/toolbox_core/protocol.py | 2 + .../toolbox-core/tests/conformance/client.py | 39 ++- .../tests/conformance/client_errors.log | 53 ++++ 6 files changed, 390 insertions(+), 1 deletion(-) create mode 100644 packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py create mode 100644 packages/toolbox-core/tests/conformance/client_errors.log diff --git a/packages/toolbox-core/src/toolbox_core/client.py b/packages/toolbox-core/src/toolbox_core/client.py index 60f02bc27..49a2c2af5 100644 --- a/packages/toolbox-core/src/toolbox_core/client.py +++ b/packages/toolbox-core/src/toolbox_core/client.py @@ -28,6 +28,7 @@ McpHttpTransportV20250326, McpHttpTransportV20250618, McpHttpTransportV20251125, + McpHttpTransportV20260618, ) from .protocol import Protocol, ToolSchema from .tool import ToolboxTool @@ -86,6 +87,15 @@ def __init__( ) match protocol: + case Protocol.MCP_v20260618: + self.__transport = McpHttpTransportV20260618( + url, + session, + protocol, + client_name, + client_version, + telemetry_enabled=telemetry_enabled, + ) case Protocol.MCP_v20251125: self.__transport = McpHttpTransportV20251125( url, diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/__init__.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/__init__.py index 95a93a79f..ca5d0217f 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/__init__.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/__init__.py @@ -16,10 +16,12 @@ from .v20250326.mcp import McpHttpTransportV20250326 from .v20250618.mcp import McpHttpTransportV20250618 from .v20251125.mcp import McpHttpTransportV20251125 +from .v20260618.mcp import McpHttpTransportV20260618 __all__ = [ "McpHttpTransportV20241105", "McpHttpTransportV20250326", "McpHttpTransportV20250618", "McpHttpTransportV20251125", + "McpHttpTransportV20260618", ] diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py new file mode 100644 index 000000000..53c69224f --- /dev/null +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py @@ -0,0 +1,285 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import time +from typing import Any, Literal, Mapping, Optional, Type, TypeVar +from pydantic import BaseModel + +from ... import version +from ...protocol import ManifestSchema +from .. import telemetry +from ..transport_base import _McpHttpTransportBase +from ..v20251125 import types +from ..v20251125.types import _BaseMCPModel + +ReceiveResultT = TypeVar("ReceiveResultT", bound=BaseModel) + + +class DiscoverResult(_BaseMCPModel): + supportedVersions: list[str] + capabilities: dict[str, Any] + serverInfo: types.Implementation + instructions: Optional[str] = None + + +class DiscoverRequest(types.MCPRequest[DiscoverResult]): + method: Literal["server/discover"] = "server/discover" + params: Optional[dict[str, Any]] = None + + def get_result_model(self) -> Type[DiscoverResult]: + return DiscoverResult + + +class McpHttpTransportV20260618(_McpHttpTransportBase): + """Stateless transport for the MCP v2026-06-18 protocol (SEP-2575).""" + + async def _send_request( + self, + url: str, + request: types.MCPRequest[ReceiveResultT] | types.MCPNotification, + headers: Optional[Mapping[str, str]] = None, + ) -> ReceiveResultT | None: + """Sends a JSON-RPC request to the MCP server, injecting stateless metadata.""" + req_headers = dict(headers or {}) + req_headers["MCP-Protocol-Version"] = self._protocol_version + + params = ( + request.params.model_dump(mode="json", exclude_none=True, by_alias=True) + if isinstance(request.params, BaseModel) + else request.params + ) + if params is None: + params = {} + + # Inject _meta for stateless protocol + meta = params.get("_meta", {}) + meta.update({ + "io.modelcontextprotocol/protocolVersion": self._protocol_version, + "io.modelcontextprotocol/clientInfo": { + "name": self._client_name or "toolbox-core-python", + "version": self._client_version or version.__version__ + }, + "io.modelcontextprotocol/clientCapabilities": { + "tools": {} + } + }) + params["_meta"] = meta + + rpc_msg: BaseModel + if isinstance(request, types.MCPNotification): + rpc_msg = types.JSONRPCNotification(method=request.method, params=params) + else: + rpc_msg = types.JSONRPCRequest(method=request.method, params=params) + + payload = rpc_msg.model_dump(mode="json", exclude_none=True) + + async with self._session.post( + url, json=payload, headers=req_headers + ) as response: + if not response.ok: + error_text = await response.text() + raise RuntimeError( + "API request failed with status" + f" {response.status} ({response.reason}). Server response:" + f" {error_text}" + ) + + if response.status == 204 or response.content.at_eof(): + return None + + json_resp = await response.json() + + # Check for JSON-RPC Error + if "error" in json_resp: + try: + err = types.JSONRPCError.model_validate(json_resp).error + raise RuntimeError( + f"MCP request failed with code {err.code}: {err.message}" + ) + except Exception: + # Fallback if the error doesn't match our schema exactly + raw_error = json_resp.get("error", {}) + raise RuntimeError(f"MCP request failed: {raw_error}") + + # Parse Result + if isinstance(request, types.MCPRequest): + try: + rpc_resp = types.JSONRPCResponse.model_validate(json_resp) + return request.get_result_model().model_validate(rpc_resp.result) + except Exception as e: + raise RuntimeError(f"Failed to parse JSON-RPC response: {e}") + return None + + async def _initialize_session( + self, headers: Optional[Mapping[str, str]] = None + ) -> None: + """Stateless initialization fetches server version and capabilities via server/discover.""" + try: + result = await self._send_request( + url=self._mcp_base_url, + request=DiscoverRequest(), + headers=headers, + ) + if result is not None: + self._server_version = result.serverInfo.version + except Exception: + # Fallback to mock version if server/discover fails + self._server_version = "1.0.0" + + async def tools_list( + self, + toolset_name: Optional[str] = None, + headers: Optional[Mapping[str, str]] = None, + ) -> ManifestSchema: + """Lists available tools from the server using the MCP protocol.""" + await self._ensure_initialized(headers=headers) + + url = self._mcp_base_url + (toolset_name if toolset_name else "") + + meta: Optional[types.MCPMeta] = None + + if self._telemetry_enabled: + operation_start = time.time() + span, traceparent, tracestate = telemetry.start_span( + self._tracer, + "tools/list", + self._protocol_version, + url, + network_transport="tcp", + ) + if span is not None: + meta = types.MCPMeta( + traceparent=traceparent or None, + tracestate=tracestate or None, + ) + + error: Optional[Exception] = None + try: + result = await self._send_request( + url=url, + request=types.ListToolsRequest( + params=types.ListToolsRequestParams(field_meta=meta) + ), + headers=headers, + ) + if result is None: + raise RuntimeError("Failed to list tools: No response from server.") + + tools_map = { + t.name: self._convert_tool_schema( + t.model_dump(mode="json", by_alias=True) + ) + for t in result.tools + } + if self._server_version is None: + raise RuntimeError("Server version not available.") + + return ManifestSchema( + serverVersion=self._server_version, + tools=tools_map, + ) + except Exception as e: + error = e + raise + finally: + if self._telemetry_enabled: + # Record operation duration metric + operation_duration = time.time() - operation_start + telemetry.record_operation_duration( + self._operation_duration_histogram, + operation_duration, + "tools/list", + self._protocol_version, + url, + network_transport="tcp", + error=error, + ) + # End span + telemetry.end_span(span, error=error) + + async def tool_get( + self, tool_name: str, headers: Optional[Mapping[str, str]] = None + ) -> ManifestSchema: + """Gets a single tool from the server by listing all and filtering.""" + manifest = await self.tools_list(headers=headers) + + if tool_name not in manifest.tools: + raise ValueError(f"Tool '{tool_name}' not found.") + + return ManifestSchema( + serverVersion=manifest.serverVersion, + tools={tool_name: manifest.tools[tool_name]}, + ) + + async def tool_invoke( + self, tool_name: str, arguments: dict, headers: Optional[Mapping[str, str]] = None + ) -> str: + """Invokes a specific tool on the server using the MCP protocol.""" + await self._ensure_initialized(headers=headers) + + meta: Optional[types.MCPMeta] = None + + if self._telemetry_enabled: + operation_start = time.time() + span, traceparent, tracestate = telemetry.start_span( + self._tracer, + "tools/call", + self._protocol_version, + self._mcp_base_url, + tool_name=tool_name, + network_transport="tcp", + ) + if span is not None: + meta = types.MCPMeta( + traceparent=traceparent or None, + tracestate=tracestate or None, + ) + + error: Optional[Exception] = None + try: + result = await self._send_request( + url=self._mcp_base_url, + request=types.CallToolRequest( + params=types.CallToolRequestParams( + name=tool_name, arguments=arguments, field_meta=meta + ) + ), + headers=headers, + ) + + if result is None: + raise RuntimeError( + f"Failed to invoke tool '{tool_name}': No response from server." + ) + + return self._process_tool_result_content(result.content) + except Exception as e: + error = e + raise + finally: + if self._telemetry_enabled: + # Record operation duration metric + operation_duration = time.time() - operation_start + telemetry.record_operation_duration( + self._operation_duration_histogram, + operation_duration, + "tools/call", + self._protocol_version, + self._mcp_base_url, + tool_name=tool_name, + network_transport="tcp", + error=error, + ) + # End span + telemetry.end_span(span, error=error) diff --git a/packages/toolbox-core/src/toolbox_core/protocol.py b/packages/toolbox-core/src/toolbox_core/protocol.py index 378f83fe8..a752fd3bd 100644 --- a/packages/toolbox-core/src/toolbox_core/protocol.py +++ b/packages/toolbox-core/src/toolbox_core/protocol.py @@ -51,6 +51,7 @@ class Protocol(str, Enum): MCP_v20250326 = "2025-03-26" MCP_v20241105 = "2024-11-05" MCP_v20251125 = "2025-11-25" + MCP_v20260618 = "DRAFT-2026-v1" MCP = MCP_v20250618 MCP_LATEST = MCP_v20251125 @@ -58,6 +59,7 @@ class Protocol(str, Enum): def get_supported_mcp_versions() -> list[str]: """Returns a list of supported MCP protocol versions.""" return [ + Protocol.MCP_v20260618.value, Protocol.MCP_v20251125.value, Protocol.MCP_v20250618.value, Protocol.MCP_v20250326.value, diff --git a/packages/toolbox-core/tests/conformance/client.py b/packages/toolbox-core/tests/conformance/client.py index 9ab58812c..26ebffd68 100644 --- a/packages/toolbox-core/tests/conformance/client.py +++ b/packages/toolbox-core/tests/conformance/client.py @@ -18,6 +18,7 @@ import sys from toolbox_core.client import ToolboxClient +from toolbox_core.protocol import Protocol async def main(): @@ -34,14 +35,22 @@ async def main(): scenario = os.environ.get("MCP_CONFORMANCE_SCENARIO", "") context_json = os.environ.get("MCP_CONFORMANCE_CONTEXT", "{}") context = json.loads(context_json) + protocol_version_str = os.environ.get("MCP_CONFORMANCE_PROTOCOL_VERSION", "") print(f"Running scenario: {scenario}", file=sys.stderr) print(f"Server URL: {server_url}", file=sys.stderr) print(f"Context: {context_json}", file=sys.stderr) + print(f"Protocol Version: {protocol_version_str}", file=sys.stderr) + + protocol = Protocol.MCP_LATEST + if protocol_version_str == "DRAFT-2026-v1": + protocol = Protocol.MCP_v20260618 client_headers = {"Accept": "application/json, text/event-stream"} - async with ToolboxClient(server_url, client_headers=client_headers) as client: + async with ToolboxClient( + server_url, protocol=protocol, client_headers=client_headers + ) as client: if scenario == "initialize": await client.load_toolset() print("Client initialization test completed", file=sys.stderr) @@ -51,6 +60,32 @@ async def main(): await add_numbers(a=1, b=2) print("Invoked add_numbers(a=1, b=2)", file=sys.stderr) + elif scenario == "stateless": + # 1. Load toolset (triggers server/discover and tools/list internally) + await client.load_toolset() + print("Stateless load_toolset completed", file=sys.stderr) + + # 2. Trigger consistent version check by loading a single tool + try: + await client.load_tool("test_tool") + print("Stateless load_tool completed", file=sys.stderr) + except Exception: + # It is fine if the tool doesn't exist (we just want to trigger the call) + pass + + # 3. Trigger cancellation (HTTP abort) by calling a long-running task and cancelling + try: + long_running_tool = await client.load_tool("long_running_task") + # Cancel it using asyncio.wait_for timeout + await asyncio.wait_for(long_running_tool(), timeout=0.05) + except asyncio.TimeoutError: + print( + "Aborted long running task as expected via timeout", + file=sys.stderr, + ) + except Exception as e: + print(f"Long running task threw exception: {e}", file=sys.stderr) + else: # Default behavior: load default toolset to trigger standard interactions await client.load_toolset() @@ -69,3 +104,5 @@ async def main(): ) traceback.print_exc(file=sys.stderr) sys.exit(1) + + diff --git a/packages/toolbox-core/tests/conformance/client_errors.log b/packages/toolbox-core/tests/conformance/client_errors.log new file mode 100644 index 000000000..e28160f4e --- /dev/null +++ b/packages/toolbox-core/tests/conformance/client_errors.log @@ -0,0 +1,53 @@ + +=== ERROR FOR SCENARIO: stateless === +Traceback (most recent call last): + File "/Users/anubhavdhawan/Documents/mcp-toolbox-sdk-python/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py", line 119, in _send_request + return request.get_result_model().model_validate(rpc_resp.result) + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^ + File "/Users/anubhavdhawan/Documents/mcp-toolbox-sdk-python/.venv/lib/python3.13/site-packages/pydantic/main.py", line 732, in model_validate + return cls.__pydantic_validator__.validate_python( + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^ + obj, + ^^^^ + ...<5 lines>... + by_name=by_name, + ^^^^^^^^^^^^^^^^ + ) + ^ +pydantic_core._pydantic_core.ValidationError: 1 validation error for ListToolsResult +tools + Field required [type=missing, input_value={}, input_type=dict] + For further information visit https://errors.pydantic.dev/2.13/v/missing + +During handling of the above exception, another exception occurred: + +Traceback (most recent call last): + File "/Users/anubhavdhawan/Documents/mcp-toolbox-sdk-python/packages/toolbox-core/tests/conformance/client.py", line 100, in + asyncio.run(main()) + ~~~~~~~~~~~^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.13/lib/python3.13/asyncio/runners.py", line 195, in run + return runner.run(main) + ~~~~~~~~~~^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.13/lib/python3.13/asyncio/runners.py", line 118, in run + return self._loop.run_until_complete(task) + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.13/lib/python3.13/asyncio/base_events.py", line 725, in run_until_complete + return future.result() + ~~~~~~~~~~~~~^^ + File "/Users/anubhavdhawan/Documents/mcp-toolbox-sdk-python/packages/toolbox-core/tests/conformance/client.py", line 65, in main + await client.load_toolset() + File "/Users/anubhavdhawan/Documents/mcp-toolbox-sdk-python/packages/toolbox-core/src/toolbox_core/client.py", line 336, in load_toolset + manifest = await self.__transport.tools_list(name, resolved_headers) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/anubhavdhawan/Documents/mcp-toolbox-sdk-python/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py", line 169, in tools_list + result = await self._send_request( + ^^^^^^^^^^^^^^^^^^^^^^^^^ + ...<5 lines>... + ) + ^ + File "/Users/anubhavdhawan/Documents/mcp-toolbox-sdk-python/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py", line 121, in _send_request + raise RuntimeError(f"Failed to parse JSON-RPC response: {e}") +RuntimeError: Failed to parse JSON-RPC response: 1 validation error for ListToolsResult +tools + Field required [type=missing, input_value={}, input_type=dict] + For further information visit https://errors.pydantic.dev/2.13/v/missing From 64ea9c46c31c93ef12aefb081fb140d7873e9145 Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Wed, 13 May 2026 22:16:29 +0530 Subject: [PATCH 02/20] chore: remove error logs --- .../tests/conformance/client_errors.log | 53 ------------------- 1 file changed, 53 deletions(-) delete mode 100644 packages/toolbox-core/tests/conformance/client_errors.log diff --git a/packages/toolbox-core/tests/conformance/client_errors.log b/packages/toolbox-core/tests/conformance/client_errors.log deleted file mode 100644 index e28160f4e..000000000 --- a/packages/toolbox-core/tests/conformance/client_errors.log +++ /dev/null @@ -1,53 +0,0 @@ - -=== ERROR FOR SCENARIO: stateless === -Traceback (most recent call last): - File "/Users/anubhavdhawan/Documents/mcp-toolbox-sdk-python/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py", line 119, in _send_request - return request.get_result_model().model_validate(rpc_resp.result) - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^ - File "/Users/anubhavdhawan/Documents/mcp-toolbox-sdk-python/.venv/lib/python3.13/site-packages/pydantic/main.py", line 732, in model_validate - return cls.__pydantic_validator__.validate_python( - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^ - obj, - ^^^^ - ...<5 lines>... - by_name=by_name, - ^^^^^^^^^^^^^^^^ - ) - ^ -pydantic_core._pydantic_core.ValidationError: 1 validation error for ListToolsResult -tools - Field required [type=missing, input_value={}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/missing - -During handling of the above exception, another exception occurred: - -Traceback (most recent call last): - File "/Users/anubhavdhawan/Documents/mcp-toolbox-sdk-python/packages/toolbox-core/tests/conformance/client.py", line 100, in - asyncio.run(main()) - ~~~~~~~~~~~^^^^^^^^ - File "/Library/Frameworks/Python.framework/Versions/3.13/lib/python3.13/asyncio/runners.py", line 195, in run - return runner.run(main) - ~~~~~~~~~~^^^^^^ - File "/Library/Frameworks/Python.framework/Versions/3.13/lib/python3.13/asyncio/runners.py", line 118, in run - return self._loop.run_until_complete(task) - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^ - File "/Library/Frameworks/Python.framework/Versions/3.13/lib/python3.13/asyncio/base_events.py", line 725, in run_until_complete - return future.result() - ~~~~~~~~~~~~~^^ - File "/Users/anubhavdhawan/Documents/mcp-toolbox-sdk-python/packages/toolbox-core/tests/conformance/client.py", line 65, in main - await client.load_toolset() - File "/Users/anubhavdhawan/Documents/mcp-toolbox-sdk-python/packages/toolbox-core/src/toolbox_core/client.py", line 336, in load_toolset - manifest = await self.__transport.tools_list(name, resolved_headers) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/Users/anubhavdhawan/Documents/mcp-toolbox-sdk-python/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py", line 169, in tools_list - result = await self._send_request( - ^^^^^^^^^^^^^^^^^^^^^^^^^ - ...<5 lines>... - ) - ^ - File "/Users/anubhavdhawan/Documents/mcp-toolbox-sdk-python/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py", line 121, in _send_request - raise RuntimeError(f"Failed to parse JSON-RPC response: {e}") -RuntimeError: Failed to parse JSON-RPC response: 1 validation error for ListToolsResult -tools - Field required [type=missing, input_value={}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/missing From e6864d6ad7106fa6d18b4de84e574d77b984c1d3 Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Wed, 13 May 2026 22:16:48 +0530 Subject: [PATCH 03/20] chore: fix presubmits --- packages/toolbox-core/src/toolbox_core/protocol.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/toolbox-core/src/toolbox_core/protocol.py b/packages/toolbox-core/src/toolbox_core/protocol.py index a752fd3bd..3f7b200b8 100644 --- a/packages/toolbox-core/src/toolbox_core/protocol.py +++ b/packages/toolbox-core/src/toolbox_core/protocol.py @@ -59,7 +59,7 @@ class Protocol(str, Enum): def get_supported_mcp_versions() -> list[str]: """Returns a list of supported MCP protocol versions.""" return [ - Protocol.MCP_v20260618.value, + # Protocol.MCP_v20260618.value, Protocol.MCP_v20251125.value, Protocol.MCP_v20250618.value, Protocol.MCP_v20250326.value, From eca73773e2ab382f2e21e101a54805f7369e4c2f Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Wed, 13 May 2026 22:18:07 +0530 Subject: [PATCH 04/20] chore: add todo --- packages/toolbox-core/src/toolbox_core/protocol.py | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/toolbox-core/src/toolbox_core/protocol.py b/packages/toolbox-core/src/toolbox_core/protocol.py index 3f7b200b8..fa8f3ab8d 100644 --- a/packages/toolbox-core/src/toolbox_core/protocol.py +++ b/packages/toolbox-core/src/toolbox_core/protocol.py @@ -59,6 +59,7 @@ class Protocol(str, Enum): def get_supported_mcp_versions() -> list[str]: """Returns a list of supported MCP protocol versions.""" return [ + # TODO: Uncomment # Protocol.MCP_v20260618.value, Protocol.MCP_v20251125.value, Protocol.MCP_v20250618.value, From 72d97c99a2446e0d5e6097c5371d82c1451d311d Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Sat, 16 May 2026 02:45:00 +0530 Subject: [PATCH 05/20] feat(core): support DRAFT-2026-v1 stateless transport and version negotiation retry limit --- .../mcp_transport/v20260618/mcp.py | 168 ++++++----- .../mcp_transport/v20260618/types.py | 146 +++++++++ .../toolbox-core/src/toolbox_core/protocol.py | 7 +- .../toolbox-core/tests/conformance/client.py | 43 +-- .../tests/mcp_transport/test_v20260618.py | 278 ++++++++++++++++++ 5 files changed, 531 insertions(+), 111 deletions(-) create mode 100644 packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/types.py create mode 100644 packages/toolbox-core/tests/mcp_transport/test_v20260618.py diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py index 53c69224f..10a4d9ad7 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py @@ -13,68 +13,48 @@ # limitations under the License. import time -from typing import Any, Literal, Mapping, Optional, Type, TypeVar +from typing import Mapping, Optional, TypeVar + from pydantic import BaseModel from ... import version -from ...protocol import ManifestSchema +from ...protocol import ManifestSchema, TelemetryAttributes from .. import telemetry from ..transport_base import _McpHttpTransportBase -from ..v20251125 import types -from ..v20251125.types import _BaseMCPModel +from . import types ReceiveResultT = TypeVar("ReceiveResultT", bound=BaseModel) -class DiscoverResult(_BaseMCPModel): - supportedVersions: list[str] - capabilities: dict[str, Any] - serverInfo: types.Implementation - instructions: Optional[str] = None - - -class DiscoverRequest(types.MCPRequest[DiscoverResult]): - method: Literal["server/discover"] = "server/discover" - params: Optional[dict[str, Any]] = None - - def get_result_model(self) -> Type[DiscoverResult]: - return DiscoverResult - - class McpHttpTransportV20260618(_McpHttpTransportBase): - """Stateless transport for the MCP v2026-06-18 protocol (SEP-2575).""" + """Transport for the MCP draft Request-Metadata (v2026-06-18) protocol.""" async def _send_request( self, url: str, request: types.MCPRequest[ReceiveResultT] | types.MCPNotification, headers: Optional[Mapping[str, str]] = None, + is_retry: bool = False, ) -> ReceiveResultT | None: - """Sends a JSON-RPC request to the MCP server, injecting stateless metadata.""" + """Sends a JSON-RPC request to the MCP server with version negotiation retry.""" req_headers = dict(headers or {}) req_headers["MCP-Protocol-Version"] = self._protocol_version + # Dynamically update the _meta protocol version in the parameters model + if hasattr(request, "params") and request.params is not None: + if ( + hasattr(request.params, "field_meta") + and request.params.field_meta is not None + ): + request.params.field_meta.protocol_version = ( + self._protocol_version + ) + params = ( request.params.model_dump(mode="json", exclude_none=True, by_alias=True) if isinstance(request.params, BaseModel) else request.params ) - if params is None: - params = {} - - # Inject _meta for stateless protocol - meta = params.get("_meta", {}) - meta.update({ - "io.modelcontextprotocol/protocolVersion": self._protocol_version, - "io.modelcontextprotocol/clientInfo": { - "name": self._client_name or "toolbox-core-python", - "version": self._client_version or version.__version__ - }, - "io.modelcontextprotocol/clientCapabilities": { - "tools": {} - } - }) - params["_meta"] = meta rpc_msg: BaseModel if isinstance(request, types.MCPNotification): @@ -87,6 +67,45 @@ async def _send_request( async with self._session.post( url, json=payload, headers=req_headers ) as response: + if response.status == 400: + try: + json_resp = await response.json() + if ( + "error" in json_resp + and json_resp["error"].get("code") == -32001 + ): + if is_retry: + raise RuntimeError( + "Protocol negotiation failed: server rejected negotiated version" + ) + + server_supported = ( + json_resp["error"] + .get("data", {}) + .get("supported", []) + ) + from ...protocol import Protocol + + client_supported = Protocol.get_supported_mcp_versions() + mutually_supported = [ + v for v in client_supported if v in server_supported + ] + + if mutually_supported: + self._protocol_version = mutually_supported[0] + return await self._send_request( + url, request, headers=headers, is_retry=True + ) + else: + raise RuntimeError( + "No mutually supported protocol version. " + f"Client supports: {client_supported}, " + f"Server supports: {server_supported}" + ) + except Exception as e: + if isinstance(e, RuntimeError): + raise e + if not response.ok: error_text = await response.text() raise RuntimeError( @@ -116,7 +135,9 @@ async def _send_request( if isinstance(request, types.MCPRequest): try: rpc_resp = types.JSONRPCResponse.model_validate(json_resp) - return request.get_result_model().model_validate(rpc_resp.result) + return request.get_result_model().model_validate( + rpc_resp.result + ) except Exception as e: raise RuntimeError(f"Failed to parse JSON-RPC response: {e}") return None @@ -124,18 +145,8 @@ async def _send_request( async def _initialize_session( self, headers: Optional[Mapping[str, str]] = None ) -> None: - """Stateless initialization fetches server version and capabilities via server/discover.""" - try: - result = await self._send_request( - url=self._mcp_base_url, - request=DiscoverRequest(), - headers=headers, - ) - if result is not None: - self._server_version = result.serverInfo.version - except Exception: - # Fallback to mock version if server/discover fails - self._server_version = "1.0.0" + """No-op for stateless transport since there is no session handshake.""" + pass async def tools_list( self, @@ -147,7 +158,14 @@ async def tools_list( url = self._mcp_base_url + (toolset_name if toolset_name else "") - meta: Optional[types.MCPMeta] = None + meta = types.MCPMeta( + protocol_version=self._protocol_version, + client_info=types.Implementation( + name=self._client_name or "toolbox-core-python", + version=self._client_version or version.__version__, + ), + client_capabilities=types.ClientCapabilities(), + ) if self._telemetry_enabled: operation_start = time.time() @@ -159,10 +177,8 @@ async def tools_list( network_transport="tcp", ) if span is not None: - meta = types.MCPMeta( - traceparent=traceparent or None, - tracestate=tracestate or None, - ) + meta.traceparent = traceparent or None + meta.tracestate = tracestate or None error: Optional[Exception] = None try: @@ -177,16 +193,11 @@ async def tools_list( raise RuntimeError("Failed to list tools: No response from server.") tools_map = { - t.name: self._convert_tool_schema( - t.model_dump(mode="json", by_alias=True) - ) - for t in result.tools + t["name"]: self._convert_tool_schema(t) for t in result.tools } - if self._server_version is None: - raise RuntimeError("Server version not available.") return ManifestSchema( - serverVersion=self._server_version, + serverVersion="1.0.0", tools=tools_map, ) except Exception as e: @@ -194,7 +205,6 @@ async def tools_list( raise finally: if self._telemetry_enabled: - # Record operation duration metric operation_duration = time.time() - operation_start telemetry.record_operation_duration( self._operation_duration_histogram, @@ -205,7 +215,6 @@ async def tools_list( network_transport="tcp", error=error, ) - # End span telemetry.end_span(span, error=error) async def tool_get( @@ -223,13 +232,28 @@ async def tool_get( ) async def tool_invoke( - self, tool_name: str, arguments: dict, headers: Optional[Mapping[str, str]] = None + self, + tool_name: str, + arguments: dict, + headers: Optional[Mapping[str, str]], + telemetry_attributes: Optional[TelemetryAttributes] = None, ) -> str: """Invokes a specific tool on the server using the MCP protocol.""" await self._ensure_initialized(headers=headers) - meta: Optional[types.MCPMeta] = None + payload = self._build_telemetry_payload(telemetry_attributes) + + meta = types.MCPMeta( + protocol_version=self._protocol_version, + client_info=types.Implementation( + name=self._client_name or "toolbox-core-python", + version=self._client_version or version.__version__, + ), + client_capabilities=types.ClientCapabilities(), + telemetry_attributes=payload, + ) + span = None if self._telemetry_enabled: operation_start = time.time() span, traceparent, tracestate = telemetry.start_span( @@ -240,11 +264,11 @@ async def tool_invoke( tool_name=tool_name, network_transport="tcp", ) - if span is not None: - meta = types.MCPMeta( - traceparent=traceparent or None, - tracestate=tracestate or None, - ) + meta.traceparent = traceparent or None + meta.tracestate = tracestate or None + if span is not None and payload: + for key, value in payload.items(): + span.set_attribute(key, value) error: Optional[Exception] = None try: @@ -269,7 +293,6 @@ async def tool_invoke( raise finally: if self._telemetry_enabled: - # Record operation duration metric operation_duration = time.time() - operation_start telemetry.record_operation_duration( self._operation_duration_histogram, @@ -281,5 +304,4 @@ async def tool_invoke( network_transport="tcp", error=error, ) - # End span telemetry.end_span(span, error=error) diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/types.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/types.py new file mode 100644 index 000000000..79448aed9 --- /dev/null +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/types.py @@ -0,0 +1,146 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import uuid +from typing import Any, Generic, Literal, Type, TypeVar + +from pydantic import BaseModel, ConfigDict, Field + + +class _BaseMCPModel(BaseModel): + """Base model with common configuration.""" + + model_config = ConfigDict(extra="allow") + + +class JSONRPCRequest(_BaseMCPModel): + jsonrpc: Literal["2.0"] = "2.0" + id: str | int = Field(default_factory=lambda: str(uuid.uuid4())) + method: str + params: dict[str, Any] | None = None + + +class JSONRPCNotification(_BaseMCPModel): + """A notification which does not expect a response (no ID).""" + + jsonrpc: Literal["2.0"] = "2.0" + method: str + params: dict[str, Any] | None = None + + +class JSONRPCResponse(_BaseMCPModel): + jsonrpc: Literal["2.0"] + id: str | int + result: dict[str, Any] + + +class ErrorData(_BaseMCPModel): + code: int + message: str + data: Any | None = None + + +class JSONRPCError(_BaseMCPModel): + jsonrpc: Literal["2.0"] + id: str | int + error: ErrorData + + +class ClientCapabilities(_BaseMCPModel): + tools: dict[str, Any] = Field(default_factory=dict) + + +class Implementation(_BaseMCPModel): + name: str + version: str + + +class MCPMeta(_BaseMCPModel): + """Metadata for MCP requests. + + Carries the three required fields in io.modelcontextprotocol/* namespace. + """ + + protocol_version: str = Field( + ..., serialization_alias="io.modelcontextprotocol/protocolVersion" + ) + client_info: Implementation = Field( + ..., serialization_alias="io.modelcontextprotocol/clientInfo" + ) + client_capabilities: ClientCapabilities = Field( + ..., serialization_alias="io.modelcontextprotocol/clientCapabilities" + ) + + # Tracing and attributes + traceparent: str | None = None + tracestate: str | None = None + telemetry_attributes: dict[str, Any] | None = Field( + default=None, serialization_alias="dev.mcp-toolbox/telemetry" + ) + + +class ListToolsResult(_BaseMCPModel): + tools: list[dict[str, Any]] + + +class TextContent(_BaseMCPModel): + type: Literal["text"] + text: str + + +class CallToolResult(_BaseMCPModel): + content: list[TextContent] + isError: bool = False + + +ResultT = TypeVar("ResultT", bound=BaseModel) + + +class MCPRequest(_BaseMCPModel, Generic[ResultT]): + method: str + params: dict[str, Any] | BaseModel | None = None + + def get_result_model(self) -> Type[ResultT]: + raise NotImplementedError + + +class MCPNotification(_BaseMCPModel): + method: str + params: dict[str, Any] | BaseModel | None = None + + +class ListToolsRequestParams(_BaseMCPModel): + field_meta: MCPMeta = Field(..., serialization_alias="_meta") + + +class ListToolsRequest(MCPRequest[ListToolsResult]): + method: Literal["tools/list"] = "tools/list" + params: ListToolsRequestParams + + def get_result_model(self) -> Type[ListToolsResult]: + return ListToolsResult + + +class CallToolRequestParams(_BaseMCPModel): + name: str + arguments: dict[str, Any] + field_meta: MCPMeta = Field(..., serialization_alias="_meta") + + +class CallToolRequest(MCPRequest[CallToolResult]): + method: Literal["tools/call"] = "tools/call" + params: CallToolRequestParams + + def get_result_model(self) -> Type[CallToolResult]: + return CallToolResult diff --git a/packages/toolbox-core/src/toolbox_core/protocol.py b/packages/toolbox-core/src/toolbox_core/protocol.py index fa8f3ab8d..472580a94 100644 --- a/packages/toolbox-core/src/toolbox_core/protocol.py +++ b/packages/toolbox-core/src/toolbox_core/protocol.py @@ -47,20 +47,19 @@ def _empty_string_to_none(cls, value: Any) -> Any: class Protocol(str, Enum): """Defines how the client should choose between communication protocols.""" + MCP_v20260618 = "DRAFT-2026-v1" MCP_v20250618 = "2025-06-18" MCP_v20250326 = "2025-03-26" MCP_v20241105 = "2024-11-05" MCP_v20251125 = "2025-11-25" - MCP_v20260618 = "DRAFT-2026-v1" MCP = MCP_v20250618 - MCP_LATEST = MCP_v20251125 + MCP_LATEST = MCP_v20260618 @staticmethod def get_supported_mcp_versions() -> list[str]: """Returns a list of supported MCP protocol versions.""" return [ - # TODO: Uncomment - # Protocol.MCP_v20260618.value, + Protocol.MCP_v20260618.value, Protocol.MCP_v20251125.value, Protocol.MCP_v20250618.value, Protocol.MCP_v20250326.value, diff --git a/packages/toolbox-core/tests/conformance/client.py b/packages/toolbox-core/tests/conformance/client.py index 26ebffd68..23df0c2c4 100644 --- a/packages/toolbox-core/tests/conformance/client.py +++ b/packages/toolbox-core/tests/conformance/client.py @@ -18,7 +18,6 @@ import sys from toolbox_core.client import ToolboxClient -from toolbox_core.protocol import Protocol async def main(): @@ -35,21 +34,21 @@ async def main(): scenario = os.environ.get("MCP_CONFORMANCE_SCENARIO", "") context_json = os.environ.get("MCP_CONFORMANCE_CONTEXT", "{}") context = json.loads(context_json) - protocol_version_str = os.environ.get("MCP_CONFORMANCE_PROTOCOL_VERSION", "") print(f"Running scenario: {scenario}", file=sys.stderr) print(f"Server URL: {server_url}", file=sys.stderr) print(f"Context: {context_json}", file=sys.stderr) - print(f"Protocol Version: {protocol_version_str}", file=sys.stderr) - - protocol = Protocol.MCP_LATEST - if protocol_version_str == "DRAFT-2026-v1": - protocol = Protocol.MCP_v20260618 client_headers = {"Accept": "application/json, text/event-stream"} + from toolbox_core.protocol import Protocol + + protocol = Protocol.MCP + if scenario == "request-metadata": + protocol = Protocol.MCP_v20260618 + async with ToolboxClient( - server_url, protocol=protocol, client_headers=client_headers + server_url, client_headers=client_headers, protocol=protocol ) as client: if scenario == "initialize": await client.load_toolset() @@ -60,31 +59,9 @@ async def main(): await add_numbers(a=1, b=2) print("Invoked add_numbers(a=1, b=2)", file=sys.stderr) - elif scenario == "stateless": - # 1. Load toolset (triggers server/discover and tools/list internally) + elif scenario == "request-metadata": await client.load_toolset() - print("Stateless load_toolset completed", file=sys.stderr) - - # 2. Trigger consistent version check by loading a single tool - try: - await client.load_tool("test_tool") - print("Stateless load_tool completed", file=sys.stderr) - except Exception: - # It is fine if the tool doesn't exist (we just want to trigger the call) - pass - - # 3. Trigger cancellation (HTTP abort) by calling a long-running task and cancelling - try: - long_running_tool = await client.load_tool("long_running_task") - # Cancel it using asyncio.wait_for timeout - await asyncio.wait_for(long_running_tool(), timeout=0.05) - except asyncio.TimeoutError: - print( - "Aborted long running task as expected via timeout", - file=sys.stderr, - ) - except Exception as e: - print(f"Long running task threw exception: {e}", file=sys.stderr) + print("Client request-metadata test completed", file=sys.stderr) else: # Default behavior: load default toolset to trigger standard interactions @@ -104,5 +81,3 @@ async def main(): ) traceback.print_exc(file=sys.stderr) sys.exit(1) - - diff --git a/packages/toolbox-core/tests/mcp_transport/test_v20260618.py b/packages/toolbox-core/tests/mcp_transport/test_v20260618.py new file mode 100644 index 000000000..435f0bdf7 --- /dev/null +++ b/packages/toolbox-core/tests/mcp_transport/test_v20260618.py @@ -0,0 +1,278 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest +import pytest_asyncio +from aiohttp import ClientSession + +from toolbox_core.mcp_transport.v20260618 import types +from toolbox_core.mcp_transport.v20260618.mcp import McpHttpTransportV20260618 +from toolbox_core.protocol import ManifestSchema, Protocol + + +def create_fake_tools_list_result(): + return types.ListToolsResult( + tools=[ + { + "name": "get_weather", + "description": "Gets the weather.", + "inputSchema": { + "type": "object", + "properties": {"location": {"type": "string"}}, + "required": ["location"], + }, + } + ] + ) + + +@pytest_asyncio.fixture( + params=[False, True], ids=["telemetry_disabled", "telemetry_enabled"] +) +async def transport(request, mocker): + if request.param: + mocker.patch("toolbox_core.mcp_transport.telemetry.TELEMETRY_AVAILABLE", True) + mocker.patch( + "toolbox_core.mcp_transport.telemetry.get_tracer", return_value=MagicMock() + ) + mocker.patch( + "toolbox_core.mcp_transport.telemetry.get_meter", return_value=MagicMock() + ) + mocker.patch( + "toolbox_core.mcp_transport.telemetry.create_operation_duration_histogram", + return_value=MagicMock(), + ) + mocker.patch( + "toolbox_core.mcp_transport.telemetry.create_session_duration_histogram", + return_value=MagicMock(), + ) + mocker.patch( + "toolbox_core.mcp_transport.telemetry.start_span", + return_value=(MagicMock(), "00-traceparent", ""), + ) + mocker.patch("toolbox_core.mcp_transport.telemetry.end_span") + mocker.patch("toolbox_core.mcp_transport.telemetry.record_operation_duration") + mocker.patch("toolbox_core.mcp_transport.telemetry.record_session_duration") + mock_session = AsyncMock(spec=ClientSession) + transport = McpHttpTransportV20260618( + "http://fake-server.com", + session=mock_session, + protocol=Protocol.MCP_v20260618, + telemetry_enabled=request.param, + ) + yield transport + await transport.close() + + +@pytest.mark.asyncio +class TestMcpHttpTransportV20260618: + + # --- Request Sending Tests (Standard + Header) --- + + async def test_send_request_success(self, transport): + mock_response = AsyncMock() + mock_response.ok = True + mock_response.status = 200 + mock_response.content = Mock() + mock_response.content.at_eof.return_value = False + mock_response.json.return_value = {"jsonrpc": "2.0", "id": "1", "result": {}} + transport._session.post.return_value.__aenter__.return_value = mock_response + + class TestResult(types.BaseModel): + pass + + class TestRequest(types.MCPRequest[TestResult]): + method: str = "method" + params: dict = {} + + def get_result_model(self): + return TestResult + + result = await transport._send_request("url", TestRequest()) + assert result == TestResult() + + async def test_send_request_adds_protocol_header(self, transport): + """Test that the MCP-Protocol-Version header is added.""" + mock_response = AsyncMock() + mock_response.ok = True + mock_response.content = Mock() + mock_response.content.at_eof.return_value = False + mock_response.json.return_value = {"jsonrpc": "2.0", "id": "1", "result": {}} + transport._session.post.return_value.__aenter__.return_value = mock_response + + class TestResult(types.BaseModel): + pass + + class TestRequest(types.MCPRequest[TestResult]): + method: str = "method" + params: dict = {} + + def get_result_model(self): + return TestResult + + await transport._send_request("url", TestRequest()) + + call_args = transport._session.post.call_args + headers = call_args.kwargs["headers"] + assert headers["MCP-Protocol-Version"] == "DRAFT-2026-v1" + + # --- Version Negotiation Tests --- + + async def test_version_negotiation_retry_success(self, transport): + """Tests that the client retries when the server rejects initial version.""" + mock_response_reject = AsyncMock() + mock_response_reject.ok = False + mock_response_reject.status = 400 + mock_response_reject.json.return_value = { + "jsonrpc": "2.0", + "id": "1", + "error": { + "code": -32001, + "message": "Unsupported protocol version", + "data": {"supported": ["DRAFT-2026-v1"]}, + }, + } + + mock_response_accept = AsyncMock() + mock_response_accept.ok = True + mock_response_accept.status = 200 + mock_response_accept.content = Mock() + mock_response_accept.content.at_eof.return_value = False + mock_response_accept.json.return_value = { + "jsonrpc": "2.0", + "id": "1", + "result": {}, + } + + # Configure first call to return reject response, second call to succeed + transport._session.post.return_value.__aenter__.side_effect = [ + mock_response_reject, + mock_response_accept, + ] + + class TestResult(types.BaseModel): + pass + + class TestRequest(types.MCPRequest[TestResult]): + method: str = "method" + params: dict = {} + + def get_result_model(self): + return TestResult + + result = await transport._send_request("url", TestRequest()) + assert result == TestResult() + assert transport._session.post.call_count == 2 + + async def test_version_negotiation_loop_prevention(self, transport): + """Tests that the client raises an error if the retry gets rejected (loop prevention).""" + mock_response_reject = AsyncMock() + mock_response_reject.ok = False + mock_response_reject.status = 400 + mock_response_reject.json.return_value = { + "jsonrpc": "2.0", + "id": "1", + "error": { + "code": -32001, + "message": "Unsupported protocol version", + "data": {"supported": ["DRAFT-2026-v1"]}, + }, + } + + # Return rejection repeatedly + transport._session.post.return_value.__aenter__.side_effect = [ + mock_response_reject, + mock_response_reject, + ] + + class TestResult(types.BaseModel): + pass + + class TestRequest(types.MCPRequest[TestResult]): + method: str = "method" + params: dict = {} + + def get_result_model(self): + return TestResult + + with pytest.raises( + RuntimeError, + match="Protocol negotiation failed: server rejected negotiated version", + ): + await transport._send_request("url", TestRequest()) + + assert transport._session.post.call_count == 2 + + async def test_version_negotiation_empty_intersection(self, transport): + """Tests that the client errors immediately without retrying when there is no mutual version.""" + mock_response_reject = AsyncMock() + mock_response_reject.ok = False + mock_response_reject.status = 400 + mock_response_reject.json.return_value = { + "jsonrpc": "2.0", + "id": "1", + "error": { + "code": -32001, + "message": "Unsupported protocol version", + "data": {"supported": ["UNSUPPORTED-VERSION"]}, + }, + } + + transport._session.post.return_value.__aenter__.return_value = mock_response_reject + + class TestResult(types.BaseModel): + pass + + class TestRequest(types.MCPRequest[TestResult]): + method: str = "method" + params: dict = {} + + def get_result_model(self): + return TestResult + + with pytest.raises( + RuntimeError, match="No mutually supported protocol version" + ): + await transport._send_request("url", TestRequest()) + + assert transport._session.post.call_count == 1 + + # --- Tool Management Tests --- + + async def test_tools_list_success(self, transport, mocker): + mocker.patch.object(transport, "_ensure_initialized", new_callable=AsyncMock) + mocker.patch.object( + transport, + "_send_request", + new_callable=AsyncMock, + return_value=create_fake_tools_list_result(), + ) + manifest = await transport.tools_list() + assert isinstance(manifest, ManifestSchema) + assert "get_weather" in manifest.tools + + async def test_tool_invoke_success(self, transport, mocker): + mocker.patch.object(transport, "_ensure_initialized", new_callable=AsyncMock) + mocker.patch.object( + transport, + "_send_request", + new_callable=AsyncMock, + return_value=types.CallToolResult( + content=[types.TextContent(type="text", text="Result")] + ), + ) + result = await transport.tool_invoke("tool", {}, {}) + assert result == "Result" From 35936742dfebe6d7b6fdff7092605a868954488b Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Sat, 16 May 2026 03:09:56 +0530 Subject: [PATCH 06/20] style: format python transport files with black and isort --- .../toolbox_core/mcp_transport/v20260618/mcp.py | 16 ++++------------ .../tests/mcp_transport/test_v20260618.py | 4 +++- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py index 10a4d9ad7..d29681df5 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py @@ -46,9 +46,7 @@ async def _send_request( hasattr(request.params, "field_meta") and request.params.field_meta is not None ): - request.params.field_meta.protocol_version = ( - self._protocol_version - ) + request.params.field_meta.protocol_version = self._protocol_version params = ( request.params.model_dump(mode="json", exclude_none=True, by_alias=True) @@ -80,9 +78,7 @@ async def _send_request( ) server_supported = ( - json_resp["error"] - .get("data", {}) - .get("supported", []) + json_resp["error"].get("data", {}).get("supported", []) ) from ...protocol import Protocol @@ -135,9 +131,7 @@ async def _send_request( if isinstance(request, types.MCPRequest): try: rpc_resp = types.JSONRPCResponse.model_validate(json_resp) - return request.get_result_model().model_validate( - rpc_resp.result - ) + return request.get_result_model().model_validate(rpc_resp.result) except Exception as e: raise RuntimeError(f"Failed to parse JSON-RPC response: {e}") return None @@ -192,9 +186,7 @@ async def tools_list( if result is None: raise RuntimeError("Failed to list tools: No response from server.") - tools_map = { - t["name"]: self._convert_tool_schema(t) for t in result.tools - } + tools_map = {t["name"]: self._convert_tool_schema(t) for t in result.tools} return ManifestSchema( serverVersion="1.0.0", diff --git a/packages/toolbox-core/tests/mcp_transport/test_v20260618.py b/packages/toolbox-core/tests/mcp_transport/test_v20260618.py index 435f0bdf7..a68e843cd 100644 --- a/packages/toolbox-core/tests/mcp_transport/test_v20260618.py +++ b/packages/toolbox-core/tests/mcp_transport/test_v20260618.py @@ -231,7 +231,9 @@ async def test_version_negotiation_empty_intersection(self, transport): }, } - transport._session.post.return_value.__aenter__.return_value = mock_response_reject + transport._session.post.return_value.__aenter__.return_value = ( + mock_response_reject + ) class TestResult(types.BaseModel): pass From b16ef8aec5db4ecdebac42143f80d02afa864eae Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Sat, 16 May 2026 03:23:58 +0530 Subject: [PATCH 07/20] refactor: move Protocol import to the top of the file --- packages/toolbox-core/tests/conformance/client.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/toolbox-core/tests/conformance/client.py b/packages/toolbox-core/tests/conformance/client.py index 23df0c2c4..cf9877c20 100644 --- a/packages/toolbox-core/tests/conformance/client.py +++ b/packages/toolbox-core/tests/conformance/client.py @@ -18,6 +18,7 @@ import sys from toolbox_core.client import ToolboxClient +from toolbox_core.protocol import Protocol async def main(): @@ -41,8 +42,6 @@ async def main(): client_headers = {"Accept": "application/json, text/event-stream"} - from toolbox_core.protocol import Protocol - protocol = Protocol.MCP if scenario == "request-metadata": protocol = Protocol.MCP_v20260618 From 7375565db709f7f18d416ac0fbc2a260ddc3ea6b Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Mon, 18 May 2026 14:32:10 +0530 Subject: [PATCH 08/20] test: filter out draft versions in Go E2E tests, add todo remarks, and update protocol unit test expected versions --- packages/toolbox-core/tests/test_e2e_mcp.py | 4 +++- packages/toolbox-core/tests/test_protocol.py | 8 +++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/toolbox-core/tests/test_e2e_mcp.py b/packages/toolbox-core/tests/test_e2e_mcp.py index 6acaeaded..d6f47c5eb 100644 --- a/packages/toolbox-core/tests/test_e2e_mcp.py +++ b/packages/toolbox-core/tests/test_e2e_mcp.py @@ -24,9 +24,11 @@ from toolbox_core.tool import ToolboxTool +# TODO: Include draft versions in E2E integration tests once the server +# supports SEP-2575 (stateless MCP / Request-Metadata). @pytest_asyncio.fixture( scope="function", - params=Protocol.get_supported_mcp_versions(), + params=[v for v in Protocol.get_supported_mcp_versions() if "DRAFT" not in v], ) async def toolbox(request): """Creates a ToolboxClient instance shared by all tests in this module.""" diff --git a/packages/toolbox-core/tests/test_protocol.py b/packages/toolbox-core/tests/test_protocol.py index d8079d8ee..3c50b3225 100644 --- a/packages/toolbox-core/tests/test_protocol.py +++ b/packages/toolbox-core/tests/test_protocol.py @@ -77,7 +77,13 @@ def test_get_supported_mcp_versions(): Tests that get_supported_mcp_versions returns the correct list of versions, sorted from newest to oldest. """ - expected_versions = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"] + expected_versions = [ + "DRAFT-2026-v1", + "2025-11-25", + "2025-06-18", + "2025-03-26", + "2024-11-05", + ] supported_versions = Protocol.get_supported_mcp_versions() assert supported_versions == expected_versions From f81586244f3c7b924dc086de2c862f167fa1766c Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Mon, 18 May 2026 14:59:27 +0530 Subject: [PATCH 09/20] docs: add error requirements docstring in conformance client script --- packages/toolbox-core/tests/conformance/client.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/toolbox-core/tests/conformance/client.py b/packages/toolbox-core/tests/conformance/client.py index cf9877c20..5d59d593a 100644 --- a/packages/toolbox-core/tests/conformance/client.py +++ b/packages/toolbox-core/tests/conformance/client.py @@ -22,6 +22,12 @@ async def main(): + """Harness main execution block. + + NOTE: All non-protocol outputs (logs, traces, errors) must be directed to + sys.stderr. The test runner captures stdout for protocol messages only, + printing other content to stdout will pollute the stream and crash the runner. + """ if len(sys.argv) < 2: print("Usage: client.py ", file=sys.stderr) sys.exit(1) From 0da2ae73318db277ef277678089c2a670b50bedd Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Sat, 30 May 2026 01:10:38 +0530 Subject: [PATCH 10/20] fix(core): update stateless protocol version retry logic to use spec-compliant -32004 code --- .../src/toolbox_core/mcp_transport/v20260618/mcp.py | 2 +- packages/toolbox-core/tests/mcp_transport/test_v20260618.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py index d29681df5..3f557a1de 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py @@ -70,7 +70,7 @@ async def _send_request( json_resp = await response.json() if ( "error" in json_resp - and json_resp["error"].get("code") == -32001 + and json_resp["error"].get("code") == -32004 ): if is_retry: raise RuntimeError( diff --git a/packages/toolbox-core/tests/mcp_transport/test_v20260618.py b/packages/toolbox-core/tests/mcp_transport/test_v20260618.py index a68e843cd..8579bb1fa 100644 --- a/packages/toolbox-core/tests/mcp_transport/test_v20260618.py +++ b/packages/toolbox-core/tests/mcp_transport/test_v20260618.py @@ -140,7 +140,7 @@ async def test_version_negotiation_retry_success(self, transport): "jsonrpc": "2.0", "id": "1", "error": { - "code": -32001, + "code": -32004, "message": "Unsupported protocol version", "data": {"supported": ["DRAFT-2026-v1"]}, }, @@ -186,7 +186,7 @@ async def test_version_negotiation_loop_prevention(self, transport): "jsonrpc": "2.0", "id": "1", "error": { - "code": -32001, + "code": -32004, "message": "Unsupported protocol version", "data": {"supported": ["DRAFT-2026-v1"]}, }, @@ -225,7 +225,7 @@ async def test_version_negotiation_empty_intersection(self, transport): "jsonrpc": "2.0", "id": "1", "error": { - "code": -32001, + "code": -32004, "message": "Unsupported protocol version", "data": {"supported": ["UNSUPPORTED-VERSION"]}, }, From e4867cf4b4c5d0054c234c744240e461ca2ce8fd Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Sat, 30 May 2026 01:34:13 +0530 Subject: [PATCH 11/20] fix(core): align ClientCapabilities Pydantic schema with spec --- .../mcp_transport/v20260618/types.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/types.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/types.py index 79448aed9..a18eea704 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/types.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/types.py @@ -57,8 +57,22 @@ class JSONRPCError(_BaseMCPModel): error: ErrorData +class SamplingCapabilities(_BaseMCPModel): + context: dict[str, Any] | None = None + tools: dict[str, Any] | None = None + + +class ElicitationCapabilities(_BaseMCPModel): + form: dict[str, Any] | None = None + url: dict[str, Any] | None = None + + class ClientCapabilities(_BaseMCPModel): - tools: dict[str, Any] = Field(default_factory=dict) + experimental: dict[str, Any] | None = None + roots: dict[str, Any] | None = None + sampling: SamplingCapabilities | None = None + elicitation: ElicitationCapabilities | None = None + extensions: dict[str, Any] | None = None class Implementation(_BaseMCPModel): From 8cebffef0e66d77722f3accfb3c65a114615f2f8 Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Tue, 16 Jun 2026 12:18:04 +0530 Subject: [PATCH 12/20] refactor: simplify version negotiation retry logic in MCP transport --- .../mcp_transport/v20260618/mcp.py | 186 +++++++++--------- 1 file changed, 93 insertions(+), 93 deletions(-) diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py index 3f557a1de..006f8aa97 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py @@ -34,108 +34,108 @@ async def _send_request( url: str, request: types.MCPRequest[ReceiveResultT] | types.MCPNotification, headers: Optional[Mapping[str, str]] = None, - is_retry: bool = False, ) -> ReceiveResultT | None: """Sends a JSON-RPC request to the MCP server with version negotiation retry.""" - req_headers = dict(headers or {}) - req_headers["MCP-Protocol-Version"] = self._protocol_version - - # Dynamically update the _meta protocol version in the parameters model - if hasattr(request, "params") and request.params is not None: - if ( - hasattr(request.params, "field_meta") - and request.params.field_meta is not None - ): - request.params.field_meta.protocol_version = self._protocol_version - - params = ( - request.params.model_dump(mode="json", exclude_none=True, by_alias=True) - if isinstance(request.params, BaseModel) - else request.params - ) + is_retry = False + while True: + req_headers = dict(headers or {}) + req_headers["MCP-Protocol-Version"] = self._protocol_version + + # Dynamically update the _meta protocol version in the parameters model + if hasattr(request, "params") and request.params is not None: + if ( + hasattr(request.params, "field_meta") + and request.params.field_meta is not None + ): + request.params.field_meta.protocol_version = self._protocol_version + + params = ( + request.params.model_dump(mode="json", exclude_none=True, by_alias=True) + if isinstance(request.params, BaseModel) + else request.params + ) - rpc_msg: BaseModel - if isinstance(request, types.MCPNotification): - rpc_msg = types.JSONRPCNotification(method=request.method, params=params) - else: - rpc_msg = types.JSONRPCRequest(method=request.method, params=params) - - payload = rpc_msg.model_dump(mode="json", exclude_none=True) - - async with self._session.post( - url, json=payload, headers=req_headers - ) as response: - if response.status == 400: - try: - json_resp = await response.json() - if ( - "error" in json_resp - and json_resp["error"].get("code") == -32004 - ): - if is_retry: - raise RuntimeError( - "Protocol negotiation failed: server rejected negotiated version" + rpc_msg: BaseModel + if isinstance(request, types.MCPNotification): + rpc_msg = types.JSONRPCNotification(method=request.method, params=params) + else: + rpc_msg = types.JSONRPCRequest(method=request.method, params=params) + + payload = rpc_msg.model_dump(mode="json", exclude_none=True) + + async with self._session.post( + url, json=payload, headers=req_headers + ) as response: + if response.status == 400: + try: + json_resp = await response.json() + if ( + "error" in json_resp + and json_resp["error"].get("code") == -32004 + ): + if is_retry: + raise RuntimeError( + "Protocol negotiation failed: server rejected negotiated version" + ) + + server_supported = ( + json_resp["error"].get("data", {}).get("supported", []) ) + from ...protocol import Protocol + + client_supported = Protocol.get_supported_mcp_versions() + mutually_supported = [ + v for v in client_supported if v in server_supported + ] + + if mutually_supported: + self._protocol_version = mutually_supported[0] + is_retry = True + continue + else: + raise RuntimeError( + "No mutually supported protocol version. " + f"Client supports: {client_supported}, " + f"Server supports: {server_supported}" + ) + except Exception as e: + if isinstance(e, RuntimeError): + raise e + + if not response.ok: + error_text = await response.text() + raise RuntimeError( + "API request failed with status" + f" {response.status} ({response.reason}). Server response:" + f" {error_text}" + ) - server_supported = ( - json_resp["error"].get("data", {}).get("supported", []) - ) - from ...protocol import Protocol - - client_supported = Protocol.get_supported_mcp_versions() - mutually_supported = [ - v for v in client_supported if v in server_supported - ] - - if mutually_supported: - self._protocol_version = mutually_supported[0] - return await self._send_request( - url, request, headers=headers, is_retry=True - ) - else: - raise RuntimeError( - "No mutually supported protocol version. " - f"Client supports: {client_supported}, " - f"Server supports: {server_supported}" - ) - except Exception as e: - if isinstance(e, RuntimeError): - raise e + if response.status == 204 or response.content.at_eof(): + return None - if not response.ok: - error_text = await response.text() - raise RuntimeError( - "API request failed with status" - f" {response.status} ({response.reason}). Server response:" - f" {error_text}" - ) + json_resp = await response.json() - if response.status == 204 or response.content.at_eof(): + # Check for JSON-RPC Error + if "error" in json_resp: + try: + err = types.JSONRPCError.model_validate(json_resp).error + raise RuntimeError( + f"MCP request failed with code {err.code}: {err.message}" + ) + except Exception: + # Fallback if the error doesn't match our schema exactly + raw_error = json_resp.get("error", {}) + raise RuntimeError(f"MCP request failed: {raw_error}") + + # Parse Result + if isinstance(request, types.MCPRequest): + try: + rpc_resp = types.JSONRPCResponse.model_validate(json_resp) + return request.get_result_model().model_validate(rpc_resp.result) + except Exception as e: + raise RuntimeError(f"Failed to parse JSON-RPC response: {e}") return None - json_resp = await response.json() - - # Check for JSON-RPC Error - if "error" in json_resp: - try: - err = types.JSONRPCError.model_validate(json_resp).error - raise RuntimeError( - f"MCP request failed with code {err.code}: {err.message}" - ) - except Exception: - # Fallback if the error doesn't match our schema exactly - raw_error = json_resp.get("error", {}) - raise RuntimeError(f"MCP request failed: {raw_error}") - - # Parse Result - if isinstance(request, types.MCPRequest): - try: - rpc_resp = types.JSONRPCResponse.model_validate(json_resp) - return request.get_result_model().model_validate(rpc_resp.result) - except Exception as e: - raise RuntimeError(f"Failed to parse JSON-RPC response: {e}") - return None - async def _initialize_session( self, headers: Optional[Mapping[str, str]] = None ) -> None: From 41f48d848be0cd021885042388c866fa636184f9 Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Tue, 16 Jun 2026 12:20:05 +0530 Subject: [PATCH 13/20] chore: delint --- .../src/toolbox_core/mcp_transport/v20260618/mcp.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py index 006f8aa97..562c55196 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py @@ -57,7 +57,9 @@ async def _send_request( rpc_msg: BaseModel if isinstance(request, types.MCPNotification): - rpc_msg = types.JSONRPCNotification(method=request.method, params=params) + rpc_msg = types.JSONRPCNotification( + method=request.method, params=params + ) else: rpc_msg = types.JSONRPCRequest(method=request.method, params=params) @@ -131,7 +133,9 @@ async def _send_request( if isinstance(request, types.MCPRequest): try: rpc_resp = types.JSONRPCResponse.model_validate(json_resp) - return request.get_result_model().model_validate(rpc_resp.result) + return request.get_result_model().model_validate( + rpc_resp.result + ) except Exception as e: raise RuntimeError(f"Failed to parse JSON-RPC response: {e}") return None From 70c29cdf093c66874fe168031dc15e7435a8b720 Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Tue, 16 Jun 2026 15:17:59 +0530 Subject: [PATCH 14/20] refactor(core): implement transport proxy for protocol fallback --- .../toolbox-core/src/toolbox_core/client.py | 162 ++++++++++++------ .../src/toolbox_core/exceptions.py | 27 +++ .../mcp_transport/v20260618/mcp.py | 7 +- .../tests/mcp_transport/test_v20260618.py | 66 +------ packages/toolbox-core/tests/test_client.py | 84 +++++++++ packages/toolbox-core/tests/test_e2e_mcp.py | 15 ++ 6 files changed, 252 insertions(+), 109 deletions(-) create mode 100644 packages/toolbox-core/src/toolbox_core/exceptions.py diff --git a/packages/toolbox-core/src/toolbox_core/client.py b/packages/toolbox-core/src/toolbox_core/client.py index 49a2c2af5..df6d1a26d 100644 --- a/packages/toolbox-core/src/toolbox_core/client.py +++ b/packages/toolbox-core/src/toolbox_core/client.py @@ -21,6 +21,8 @@ from aiohttp import ClientSession from deprecated import deprecated +from toolbox_core.exceptions import ProtocolNegotiationError + from . import version from .itransport import ITransport from .mcp_transport import ( @@ -40,6 +42,110 @@ ) +class _McpTransportProxy(ITransport): + """A proxy transport that transparently handles protocol fallback negotiation.""" + + def __init__( + self, + url: str, + session: Optional[ClientSession], + protocol: Protocol, + client_name: Optional[str], + client_version: Optional[str], + telemetry_enabled: bool, + ): + self._url = url + self._session = session + self._client_name = client_name + self._client_version = client_version + self._telemetry_enabled = telemetry_enabled + self._active_transport = self._create_transport(protocol) + + def _create_transport(self, protocol: Protocol) -> ITransport: + match protocol: + case Protocol.MCP_v20260618: + return McpHttpTransportV20260618( + self._url, + self._session, + protocol, + self._client_name, + self._client_version, + telemetry_enabled=self._telemetry_enabled, + ) + case Protocol.MCP_v20251125: + return McpHttpTransportV20251125( + self._url, + self._session, + protocol, + self._client_name, + self._client_version, + telemetry_enabled=self._telemetry_enabled, + ) + case Protocol.MCP_v20250618: + return McpHttpTransportV20250618( + self._url, + self._session, + protocol, + self._client_name, + self._client_version, + telemetry_enabled=self._telemetry_enabled, + ) + case Protocol.MCP_v20250326: + return McpHttpTransportV20250326( + self._url, + self._session, + protocol, + self._client_name, + self._client_version, + telemetry_enabled=self._telemetry_enabled, + ) + case Protocol.MCP_v20241105: + return McpHttpTransportV20241105( + self._url, + self._session, + protocol, + self._client_name, + self._client_version, + telemetry_enabled=self._telemetry_enabled, + ) + case _: + raise ValueError(f"Unsupported MCP protocol version: {protocol}") + + @property + def base_url(self) -> str: + return self._active_transport.base_url + + @property + def _protocol_version(self) -> str: + # We must expose this for tests asserting the current protocol version. + return getattr(self._active_transport, "_protocol_version", "") + + async def _execute_with_fallback(self, method_name: str, *args, **kwargs) -> Any: + try: + return await getattr(self._active_transport, method_name)(*args, **kwargs) + except ProtocolNegotiationError as e: + fallback_protocol = Protocol(e.negotiated_version) + logging.warning( + f"Protocol fallback required. Switching from " + f"{self._protocol_version} to {fallback_protocol.value}" + ) + await self._active_transport.close() + self._active_transport = self._create_transport(fallback_protocol) + return await getattr(self._active_transport, method_name)(*args, **kwargs) + + async def tool_get(self, *args, **kwargs) -> Any: + return await self._execute_with_fallback("tool_get", *args, **kwargs) + + async def tools_list(self, *args, **kwargs) -> Any: + return await self._execute_with_fallback("tools_list", *args, **kwargs) + + async def tool_invoke(self, *args, **kwargs) -> Any: + return await self._execute_with_fallback("tool_invoke", *args, **kwargs) + + async def close(self) -> None: + await self._active_transport.close() + + class ToolboxClient: """ An asynchronous client for interacting with a Toolbox service. @@ -86,54 +192,14 @@ def __init__( "Please use Protocol.MCP_LATEST to use the latest features." ) - match protocol: - case Protocol.MCP_v20260618: - self.__transport = McpHttpTransportV20260618( - url, - session, - protocol, - client_name, - client_version, - telemetry_enabled=telemetry_enabled, - ) - case Protocol.MCP_v20251125: - self.__transport = McpHttpTransportV20251125( - url, - session, - protocol, - client_name, - client_version, - telemetry_enabled=telemetry_enabled, - ) - case Protocol.MCP_v20250618: - self.__transport = McpHttpTransportV20250618( - url, - session, - protocol, - client_name, - client_version, - telemetry_enabled=telemetry_enabled, - ) - case Protocol.MCP_v20250326: - self.__transport = McpHttpTransportV20250326( - url, - session, - protocol, - client_name, - client_version, - telemetry_enabled=telemetry_enabled, - ) - case Protocol.MCP_v20241105: - self.__transport = McpHttpTransportV20241105( - url, - session, - protocol, - client_name, - client_version, - telemetry_enabled=telemetry_enabled, - ) - case _: - raise ValueError(f"Unsupported MCP protocol version: {protocol}") + self.__transport = _McpTransportProxy( + url, + session, + protocol, + client_name, + client_version, + telemetry_enabled, + ) self.__client_headers = client_headers if client_headers is not None else {} warn_if_http_and_headers(url, self.__client_headers) diff --git a/packages/toolbox-core/src/toolbox_core/exceptions.py b/packages/toolbox-core/src/toolbox_core/exceptions.py new file mode 100644 index 000000000..dfa696806 --- /dev/null +++ b/packages/toolbox-core/src/toolbox_core/exceptions.py @@ -0,0 +1,27 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-; +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +class ToolboxError(Exception): + """Base exception for all MCP Toolbox errors.""" + + pass + + +class ProtocolNegotiationError(ToolboxError): + """Raised when the server requires a different protocol version during a stateless request.""" + + def __init__(self, negotiated_version: str): + self.negotiated_version = negotiated_version + super().__init__(f"Server requires protocol fallback to {negotiated_version}") diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py index 562c55196..53f81a63a 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py @@ -18,6 +18,7 @@ from pydantic import BaseModel from ... import version +from ...exceptions import ProtocolNegotiationError from ...protocol import ManifestSchema, TelemetryAttributes from .. import telemetry from ..transport_base import _McpHttpTransportBase @@ -91,9 +92,7 @@ async def _send_request( ] if mutually_supported: - self._protocol_version = mutually_supported[0] - is_retry = True - continue + raise ProtocolNegotiationError(mutually_supported[0]) else: raise RuntimeError( "No mutually supported protocol version. " @@ -101,7 +100,7 @@ async def _send_request( f"Server supports: {server_supported}" ) except Exception as e: - if isinstance(e, RuntimeError): + if isinstance(e, (RuntimeError, ProtocolNegotiationError)): raise e if not response.ok: diff --git a/packages/toolbox-core/tests/mcp_transport/test_v20260618.py b/packages/toolbox-core/tests/mcp_transport/test_v20260618.py index 8579bb1fa..4b1be165b 100644 --- a/packages/toolbox-core/tests/mcp_transport/test_v20260618.py +++ b/packages/toolbox-core/tests/mcp_transport/test_v20260618.py @@ -131,54 +131,10 @@ def get_result_model(self): # --- Version Negotiation Tests --- - async def test_version_negotiation_retry_success(self, transport): - """Tests that the client retries when the server rejects initial version.""" - mock_response_reject = AsyncMock() - mock_response_reject.ok = False - mock_response_reject.status = 400 - mock_response_reject.json.return_value = { - "jsonrpc": "2.0", - "id": "1", - "error": { - "code": -32004, - "message": "Unsupported protocol version", - "data": {"supported": ["DRAFT-2026-v1"]}, - }, - } - - mock_response_accept = AsyncMock() - mock_response_accept.ok = True - mock_response_accept.status = 200 - mock_response_accept.content = Mock() - mock_response_accept.content.at_eof.return_value = False - mock_response_accept.json.return_value = { - "jsonrpc": "2.0", - "id": "1", - "result": {}, - } - - # Configure first call to return reject response, second call to succeed - transport._session.post.return_value.__aenter__.side_effect = [ - mock_response_reject, - mock_response_accept, - ] - - class TestResult(types.BaseModel): - pass - - class TestRequest(types.MCPRequest[TestResult]): - method: str = "method" - params: dict = {} + async def test_version_negotiation_raises_fallback(self, transport): + """Tests that the client raises ProtocolNegotiationError when the server requests a fallback.""" + from toolbox_core.exceptions import ProtocolNegotiationError - def get_result_model(self): - return TestResult - - result = await transport._send_request("url", TestRequest()) - assert result == TestResult() - assert transport._session.post.call_count == 2 - - async def test_version_negotiation_loop_prevention(self, transport): - """Tests that the client raises an error if the retry gets rejected (loop prevention).""" mock_response_reject = AsyncMock() mock_response_reject.ok = False mock_response_reject.status = 400 @@ -192,11 +148,9 @@ async def test_version_negotiation_loop_prevention(self, transport): }, } - # Return rejection repeatedly - transport._session.post.return_value.__aenter__.side_effect = [ - mock_response_reject, - mock_response_reject, - ] + transport._session.post.return_value.__aenter__.return_value = ( + mock_response_reject + ) class TestResult(types.BaseModel): pass @@ -208,13 +162,11 @@ class TestRequest(types.MCPRequest[TestResult]): def get_result_model(self): return TestResult - with pytest.raises( - RuntimeError, - match="Protocol negotiation failed: server rejected negotiated version", - ): + with pytest.raises(ProtocolNegotiationError) as exc_info: await transport._send_request("url", TestRequest()) - assert transport._session.post.call_count == 2 + assert exc_info.value.negotiated_version == "DRAFT-2026-v1" + assert transport._session.post.call_count == 1 async def test_version_negotiation_empty_intersection(self, transport): """Tests that the client errors immediately without retrying when there is no mutual version.""" diff --git a/packages/toolbox-core/tests/test_client.py b/packages/toolbox-core/tests/test_client.py index 16a00e3f3..946e8f157 100644 --- a/packages/toolbox-core/tests/test_client.py +++ b/packages/toolbox-core/tests/test_client.py @@ -248,6 +248,90 @@ async def test_load_tool_not_found_in_manifest(mock_transport, test_tool_str): mock_transport.tool_get_mock.assert_awaited_once_with(REQUESTED_TOOL_NAME, {}) +@pytest.mark.asyncio +async def test_load_tool_protocol_fallback_success(test_tool_str): + """ + Tests that the client successfully swaps transports and retries when a + ProtocolNegotiationError is raised. + """ + TOOL_NAME = "test_tool_1" + manifest = ManifestSchema(serverVersion="0.0.0", tools={TOOL_NAME: test_tool_str}) + + from toolbox_core.exceptions import ProtocolNegotiationError + + # We need to mock the transports that client.py will instantiate + with ( + patch("toolbox_core.client.McpHttpTransportV20260618") as mock_2026_cls, + patch("toolbox_core.client.McpHttpTransportV20250618") as mock_2025_cls, + ): + + mock_2026 = AsyncMock() + mock_2026.tool_get.side_effect = ProtocolNegotiationError("2025-06-18") + mock_2026_cls.return_value = mock_2026 + + mock_2025 = AsyncMock() + mock_2025.tool_get.return_value = manifest + mock_2025.tool_invoke.return_value = "ok_from_fallback" + mock_2025_cls.return_value = mock_2025 + + async with ToolboxClient( + TEST_BASE_URL, protocol=Protocol.MCP_v20260618 + ) as client: + # This should trigger the fallback + loaded_tool = await client.load_tool(TOOL_NAME) + + # Assert the first transport was closed + mock_2026.close.assert_awaited_once() + + # Assert the second transport was instantiated and used + mock_2025_cls.assert_called_once() + mock_2025.tool_get.assert_awaited_once_with(TOOL_NAME, {}) + + # Assert the tool was bound to the *new* transport + assert await loaded_tool("some value") == "ok_from_fallback" + mock_2025.tool_invoke.assert_awaited_once_with( + TOOL_NAME, {"param1": "some value"}, {} + ) + + +@pytest.mark.asyncio +async def test_load_tool_protocol_fallback_infinite_loop_prevention(test_tool_str): + """ + Tests that if the fallback transport *also* raises ProtocolNegotiationError, + the client does not get stuck in an infinite loop. + """ + TOOL_NAME = "test_tool_1" + + from toolbox_core.exceptions import ProtocolNegotiationError + + with ( + patch("toolbox_core.client.McpHttpTransportV20260618") as mock_2026_cls, + patch("toolbox_core.client.McpHttpTransportV20250618") as mock_2025_cls, + ): + + mock_2026 = AsyncMock() + mock_2026.tool_get.side_effect = ProtocolNegotiationError("2025-06-18") + mock_2026_cls.return_value = mock_2026 + + mock_2025 = AsyncMock() + # The fallback transport also throws the error + mock_2025.tool_get.side_effect = ProtocolNegotiationError("2024-11-05") + mock_2025_cls.return_value = mock_2025 + + async with ToolboxClient( + TEST_BASE_URL, protocol=Protocol.MCP_v20260618 + ) as client: + with pytest.raises( + ProtocolNegotiationError, + match="Server requires protocol fallback to 2024-11-05", + ): + await client.load_tool(TOOL_NAME) + + # Assert we tried both, but then let the exception bubble up instead of looping + mock_2026.tool_get.assert_awaited_once() + mock_2025.tool_get.assert_awaited_once() + + class TestAuth: @pytest.fixture def expected_header(self): diff --git a/packages/toolbox-core/tests/test_e2e_mcp.py b/packages/toolbox-core/tests/test_e2e_mcp.py index d6f47c5eb..c35695ffa 100644 --- a/packages/toolbox-core/tests/test_e2e_mcp.py +++ b/packages/toolbox-core/tests/test_e2e_mcp.py @@ -100,6 +100,21 @@ async def test_run_tool_missing_params(self, get_n_rows_tool: ToolboxTool): with pytest.raises(TypeError, match="missing a required argument: 'num_rows'"): await get_n_rows_tool() + async def test_protocol_fallback_e2e(self): + """Tests that a client using MCP_LATEST can fallback to an older protocol against a server that doesn't support the latest version.""" + # The E2E server currently does not support DRAFT 2026, so this will trigger a fallback. + async with ToolboxClient( + "http://localhost:5000", protocol=Protocol.MCP_LATEST + ) as client: + tool = await client.load_tool("get-n-rows") + response = await tool(num_rows="1") + assert "row1" in response + # Verify that fallback occurred by checking the transport's final protocol version + assert ( + client._ToolboxClient__transport._protocol_version + != Protocol.MCP_LATEST.value + ) + async def test_run_tool_wrong_param_type(self, get_n_rows_tool: ToolboxTool): """Invoke a tool with wrong param type.""" with pytest.raises( From 5bcac7dbf149ac14ef74af015d231f180f502e94 Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Tue, 16 Jun 2026 18:44:38 +0530 Subject: [PATCH 15/20] chore: delint --- packages/toolbox-core/src/toolbox_core/client.py | 10 ++++++---- packages/toolbox-core/tests/test_client.py | 4 ++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/toolbox-core/src/toolbox_core/client.py b/packages/toolbox-core/src/toolbox_core/client.py index df6d1a26d..7bb32cdf9 100644 --- a/packages/toolbox-core/src/toolbox_core/client.py +++ b/packages/toolbox-core/src/toolbox_core/client.py @@ -120,7 +120,9 @@ def _protocol_version(self) -> str: # We must expose this for tests asserting the current protocol version. return getattr(self._active_transport, "_protocol_version", "") - async def _execute_with_fallback(self, method_name: str, *args, **kwargs) -> Any: + async def _execute_with_fallback( + self, method_name: str, *args: Any, **kwargs: Any + ) -> Any: try: return await getattr(self._active_transport, method_name)(*args, **kwargs) except ProtocolNegotiationError as e: @@ -133,13 +135,13 @@ async def _execute_with_fallback(self, method_name: str, *args, **kwargs) -> Any self._active_transport = self._create_transport(fallback_protocol) return await getattr(self._active_transport, method_name)(*args, **kwargs) - async def tool_get(self, *args, **kwargs) -> Any: + async def tool_get(self, *args: Any, **kwargs: Any) -> Any: return await self._execute_with_fallback("tool_get", *args, **kwargs) - async def tools_list(self, *args, **kwargs) -> Any: + async def tools_list(self, *args: Any, **kwargs: Any) -> Any: return await self._execute_with_fallback("tools_list", *args, **kwargs) - async def tool_invoke(self, *args, **kwargs) -> Any: + async def tool_invoke(self, *args: Any, **kwargs: Any) -> Any: return await self._execute_with_fallback("tool_invoke", *args, **kwargs) async def close(self) -> None: diff --git a/packages/toolbox-core/tests/test_client.py b/packages/toolbox-core/tests/test_client.py index 946e8f157..d41b3f581 100644 --- a/packages/toolbox-core/tests/test_client.py +++ b/packages/toolbox-core/tests/test_client.py @@ -266,10 +266,12 @@ async def test_load_tool_protocol_fallback_success(test_tool_str): ): mock_2026 = AsyncMock() + mock_2026.base_url = TEST_BASE_URL mock_2026.tool_get.side_effect = ProtocolNegotiationError("2025-06-18") mock_2026_cls.return_value = mock_2026 mock_2025 = AsyncMock() + mock_2025.base_url = TEST_BASE_URL mock_2025.tool_get.return_value = manifest mock_2025.tool_invoke.return_value = "ok_from_fallback" mock_2025_cls.return_value = mock_2025 @@ -310,10 +312,12 @@ async def test_load_tool_protocol_fallback_infinite_loop_prevention(test_tool_st ): mock_2026 = AsyncMock() + mock_2026.base_url = TEST_BASE_URL mock_2026.tool_get.side_effect = ProtocolNegotiationError("2025-06-18") mock_2026_cls.return_value = mock_2026 mock_2025 = AsyncMock() + mock_2025.base_url = TEST_BASE_URL # The fallback transport also throws the error mock_2025.tool_get.side_effect = ProtocolNegotiationError("2024-11-05") mock_2025_cls.return_value = mock_2025 From 678e443329e88f775677f8ad45e7baf6898b91fd Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Tue, 16 Jun 2026 19:13:15 +0530 Subject: [PATCH 16/20] fix: fix integration tests --- .../mcp_transport/v20260618/mcp.py | 66 +++++++++++-------- 1 file changed, 39 insertions(+), 27 deletions(-) diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py index 53f81a63a..868864020 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py @@ -19,7 +19,7 @@ from ... import version from ...exceptions import ProtocolNegotiationError -from ...protocol import ManifestSchema, TelemetryAttributes +from ...protocol import ManifestSchema, Protocol, TelemetryAttributes from .. import telemetry from ..transport_base import _McpHttpTransportBase from . import types @@ -72,33 +72,45 @@ async def _send_request( if response.status == 400: try: json_resp = await response.json() - if ( - "error" in json_resp - and json_resp["error"].get("code") == -32004 - ): - if is_retry: - raise RuntimeError( - "Protocol negotiation failed: server rejected negotiated version" - ) - - server_supported = ( - json_resp["error"].get("data", {}).get("supported", []) - ) - from ...protocol import Protocol - - client_supported = Protocol.get_supported_mcp_versions() - mutually_supported = [ - v for v in client_supported if v in server_supported - ] - - if mutually_supported: - raise ProtocolNegotiationError(mutually_supported[0]) - else: - raise RuntimeError( - "No mutually supported protocol version. " - f"Client supports: {client_supported}, " - f"Server supports: {server_supported}" + if "error" in json_resp: + err_val = json_resp["error"] + if ( + isinstance(err_val, dict) + and err_val.get("code") == -32004 + ): + if is_retry: + raise RuntimeError( + "Protocol negotiation failed: server rejected negotiated version" + ) + + server_supported = err_val.get("data", {}).get( + "supported", [] ) + from ...protocol import Protocol + + client_supported = Protocol.get_supported_mcp_versions() + mutually_supported = [ + v for v in client_supported if v in server_supported + ] + + if mutually_supported: + raise ProtocolNegotiationError( + mutually_supported[0] + ) + else: + raise RuntimeError( + "No mutually supported protocol version. " + f"Client supports: {client_supported}, " + f"Server supports: {server_supported}" + ) + elif ( + isinstance(err_val, str) + and "invalid protocol version" in err_val.lower() + ): + # Legacy 2025-06-18 servers don't use the -32004 code or provide + # a supported versions list. They return this raw string error + # instead. We safely assume 2025-06-18 here. + raise ProtocolNegotiationError(Protocol.MCP_v20250618) except Exception as e: if isinstance(e, (RuntimeError, ProtocolNegotiationError)): raise e From a6ad505b134865dd7ebf78a4d5ac5dfe606e9b3d Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Tue, 16 Jun 2026 19:31:05 +0530 Subject: [PATCH 17/20] chore: delint --- .../toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py index 868864020..64438c0b3 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py @@ -86,7 +86,6 @@ async def _send_request( server_supported = err_val.get("data", {}).get( "supported", [] ) - from ...protocol import Protocol client_supported = Protocol.get_supported_mcp_versions() mutually_supported = [ From 95e233124b24b65dc089198495c84fc903b609e8 Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Tue, 23 Jun 2026 15:24:45 +0530 Subject: [PATCH 18/20] chore: fix license header Co-authored-by: Averi Kitsch --- packages/toolbox-core/src/toolbox_core/exceptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/toolbox-core/src/toolbox_core/exceptions.py b/packages/toolbox-core/src/toolbox_core/exceptions.py index dfa696806..384bcd52c 100644 --- a/packages/toolbox-core/src/toolbox_core/exceptions.py +++ b/packages/toolbox-core/src/toolbox_core/exceptions.py @@ -4,7 +4,7 @@ # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # -# http://www.apache.org/licenses/LICENSE-; +# http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, From 7e2809aa47a898af7e48ad69903015694d1ca818 Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Tue, 23 Jun 2026 15:35:24 +0530 Subject: [PATCH 19/20] chore: remove unused retry logic from inside the transport class --- .../mcp_transport/v20260618/mcp.py | 209 +++++++++--------- 1 file changed, 101 insertions(+), 108 deletions(-) diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py index 64438c0b3..8eaec6f33 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py @@ -36,119 +36,112 @@ async def _send_request( request: types.MCPRequest[ReceiveResultT] | types.MCPNotification, headers: Optional[Mapping[str, str]] = None, ) -> ReceiveResultT | None: - """Sends a JSON-RPC request to the MCP server with version negotiation retry.""" - is_retry = False - while True: - req_headers = dict(headers or {}) - req_headers["MCP-Protocol-Version"] = self._protocol_version - - # Dynamically update the _meta protocol version in the parameters model - if hasattr(request, "params") and request.params is not None: - if ( - hasattr(request.params, "field_meta") - and request.params.field_meta is not None - ): - request.params.field_meta.protocol_version = self._protocol_version - - params = ( - request.params.model_dump(mode="json", exclude_none=True, by_alias=True) - if isinstance(request.params, BaseModel) - else request.params - ) + """Sends a JSON-RPC request to the MCP server.""" + req_headers = dict(headers or {}) + req_headers["MCP-Protocol-Version"] = self._protocol_version + + # Dynamically update the _meta protocol version in the parameters model + if hasattr(request, "params") and request.params is not None: + if ( + hasattr(request.params, "field_meta") + and request.params.field_meta is not None + ): + request.params.field_meta.protocol_version = self._protocol_version + + params = ( + request.params.model_dump(mode="json", exclude_none=True, by_alias=True) + if isinstance(request.params, BaseModel) + else request.params + ) - rpc_msg: BaseModel - if isinstance(request, types.MCPNotification): - rpc_msg = types.JSONRPCNotification( - method=request.method, params=params - ) - else: - rpc_msg = types.JSONRPCRequest(method=request.method, params=params) - - payload = rpc_msg.model_dump(mode="json", exclude_none=True) - - async with self._session.post( - url, json=payload, headers=req_headers - ) as response: - if response.status == 400: - try: - json_resp = await response.json() - if "error" in json_resp: - err_val = json_resp["error"] - if ( - isinstance(err_val, dict) - and err_val.get("code") == -32004 - ): - if is_retry: - raise RuntimeError( - "Protocol negotiation failed: server rejected negotiated version" - ) - - server_supported = err_val.get("data", {}).get( - "supported", [] + rpc_msg: BaseModel + if isinstance(request, types.MCPNotification): + rpc_msg = types.JSONRPCNotification( + method=request.method, params=params + ) + else: + rpc_msg = types.JSONRPCRequest(method=request.method, params=params) + + payload = rpc_msg.model_dump(mode="json", exclude_none=True) + + async with self._session.post( + url, json=payload, headers=req_headers + ) as response: + if response.status == 400: + try: + json_resp = await response.json() + if "error" in json_resp: + err_val = json_resp["error"] + if ( + isinstance(err_val, dict) + and err_val.get("code") == -32004 + ): + server_supported = err_val.get("data", {}).get( + "supported", [] + ) + + client_supported = Protocol.get_supported_mcp_versions() + mutually_supported = [ + v for v in client_supported if v in server_supported + ] + + if mutually_supported: + raise ProtocolNegotiationError( + mutually_supported[0] ) + else: + raise RuntimeError( + "No mutually supported protocol version. " + f"Client supports: {client_supported}, " + f"Server supports: {server_supported}" + ) + elif ( + isinstance(err_val, str) + and "invalid protocol version" in err_val.lower() + ): + # Legacy 2025-06-18 servers don't use the -32004 code or provide + # a supported versions list. They return this raw string error + # instead. We safely assume 2025-06-18 here. + raise ProtocolNegotiationError(Protocol.MCP_v20250618) + except Exception as e: + if isinstance(e, (RuntimeError, ProtocolNegotiationError)): + raise e + + if not response.ok: + error_text = await response.text() + raise RuntimeError( + "API request failed with status" + f" {response.status} ({response.reason}). Server response:" + f" {error_text}" + ) - client_supported = Protocol.get_supported_mcp_versions() - mutually_supported = [ - v for v in client_supported if v in server_supported - ] - - if mutually_supported: - raise ProtocolNegotiationError( - mutually_supported[0] - ) - else: - raise RuntimeError( - "No mutually supported protocol version. " - f"Client supports: {client_supported}, " - f"Server supports: {server_supported}" - ) - elif ( - isinstance(err_val, str) - and "invalid protocol version" in err_val.lower() - ): - # Legacy 2025-06-18 servers don't use the -32004 code or provide - # a supported versions list. They return this raw string error - # instead. We safely assume 2025-06-18 here. - raise ProtocolNegotiationError(Protocol.MCP_v20250618) - except Exception as e: - if isinstance(e, (RuntimeError, ProtocolNegotiationError)): - raise e - - if not response.ok: - error_text = await response.text() + if response.status == 204 or response.content.at_eof(): + return None + + json_resp = await response.json() + + # Check for JSON-RPC Error + if "error" in json_resp: + try: + err = types.JSONRPCError.model_validate(json_resp).error raise RuntimeError( - "API request failed with status" - f" {response.status} ({response.reason}). Server response:" - f" {error_text}" + f"MCP request failed with code {err.code}: {err.message}" ) - - if response.status == 204 or response.content.at_eof(): - return None - - json_resp = await response.json() - - # Check for JSON-RPC Error - if "error" in json_resp: - try: - err = types.JSONRPCError.model_validate(json_resp).error - raise RuntimeError( - f"MCP request failed with code {err.code}: {err.message}" - ) - except Exception: - # Fallback if the error doesn't match our schema exactly - raw_error = json_resp.get("error", {}) - raise RuntimeError(f"MCP request failed: {raw_error}") - - # Parse Result - if isinstance(request, types.MCPRequest): - try: - rpc_resp = types.JSONRPCResponse.model_validate(json_resp) - return request.get_result_model().model_validate( - rpc_resp.result - ) - except Exception as e: - raise RuntimeError(f"Failed to parse JSON-RPC response: {e}") - return None + except Exception: + # Fallback if the error doesn't match our schema exactly + raw_error = json_resp.get("error", {}) + raise RuntimeError(f"MCP request failed: {raw_error}") + + # Parse Result + if isinstance(request, types.MCPRequest): + try: + rpc_resp = types.JSONRPCResponse.model_validate(json_resp) + return request.get_result_model().model_validate( + rpc_resp.result + ) + except Exception as e: + raise RuntimeError(f"Failed to parse JSON-RPC response: {e}") + return None async def _initialize_session( self, headers: Optional[Mapping[str, str]] = None From c99f6c1bb12e28384c84fbf25005ce48d4c6a54b Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Tue, 23 Jun 2026 15:39:33 +0530 Subject: [PATCH 20/20] chore: delint --- .../toolbox_core/mcp_transport/v20260618/mcp.py | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py index 8eaec6f33..5633bc4ad 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20260618/mcp.py @@ -56,9 +56,7 @@ async def _send_request( rpc_msg: BaseModel if isinstance(request, types.MCPNotification): - rpc_msg = types.JSONRPCNotification( - method=request.method, params=params - ) + rpc_msg = types.JSONRPCNotification(method=request.method, params=params) else: rpc_msg = types.JSONRPCRequest(method=request.method, params=params) @@ -72,10 +70,7 @@ async def _send_request( json_resp = await response.json() if "error" in json_resp: err_val = json_resp["error"] - if ( - isinstance(err_val, dict) - and err_val.get("code") == -32004 - ): + if isinstance(err_val, dict) and err_val.get("code") == -32004: server_supported = err_val.get("data", {}).get( "supported", [] ) @@ -86,9 +81,7 @@ async def _send_request( ] if mutually_supported: - raise ProtocolNegotiationError( - mutually_supported[0] - ) + raise ProtocolNegotiationError(mutually_supported[0]) else: raise RuntimeError( "No mutually supported protocol version. " @@ -136,9 +129,7 @@ async def _send_request( if isinstance(request, types.MCPRequest): try: rpc_resp = types.JSONRPCResponse.model_validate(json_resp) - return request.get_result_model().model_validate( - rpc_resp.result - ) + return request.get_result_model().model_validate(rpc_resp.result) except Exception as e: raise RuntimeError(f"Failed to parse JSON-RPC response: {e}") return None