Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/toolbox-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,29 @@ The core package provides a framework-agnostic way to interact with your Toolbox
- [Loading Tools](https://mcp-toolbox.dev/documentation/connect-to/toolbox-sdks/python-sdk/core/#loading-tools)
- [Invoking Tools](https://mcp-toolbox.dev/documentation/connect-to/toolbox-sdks/python-sdk/core/#invoking-tools)
- [Synchronous Usage](https://mcp-toolbox.dev/documentation/connect-to/toolbox-sdks/python-sdk/core/#synchronous-usage)
- [Protocol Negotiation](#protocol-negotiation)
- [Use With Langraph](https://mcp-toolbox.dev/documentation/connect-to/toolbox-sdks/python-sdk/core/#use-with-langgraph)

- [Client to Server Authentication](https://mcp-toolbox.dev/documentation/connect-to/toolbox-sdks/python-sdk/core/#client-to-server-authentication)
- [Authenticating Tools](https://mcp-toolbox.dev/documentation/connect-to/toolbox-sdks/python-sdk/core/#authenticating-tools)
- [Binding Parameter Values](https://mcp-toolbox.dev/documentation/connect-to/toolbox-sdks/python-sdk/core/#parameter-binding)
- [OpenTelemetry](https://mcp-toolbox.dev/documentation/connect-to/toolbox-sdks/python-sdk/core/#opentelemetry)

## Protocol Negotiation
Comment thread
anubhav756 marked this conversation as resolved.

By default, the client negotiates the newest protocol version supported by the server. You can provide a custom list of supported protocols to restrict negotiation to specific versions or a single version. Ensure you pass the [`Protocol`](src/toolbox_core/protocol.py) enum constants. Both `Protocol.MCP_LATEST` and `Protocol.MCP_DRAFT` are supported as well.

```py
from toolbox_core import ToolboxClient, Protocol

async def main():
async with ToolboxClient(
"http://127.0.0.1:5000",
protocol=[Protocol.MCP_LATEST, Protocol.MCP_DRAFT]
) as toolbox:
pass
```


# Contributing

Expand Down
38 changes: 36 additions & 2 deletions packages/toolbox-core/src/toolbox_core/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,14 @@ def __init__(
client_name: Optional[str],
client_version: Optional[str],
telemetry_enabled: bool,
supported_protocols: Optional[list[str]] = None,
):
self._url = url
self._session = session
self._client_name = client_name
self._client_version = client_version
self._telemetry_enabled = telemetry_enabled
self._supported_protocols = supported_protocols
self._active_transport = self._create_transport(protocol)

def _create_transport(self, protocol: Protocol) -> ITransport:
Expand All @@ -71,6 +73,7 @@ def _create_transport(self, protocol: Protocol) -> ITransport:
self._client_name,
self._client_version,
telemetry_enabled=self._telemetry_enabled,
supported_protocols=self._supported_protocols,
)
case Protocol.MCP_v20251125:
return McpHttpTransportV20251125(
Expand All @@ -80,6 +83,7 @@ def _create_transport(self, protocol: Protocol) -> ITransport:
self._client_name,
self._client_version,
telemetry_enabled=self._telemetry_enabled,
supported_protocols=self._supported_protocols,
)
case Protocol.MCP_v20250618:
return McpHttpTransportV20250618(
Expand All @@ -89,6 +93,7 @@ def _create_transport(self, protocol: Protocol) -> ITransport:
self._client_name,
self._client_version,
telemetry_enabled=self._telemetry_enabled,
supported_protocols=self._supported_protocols,
)
case Protocol.MCP_v20250326:
return McpHttpTransportV20250326(
Expand All @@ -98,6 +103,7 @@ def _create_transport(self, protocol: Protocol) -> ITransport:
self._client_name,
self._client_version,
telemetry_enabled=self._telemetry_enabled,
supported_protocols=self._supported_protocols,
)
case Protocol.MCP_v20241105:
return McpHttpTransportV20241105(
Expand All @@ -107,6 +113,7 @@ def _create_transport(self, protocol: Protocol) -> ITransport:
self._client_name,
self._client_version,
telemetry_enabled=self._telemetry_enabled,
supported_protocols=self._supported_protocols,
)
case _:
raise ValueError(f"Unsupported MCP protocol version: {protocol}")
Expand Down Expand Up @@ -166,7 +173,7 @@ def __init__(
client_headers: Optional[
Mapping[str, Union[Callable[[], str], Callable[[], Awaitable[str]], str]]
] = None,
protocol: Protocol = Protocol.MCP,
protocol: Union[Protocol, list[Protocol], list[str]] = Protocol.MCP,
client_name: Optional[str] = None,
client_version: Optional[str] = None,
telemetry_enabled: bool = False,
Expand All @@ -188,13 +195,40 @@ def __init__(
telemetry_enabled: Whether to enable OpenTelemetry tracing and metrics. (Default: False)
"""

if isinstance(protocol, list):
if not protocol:
raise ValueError("protocol list cannot be empty")
user_protocols = [
p.value if isinstance(p, Protocol) else str(p) for p in protocol
]

supported_mcp_versions = Protocol.get_supported_mcp_versions()
for p in user_protocols:
if p not in supported_mcp_versions:
raise ValueError(
f"Invalid protocol version '{p}'. Must be one of: {supported_mcp_versions}"
)

user_protocols_set = set(user_protocols)
# Intersect with the globally sorted list to strictly enforce newest-to-oldest ordering
supported_protocols = [
v for v in supported_mcp_versions if v in user_protocols_set
]
if not supported_protocols:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every user_protocols entry is already validated to be insupported_mcp_versions, and the list is non-empty, so the intersection can never be empty here.

Seems like unreachable code. Should we remove this?

raise ValueError("No supported protocols found in the provided list")
initial_protocol = Protocol(supported_protocols[0])
else:
supported_protocols = None
initial_protocol = protocol

Comment on lines +198 to +223

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be possible to document this new behaviour?

Eg.
protocol=Protocol.MCP_LATESTsupported_protocols=None → transport falls back through all older versions during negotiation.

protocol=[Protocol.MCP_LATEST]supported_protocols =["2025-11-25"] → negotiation is restricted to that one version, no fallback.

Also that the newest version in the list is negotiated for at first

self.__transport = _McpTransportProxy(
url,
session,
protocol,
initial_protocol,
client_name,
client_version,
telemetry_enabled,
supported_protocols,
)

self.__client_headers = client_headers if client_headers is not None else {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ def __init__(
client_name: Optional[str] = None,
client_version: Optional[str] = None,
telemetry_enabled: bool = False,
supported_protocols: Optional[list[str]] = None,
):
self._mcp_base_url = f"{base_url}/mcp/"
self._protocol_version = protocol.value
Expand All @@ -50,6 +51,9 @@ def __init__(
self._client_name = client_name
self._client_version = client_version
self._telemetry_enabled = telemetry.resolve_telemetry_enabled(telemetry_enabled)
self._supported_protocols = (
supported_protocols or Protocol.get_supported_mcp_versions()
)

self._tracer: Optional[telemetry.Tracer] = None
self._operation_duration_histogram: Optional[telemetry.Histogram] = None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ async def _send_request(
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()
client_supported = self._supported_protocols
mutually_supported = [
v for v in client_supported if v in server_supported
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ async def _send_request(
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()
client_supported = self._supported_protocols
mutually_supported = [
v for v in client_supported if v in server_supported
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ async def _send_request(
"supported", []
)

client_supported = Protocol.get_supported_mcp_versions()
client_supported = self._supported_protocols
mutually_supported = [
v for v in client_supported if v in server_supported
]
Expand Down Expand Up @@ -131,7 +131,7 @@ async def _send_request(
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()
client_supported = self._supported_protocols
mutually_supported = [
v for v in client_supported if v in server_supported
]
Expand Down
12 changes: 7 additions & 5 deletions packages/toolbox-core/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
from google.auth import compute_engine
from google.cloud import secretmanager, storage

from tests.constants import TOOLBOX_SERVER_URL_DRAFT, TOOLBOX_SERVER_URL_STABLE


#### Define Utility Functions
def get_env_var(key: str) -> str:
Expand Down Expand Up @@ -187,7 +189,7 @@ def toolbox_server(toolbox_version: str, tools_file_path: str) -> Generator[None


@pytest.fixture(
params=["http://localhost:5000", "http://localhost:5001"], scope="session"
params=[TOOLBOX_SERVER_URL_STABLE, TOOLBOX_SERVER_URL_DRAFT], scope="session"
)
def toolbox_server_url(request) -> str:
return request.param
Expand All @@ -201,13 +203,13 @@ def patch_toolbox_client_url(toolbox_server_url):
original_init = ToolboxClient.__init__
original_sync_init = ToolboxSyncClient.__init__

def new_init(self, url="http://localhost:5000", *args, **kwargs):
if url == "http://localhost:5000":
def new_init(self, url=TOOLBOX_SERVER_URL_STABLE, *args, **kwargs):
if url == TOOLBOX_SERVER_URL_STABLE:
url = toolbox_server_url
original_init(self, url, *args, **kwargs)

def new_sync_init(self, url="http://localhost:5000", *args, **kwargs):
if url == "http://localhost:5000":
def new_sync_init(self, url=TOOLBOX_SERVER_URL_STABLE, *args, **kwargs):
if url == TOOLBOX_SERVER_URL_STABLE:
url = toolbox_server_url
original_sync_init(self, url, *args, **kwargs)

Expand Down
16 changes: 16 additions & 0 deletions packages/toolbox-core/tests/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# 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.

TOOLBOX_SERVER_URL_STABLE = "http://localhost:5000"
TOOLBOX_SERVER_URL_DRAFT = "http://localhost:5001"
32 changes: 30 additions & 2 deletions packages/toolbox-core/tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import pytest
from aiohttp import web

from tests.constants import TOOLBOX_SERVER_URL_STABLE
from toolbox_core.client import ToolboxClient
from toolbox_core.itransport import ITransport
from toolbox_core.protocol import (
Expand Down Expand Up @@ -814,7 +815,7 @@ def test_toolbox_client_no_warning_on_mcp():
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")

client = ToolboxClient("http://localhost:5000", protocol=Protocol.MCP)
client = ToolboxClient(TOOLBOX_SERVER_URL_STABLE, protocol=Protocol.MCP)
assert len(w) == 0


Expand All @@ -825,6 +826,33 @@ def test_toolbox_client_no_warning_on_explicit_mcp_version():
warnings.simplefilter("always")

client = ToolboxClient(
"http://localhost:5000", protocol=Protocol.MCP_v20251125
TOOLBOX_SERVER_URL_STABLE, protocol=Protocol.MCP_v20251125
)
assert len(w) == 0


def test_toolbox_client_custom_protocols():
"""Test that custom protocols array is correctly parsed and sorted."""
with patch("toolbox_core.client._McpTransportProxy") as mock_proxy:
client = ToolboxClient(
TOOLBOX_SERVER_URL_STABLE,
protocol=[Protocol.MCP_v20241105, Protocol.MCP_DRAFT, "2025-06-18"],
)
mock_proxy.assert_called_once()
args, kwargs = mock_proxy.call_args

# Check initial_protocol
assert args[2] == Protocol.MCP_DRAFT
# Check supported_protocols (must be sorted from newest to oldest)
assert args[6] == ["DRAFT-2026-v1", "2025-06-18", "2024-11-05"]


def test_toolbox_client_custom_protocols_invalid():
"""Test that custom protocols array raises error on invalid inputs."""
import pytest

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: pytest is also exported at the top of the file


with pytest.raises(ValueError, match="protocol list cannot be empty"):
ToolboxClient(TOOLBOX_SERVER_URL_STABLE, protocol=[])

with pytest.raises(ValueError, match="Invalid protocol version 'invalid-version'"):
ToolboxClient(TOOLBOX_SERVER_URL_STABLE, protocol=["invalid-version"])
5 changes: 3 additions & 2 deletions packages/toolbox-core/tests/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import pytest_asyncio
from pydantic import ValidationError

from tests.constants import TOOLBOX_SERVER_URL_STABLE
from toolbox_core.client import ToolboxClient
from toolbox_core.protocol import Protocol
from toolbox_core.tool import ToolboxTool
Expand All @@ -28,7 +29,7 @@
@pytest_asyncio.fixture(scope="function")
async def toolbox():
"""Creates a ToolboxClient instance shared by all tests in this module."""
toolbox = ToolboxClient("http://localhost:5000", protocol=Protocol.MCP)
toolbox = ToolboxClient(TOOLBOX_SERVER_URL_STABLE, protocol=Protocol.MCP)
try:
yield toolbox
finally:
Expand Down Expand Up @@ -112,7 +113,7 @@ async def test_run_tool_wrong_param_type(self, get_n_rows_tool: ToolboxTool):
async def test_load_and_run_tool_with_telemetry(self, telemetry_enabled: bool):
"""Load and invoke a tool with telemetry_enabled=True/False."""
async with ToolboxClient(
"http://localhost:5000",
TOOLBOX_SERVER_URL_STABLE,
protocol=Protocol.MCP,
telemetry_enabled=telemetry_enabled,
) as toolbox:
Expand Down
Loading
Loading