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
43 changes: 43 additions & 0 deletions .github/workflows/python-test-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,49 @@ jobs:
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
mcp-v2-compat:
Comment thread
poshinchen marked this conversation as resolved.
name: MCP 2.x Compat
# The dependency pin is `mcp<2`, so the unit-test matrix only ever
# exercises the 1.x line and the 2.x branches of `_compat` run against
# mocks there. This job force-installs the 2.x line over the pin to verify
# against the real package that `import strands` succeeds and the compat
# layer resolves the 2.x names. Scoped to the compat tests; the full suite
# is not expected to pass on 2.x while the pin holds. Pinned to `2.0.*` so
# upstream 2.x releases cannot break unrelated PRs; bump deliberately.
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
defaults:
run:
working-directory: strands-py
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
ref: ${{ inputs.ref }}
persist-credentials: false

- name: Set up Python
uses: actions/setup-python@v7.0.0
with:
python-version: '3.10'

- name: Install package, then force mcp 2.x over the pin
run: |
pip install --no-cache-dir -e . \
"pytest>=9.0.0,<10.0.0" \
"pytest-asyncio>=1.0.0,<1.5.0" \
"pytest-timeout>=2.0.0,<3.0.0" \
"moto>=5.1.0,<6.0.0"
pip install --no-cache-dir "mcp==2.0.*"

- name: Verify import and version flag
run: python -c "import strands; from strands.tools.mcp import _compat; assert _compat.MCP_V2"

- name: Run compat tests
run: pytest tests/strands/tools/mcp/test__compat.py -vv

lint:
name: Lint
runs-on: ubuntu-latest
Expand Down
6 changes: 6 additions & 0 deletions strands-py/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,12 @@ module = [
]
ignore_missing_imports = true

[[tool.mypy.overrides]]
# _compat branches on which mcp major line is installed; mypy only ever sees
# one, so each branch's ignores are "unused" when checked against the other.
module = ["strands.tools.mcp._compat"]
warn_unused_ignores = false

[tool.ruff]
line-length = 120
include = ["examples/**/*.py", "src/**/*.py", "tests/**/*.py", "tests_integ/**/*.py"]
Expand Down
89 changes: 89 additions & 0 deletions strands-py/src/strands/tools/mcp/_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Compatibility layer over the `mcp` 1.x and 2.x lines.

The official `mcp` package renamed and relocated several public names in 2.0.
Import any version-dependent name from this module instead of from `mcp`
directly so the rest of the codebase stays version-agnostic. Branch on
`MCP_V2` only where behavior differs between the two lines; pure renames are
resolved here once.

mypy note: only one `mcp` line is installed at a time, so each try/except
branch below is an `attr-defined` error when checked against the other line.
The branch-level ignores cover whichever line mypy runs under, and the
per-module `warn_unused_ignores` override in pyproject.toml silences the
ignores the installed line doesn't need.
"""

from collections.abc import AsyncIterator
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from typing import Any

import httpx
from mcp import ClientSession

__all__ = ["MCP_V2", "GetSessionIdCallback", "MCPError", "streamable_http_transport"]

# Feature-probed rather than version-parsed so pre-releases and backports
# resolve by capability: `ClientSession.discover` is the 2.x replacement for
# the removed initialize handshake.
Comment thread
poshinchen marked this conversation as resolved.
MCP_V2: bool = hasattr(ClientSession, "discover")

try:
Comment thread
poshinchen marked this conversation as resolved.
from mcp.shared.exceptions import MCPError # type: ignore[attr-defined]
except ImportError:
# mcp 1.x spells the class McpError
from mcp.shared.exceptions import McpError as MCPError # type: ignore[attr-defined, no-redef]

try:
from mcp.client.streamable_http import GetSessionIdCallback # type: ignore[attr-defined]
except ImportError:
# mcp 2.x removed protocol sessions, so its transports never yield a
# session-id callback; the alias survives only to type 1.x transports.
from collections.abc import Callable

GetSessionIdCallback = Callable[[], str | None] # type: ignore[misc, assignment]


def streamable_http_transport(
url: str, headers: dict[str, str] | None = None, auth: httpx.Auth | None = None
) -> AbstractAsyncContextManager[Any]:
"""Open a streamable HTTP client transport on either `mcp` major line.

`mcp` 2.x replaced `streamablehttp_client(url, headers=..., auth=...)` with
`streamable_http_client(url, http_client=...)`, which takes a
pre-configured HTTPX client instead of loose header and auth kwargs. This
adapter keeps the 1.x-style call shape for both lines.

The imports are resolved at call time because each name exists on only
one major line.

Args:
url: The MCP server endpoint URL.
headers: Optional HTTP headers to send with each request.
auth: Optional HTTPX authentication handler for each request.

Returns:
An async context manager yielding the transport's read/write streams.
"""
if MCP_V2:
from mcp.client.streamable_http import ( # type: ignore[attr-defined]
create_mcp_http_client,
streamable_http_client,
)

# `streamable_http_client` closes an HTTPX client only when it created
# it (`client_provided` check in mcp 2.x), so a caller-provided client
# must be closed by the caller: enter both context managers together
# so the client's lifetime is bound to the transport's.
@asynccontextmanager
async def _owned_client_transport() -> AsyncIterator[Any]:
async with (
create_mcp_http_client(headers=headers, auth=auth) as http_client,
Comment thread
poshinchen marked this conversation as resolved.
streamable_http_client(url=url, http_client=http_client) as transport_streams,
):
yield transport_streams

return _owned_client_transport()

from mcp.client.streamable_http import streamablehttp_client # type: ignore[attr-defined]

return streamablehttp_client(url=url, headers=headers, auth=auth) # type: ignore[no-any-return]
12 changes: 5 additions & 7 deletions strands-py/src/strands/tools/mcp/mcp_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,9 @@
import httpx
from mcp import ClientSession, ListToolsResult, StdioServerParameters, stdio_client
from mcp.client.auth.extensions.client_credentials import ClientCredentialsOAuthProvider
from mcp.client.session import ElicitationFnT
from mcp.client.session import ElicitationFnT, ProgressFnT
from mcp.client.sse import sse_client
from mcp.client.streamable_http import streamablehttp_client
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
from mcp.shared.exceptions import McpError
from mcp.shared.session import ProgressFnT
from mcp.types import (
BlobResourceContents,
CancelledNotification,
Expand All @@ -64,6 +61,7 @@
from ...types.media import ImageFormat
from ...types.tools import AgentTool, ToolResultContent, ToolResultStatus
from ..tool_provider import ToolProvider
from ._compat import MCPError, streamable_http_transport
from .mcp_agent_tool import MCPAgentTool
from .mcp_instrumentation import inject_trace_context, mcp_instrumentation
from .mcp_tasks import DEFAULT_TASK_CONFIG, DEFAULT_TASK_POLL_TIMEOUT, DEFAULT_TASK_TTL, TasksConfig
Expand Down Expand Up @@ -982,7 +980,7 @@ def _handle_tool_execution_error(self, tool_use_id: str, exception: Exception) -
MCPToolResult: Error result containing either the elicitation data or the
original exception message.
"""
if isinstance(exception, McpError) and exception.error.code == -32042:
if isinstance(exception, MCPError) and exception.error.code == -32042:
try:
error_data = ElicitationRequiredErrorData.model_validate(exception.error.data)
elicitations = [e.model_dump(exclude_none=True) for e in error_data.elicitations]
Expand Down Expand Up @@ -1759,7 +1757,7 @@ def _resolve_transport_callable(
if scheme == "http" and (auth is not None or auth_provider is not None):
logger.warning("url=<%s> | sending oauth credentials over plaintext http", server_url)
resolved_auth = _build_client_credentials_auth(server_url, auth) if auth is not None else auth_provider
return lambda: streamablehttp_client(url=server_url, headers=headers, auth=resolved_auth)
return lambda: streamable_http_transport(url=server_url, headers=headers, auth=resolved_auth)


# Matches ${VAR} and ${env:VAR} where VAR is a valid environment variable identifier.
Expand Down Expand Up @@ -1849,7 +1847,7 @@ def _config_transport_callable(name: str, transport: str, server: dict[str, Any]
raise ValueError(f"server '{name}': streamable-http transport requires 'url'")
headers = server.get("headers")
resolved_auth = _parse_config_auth(name, cast(str, url), server.get("auth"))
return lambda: streamablehttp_client(url=cast(str, url), headers=headers, auth=resolved_auth)
return lambda: streamable_http_transport(url=cast(str, url), headers=headers, auth=resolved_auth)

case "sse":
url = server.get("url")
Expand Down
26 changes: 5 additions & 21 deletions strands-py/src/strands/tools/mcp/mcp_instrumentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,20 @@
from collections.abc import AsyncGenerator, Callable
from contextlib import _AsyncGeneratorContextManager, asynccontextmanager
from dataclasses import dataclass
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as _package_version
from typing import Any

from mcp.shared.message import SessionMessage
from mcp.types import JSONRPCMessage, JSONRPCRequest
from opentelemetry import context, propagate
from wrapt import ObjectProxy, register_post_import_hook, wrap_function_wrapper

from ._compat import MCP_V2

logger = logging.getLogger(__name__)

# Module-level flag to ensure instrumentation is applied only once. The lock
# makes the check-and-set atomic: `_is_mcp_v1` performs GIL-releasing I/O, so
# without it concurrent MCPClient construction could stack duplicate wrappers.
# makes the check-and-set atomic so concurrent MCPClient construction cannot
# stack duplicate wrappers.
_instrumentation_applied = False
_instrumentation_lock = threading.Lock()

Expand Down Expand Up @@ -64,20 +64,6 @@ def inject_trace_context(meta: dict[str, Any] | None) -> dict[str, Any] | None:
return carrier or None


def _is_mcp_v1() -> bool:
"""Report whether the installed `mcp` package is on the 1.x line.

The server-side patches wrap private `mcp` internals that are only stable
within 1.x. When the version cannot be determined, the patches are skipped
rather than applied to an unknown internal surface.
"""
try:
major_version = int(_package_version("mcp").split(".")[0])
except (PackageNotFoundError, ValueError):
return False
return major_version == 1


@dataclass(slots=True, frozen=True)
class ItemWithContext:
"""Wrapper for items that need to carry OpenTelemetry context.
Expand Down Expand Up @@ -120,11 +106,9 @@ def mcp_instrumentation() -> None:
# Return early if instrumentation has already been applied
if _instrumentation_applied:
return
# Set before the version probe: it leaves the GIL, and a concurrent
# caller checking under the lock must see the flag.
_instrumentation_applied = True

if not _is_mcp_v1():
if MCP_V2:
return

def transport_wrapper() -> Callable[
Expand Down
2 changes: 1 addition & 1 deletion strands-py/src/strands/tools/mcp/mcp_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
from typing import Any, Literal

from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
from mcp.client.streamable_http import GetSessionIdCallback
from mcp.shared.memory import MessageStream
from mcp.shared.message import SessionMessage
from typing_extensions import NotRequired, TypedDict

from ...types.tools import ToolResult
from ._compat import GetSessionIdCallback


class MCPClientCredentials(TypedDict):
Expand Down
12 changes: 12 additions & 0 deletions strands-py/tests/strands/tools/mcp/conftest.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
"""Shared fixtures and helpers for MCP client tests."""

from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from mcp.types import ErrorData

from strands.tools.mcp import _compat
from strands.tools.mcp._compat import MCPError


def make_mcp_error(code: int, message: str = "", data: Any = None) -> Exception:
"""Construct the installed line's MCP error: 2.x takes (code, message, data), 1.x takes ErrorData."""
if _compat.MCP_V2:
return MCPError(code, message, data=data)
return MCPError(error=ErrorData(code=code, message=message, data=data))


@pytest.fixture
Expand Down
Loading
Loading