Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
28 changes: 25 additions & 3 deletions src/nooa/mcp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from collections.abc import AsyncGenerator, Callable
from contextlib import asynccontextmanager
from datetime import timedelta
from importlib.metadata import version as distribution_version
from typing import Any, Literal, override

import httpx
Expand All @@ -21,6 +22,17 @@
# Matches the connect timeout the MCP SDK's own SSE transport defaults to.
CONNECT_TIMEOUT_SECONDS = 5.0

# MCP 1.x accepts ``timedelta`` while MCP 2.x accepts numeric seconds. Keep
# NOOA's public timeout API stable and adapt only at the SDK boundary.
_MCP_READ_TIMEOUT_USES_FLOAT = int(distribution_version("mcp").split(".", 1)[0]) >= 2


def _session_read_timeout(timeout: timedelta) -> Any:
"""Return the timeout representation expected by the installed MCP SDK."""
if _MCP_READ_TIMEOUT_USES_FLOAT:
return timeout.total_seconds()
return timeout


class MCPBaseClient(ABC):
"""Base client for creating an MCP transport session and connecting to an MCP server.
Expand Down Expand Up @@ -124,7 +136,11 @@ async def connect_to_server(self):
url=self._url,
headers=self._headers if self._headers else None,
) as (read, write),
ClientSession(read, write, read_timeout_seconds=self._tool_call_timeout) as session,
ClientSession(
read,
write,
read_timeout_seconds=_session_read_timeout(self._tool_call_timeout),
) as session,
):
await session.initialize()
yield session
Expand Down Expand Up @@ -201,7 +217,11 @@ async def connect_to_server(self):
)
async with (
stdio_client(server_params) as (read, write),
ClientSession(read, write, read_timeout_seconds=self._tool_call_timeout) as session,
ClientSession(
read,
write,
read_timeout_seconds=_session_read_timeout(self._tool_call_timeout),
) as session,
):
await session.initialize()
yield session
Expand Down Expand Up @@ -303,7 +323,9 @@ async def connect_to_server(self):
# Store the session ID callback for later retrieval
self._get_mcp_session_id = get_session_id
async with ClientSession(
read, write, read_timeout_seconds=self._tool_call_timeout
read,
write,
read_timeout_seconds=_session_read_timeout(self._tool_call_timeout),
) as session:
await session.initialize()
yield session
Expand Down
27 changes: 25 additions & 2 deletions tests/test_mcp/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from typing import Literal # noqa: E402
from unittest.mock import AsyncMock, MagicMock, patch # noqa: E402

from nooa.mcp import client as client_module # noqa: E402
from nooa.mcp import oauth # noqa: E402
from nooa.mcp.client import ( # noqa: E402
MCPBaseClient,
Expand Down Expand Up @@ -312,6 +313,24 @@ def test_tool_call_timeout_default(client_class: type[MCPBaseClient], client_kwa
assert client.tool_call_timeout == timedelta(seconds=60)


@pytest.mark.parametrize(
("uses_float_seconds", "expected"),
[
(False, timedelta(seconds=7.5)),
(True, 7.5),
],
)
def test_session_timeout_matches_mcp_sdk_contract(
monkeypatch: pytest.MonkeyPatch,
uses_float_seconds: bool,
expected: timedelta | float,
):
"""MCP 1.x expects timedelta while MCP 2.x expects float seconds."""
monkeypatch.setattr(client_module, "_MCP_READ_TIMEOUT_USES_FLOAT", uses_float_seconds)

assert client_module._session_read_timeout(timedelta(seconds=7.5)) == expected


@pytest.mark.asyncio
@pytest.mark.parametrize(
"client_fixture, transport_patch",
Expand Down Expand Up @@ -401,7 +420,9 @@ async def test_streamable_http_applies_tool_call_timeout(
assert timeout.write == 90
# Opening the connection is not a tool call and keeps its own short budget.
assert timeout.connect == 5.0
assert mock_session_class.call_args.kwargs["read_timeout_seconds"] == timedelta(seconds=90)
assert mock_session_class.call_args.kwargs[
"read_timeout_seconds"
] == client_module._session_read_timeout(timedelta(seconds=90))


@pytest.mark.asyncio
Expand Down Expand Up @@ -433,7 +454,9 @@ async def test_session_enforces_tool_call_timeout(
async with client.connect_to_server():
pass

assert mock_session_class.call_args.kwargs["read_timeout_seconds"] == expected_timeout
assert mock_session_class.call_args.kwargs[
"read_timeout_seconds"
] == client_module._session_read_timeout(expected_timeout)


@pytest.mark.parametrize(
Expand Down