Skip to content
Merged
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
3 changes: 2 additions & 1 deletion packages/toolbox-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,9 @@ You can explicitly select a protocol using the `protocol` option during client i

| Constant | Description |
| :--- | :--- |
| `Protocol.MCP` | **(Default)** Alias for the latest supported MCP version (currently `v2025-06-18`). |
| `Protocol.MCP` | **(Default)** Alias for the latest supported MCP version (currently `v2025-11-25`). |
| `Protocol.TOOLBOX` | The native Toolbox HTTP protocol. |
| `Protocol.MCP_v20251125` | MCP Protocol version 2025-11-25. |
| `Protocol.MCP_v20250618` | MCP Protocol version 2025-06-18. |
| `Protocol.MCP_v20250326` | MCP Protocol version 2025-03-26. |
| `Protocol.MCP_v20241105` | MCP Protocol version 2024-11-05. |
Expand Down
5 changes: 4 additions & 1 deletion packages/toolbox-core/src/toolbox_core/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
McpHttpTransportV20241105,
McpHttpTransportV20250326,
McpHttpTransportV20250618,
McpHttpTransportV20251125,
)
from .protocol import Protocol, ToolSchema
from .tool import ToolboxTool
Expand Down Expand Up @@ -67,7 +68,9 @@ def __init__(
if protocol == Protocol.TOOLBOX:
self.__transport = ToolboxTransport(url, session)
elif protocol in Protocol.get_supported_mcp_versions():
if protocol == Protocol.MCP_v20250618:
if protocol == Protocol.MCP_v20251125:
self.__transport = McpHttpTransportV20251125(url, session, protocol)
elif protocol == Protocol.MCP_v20250618:
self.__transport = McpHttpTransportV20250618(url, session, protocol)
elif protocol == Protocol.MCP_v20250326:
self.__transport = McpHttpTransportV20250326(url, session, protocol)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@
from .v20241105.mcp import McpHttpTransportV20241105
from .v20250326.mcp import McpHttpTransportV20250326
from .v20250618.mcp import McpHttpTransportV20250618
from .v20251125.mcp import McpHttpTransportV20251125

__all__ = [
"McpHttpTransportV20241105",
"McpHttpTransportV20250326",
"McpHttpTransportV20250618",
"McpHttpTransportV20251125",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
# 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 typing import Mapping, Optional, TypeVar

from pydantic import BaseModel

from ... import version
from ...protocol import ManifestSchema
from ..transport_base import _McpHttpTransportBase
from . import types

ReceiveResultT = TypeVar("ReceiveResultT", bound=BaseModel)


class McpHttpTransportV20251125(_McpHttpTransportBase):
"""Transport for the MCP v2025-11-25 protocol."""

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."""
req_headers = dict(headers or {})
req_headers["MCP-Protocol-Version"] = self._protocol_version

params = (
request.params.model_dump(mode="json", exclude_none=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 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:
"""Initializes the MCP session."""
params = types.InitializeRequestParams(
protocolVersion=self._protocol_version,
capabilities=types.ClientCapabilities(),
clientInfo=types.Implementation(
name="toolbox-python-sdk", version=version.__version__
),
)

result = await self._send_request(
url=self._mcp_base_url,
request=types.InitializeRequest(params=params),
headers=headers,
)

if result is None:
raise RuntimeError("Failed to initialize session: No response from server.")

self._server_version = result.serverInfo.version

if result.protocolVersion != self._protocol_version:
raise RuntimeError(
"MCP version mismatch: client does not support server version"
f" {result.protocolVersion}"
)

if not result.capabilities.tools:
if self._manage_session:
await self.close()
raise RuntimeError("Server does not support the 'tools' capability.")

await self._send_request(
url=self._mcp_base_url,
request=types.InitializedNotification(),
headers=headers,
)

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 "")
result = await self._send_request(
url=url, request=types.ListToolsRequest(), 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,
)

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]]
) -> str:
"""Invokes a specific tool on the server using the MCP protocol."""
await self._ensure_initialized(headers=headers)

result = await self._send_request(
url=self._mcp_base_url,
request=types.CallToolRequest(
params=types.CallToolRequestParams(name=tool_name, arguments=arguments)
),
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)
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# 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 RequestParams(_BaseMCPModel):
pass


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 BaseMetadata(_BaseMCPModel):
name: str


class Implementation(BaseMetadata):
version: str


class ClientCapabilities(_BaseMCPModel):
pass


class InitializeRequestParams(RequestParams):
protocolVersion: str
capabilities: ClientCapabilities
clientInfo: Implementation


class ServerCapabilities(_BaseMCPModel):
prompts: dict[str, Any] | None = None
tools: dict[str, Any] | None = None


class InitializeResult(_BaseMCPModel):
protocolVersion: str
capabilities: ServerCapabilities
serverInfo: Implementation
instructions: str | None = None


class Tool(BaseMetadata):
description: str | None = None
inputSchema: dict[str, Any]


class ListToolsResult(_BaseMCPModel):
tools: list[Tool]


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 InitializeRequest(MCPRequest[InitializeResult]):
method: Literal["initialize"] = "initialize"
params: InitializeRequestParams

def get_result_model(self) -> Type[InitializeResult]:
return InitializeResult


class InitializedNotification(MCPNotification):
method: Literal["notifications/initialized"] = "notifications/initialized"
params: dict[str, Any] = {}


class ListToolsRequest(MCPRequest[ListToolsResult]):
method: Literal["tools/list"] = "tools/list"
params: dict[str, Any] = {}

def get_result_model(self) -> Type[ListToolsResult]:
return ListToolsResult


class CallToolRequestParams(_BaseMCPModel):
name: str
arguments: dict[str, Any]


class CallToolRequest(MCPRequest[CallToolResult]):
method: Literal["tools/call"] = "tools/call"
params: CallToolRequestParams

def get_result_model(self) -> Type[CallToolResult]:
return CallToolResult
Loading
Loading