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
8 changes: 5 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,8 @@ optional-dependencies.all = [
"llama-index-embeddings-google-genai>=0.3",
"llama-index-readers-file>=0.4",
"lxml>=5.3",
"mcp>=1.24,<2",
"httpx2>=2.5,<3",
"mcp>=2.0.0,<3",
"nltk!=3.10.1",
"oci>=2.126",
"openai>=2.20,<3",
Expand Down Expand Up @@ -245,7 +246,8 @@ optional-dependencies.gcp = [
]
optional-dependencies.mcp = [
"anyio>=4.9,<5",
"mcp>=1.24,<2",
"httpx2>=2.5,<3", # mcp 2.x's HTTP layer; imported directly by mcp_session_manager.
"mcp>=2.0.0,<3",
]
optional-dependencies.oci = [
"oci>=2.126", # OCI Generative AI native SDK (OCIGenAILlm)
Expand Down Expand Up @@ -297,7 +299,7 @@ optional-dependencies.test = [
"litellm>=1.84",
"llama-index-readers-file>=0.4",
"lxml>=5.3",
"mcp>=1.24,<2",
"mcp>=2.0.0,<3",
"nltk!=3.10.1", # Transitive via rouge-score and llama-index-core; 3.10.1's import hook breaks any venv living inside the working directory (reverted upstream in nltk/nltk#3732).
"openai>=2.20,<3",
"openpyxl>=3.1.5,<4",
Expand Down
2 changes: 1 addition & 1 deletion src/google/adk/tools/load_mcp_resource_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ def _mcp_content_to_part(
try:
data = base64.b64decode(content.blob)
# Basic check for mime type or default
mime_type = content.mimeType or "application/octet-stream"
mime_type = content.mime_type or "application/octet-stream"
return types.Part.from_bytes(data=data, mime_type=mime_type)
except Exception:
return types.Part.from_text(
Expand Down
16 changes: 8 additions & 8 deletions src/google/adk/tools/mcp_tool/_agent_to_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@

from google.genai import types
from mcp import types as mcp_types
from mcp.server.fastmcp import Context
from mcp.server.fastmcp import FastMCP
from mcp.server.mcpserver import Context
from mcp.server.mcpserver import MCPServer
from mcp.server.session import ServerSession

from ...agents.base_agent import BaseAgent
Expand Down Expand Up @@ -70,13 +70,13 @@ def _part_to_content(part: types.Part) -> Optional[mcp_types.ContentBlock]:
data = base64.b64encode(blob.data).decode("ascii")
mime = blob.mime_type or "application/octet-stream"
if mime.startswith("image/"):
return mcp_types.ImageContent(type="image", data=data, mimeType=mime)
return mcp_types.ImageContent(type="image", data=data, mime_type=mime)
if mime.startswith("audio/"):
return mcp_types.AudioContent(type="audio", data=data, mimeType=mime)
return mcp_types.AudioContent(type="audio", data=data, mime_type=mime)
return mcp_types.EmbeddedResource(
type="resource",
resource=mcp_types.BlobResourceContents(
uri=_INLINE_RESOURCE_URI, blob=data, mimeType=mime
uri=_INLINE_RESOURCE_URI, blob=data, mime_type=mime
),
)
return None
Expand Down Expand Up @@ -173,7 +173,7 @@ def to_mcp_server(
name: Optional[str] = None,
instructions: Optional[str] = None,
runner: Optional[Runner] = None,
) -> FastMCP:
) -> MCPServer:
"""Exposes an ADK agent as an MCP server.

The returned server registers a single MCP tool that runs the agent: an MCP
Expand All @@ -197,7 +197,7 @@ def to_mcp_server(
services.

Returns:
A ``FastMCP`` server exposing the agent as a single tool.
A ``MCPServer`` server exposing the agent as a single tool.

Example::

Expand All @@ -206,7 +206,7 @@ def to_mcp_server(
server.run(transport="stdio")
"""
tool_name = name or agent.name or "adk_agent"
server = FastMCP(name=tool_name, instructions=instructions)
server = MCPServer(name=tool_name, instructions=instructions)
agent_runner = runner if runner is not None else _build_runner(agent)
# Maps each MCP connection to its ADK session; WeakKeyDictionary drops the
# entry when the connection is garbage-collected. pylint wrongly flags the
Expand Down
2 changes: 1 addition & 1 deletion src/google/adk/tools/mcp_tool/conversion_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ def adk_to_mcp_tool_type(tool: BaseTool) -> mcp_types.Tool:
return mcp_types.Tool(
name=tool.name,
description=tool.description,
inputSchema=input_schema,
input_schema=input_schema,
)


Expand Down
104 changes: 59 additions & 45 deletions src/google/adk/tools/mcp_tool/mcp_session_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
import google.auth
import google.auth.credentials
from google.auth.transport.requests import Request
import httpx
import httpx2

try:
from google.auth.aio.credentials import Credentials as AsyncCredentials
Expand All @@ -65,17 +65,15 @@ class AsyncAuthorizedSession: # pylint: disable=g-bad-classes
from mcp.client.sse import sse_client
from mcp.client.stdio import stdio_client
from mcp.client.streamable_http import create_mcp_http_client as _create_mcp_http_client
from mcp.client.streamable_http import McpHttpClientFactory
from mcp.client.streamable_http import streamable_http_client
from pydantic import BaseModel
from pydantic import ConfigDict

try:
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor

_HAS_HTTPX_INSTRUMENTOR = True
except (ImportError, AttributeError):
_HAS_HTTPX_INSTRUMENTOR = False
# NOTE: opentelemetry-instrumentation-httpx only instruments `httpx` clients.
# mcp 2.x builds its HTTP layer on `httpx2`, and no httpx2 instrumentor exists
# yet (the only PyPI candidate is a 0.0.0 placeholder), so MCP HTTP client
# spans are not emitted until one lands. `_meta` trace propagation in
# mcp_tool.py is unaffected -- it does not go through OTel HTTP instrumentation.

from ...features import FeatureName
from ...features import is_feature_enabled
Expand Down Expand Up @@ -103,18 +101,21 @@ class AsyncAuthorizedSession: # pylint: disable=g-bad-classes

def create_mcp_http_client(
headers: dict[str, str] | None = None,
timeout: httpx.Timeout | None = None,
auth: httpx.Auth | None = None,
) -> httpx.AsyncClient:
"""Creates MCP HTTP client and instruments it when OTel is available."""
client = _create_mcp_http_client(
timeout: httpx2.Timeout | None = None,
auth: httpx2.Auth | None = None,
) -> httpx2.AsyncClient:
"""Creates an MCP HTTP client with the SDK's SSE-friendly defaults.

The client is an ``httpx2.AsyncClient`` -- mcp 2.x's transports accept
nothing else. It is deliberately not OTel-instrumented: the available
``opentelemetry-instrumentation-httpx`` only instruments ``httpx`` (see the
note at the top of this module).
"""
return _create_mcp_http_client(
headers=headers,
timeout=timeout,
auth=auth,
)
if _HAS_HTTPX_INSTRUMENTOR:
HTTPXClientInstrumentor.instrument_client(client)
return client


_http_debug_var: contextvars.ContextVar[list[dict[str, Any]] | None] = (
Expand All @@ -136,7 +137,7 @@ class _StreamableHttpClientWrapper:
def __init__(
self,
url: str,
http_client: httpx.AsyncClient,
http_client: httpx2.AsyncClient,
terminate_on_close: bool = True,
):
self.url = url
Expand Down Expand Up @@ -236,12 +237,25 @@ class SseConnectionParams(BaseModel):


@runtime_checkable
class CheckableMcpHttpClientFactory(McpHttpClientFactory, Protocol):
pass
class CheckableMcpHttpClientFactory(Protocol):
"""Factory protocol for creating custom HTTPX async clients.

In mcp 2.0 the upstream ``McpHttpClientFactory`` protocol lives in the
private ``mcp.shared._httpx_utils`` module, so we declare the equivalent
shape locally rather than depend on a private import.
"""

def __call__(
self,
headers: dict[str, str] | None = None,
timeout: httpx2.Timeout | None = None,
auth: httpx2.Auth | None = None,
) -> httpx2.AsyncClient:
...


class _DebugHttpxClientFactory:
"""A factory wrapper that hooks into the httpx.AsyncClient responses to capture debug info."""
"""A factory wrapper that hooks into the httpx2.AsyncClient responses to capture debug info."""

def __init__(
self,
Expand All @@ -254,15 +268,15 @@ def __init__(
def __call__(
self,
headers: dict[str, str] | None = None,
timeout: httpx.Timeout | None = None,
auth: httpx.Auth | None = None,
) -> httpx.AsyncClient:
timeout: httpx2.Timeout | None = None,
auth: httpx2.Auth | None = None,
) -> httpx2.AsyncClient:
client = self._base_factory(headers=headers, timeout=timeout, auth=auth)
if hasattr(client, 'event_hooks') and isinstance(client.event_hooks, dict):
client.event_hooks.setdefault('response', []).append(self._response_hook)
return client

def _extract_session_id(self, response: httpx.Response) -> str | None:
def _extract_session_id(self, response: httpx2.Response) -> str | None:
query_params = urllib.parse.parse_qs(
urllib.parse.urlparse(str(response.url)).query
)
Expand All @@ -271,7 +285,7 @@ def _extract_session_id(self, response: httpx.Response) -> str | None:
or query_params.get('session_id', [None])[0]
)

async def _response_hook(self, response: httpx.Response):
async def _response_hook(self, response: httpx2.Response):
debug_list = None
if self._session_manager is not None:
session_id = self._extract_session_id(response)
Expand Down Expand Up @@ -435,8 +449,8 @@ def _refresh_sync(self) -> None:
self._creds.refresh(Request())


class _GoogleAuthAsyncByteStream(httpx.AsyncByteStream):
"""Adapter to bridge google-auth Response.content with httpx.AsyncByteStream."""
class _GoogleAuthAsyncByteStream(httpx2.AsyncByteStream):
"""Adapter to bridge google-auth Response.content with httpx2.AsyncByteStream."""

def __init__(self, auth_response: Any):
self._auth_response = auth_response
Expand All @@ -449,15 +463,15 @@ async def aclose(self) -> None:
await self._auth_response.close()


class _GoogleAuthAsyncTransport(httpx.AsyncBaseTransport):
"""Adapter to bridge google-auth AsyncAuthorizedSession with httpx.AsyncBaseTransport."""
class _GoogleAuthAsyncTransport(httpx2.AsyncBaseTransport):
"""Adapter to bridge google-auth AsyncAuthorizedSession with httpx2.AsyncBaseTransport."""

def __init__(self, auth_session: Any):
self._auth_session = auth_session

async def handle_async_request(
self, request: httpx.Request
) -> httpx.Response:
self, request: httpx2.Request
) -> httpx2.Response:
content = await request.aread()
headers_dict = dict(request.headers)

Expand All @@ -483,7 +497,7 @@ async def handle_async_request(

# google-auth-aio uses aiohttp internally, which automatically handles
# decompression and decodes chunked transfer encoding, but leaves the
# headers intact. We must strip these headers so httpx doesn't attempt
# headers intact. We must strip these headers so httpx2 doesn't attempt
# to decompress or parse chunked framing again on the raw stream.
response_headers = {
k: v
Expand All @@ -492,7 +506,7 @@ async def handle_async_request(
not in ('content-encoding', 'content-length', 'transfer-encoding')
}

return httpx.Response(
return httpx2.Response(
status_code=auth_response.status_code,
headers=response_headers,
stream=_GoogleAuthAsyncByteStream(auth_response),
Expand All @@ -502,32 +516,32 @@ async def aclose(self) -> None:
await self._auth_session.close()


class _SharedAsyncTransport(httpx.AsyncBaseTransport):
class _SharedAsyncTransport(httpx2.AsyncBaseTransport):
"""Wrapper transport that prevents the wrapped transport from being closed."""

def __init__(self, transport: httpx.AsyncBaseTransport):
def __init__(self, transport: httpx2.AsyncBaseTransport):
self._transport = transport

async def handle_async_request(
self, request: httpx.Request
) -> httpx.Response:
self, request: httpx2.Request
) -> httpx2.Response:
return await self._transport.handle_async_request(request)

async def aclose(self) -> None:
pass


def _create_mtls_client_factory(
mtls_transport: httpx.AsyncBaseTransport,
mtls_transport: httpx2.AsyncBaseTransport,
) -> CheckableMcpHttpClientFactory:
"""Returns a factory that creates httpx.AsyncClient using the mtls_transport."""
"""Returns a factory that creates httpx2.AsyncClient using the mtls_transport."""

def factory(
headers: dict[str, Any] | None = None,
timeout: httpx.Timeout | None = None,
auth: httpx.Auth | None = None,
) -> httpx.AsyncClient:
return httpx.AsyncClient(
timeout: httpx2.Timeout | None = None,
auth: httpx2.Auth | None = None,
) -> httpx2.AsyncClient:
return httpx2.AsyncClient(
headers=headers,
auth=auth,
timeout=timeout,
Expand Down Expand Up @@ -1015,7 +1029,7 @@ def _evict_idle_sessions(self, keep_key: str) -> None:
def _create_client(
self,
merged_headers: dict[str, str] | None = None,
mtls_transport: httpx.AsyncBaseTransport | None = None,
mtls_transport: httpx2.AsyncBaseTransport | None = None,
*,
session_key: str | None = None,
) -> AbstractAsyncContextManager[Any]:
Expand Down Expand Up @@ -1067,7 +1081,7 @@ def _create_client(
)
http_client = debug_factory(
headers=merged_headers,
timeout=httpx.Timeout(
timeout=httpx2.Timeout(
self._connection_params.timeout,
read=self._connection_params.sse_read_timeout,
),
Expand Down
18 changes: 11 additions & 7 deletions src/google/adk/tools/mcp_tool/mcp_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@
from fastapi.openapi.models import APIKeyIn
from google.genai.types import FunctionDeclaration
from mcp import ClientSession
from mcp.shared.exceptions import McpError
from mcp.shared.session import ProgressFnT
from mcp.shared.dispatcher import ProgressFnT
from mcp.shared.exceptions import MCPError
from mcp.types import Tool as McpBaseTool
from opentelemetry import propagate
from typing_extensions import override
Expand Down Expand Up @@ -222,8 +222,8 @@ def _get_declaration(self) -> FunctionDeclaration:
Returns:
FunctionDeclaration: The Gemini function declaration for the tool.
"""
input_schema = self._mcp_tool.inputSchema
output_schema = self._mcp_tool.outputSchema
input_schema = self._mcp_tool.input_schema
output_schema = self._mcp_tool.output_schema
if is_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL):
function_decl = FunctionDeclaration(
name=self.name,
Expand Down Expand Up @@ -396,8 +396,8 @@ async def run_async(
# any AGW policy) returns a 403 mid-tool-call.
try:
return await super().run_async(args=args, tool_context=tool_context)
except McpError as e:
logger.warning("MCP tool execution failed with McpError: %s", e)
except MCPError as e:
logger.warning("MCP tool execution failed with MCPError: %s", e)
return {"error": f"MCP tool execution failed: {e}"}
except Exception as e: # pylint: disable=broad-exception-caught
logger.warning(
Expand Down Expand Up @@ -508,7 +508,11 @@ async def _run_async_impl(
finally:
self._mcp_session_manager._end_session_use(final_headers) # pylint: disable=protected-access

result = response.model_dump(exclude_none=True, mode="json")
# Serialize with camelCase aliases so the payload keeps the MCP wire shape
# the model saw on mcp 1.x ('isError', 'structuredContent', ...). Without
# by_alias the 2.x snake_case field names would leak into the tool result
# and silently break the 'isError' lookup in _detect_error_in_response.
result = response.model_dump(exclude_none=True, mode="json", by_alias=True)

# Push UI widget to the event actions if the tool supports it.
if self.mcp_app_resource_uri:
Expand Down
Loading