Skip to content

_read_timeout returns timedelta on mcp 2.x, breaking every MCP session (docstring describes the missing branch) #6938

Description

@dc-syntia

Description

google/adk/tools/mcp_tool/session_context.py::_read_timeout documents a version branch that its body never implements.

def _read_timeout(seconds: Optional[float]) -> Optional[timedelta]:
  """Converts a timeout in seconds to the type ``ClientSession`` expects.

  ADK carries every timeout as float seconds. MCP SDK 1.x wants a
  ``timedelta`` here, while 2.x wants the float. Converting in one place keeps
  that difference to a single function.
  """
  if seconds is None:
    return None
  return timedelta(seconds=seconds)

The docstring states 2.x wants the float, but the function returns a timedelta unconditionally. On mcp 2.x that value reaches anyio.fail_after, which adds it to current_time():

File "mcp/shared/jsonrpc_dispatcher.py", line 401, in send_raw_request
    with anyio.fail_after(opts.get("timeout")):
File "anyio/_core/_tasks.py", line 120, in fail_after
    deadline = (current_time() + delay) if delay is not None else math.inf
TypeError: unsupported operand type(s) for +: 'float' and 'datetime.timedelta'

Every session creation fails at initialize().

Reproduction

ADK 2.8.0, mcp 2.1.1. Patching just this one function makes the timedelta error disappear:

import google.adk.tools.mcp_tool.session_context as sc
sc._read_timeout = lambda seconds: seconds

Suggested fix

The docstring already specifies the behaviour:

from importlib.metadata import version

_MCP_MAJOR = int(version("mcp").split(".")[0])

def _read_timeout(seconds: Optional[float]) -> Optional[timedelta | float]:
  if seconds is None:
    return None
  return seconds if _MCP_MAJOR >= 2 else timedelta(seconds=seconds)

Context: what else blocks mcp 2.x

I hit this while getting McpToolset working against mcp 2.1.1, and this was not the only blocker — recording the rest in case it is useful for the wider 2.x migration:

  1. Renames, all resolvable by aliasing:

    • mcp.shared.session.ProgressFnTmcp.client.session.ProgressFnT (used only as a type annotation)
    • mcp.shared.exceptions.McpErrorMCPError, same module
    • mcp.server.fastmcp.FastMCPmcp.server.mcpserver.MCPServer — needed only by _agent_to_mcp, but mcp_tool/__init__.py imports it eagerly, so the whole toolset surface disappears without it
  2. This bug.

  3. Two HTTP stacks. ADK types its client factory as httpx.*; mcp 2.x runs on httpx2, a separate distribution (httpx.Timeout is not httpx2.Timeout). ADK's Timeout object reaches httpcore2 and fails the same way. This one is already solvable from outside via httpx_client_factory on the connection params — a public, documented seam. It worked well: with the aliases, the _read_timeout patch, and a factory returning an httpx2.AsyncClient, McpToolset connects to a real streamable-HTTP server and returns fully-typed tool declarations.

So on 2.8.0 the only change that needs to happen inside ADK is this one function; the rest can be handled by a caller. Happy to open a PR if that would help.

Note on the silent failure

mcp_tool/__init__.py catches the ImportError and logs at DEBUG, so on mcp 2.x McpToolset, McpTool and require_confirmation are simply absent from the package rather than raising. from google.adk.tools.mcp_tool import McpToolset then fails with a bare "cannot import name", which does not point at the dependency conflict. A warning naming the mcp version would have saved a fair amount of digging.

Metadata

Metadata

Assignees

Labels

mcp[Component] This issues is related to MCP support

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions