Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
47a2a46
try
twishabansal Aug 26, 2025
9cd9a79
version negotiation
twishabansal Aug 26, 2025
5964bcc
small changes
twishabansal Aug 26, 2025
4bac725
lint
twishabansal Aug 26, 2025
d8c6efb
fix endpoint
twishabansal Aug 26, 2025
b17e3ee
add some todos
twishabansal Aug 28, 2025
5b4d12c
lint
twishabansal Aug 28, 2025
388c7f9
initialise in init
twishabansal Aug 28, 2025
ef4e543
lint
twishabansal Aug 28, 2025
da384be
add support for 'Mcp-session-id'
twishabansal Aug 29, 2025
c2ad274
lint
twishabansal Aug 29, 2025
e88dfa7
add todo
twishabansal Aug 29, 2025
c9728a9
add mcp protocol version to the latest protocol
twishabansal Aug 29, 2025
c66dd26
add test coverage
twishabansal Aug 29, 2025
3cd00ea
small fix
twishabansal Aug 29, 2025
11ac6a2
small fix
twishabansal Aug 29, 2025
02baad7
small fix
twishabansal Aug 29, 2025
6ae38e1
thread fixes
twishabansal Aug 29, 2025
fb59bb5
try
twishabansal Aug 29, 2025
765db81
add tests
twishabansal Aug 29, 2025
f1c0807
lint
twishabansal Aug 29, 2025
24db78d
change small
twishabansal Aug 29, 2025
dcc811a
nit
twishabansal Sep 1, 2025
a4a4f55
small debugging
twishabansal Sep 1, 2025
19a1cf2
add todos
twishabansal Sep 1, 2025
914ec46
small bug fixes
twishabansal Sep 1, 2025
e922472
add todo
twishabansal Sep 1, 2025
8c14096
remove id field from notifications
twishabansal Sep 1, 2025
6c97083
refactor
twishabansal Sep 1, 2025
9dfa8cb
preprocess tools with empty params
twishabansal Sep 1, 2025
6f74838
fix types
twishabansal Sep 1, 2025
9118a89
fix bugs
twishabansal Sep 1, 2025
fbce7e9
better error log
twishabansal Sep 1, 2025
b6b2dbe
small cleanup
twishabansal Sep 1, 2025
ac2a924
handle notifications
twishabansal Sep 1, 2025
1fd0581
fix unit tests
twishabansal Sep 1, 2025
ec17eb8
lint
twishabansal Sep 1, 2025
1cffac1
decouple client from transport
twishabansal Sep 1, 2025
cc30a17
lint
twishabansal Sep 1, 2025
2f04c95
use toolbox protocol for e2e tests
twishabansal Sep 1, 2025
d80c41f
add e2e tests for mcp
twishabansal Sep 1, 2025
baf9d06
lint
twishabansal Sep 1, 2025
cd9841e
remove mcp as default protocol
twishabansal Sep 2, 2025
83030dc
remove auth tests from mcp
twishabansal Sep 2, 2025
bb8dc97
remove redundant lines
twishabansal Sep 2, 2025
8920538
remove redundant lines
twishabansal Sep 2, 2025
f70710f
lint
twishabansal Sep 2, 2025
80c688a
revert some changes
twishabansal Sep 2, 2025
4c42d33
initialise session in a better way
twishabansal Sep 2, 2025
9c119e8
small fix
twishabansal Sep 2, 2025
e281556
Made methods private
twishabansal Sep 4, 2025
e0a1337
lint
twishabansal Sep 4, 2025
85e5d29
rename base url
twishabansal Sep 4, 2025
ac2acfe
resolve comment
twishabansal Sep 4, 2025
d061f3e
better readability
twishabansal Sep 4, 2025
45d66d9
refactor mcp versions
twishabansal Oct 14, 2025
a444a4b
lint
twishabansal Oct 14, 2025
1e2d09c
Merge branch 'client-transport-decouple' into mcp-transport-implement
twishabansal Oct 14, 2025
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
24 changes: 21 additions & 3 deletions packages/toolbox-core/src/toolbox_core/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@
from deprecated import deprecated

from .itransport import ITransport
from .protocol import ToolSchema
from .mcp_transport import (
McpHttpTransport_v20241105,
McpHttpTransport_v20250326,
McpHttpTransport_v20250618,
)
from .protocol import Protocol, ToolSchema
from .tool import ToolboxTool
from .toolbox_transport import ToolboxTransport
from .utils import identify_auth_requirements, resolve_value
Expand All @@ -44,6 +49,7 @@ def __init__(
client_headers: Optional[
Mapping[str, Union[Callable[[], str], Callable[[], Awaitable[str]], str]]
] = None,
protocol: Protocol = Protocol.TOOLBOX,
):
"""
Initializes the ToolboxClient.
Expand All @@ -54,10 +60,22 @@ def __init__(
If None (default), a new session is created internally. Note that
if a session is provided, its lifecycle (including closing)
should typically be managed externally.
client_headers: Headers to include in each request sent through this client.
client_headers: Headers to include in each request sent through this
client.
protocol: The communication protocol to use.
"""
if protocol == Protocol.TOOLBOX:
self.__transport = ToolboxTransport(url, session)
elif protocol in Protocol.get_supported_mcp_versions():
if protocol == Protocol.MCP_v20250618:
self.__transport = McpHttpTransport_v20250618(url, session, protocol)
elif protocol == Protocol.MCP_v20250326:
self.__transport = McpHttpTransport_v20250326(url, session, protocol)
elif protocol == Protocol.MCP_v20241105:
self.__transport = McpHttpTransport_v20241105(url, session, protocol)
else:
raise ValueError(f"Unsupported MCP protocol version: {protocol}")

self.__transport = ToolboxTransport(url, session)
self.__client_headers = client_headers if client_headers is not None else {}

def __parse_tool(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Copyright 2025 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 .v20241105 import McpHttpTransport_v20241105
from .v20250326 import McpHttpTransport_v20250326
from .v20250618 import McpHttpTransport_v20250618

__all__ = [
"McpHttpTransport_v20241105",
"McpHttpTransport_v20250326",
"McpHttpTransport_v20250618",
]
223 changes: 223 additions & 0 deletions packages/toolbox-core/src/toolbox_core/mcp_transport/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
# Copyright 2025 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 asyncio
from abc import ABC, abstractmethod
from typing import Any, Mapping, Optional

from aiohttp import ClientSession

from ..itransport import ITransport
from ..protocol import (
AdditionalPropertiesSchema,
ManifestSchema,
ParameterSchema,
Protocol,
ToolSchema,
)


class _McpHttpTransportBase(ITransport, ABC):
"""Base transport for MCP protocols."""

def __init__(
self,
base_url: str,
session: Optional[ClientSession] = None,
protocol: Protocol = Protocol.MCP,
):
self._mcp_base_url = base_url + "/mcp/"
self._protocol_version = protocol.value
self._server_version: Optional[str] = None

self._manage_session = session is None
self._session = session or ClientSession()
self._init_lock = asyncio.Lock()
self._init_task: Optional[asyncio.Task] = None

async def _ensure_initialized(self):
"""Ensures the session is initialized before making requests."""
async with self._init_lock:
if self._init_task is None:
self._init_task = asyncio.create_task(self._initialize_session())
await self._init_task

@property
def base_url(self) -> str:
return self._mcp_base_url

def _convert_tool_schema(self, tool_data: dict) -> ToolSchema:
parameters = []
input_schema = tool_data.get("inputSchema", {})
properties = input_schema.get("properties", {})
required = input_schema.get("required", [])

for name, schema in properties.items():
additional_props = schema.get("additionalProperties")
if isinstance(additional_props, dict):
additional_props = AdditionalPropertiesSchema(
type=additional_props["type"]
)
else:
additional_props = True
parameters.append(
ParameterSchema(
name=name,
type=schema["type"],
description=schema.get("description", ""),
required=name in required,
additionalProperties=additional_props,
)
)

return ToolSchema(description=tool_data["description"], parameters=parameters)

async def _list_tools(
self,
toolset_name: Optional[str] = None,
headers: Optional[Mapping[str, str]] = None,
) -> Any:
"""Private helper to fetch the raw tool list from the server."""
if toolset_name:
url = self._mcp_base_url + toolset_name
else:
url = self._mcp_base_url
return await self._send_request(
url=url, method="tools/list", params={}, headers=headers
)

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."""
await self._ensure_initialized()

if self._server_version is None:
raise RuntimeError("Server version not available.")

result = await self._list_tools(headers=headers)
tool_def = None
for tool in result.get("tools", []):
if tool.get("name") == tool_name:
tool_def = self._convert_tool_schema(tool)
break

if tool_def is None:
raise ValueError(f"Tool '{tool_name}' not found.")

tool_details = ManifestSchema(
serverVersion=self._server_version,
tools={tool_name: tool_def},
)
return tool_details

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()

if self._server_version is None:
raise RuntimeError("Server version not available.")

result = await self._list_tools(toolset_name, headers)
tools = result.get("tools")

return ManifestSchema(
serverVersion=self._server_version,
tools={tool["name"]: self._convert_tool_schema(tool) for tool in tools},
)

async def tool_invoke(
self, tool_name: str, arguments: dict, headers: Optional[Mapping[str, str]]
) -> str:
"""Invokes a specific tool on the server using the MCP protocol."""
await self._ensure_initialized()

url = self._mcp_base_url
params = {"name": tool_name, "arguments": arguments}
result = await self._send_request(
url=url, method="tools/call", params=params, headers=headers
)
all_content = result.get("content", result)
content_str = "".join(
content.get("text", "")
for content in all_content
if isinstance(content, dict)
)
return content_str or "null"

async def close(self):
async with self._init_lock:
if self._init_task:
try:
await self._init_task
except Exception:
# If initialization failed, we can still try to close.
pass
if self._manage_session and self._session and not self._session.closed:
await self._session.close()

async def _perform_initialization_and_negotiation(
self, params: dict, headers: Optional[Mapping[str, str]] = None
) -> Any:
"""Performs the common initialization and version negotiation logic."""
initialize_result = await self._send_request(
url=self._mcp_base_url, method="initialize", params=params, headers=headers
)

server_info = initialize_result.get("serverInfo")
if not server_info:
raise RuntimeError("Server info not found in initialize response")

self._server_version = server_info.get("version")
if not self._server_version:
raise RuntimeError("Server version not found in initialize response")

server_protocol_version = initialize_result.get("protocolVersion")
if server_protocol_version:
if server_protocol_version != self._protocol_version:
raise RuntimeError(
"MCP version mismatch: client does not support server version"
f" {server_protocol_version}"
)
else:
if self._manage_session:
await self.close()
raise RuntimeError("MCP Protocol version not found in initialize response")

server_capabilities = initialize_result.get("capabilities")
if not server_capabilities or "tools" not in server_capabilities:
if self._manage_session:
await self.close()
raise RuntimeError("Server does not support the 'tools' capability.")
return initialize_result

@abstractmethod
async def _initialize_session(self):
"""Initializes the MCP session."""
pass

@abstractmethod
async def _send_request(
self,
url: str,
method: str,
params: dict,
headers: Optional[Mapping[str, str]] = None,
) -> Any:
"""Sends a JSON-RPC request to the MCP server."""
pass
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Copyright 2025 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 os
import uuid
from typing import Any, Mapping, Optional

from .. import version
from .base import _McpHttpTransportBase


class McpHttpTransport_v20241105(_McpHttpTransportBase):
"""Transport for the MCP v2024-11-05 protocol."""

async def _send_request(
self,
url: str,
method: str,
params: dict,
headers: Optional[Mapping[str, str]] = None,
) -> Any:
"""Sends a JSON-RPC request to the MCP server."""
req_headers = dict(headers or {})
payload = {
"jsonrpc": "2.0",
"method": method,
"params": params,
}

if not method.startswith("notifications/"):
payload["id"] = str(uuid.uuid4())

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_response = await response.json()
if "error" in json_response:
error = json_response["error"]
raise RuntimeError(
f"MCP request failed with code {error['code']}: {error['message']}"
)
return json_response.get("result")

async def _initialize_session(self):
"""Initializes the MCP session."""
params = {
"processId": os.getpid(),
"clientInfo": {
"name": "toolbox-python-sdk",
"version": version.__version__,
},
"capabilities": {},
"protocolVersion": self._protocol_version,
}

await self._perform_initialization_and_negotiation(params)

await self._send_request(
url=self._mcp_base_url, method="notifications/initialized", params={}
)
Loading