diff --git a/.github/scripts/run_integration_tests.py b/.github/scripts/run_integration_tests.py index 28aa259d71..28dbf65bb8 100644 --- a/.github/scripts/run_integration_tests.py +++ b/.github/scripts/run_integration_tests.py @@ -24,6 +24,7 @@ ) PROFILES = ( "packaging", + "mcp-v1", "core", "providers", "realtime", @@ -78,7 +79,12 @@ def _any_llm_provider_extras( def create_environment( - name: str, distribution: Path, *, extras: bool = False, optional_extra: str | None = None + name: str, + distribution: Path, + *, + extras: bool = False, + optional_extra: str | None = None, + additional_requirements: tuple[str, ...] = (), ) -> Path: environment = WORKSPACE / name venv_command = ["uv", "venv", "--clear", str(environment)] @@ -88,7 +94,13 @@ def create_environment( python = environment / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python") selected_extra = EXTRAS if extras else optional_extra requirement = f"{distribution}[{selected_extra}]" if selected_extra else str(distribution) - requirements = [requirement, "pytest", "pytest-asyncio", "pytest-timeout"] + requirements = [ + requirement, + "pytest", + "pytest-asyncio", + "pytest-timeout", + *additional_requirements, + ] external_providers_enabled = os.environ.get( "OPENAI_AGENTS_INTEGRATION_EXTERNAL_PROVIDERS", "" ).lower() in {"1", "true", "yes"} @@ -126,6 +138,7 @@ def run_suite( *, selection: str, environment_kind: str, + additional_env: dict[str, str] | None = None, ) -> None: child_env = dict(os.environ) child_env.pop("PYTHONPATH", None) @@ -147,6 +160,8 @@ def run_suite( child_env["OPENAI_AGENTS_INTEGRATION_WHEEL"] = str(wheel) child_env["OPENAI_AGENTS_INTEGRATION_SDIST"] = str(sdist) child_env["OPENAI_AGENTS_INTEGRATION_ENVIRONMENT"] = environment_kind + if additional_env: + child_env.update(additional_env) if environment_kind.startswith("extra-"): child_env["OPENAI_AGENTS_INTEGRATION_EXTRA"] = environment_kind.removeprefix("extra-") if not os.environ.get("OPENAI_AGENTS_INTEGRATION_ENABLE_TRACING"): @@ -182,6 +197,23 @@ def main() -> None: wheel, sdist = build_distributions() print(f"[integration] wheel={wheel.name} sdist={sdist.name} profile={args.profile}") + if args.profile == "mcp-v1": + for mcp_version in ("1.19.0", "1.29.0"): + environment_kind = f"mcp-v1-{mcp_version}" + python = create_environment( + environment_kind, + wheel, + additional_requirements=(f"mcp=={mcp_version}",), + ) + run_suite( + python, + wheel, + sdist, + selection="mcp_compat", + environment_kind=environment_kind, + additional_env={"OPENAI_AGENTS_INTEGRATION_MCP_VERSION": mcp_version}, + ) + if args.profile in {"packaging", "core", "hosted", "full", "release", "nightly", "manual"}: python = create_environment("core", wheel) selections = { diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b609de27be..a61c28bec1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -110,6 +110,31 @@ jobs: if: steps.changes.outputs.run != 'true' run: echo "Skipping tests for non-code changes." + mcp-v1-compat: + runs-on: ubuntu-latest + env: + OPENAI_API_KEY: fake-for-tests + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - name: Detect code changes + id: changes + run: ./.github/scripts/detect-changes.sh code "${{ github.event.pull_request.base.sha || github.event.before }}" "${{ github.sha }}" + - name: Setup uv + if: steps.changes.outputs.run == 'true' + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # setup-uv v9.0.0; uv 0.11.14 + with: + version: "0.11.14" + enable-cache: true + prune-cache: true + python-version: "3.12" + - name: Run packaged MCP v1 compatibility tests + if: steps.changes.outputs.run == 'true' + run: make integration-tests-mcp-v1 + - name: Skip MCP v1 compatibility tests + if: steps.changes.outputs.run != 'true' + run: echo "Skipping MCP v1 compatibility tests for non-code changes." + tests-windows: runs-on: windows-latest env: diff --git a/Makefile b/Makefile index 86b6fa4f10..bbe2a53870 100644 --- a/Makefile +++ b/Makefile @@ -75,6 +75,10 @@ integration-tests-manual: integration-tests-packaging: uv run python .github/scripts/run_integration_tests.py --profile packaging +.PHONY: integration-tests-mcp-v1 +integration-tests-mcp-v1: + uv run python .github/scripts/run_integration_tests.py --profile mcp-v1 + .PHONY: integration-tests-core integration-tests-core: uv run python .github/scripts/run_integration_tests.py --profile core diff --git a/examples/mcp/manager_example/README.md b/examples/mcp/manager_example/README.md index ec4dcbe4bc..715fa40532 100644 --- a/examples/mcp/manager_example/README.md +++ b/examples/mcp/manager_example/README.md @@ -1,5 +1,7 @@ # MCP Manager Example (FastAPI) +This repository example targets MCP Python SDK v2 and is intended to run with the repository's locked development environment. The Agents SDK client itself supports both MCP v1 and v2. + This example shows how to use `MCPServerManager` to keep MCP server lifecycle management in a single task inside a FastAPI app with the Streamable HTTP transport. ## Run the MCP server (Streamable HTTP) diff --git a/examples/mcp/manager_example/mcp_server.py b/examples/mcp/manager_example/mcp_server.py index a67c224994..92c6709abd 100644 --- a/examples/mcp/manager_example/mcp_server.py +++ b/examples/mcp/manager_example/mcp_server.py @@ -1,15 +1,11 @@ import os -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer STREAMABLE_HTTP_HOST = os.getenv("STREAMABLE_HTTP_HOST", "127.0.0.1") STREAMABLE_HTTP_PORT = int(os.getenv("STREAMABLE_HTTP_PORT", "8000")) -mcp = FastMCP( - "FastAPI Example Server", - host=STREAMABLE_HTTP_HOST, - port=STREAMABLE_HTTP_PORT, -) +mcp = MCPServer("FastAPI Example Server") @mcp.tool() @@ -23,4 +19,8 @@ def echo(message: str) -> str: if __name__ == "__main__": - mcp.run(transport="streamable-http") + mcp.run( + transport="streamable-http", + host=STREAMABLE_HTTP_HOST, + port=STREAMABLE_HTTP_PORT, + ) diff --git a/examples/mcp/prompt_server/README.md b/examples/mcp/prompt_server/README.md index 74ee0fe07e..562fb573ca 100644 --- a/examples/mcp/prompt_server/README.md +++ b/examples/mcp/prompt_server/README.md @@ -1,5 +1,7 @@ # MCP Prompt Server Example +This repository example targets MCP Python SDK v2 and is intended to run with the repository's locked development environment. The Agents SDK client itself supports both MCP v1 and v2. + This example uses a local MCP prompt server in [server.py](server.py). Run the example via: diff --git a/examples/mcp/prompt_server/server.py b/examples/mcp/prompt_server/server.py index 7d6629acd7..22125cf7dd 100644 --- a/examples/mcp/prompt_server/server.py +++ b/examples/mcp/prompt_server/server.py @@ -1,12 +1,12 @@ import os -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer STREAMABLE_HTTP_HOST = os.getenv("STREAMABLE_HTTP_HOST", "127.0.0.1") STREAMABLE_HTTP_PORT = int(os.getenv("STREAMABLE_HTTP_PORT", "18080")) # Create server -mcp = FastMCP("Prompt Server", host=STREAMABLE_HTTP_HOST, port=STREAMABLE_HTTP_PORT) +mcp = MCPServer("Prompt Server") # Instruction-generating prompts (user-controlled) @@ -39,4 +39,8 @@ def generate_code_review_instructions( if __name__ == "__main__": - mcp.run(transport="streamable-http") + mcp.run( + transport="streamable-http", + host=STREAMABLE_HTTP_HOST, + port=STREAMABLE_HTTP_PORT, + ) diff --git a/examples/mcp/sse_example/README.md b/examples/mcp/sse_example/README.md index 9a667d31e1..cd9747bb98 100644 --- a/examples/mcp/sse_example/README.md +++ b/examples/mcp/sse_example/README.md @@ -1,5 +1,7 @@ # MCP SSE Example +This repository example targets MCP Python SDK v2 and is intended to run with the repository's locked development environment. The Agents SDK client itself supports both MCP v1 and v2. + This example uses a local SSE server in [server.py](server.py). Run the example via: diff --git a/examples/mcp/sse_example/server.py b/examples/mcp/sse_example/server.py index 075137fe03..a8f65c261b 100644 --- a/examples/mcp/sse_example/server.py +++ b/examples/mcp/sse_example/server.py @@ -1,13 +1,13 @@ import os import random -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer SSE_HOST = os.getenv("SSE_HOST", "127.0.0.1") SSE_PORT = int(os.getenv("SSE_PORT", "8000")) # Create server -mcp = FastMCP("Echo Server", host=SSE_HOST, port=SSE_PORT) +mcp = MCPServer("Echo Server") @mcp.tool() @@ -39,4 +39,4 @@ def get_current_weather(city: str) -> str: if __name__ == "__main__": - mcp.run(transport="sse") + mcp.run(transport="sse", host=SSE_HOST, port=SSE_PORT) diff --git a/examples/mcp/streamablehttp_custom_client_example/README.md b/examples/mcp/streamablehttp_custom_client_example/README.md index fc269a0644..33890a45e6 100644 --- a/examples/mcp/streamablehttp_custom_client_example/README.md +++ b/examples/mcp/streamablehttp_custom_client_example/README.md @@ -1,5 +1,7 @@ # Custom HTTP Client Factory Example +This repository example targets MCP Python SDK v2 and `httpx2`, and is intended to run with the repository's locked development environment. The Agents SDK client itself supports MCP v1 with `httpx` and MCP v2 with `httpx2`. + This example demonstrates how to use the new `httpx_client_factory` parameter in `MCPServerStreamableHttp` to configure custom HTTP client behavior for MCP StreamableHTTP connections. ## Features Demonstrated @@ -25,13 +27,13 @@ This example demonstrates how to use the new `httpx_client_factory` parameter in ### Basic Custom Client ```python -import httpx +import httpx2 from agents.mcp import MCPServerStreamableHttp -def create_custom_http_client() -> httpx.AsyncClient: - return httpx.AsyncClient( +def create_custom_http_client() -> httpx2.AsyncClient: + return httpx2.AsyncClient( verify=False, # Disable SSL verification for testing - timeout=httpx.Timeout(60.0, read=120.0), + timeout=httpx2.Timeout(60.0, read=120.0), headers={"X-Custom-Client": "my-app"}, ) diff --git a/examples/mcp/streamablehttp_custom_client_example/main.py b/examples/mcp/streamablehttp_custom_client_example/main.py index 8a70cf820a..548c06391f 100644 --- a/examples/mcp/streamablehttp_custom_client_example/main.py +++ b/examples/mcp/streamablehttp_custom_client_example/main.py @@ -12,7 +12,7 @@ import time from typing import Any, cast -import httpx +import httpx2 from agents import Agent, Runner, gen_trace_id, trace from agents.mcp import MCPServer, MCPServerStreamableHttp @@ -38,9 +38,9 @@ def _choose_port() -> int: def create_custom_http_client( 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: """Create a custom HTTP client with specific configurations. This function demonstrates how to configure: @@ -55,14 +55,14 @@ def create_custom_http_client( "User-Agent": "OpenAI-Agents-MCP/1.0", } if timeout is None: - timeout = httpx.Timeout(60.0, read=120.0) + timeout = httpx2.Timeout(60.0, read=120.0) if auth is None: auth = None - return httpx.AsyncClient( + return httpx2.AsyncClient( # Disable SSL verification for testing (not recommended for production) verify=False, # Set custom timeout - timeout=httpx.Timeout(60.0, read=120.0), + timeout=httpx2.Timeout(60.0, read=120.0), # Add custom headers that will be sent with every request headers=headers, ) diff --git a/examples/mcp/streamablehttp_custom_client_example/server.py b/examples/mcp/streamablehttp_custom_client_example/server.py index dd0d468753..e6ec5d5f93 100644 --- a/examples/mcp/streamablehttp_custom_client_example/server.py +++ b/examples/mcp/streamablehttp_custom_client_example/server.py @@ -1,13 +1,13 @@ import os import random -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer STREAMABLE_HTTP_HOST = os.getenv("STREAMABLE_HTTP_HOST", "127.0.0.1") STREAMABLE_HTTP_PORT = int(os.getenv("STREAMABLE_HTTP_PORT", "18080")) # Create server -mcp = FastMCP("Echo Server", host=STREAMABLE_HTTP_HOST, port=STREAMABLE_HTTP_PORT) +mcp = MCPServer("Echo Server") @mcp.tool() @@ -24,4 +24,8 @@ def get_secret_word() -> str: if __name__ == "__main__": - mcp.run(transport="streamable-http") + mcp.run( + transport="streamable-http", + host=STREAMABLE_HTTP_HOST, + port=STREAMABLE_HTTP_PORT, + ) diff --git a/examples/mcp/streamablehttp_example/README.md b/examples/mcp/streamablehttp_example/README.md index 83cae670b6..0c446ddfe6 100644 --- a/examples/mcp/streamablehttp_example/README.md +++ b/examples/mcp/streamablehttp_example/README.md @@ -1,5 +1,7 @@ # MCP Streamable HTTP Example +This repository example targets MCP Python SDK v2 and is intended to run with the repository's locked development environment. The Agents SDK client itself supports both MCP v1 and v2. + This example uses a local Streamable HTTP server in [server.py](server.py). Run the example via: diff --git a/examples/mcp/streamablehttp_example/server.py b/examples/mcp/streamablehttp_example/server.py index d73ab895b6..2afb6587b5 100644 --- a/examples/mcp/streamablehttp_example/server.py +++ b/examples/mcp/streamablehttp_example/server.py @@ -2,13 +2,13 @@ import random import requests -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer STREAMABLE_HTTP_HOST = os.getenv("STREAMABLE_HTTP_HOST", "127.0.0.1") STREAMABLE_HTTP_PORT = int(os.getenv("STREAMABLE_HTTP_PORT", "18080")) # Create server -mcp = FastMCP("Echo Server", host=STREAMABLE_HTTP_HOST, port=STREAMABLE_HTTP_PORT) +mcp = MCPServer("Echo Server") @mcp.tool() @@ -40,4 +40,8 @@ def get_current_weather(city: str) -> str: if __name__ == "__main__": - mcp.run(transport="streamable-http") + mcp.run( + transport="streamable-http", + host=STREAMABLE_HTTP_HOST, + port=STREAMABLE_HTTP_PORT, + ) diff --git a/examples/sandbox/README.md b/examples/sandbox/README.md index e411ae70f1..733159a065 100644 --- a/examples/sandbox/README.md +++ b/examples/sandbox/README.md @@ -4,6 +4,8 @@ These examples show how to run agents with an isolated workspace. Start with the Most examples call a model through `Runner`, so set `OPENAI_API_KEY` in the repository-root `.env` file, in the example's `.env` file when it has one, or in your shell environment. +`sandbox_agent_with_tools.py` starts the repository's MCP v2 reference server and is intended to run with the locked development environment. The Agents SDK client itself supports both MCP v1 and v2. + ## Small API examples | Example | Run | What it shows | diff --git a/examples/sandbox/misc/reference_policy_mcp_server.py b/examples/sandbox/misc/reference_policy_mcp_server.py index 0e6486d575..4bf915e6dd 100644 --- a/examples/sandbox/misc/reference_policy_mcp_server.py +++ b/examples/sandbox/misc/reference_policy_mcp_server.py @@ -1,6 +1,6 @@ -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer -mcp = FastMCP("Reference Policy Server") +mcp = MCPServer("Reference Policy Server") @mcp.tool() diff --git a/integration_tests/README.md b/integration_tests/README.md index 4d5db77f50..150307c4ca 100644 --- a/integration_tests/README.md +++ b/integration_tests/README.md @@ -7,7 +7,7 @@ Run the complete release-oriented matrix with: export UV_DEFAULT_INDEX=https://pypi.org/simple make integration-tests -`make integration-tests-release` runs the same release-safe matrix explicitly. `make integration-tests-nightly` also includes extended capability and transport checks, while `make integration-tests-manual` includes checks reserved for an intentionally configured manual run. Focused entry points are `make integration-tests-packaging`, `make integration-tests-core`, `make integration-tests-providers`, `make integration-tests-providers-external`, `make integration-tests-providers-all`, `make integration-tests-realtime`, `make integration-tests-voice`, `make integration-tests-hosted`, and `make integration-tests-extras`. +`make integration-tests-release` runs the same release-safe matrix explicitly. `make integration-tests-nightly` also includes extended capability and transport checks, while `make integration-tests-manual` includes checks reserved for an intentionally configured manual run. Focused entry points are `make integration-tests-packaging`, `make integration-tests-mcp-v1`, `make integration-tests-core`, `make integration-tests-providers`, `make integration-tests-providers-external`, `make integration-tests-providers-all`, `make integration-tests-realtime`, `make integration-tests-voice`, `make integration-tests-hosted`, and `make integration-tests-extras`. The MCP v1 profile installs the built wheel with both the supported v1 floor and latest tested v1 release in clean environments; the regular test job validates the locked MCP v2 dependency. Invoke the repository-local `$integration-tests` skill to run the release profile with configured OpenRouter-backed provider checks. OpenRouter provides a single configured gateway for the standard multi-provider matrix; provider-specific direct connections are optional extensions selected explicitly. When a release review also requires runnable examples, run `$examples-auto-run` first and then `$integration-tests`. diff --git a/integration_tests/packaging/mcp_legacy_server.py b/integration_tests/packaging/mcp_legacy_server.py new file mode 100644 index 0000000000..64b801b65e --- /dev/null +++ b/integration_tests/packaging/mcp_legacy_server.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import json +import sys + + +def send(message: dict[str, object]) -> None: + sys.stdout.write(json.dumps(message) + "\n") + sys.stdout.flush() + + +def main() -> None: + for line in sys.stdin: + message = json.loads(line) + request_id = message.get("id") + method = message.get("method") + if request_id is None: + continue + if method == "initialize": + send( + { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "protocolVersion": "2025-06-18", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "legacy-test-server", "version": "1.0"}, + }, + } + ) + elif method == "tools/list": + send( + { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "tools": [ + { + "name": "legacy_tool", + "inputSchema": {"type": "object", "properties": {}}, + } + ] + }, + } + ) + elif method == "tools/call": + send( + { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{"type": "text", "text": "legacy-result"}], + "isError": False, + }, + } + ) + else: + send( + { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32601, "message": f"Unknown method: {method}"}, + } + ) + + +if __name__ == "__main__": + main() diff --git a/integration_tests/packaging/test_mcp_compat.py b/integration_tests/packaging/test_mcp_compat.py new file mode 100644 index 0000000000..cdf7dc2446 --- /dev/null +++ b/integration_tests/packaging/test_mcp_compat.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import importlib.metadata +import os +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import httpx +import pytest + +from agents.mcp import MCPServerSse, MCPServerStdio, MCPServerStreamableHttp + +pytestmark = pytest.mark.mcp_compat + +LEGACY_SERVER_PATH = Path(__file__).with_name("mcp_legacy_server.py") + + +@pytest.mark.asyncio +async def test_packaged_client_supports_mcp_v1() -> None: + expected_version = os.environ["OPENAI_AGENTS_INTEGRATION_MCP_VERSION"] + assert importlib.metadata.version("mcp") == expected_version + + server = MCPServerStdio( + name="legacy-test-server", + params={"command": sys.executable, "args": [str(LEGACY_SERVER_PATH)]}, + ) + + async with server: + tools = await server.list_tools() + result = await server.call_tool("legacy_tool", {}) + + assert [tool.name for tool in tools] == ["legacy_tool"] + assert getattr(result, "isError", getattr(result, "is_error", None)) is False + assert result.content[0].type == "text" + assert result.content[0].text == "legacy-result" + + +def test_packaged_client_uses_mcp_v1_sse_transport() -> None: + with patch("agents.mcp.server.sse_client") as mock_client: + mock_client.return_value = MagicMock() + server = MCPServerSse( + params={ + "url": "https://example.test/sse", + "headers": {"Authorization": "Bearer token"}, + } + ) + + server.create_streams() + + mock_client.assert_called_once() + assert mock_client.call_args.kwargs["url"] == "https://example.test/sse" + assert mock_client.call_args.kwargs["headers"] == {"Authorization": "Bearer token"} + assert mock_client.call_args.kwargs["timeout"] == 5 + assert mock_client.call_args.kwargs["sse_read_timeout"] == 300 + assert callable(mock_client.call_args.kwargs["httpx_client_factory"]) + + +def test_packaged_client_uses_mcp_v1_streamable_http_auth_and_factory() -> None: + auth = httpx.BasicAuth("user", "pass") + + def factory(headers=None, timeout=None, auth=None): + return httpx.AsyncClient(headers=headers, timeout=timeout, auth=auth) + + with patch("agents.mcp.server.streamablehttp_client") as mock_client: + mock_client.return_value = MagicMock() + server = MCPServerStreamableHttp( + params={ + "url": "https://example.test/mcp", + "auth": auth, + "httpx_client_factory": factory, + } + ) + + server.create_streams() + + mock_client.assert_called_once_with( + url="https://example.test/mcp", + headers=None, + timeout=5, + sse_read_timeout=300, + terminate_on_close=True, + auth=auth, + httpx_client_factory=factory, + ) diff --git a/integration_tests/pytest.ini b/integration_tests/pytest.ini index ab59a65e6e..ed81acb658 100644 --- a/integration_tests/pytest.ini +++ b/integration_tests/pytest.ini @@ -6,6 +6,7 @@ timeout = 75 testpaths = . markers = packaging: Distribution contents and installed-package boundaries. + mcp_compat: Packaged MCP client compatibility across supported dependency versions. extras: Independently installed optional dependency groups. core: Live OpenAI Responses and Chat Completions coverage. providers: Live AnyLLM and LiteLLM provider-adapter coverage. diff --git a/pyproject.toml b/pyproject.toml index 122b15f3b4..2a840f6cd3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ "typing-extensions>=4.12.2, <5", "requests>=2.0, <3", "websockets>=15.0, <17", - "mcp>=1.19.0, <2; python_version >= '3.10'", + "mcp>=1.19.0, <3; python_version >= '3.10'", ] classifiers = [ "Typing :: Typed", diff --git a/src/agents/mcp/_compat.py b/src/agents/mcp/_compat.py new file mode 100644 index 0000000000..859d71f3aa --- /dev/null +++ b/src/agents/mcp/_compat.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from importlib import import_module +from importlib.metadata import version +from types import ModuleType +from typing import Any, cast + +import httpx +from pydantic import AnyUrl + + +def _major_version(distribution: str) -> int: + raw_version = version(distribution) + major, separator, _ = raw_version.partition(".") + if not separator or not major.isdigit(): # pragma: no cover - package versions are validated + raise RuntimeError(f"Unsupported {distribution} version: {raw_version}") + return int(major) + + +MCP_MAJOR_VERSION = _major_version("mcp") +MCP_V2 = MCP_MAJOR_VERSION >= 2 + +_mcp_exceptions = import_module("mcp.shared.exceptions") +MCPError = cast( + type[Exception], + vars(_mcp_exceptions).get("MCPError") or vars(_mcp_exceptions)["McpError"], +) + +MCP_HTTPX: ModuleType = import_module("httpx2") if MCP_V2 else httpx + +HTTP_STATUS_ERROR_TYPES: tuple[type[Exception], ...] = tuple( + dict.fromkeys((httpx.HTTPStatusError, cast(type[Exception], MCP_HTTPX.HTTPStatusError))) +) +HTTP_REQUEST_ERROR_TYPES: tuple[type[Exception], ...] = tuple( + dict.fromkeys((httpx.RequestError, cast(type[Exception], MCP_HTTPX.RequestError))) +) +HTTP_CONNECT_ERROR_TYPES: tuple[type[Exception], ...] = tuple( + dict.fromkeys((httpx.ConnectError, cast(type[Exception], MCP_HTTPX.ConnectError))) +) +HTTP_TIMEOUT_ERROR_TYPES: tuple[type[Exception], ...] = tuple( + dict.fromkeys((httpx.TimeoutException, cast(type[Exception], MCP_HTTPX.TimeoutException))) +) +HTTP_ERROR_TYPES: tuple[type[Exception], ...] = tuple( + dict.fromkeys((httpx.HTTPError, cast(type[Exception], MCP_HTTPX.HTTPError))) +) +HTTP_INVALID_URL_TYPES: tuple[type[Exception], ...] = tuple( + dict.fromkeys((httpx.InvalidURL, cast(type[Exception], MCP_HTTPX.InvalidURL))) +) + + +def create_v2_client( + transport: Any, + *, + read_timeout_seconds: float | None, + message_handler: Any, +) -> Any: + if not MCP_V2: # pragma: no cover - guarded by the caller + raise RuntimeError("MCP v2 client requested with MCP v1 installed.") + client_class = vars(import_module("mcp"))["Client"] + return client_class( + transport, + mode="auto", + cache=None, + read_timeout_seconds=read_timeout_seconds, + message_handler=message_handler, + ) + + +def streamable_http_client_v2( + url: str, + *, + http_client: Any, + terminate_on_close: bool, +) -> Any: + if not MCP_V2: # pragma: no cover - guarded by the caller + raise RuntimeError("MCP v2 transport requested with MCP v1 installed.") + module = import_module("mcp.client.streamable_http") + return module.streamable_http_client( + url, + http_client=http_client, + terminate_on_close=terminate_on_close, + ) + + +def tool_input_schema(tool: Any) -> dict[str, Any]: + return cast(dict[str, Any], tool.input_schema if MCP_V2 else tool.inputSchema) + + +def result_next_cursor(result: Any) -> str | None: + return cast(str | None, result.next_cursor if MCP_V2 else result.nextCursor) + + +def clear_result_next_cursor(result: Any, **updates: Any) -> Any: + updates["next_cursor" if MCP_V2 else "nextCursor"] = None + return result.model_copy(update=updates) + + +def result_structured_content(result: Any) -> dict[str, Any] | None: + return cast( + dict[str, Any] | None, + result.structured_content if MCP_V2 else result.structuredContent, + ) + + +def result_is_error(result: Any) -> bool | None: + return cast(bool | None, result.is_error if MCP_V2 else result.isError) + + +def image_mime_type(content: Any) -> str: + return cast(str, content.mime_type if MCP_V2 else content.mimeType) + + +def resource_uri(uri: str) -> str | AnyUrl: + return uri if MCP_V2 else AnyUrl(uri) + + +def mcp_error_code(error: BaseException) -> int | None: + if not isinstance(error, MCPError): + return None + if MCP_V2: + return cast(int, cast(Any, error).code) + error_data = getattr(error, "error", None) + return cast(int | None, getattr(error_data, "code", None)) + + +def mcp_error_message(error: BaseException) -> str: + if not isinstance(error, MCPError): + return str(error) + if MCP_V2: + return cast(str, cast(Any, error).message) + error_data = getattr(error, "error", None) + return cast(str, getattr(error_data, "message", str(error))) + + +def mcp_request_timeout_code() -> int: + return -32001 if MCP_V2 else int(httpx.codes.REQUEST_TIMEOUT) + + +def is_mcp_timeout_error(error: BaseException) -> bool: + return mcp_error_code(error) == mcp_request_timeout_code() + + +def is_mcp_connection_closed_error(error: BaseException) -> bool: + if not MCP_V2: + return False + connection_closed = int(vars(import_module("mcp_types"))["CONNECTION_CLOSED"]) + return mcp_error_code(error) == connection_closed + + +def is_http_status_error(error: BaseException) -> bool: + return isinstance(error, HTTP_STATUS_ERROR_TYPES) + + +def is_http_request_error(error: BaseException) -> bool: + return isinstance(error, HTTP_REQUEST_ERROR_TYPES) + + +def is_http_connect_error(error: BaseException) -> bool: + return isinstance(error, HTTP_CONNECT_ERROR_TYPES) + + +def is_http_timeout_error(error: BaseException) -> bool: + return isinstance(error, HTTP_TIMEOUT_ERROR_TYPES) + + +def is_http_transport_error(error: BaseException) -> bool: + return is_http_status_error(error) or is_http_request_error(error) + + +def http_status_code(error: BaseException) -> int: + return cast(int, cast(Any, error).response.status_code) + + +def http_reason_phrase(error: BaseException) -> str: + return cast(str, cast(Any, error).response.reason_phrase) diff --git a/src/agents/mcp/server.py b/src/agents/mcp/server.py index 5e8e2543ea..e4b2fc6c5f 100644 --- a/src/agents/mcp/server.py +++ b/src/agents/mcp/server.py @@ -3,6 +3,7 @@ import abc import asyncio import inspect +import json import math import sys from collections.abc import AsyncGenerator, Awaitable, Callable @@ -17,16 +18,9 @@ if sys.version_info < (3, 11): from exceptiongroup import BaseExceptionGroup # pyright: ignore[reportMissingImports] from anyio import ClosedResourceError -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from mcp import ClientSession, StdioServerParameters, Tool as MCPTool, stdio_client from mcp.client.session import MessageHandlerFnT from mcp.client.sse import sse_client -from mcp.client.streamable_http import ( - GetSessionIdCallback, - StreamableHTTPTransport, - streamablehttp_client, -) -from mcp.shared.exceptions import McpError from mcp.shared.message import SessionMessage from mcp.types import ( CallToolResult, @@ -52,6 +46,31 @@ from ..run_context import RunContextWrapper from ..tool import ToolErrorFunction from ..util._types import MaybeAwaitable +from ._compat import ( + HTTP_CONNECT_ERROR_TYPES, + HTTP_ERROR_TYPES, + HTTP_INVALID_URL_TYPES, + HTTP_REQUEST_ERROR_TYPES, + HTTP_STATUS_ERROR_TYPES, + HTTP_TIMEOUT_ERROR_TYPES, + MCP_HTTPX, + MCP_V2, + MCPError, + clear_result_next_cursor, + create_v2_client, + http_reason_phrase, + http_status_code, + is_http_connect_error, + is_http_request_error, + is_http_status_error, + is_http_timeout_error, + is_mcp_connection_closed_error, + is_mcp_timeout_error, + resource_uri, + result_next_cursor, + streamable_http_client_v2, + tool_input_schema, +) from ._logging import get_mcp_server_log_message, get_mcp_server_log_name from .util import ( HttpClientFactory, @@ -103,12 +122,19 @@ class RequireApprovalObject(TypedDict, total=False): T = TypeVar("T") +GetSessionIdCallback = Callable[[], str | None] + +_streamable_http_module = __import__( + "mcp.client.streamable_http", fromlist=["StreamableHTTPTransport"] +) +StreamableHTTPTransport = cast(Any, vars(_streamable_http_module)["StreamableHTTPTransport"]) +streamablehttp_client = vars(_streamable_http_module).get("streamablehttp_client") _SAFE_EXCEPTION_GROUP_MESSAGE = "MCP request failed with additional errors." _SAFE_EXCEPTION_MESSAGE = "An additional error occurred during the MCP request." -def _client_session_read_timeout(timeout_seconds: float | None) -> timedelta | None: +def _client_session_read_timeout(timeout_seconds: float | None) -> timedelta | float | None: """Convert an MCP read timeout while intentionally treating zero as no timeout.""" if timeout_seconds is None: return None @@ -132,21 +158,22 @@ def _client_session_read_timeout(timeout_seconds: float | None) -> timedelta | N raise ValueError( "client_session_timeout_seconds must fit in a datetime.timedelta." ) from error - return timeout + return timeout_seconds if MCP_V2 else timeout def _transport_error_urls_are_safe( - http_error: httpx.HTTPStatusError | httpx.RequestError, + http_error: Exception, ) -> bool: """Return whether one HTTPX exception contains only credential-safe URLs.""" request_urls: list[str] = [] try: - request_urls.append(str(http_error.request.url)) + request_urls.append(str(cast(Any, http_error).request.url)) except RuntimeError: pass - if isinstance(http_error, httpx.HTTPStatusError): - for response in [*http_error.response.history, http_error.response]: + if is_http_status_error(http_error): + original_response = cast(Any, http_error).response + for response in [*original_response.history, original_response]: try: response_url = response.request.url except RuntimeError: @@ -157,7 +184,7 @@ def _transport_error_urls_are_safe( if redirect_location is not None: try: request_urls.append(str(response_url.join(redirect_location))) - except (httpx.InvalidURL, ValueError): + except HTTP_INVALID_URL_TYPES + (ValueError,): return False return all(get_mcp_server_log_name(url) == url for url in request_urls) @@ -165,7 +192,7 @@ def _transport_error_urls_are_safe( def _safe_transport_cause(http_error: Exception) -> Exception | None: """Keep an unchained transport exception only when its HTTPX URLs are credential-safe.""" - if not isinstance(http_error, httpx.HTTPStatusError | httpx.RequestError): + if not _is_http_transport_error(http_error): return http_error if not _transport_error_urls_are_safe(http_error): @@ -186,8 +213,7 @@ def _first_unsafe_transport_error(http_errors: list[Exception]) -> Exception | N ( error for error in http_errors - if isinstance(error, httpx.HTTPStatusError | httpx.RequestError) - and not _transport_error_urls_are_safe(error) + if _is_http_transport_error(error) and not _transport_error_urls_are_safe(error) ), None, ) @@ -200,7 +226,7 @@ def _first_unretainable_transport_error(http_errors: list[Exception]) -> Excepti def _is_http_transport_error(error: BaseException) -> bool: """Return whether an exception is an HTTPX transport error.""" - return isinstance(error, httpx.HTTPStatusError | httpx.RequestError) + return is_http_status_error(error) or is_http_request_error(error) def _credential_safe_exception_group(error_group: BaseExceptionGroup) -> BaseExceptionGroup: @@ -245,11 +271,11 @@ def _log_transport_warning(message: str, http_error: Exception) -> None: def _get_cleanup_transport_error_message(http_error: Exception) -> str: """Return the cleanup warning message for an HTTPX transport failure.""" - if isinstance(http_error, httpx.HTTPStatusError): + if is_http_status_error(http_error): return "HTTP error during cleanup of MCP server" - if isinstance(http_error, httpx.ConnectError): + if is_http_connect_error(http_error): return "Connection error during cleanup of MCP server" - if isinstance(http_error, httpx.TimeoutException): + if is_http_timeout_error(http_error): return "Timeout error during cleanup of MCP server" return "Request error during cleanup of MCP server" @@ -261,10 +287,20 @@ def _log_cleanup_transport_warning(message: str) -> None: def _create_default_streamable_http_client( headers: dict[str, str] | None = None, - timeout: httpx.Timeout | None = None, - auth: httpx.Auth | None = None, -) -> httpx.AsyncClient: + timeout: Any = None, + auth: Any = None, +) -> Any: kwargs: dict[str, Any] = {"follow_redirects": False} + if MCP_V2: + _validate_v2_http_auth(auth) + if timeout is not None: + kwargs["timeout"] = timeout + if headers is not None: + kwargs["headers"] = headers + if auth is not None: + kwargs["auth"] = auth + return MCP_HTTPX.AsyncClient(**kwargs) + if timeout is not None: kwargs["timeout"] = timeout if headers is not None: @@ -274,7 +310,105 @@ def _create_default_streamable_http_client( return httpx.AsyncClient(**kwargs) -class _InitializedNotificationTolerantStreamableHTTPTransport(StreamableHTTPTransport): +def _validate_v2_http_auth(auth: Any) -> None: + if auth is None or isinstance(auth, MCP_HTTPX.Auth): + return + raise UserError( + "MCP Python SDK v2 requires auth to be an httpx2.Auth instance. " + "Use httpx2 authentication, configure an Authorization header, or pin mcp<2." + ) + + +def _validated_v2_http_client_factory(factory: Callable[..., Any]) -> Callable[..., Any]: + def create_client( + headers: dict[str, str] | None = None, + timeout: Any = None, + auth: Any = None, + ) -> Any: + _validate_v2_http_auth(auth) + client = factory(headers=headers, timeout=timeout, auth=auth) + if not isinstance(client, MCP_HTTPX.AsyncClient): + raise UserError( + "MCP Python SDK v2 requires httpx_client_factory to return an " + "httpx2.AsyncClient. Use an httpx2 factory or pin mcp<2." + ) + return client + + return create_client + + +def _jsonrpc_request_method(request: Any) -> str | None: + try: + payload = json.loads(request.content) + except (TypeError, ValueError, UnicodeDecodeError): + return None + if not isinstance(payload, dict): + return None + method = payload.get("method") + return method if isinstance(method, str) else None + + +def _configure_v2_session_id_hook( + client: Any, + *, + on_session_id: Callable[[str], None] | None, +) -> None: + async def handle_response(response: Any) -> None: + if response.status_code >= 500: + response.raise_for_status() + method = _jsonrpc_request_method(response.request) + if ( + on_session_id is not None + and method == "initialize" + and 200 <= response.status_code < 300 + ): + session_id = response.headers.get("mcp-session-id") + if session_id: + on_session_id(session_id) + + client.event_hooks.setdefault("response", []).append(handle_response) + + +@asynccontextmanager +async def _streamablehttp_client_v2( + url: str, + *, + headers: dict[str, str] | None, + timeout: float | timedelta, # noqa: ASYNC109 + sse_read_timeout: float | timedelta, + terminate_on_close: bool, + httpx_client_factory: Callable[..., Any], + auth: Any, + on_session_id: Callable[[str], None] | None, +) -> AsyncGenerator[MCPStreamTransport, None]: + timeout_seconds = timeout.total_seconds() if isinstance(timeout, timedelta) else timeout + sse_read_timeout_seconds = ( + sse_read_timeout.total_seconds() + if isinstance(sse_read_timeout, timedelta) + else sse_read_timeout + ) + factory = _validated_v2_http_client_factory(httpx_client_factory) + client = factory( + headers=headers, + timeout=MCP_HTTPX.Timeout(timeout_seconds, read=sse_read_timeout_seconds), + auth=auth, + ) + _configure_v2_session_id_hook( + client, + on_session_id=on_session_id, + ) + async with client: + async with streamable_http_client_v2( + url, + http_client=client, + terminate_on_close=terminate_on_close, + ) as streams: + yield streams + + +class _InitializedNotificationTolerantStreamableHTTPTransport( + StreamableHTTPTransport # type: ignore[misc, valid-type] +): async def _handle_post_request(self, ctx: Any) -> None: message = ctx.session_message.message if not self._is_initialized_notification(message): @@ -283,7 +417,7 @@ async def _handle_post_request(self, ctx: Any) -> None: try: await super()._handle_post_request(ctx) - except httpx.HTTPError as exc: + except HTTP_ERROR_TYPES as exc: _log_transport_warning( "Ignoring initialized notification HTTP failure", exc, @@ -302,7 +436,7 @@ async def _streamablehttp_client_with_transport( terminate_on_close: bool = True, httpx_client_factory: HttpClientFactory = _create_default_streamable_http_client, auth: httpx.Auth | None = None, - transport_factory: Callable[[str], StreamableHTTPTransport] = StreamableHTTPTransport, + transport_factory: Callable[[str], Any] = StreamableHTTPTransport, ) -> AsyncGenerator[MCPStreamTransport, None]: timeout_seconds = timeout.total_seconds() if isinstance(timeout, timedelta) else timeout sse_read_timeout_seconds = ( @@ -361,6 +495,12 @@ def start_get_stream() -> None: await write_stream.aclose() +def _require_streamablehttp_client_v1() -> Callable[..., Any]: + if streamablehttp_client is None: # pragma: no cover - guarded by MCP major + raise RuntimeError("The legacy streamable HTTP client requires MCP Python SDK v1.") + return cast(Callable[..., Any], streamablehttp_client) + + class _SharedSessionRequestNeedsIsolation(Exception): """Raised when a shared-session request should be retried on an isolated session.""" @@ -379,17 +519,7 @@ class _UnsetType: from ..agent import AgentBase -MCPStreamTransport = ( - tuple[ - MemoryObjectReceiveStream[SessionMessage | Exception], - MemoryObjectSendStream[SessionMessage], - ] - | tuple[ - MemoryObjectReceiveStream[SessionMessage | Exception], - MemoryObjectSendStream[SessionMessage], - GetSessionIdCallback | None, - ] -) +MCPStreamTransport = tuple[Any, Any] | tuple[Any, Any, GetSessionIdCallback | None] class MCPServer(abc.ABC): @@ -506,14 +636,15 @@ async def list_resources(self, cursor: str | None = None) -> ListResourcesResult Args: cursor: An opaque pagination cursor returned in a previous - :class:`~mcp.types.ListResourcesResult` as ``nextCursor``. Pass it - here to fetch the next page of results. ``None`` fetches the first - page. + :class:`~mcp.types.ListResourcesResult` as ``next_cursor`` under + MCP v2 or ``nextCursor`` under MCP v1. Pass it here to fetch the + next page of results. ``None`` fetches the first page. Returns a :class:`~mcp.types.ListResourcesResult`. When the result contains - a ``nextCursor`` field, call this method again with that cursor to retrieve - the next page. Subclasses that do not support resources may leave this - unimplemented; it will raise :exc:`NotImplementedError` at call time. + a ``next_cursor`` field under MCP v2 or ``nextCursor`` under MCP v1, call + this method again with that cursor to retrieve the next page. Subclasses + that do not support resources may leave this unimplemented; it will raise + :exc:`NotImplementedError` at call time. """ raise NotImplementedError( f"MCP server '{self._error_name}' does not support list_resources. " @@ -527,15 +658,15 @@ async def list_resource_templates( Args: cursor: An opaque pagination cursor returned in a previous - :class:`~mcp.types.ListResourceTemplatesResult` as ``nextCursor``. - Pass it here to fetch the next page of results. ``None`` fetches - the first page. + :class:`~mcp.types.ListResourceTemplatesResult` as ``next_cursor`` + under MCP v2 or ``nextCursor`` under MCP v1. Pass it here to fetch + the next page of results. ``None`` fetches the first page. Returns a :class:`~mcp.types.ListResourceTemplatesResult`. When the result - contains a ``nextCursor`` field, call this method again with that cursor to - retrieve the next page. Subclasses that do not support resource templates - may leave this unimplemented; it will raise :exc:`NotImplementedError` at - call time. + contains a ``next_cursor`` field under MCP v2 or ``nextCursor`` under MCP + v1, call this method again with that cursor to retrieve the next page. + Subclasses that do not support resource templates may leave this + unimplemented; it will raise :exc:`NotImplementedError` at call time. """ raise NotImplementedError( f"MCP server '{self._error_name}' does not support list_resource_templates. " @@ -793,6 +924,7 @@ def __init__( self.tool_filter = tool_filter self._serialize_session_requests = False self._get_session_id: GetSessionIdCallback | None = None + self._v2_session_id: str | None = None async def _maybe_serialize_request(self, func: Callable[[], Awaitable[T]]) -> T: if not self._serialize_session_requests: @@ -927,7 +1059,8 @@ def invalidate_tools_cache(self): def _extract_http_errors_from_exception(self, e: BaseException) -> list[Exception]: """Extract all HTTP errors from an exception or nested ExceptionGroup.""" - if isinstance(e, httpx.HTTPStatusError | httpx.RequestError): + if _is_http_transport_error(e): + assert isinstance(e, Exception) return [e] if isinstance(e, BaseExceptionGroup): @@ -947,16 +1080,16 @@ def _select_cleanup_transport_error(self, error: BaseException) -> Exception | N return unsafe_http_error candidates = error.exceptions if isinstance(error, BaseExceptionGroup) else (error,) - for error_type in ( - httpx.HTTPStatusError, - httpx.ConnectError, - httpx.TimeoutException, + for error_types in ( + HTTP_STATUS_ERROR_TYPES, + HTTP_CONNECT_ERROR_TYPES, + HTTP_TIMEOUT_ERROR_TYPES, ): selected_http_error = next( ( candidate for candidate in reversed(candidates) - if isinstance(candidate, Exception) and isinstance(candidate, error_type) + if isinstance(candidate, Exception) and isinstance(candidate, error_types) ), None, ) @@ -973,18 +1106,18 @@ def _user_error_for_http_error( ) -> UserError: """Build a UserError from safe HTTP diagnostics.""" error_message = f"Failed to connect to MCP server '{self._error_name}': " - if isinstance(http_error, httpx.HTTPStatusError): - error_message += f"HTTP error {http_error.response.status_code}" + if is_http_status_error(http_error): + error_message += f"HTTP error {http_status_code(http_error)}" if include_http_reason_phrase: - error_message += f" ({http_error.response.reason_phrase})" + error_message += f" ({http_reason_phrase(http_error)})" - elif isinstance(http_error, httpx.ConnectError): + elif is_http_connect_error(http_error): error_message += "Could not reach the server." - elif isinstance(http_error, httpx.TimeoutException): + elif is_http_timeout_error(http_error): error_message += "Connection timeout." - elif isinstance(http_error, httpx.RequestError): + elif is_http_request_error(http_error): error_message += "Request failed." return UserError(error_message) @@ -1003,11 +1136,11 @@ def _user_error_for_request_operation( ) -> UserError: """Build a credential-safe error for an MCP request operation.""" error_message = f"Failed to {operation} on MCP server '{self._error_name}': " - if isinstance(http_error, httpx.HTTPStatusError): - error_message += f"HTTP error {http_error.response.status_code}" - elif isinstance(http_error, httpx.ConnectError): + if is_http_status_error(http_error): + error_message += f"HTTP error {http_status_code(http_error)}" + elif is_http_connect_error(http_error): error_message += "Connection lost. The server may have disconnected." - elif isinstance(http_error, httpx.TimeoutException): + elif is_http_timeout_error(http_error): error_message += "Connection timeout." else: error_message += "Request failed." @@ -1023,7 +1156,7 @@ async def _run_request_with_transport_error_redaction( base_error_group: BaseExceptionGroup | None = None try: return await func() - except (httpx.HTTPStatusError, httpx.RequestError) as http_error: + except HTTP_STATUS_ERROR_TYPES + HTTP_REQUEST_ERROR_TYPES as http_error: transport_error = self._user_error_for_request_operation(operation, http_error) except BaseExceptionGroup as error_group: http_errors = self._extract_http_errors_from_exception(error_group) @@ -1066,6 +1199,79 @@ async def _run_with_retries(self, func: Callable[[], Awaitable[T]]) -> T: backoff = self.retry_backoff_seconds_base * (2 ** (attempts - 1)) await asyncio.sleep(backoff) + @asynccontextmanager + async def _client_session_context(self, read_timeout: timedelta | float | None): + """Create one initialized or discovered client session for the installed MCP major.""" + async with AsyncExitStack() as exit_stack: + if MCP_V2: + v2_timeout = cast(float | None, read_timeout) + session_ready: asyncio.Future[ClientSession] = ( + asyncio.get_running_loop().create_future() + ) + close_client = asyncio.Event() + + async def run_client() -> None: + try: + client = create_v2_client( + self.create_streams(), + read_timeout_seconds=v2_timeout, + message_handler=self.message_handler, + ) + async with client as connected_client: + session_ready.set_result(connected_client.session) + await close_client.wait() + except BaseException as exc: + if not session_ready.done(): + session_ready.set_exception(exc) + return + raise + + client_task = asyncio.create_task(run_client()) + try: + try: + session = await asyncio.shield(session_ready) + except asyncio.CancelledError: + session_ready.cancel() + client_task.cancel() + try: + await client_task + except BaseException: + pass + raise + except BaseException: + await client_task + raise + + try: + yield session + finally: + close_client.set() + try: + await client_task + except asyncio.CancelledError: + client_task.cancel() + try: + await client_task + except BaseException: + pass + raise + finally: + close_client.set() + return + + transport = await exit_stack.enter_async_context(self.create_streams()) + read, write, *rest = transport + session = await exit_stack.enter_async_context( + cast(Any, ClientSession)( + read, + write, + cast(timedelta | None, read_timeout), + message_handler=self.message_handler, + ) + ) + await session.initialize() + yield session + async def connect(self): """Connect to the server.""" read_timeout = _client_session_read_timeout(self.client_session_timeout_seconds) @@ -1075,24 +1281,25 @@ async def connect(self): connection_exception: BaseException | None = None cleanup_failure: BaseException | None = None try: - transport = await self.exit_stack.enter_async_context(self.create_streams()) - # streamablehttp_client returns (read, write, get_session_id) - # sse_client returns (read, write) - - read, write, *rest = transport - # Capture the session-id callback when present (streamablehttp_client only). - self._get_session_id = rest[0] if rest and callable(rest[0]) else None - - session = await self.exit_stack.enter_async_context( - ClientSession( - read, - write, - read_timeout, - message_handler=self.message_handler, + if MCP_V2: + session = await self.exit_stack.enter_async_context( + self._client_session_context(read_timeout) ) - ) - server_result = await session.initialize() - self.server_initialize_result = server_result + self.server_initialize_result = getattr(session, "initialize_result", None) + else: + v1_read_timeout = cast(timedelta | None, read_timeout) + transport = await self.exit_stack.enter_async_context(self.create_streams()) + read, write, *rest = transport + self._get_session_id = rest[0] if rest and callable(rest[0]) else None + session = await self.exit_stack.enter_async_context( + cast(Any, ClientSession)( + read, + write, + v1_read_timeout, + message_handler=self.message_handler, + ) + ) + self.server_initialize_result = await session.initialize() self.session = session connection_succeeded = True except BaseException as e: @@ -1106,9 +1313,10 @@ async def connect(self): unsafe_http_error = _first_unretainable_transport_error(http_errors) http_error = unsafe_http_error or http_errors[0] connection_cause = _safe_transport_cause(http_error) - maps_safe_error = isinstance( - http_error, - httpx.HTTPStatusError | httpx.ConnectError | httpx.TimeoutException, + maps_safe_error = ( + is_http_status_error(http_error) + or is_http_connect_error(http_error) + or is_http_timeout_error(http_error) ) if connection_cause is not None and not maps_safe_error: connection_exception = e @@ -1191,7 +1399,7 @@ async def fetch_pages() -> bool: result = await self._list_tools_page(session, cursor) tools.extend(result.tools) seen_cursors.add(cursor) - next_cursor = result.nextCursor + next_cursor = result_next_cursor(result) if next_cursor is None: return True if next_cursor in seen_cursors: @@ -1237,23 +1445,23 @@ async def fetch_pages() -> bool: if self.tool_filter is not None: filtered_tools = await self._apply_tool_filter(filtered_tools, run_context, agent) return filtered_tools - except httpx.HTTPStatusError as e: - status_code = e.response.status_code + except HTTP_STATUS_ERROR_TYPES as e: + status_code = http_status_code(e) transport_error = UserError( f"Failed to list tools from MCP server '{self._error_name}': " f"HTTP error {status_code}" ) transport_cause = _safe_transport_cause(e) - except httpx.RequestError as e: + except HTTP_REQUEST_ERROR_TYPES as e: transport_cause = _safe_transport_cause(e) - if transport_cause is not None and not isinstance(e, httpx.ConnectError): + if transport_cause is not None and not is_http_connect_error(e): raise - if isinstance(e, httpx.ConnectError): + if is_http_connect_error(e): transport_error = UserError( f"Failed to list tools from MCP server '{self._error_name}': Connection lost. " f"The server may have disconnected." ) - elif isinstance(e, httpx.TimeoutException): + elif is_http_timeout_error(e): transport_error = UserError( f"Failed to list tools from MCP server '{self._error_name}': " "Connection timeout." @@ -1290,26 +1498,26 @@ async def call_tool( ) return await self._run_with_retries( lambda: self._maybe_serialize_request( - lambda: session.call_tool(tool_name, arguments, meta=meta) + lambda: cast(Any, session).call_tool(tool_name, arguments, meta=meta) ) ) - except httpx.HTTPStatusError as e: - status_code = e.response.status_code + except HTTP_STATUS_ERROR_TYPES as e: + status_code = http_status_code(e) transport_error = UserError( f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': " f"HTTP error {status_code}" ) transport_cause = _safe_transport_cause(e) - except httpx.RequestError as e: + except HTTP_REQUEST_ERROR_TYPES as e: transport_cause = _safe_transport_cause(e) - if transport_cause is not None and not isinstance(e, httpx.ConnectError): + if transport_cause is not None and not is_http_connect_error(e): raise - if isinstance(e, httpx.ConnectError): + if is_http_connect_error(e): transport_error = UserError( f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': " "Connection lost. The server may have disconnected." ) - elif isinstance(e, httpx.TimeoutException): + elif is_http_timeout_error(e): transport_error = UserError( f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': " "Connection timeout." @@ -1331,10 +1539,10 @@ def _validate_required_parameters( return tool = next((item for item in self._tools_list if item.name == tool_name), None) - if tool is None or not isinstance(tool.inputSchema, dict): + if tool is None or not isinstance(tool_input_schema(tool), dict): return - raw_required = tool.inputSchema.get("required") + raw_required = tool_input_schema(tool).get("required") if not isinstance(raw_required, list) or not raw_required: return @@ -1366,11 +1574,11 @@ async def list_prompts( session = self.session assert session is not None result = await self._list_prompts_page(session) - if result.nextCursor is None: + if result_next_cursor(result) is None: return result prompts = list(result.prompts) - cursor: str | None = result.nextCursor + cursor: str | None = result_next_cursor(result) seen_cursors: set[str | None] = {None} pagination_failure: BaseException | None = None repeated_cursor = False @@ -1391,7 +1599,7 @@ async def list_prompts( break prompts.extend(page.prompts) seen_cursors.add(cursor) - next_cursor = page.nextCursor + next_cursor = result_next_cursor(page) if next_cursor is not None and next_cursor in seen_cursors: repeated_cursor = True break @@ -1410,7 +1618,7 @@ async def list_prompts( f"MCP server '{self._error_name}' returned a repeated cursor while listing prompts." ) from None - return result.model_copy(update={"prompts": prompts, "nextCursor": None}) + return cast(ListPromptsResult, clear_result_next_cursor(result, prompts=prompts)) async def get_prompt( self, name: str, arguments: dict[str, Any] | None = None @@ -1433,7 +1641,15 @@ async def list_resources(self, cursor: str | None = None) -> ListResourcesResult assert session is not None return await self._run_request_with_transport_error_redaction( "list resources", - lambda: self._maybe_serialize_request(lambda: session.list_resources(cursor)), + lambda: self._maybe_serialize_request( + lambda: ( + session.list_resources() + if cursor is None + else session.list_resources(params=PaginatedRequestParams(cursor=cursor)) + ) + if MCP_V2 + else cast(Any, session).list_resources(cursor) + ), ) async def list_resource_templates( @@ -1446,7 +1662,17 @@ async def list_resource_templates( assert session is not None return await self._run_request_with_transport_error_redaction( "list resource templates", - lambda: self._maybe_serialize_request(lambda: session.list_resource_templates(cursor)), + lambda: self._maybe_serialize_request( + lambda: ( + session.list_resource_templates() + if cursor is None + else session.list_resource_templates( + params=PaginatedRequestParams(cursor=cursor) + ) + ) + if MCP_V2 + else cast(Any, session).list_resource_templates(cursor) + ), ) async def read_resource(self, uri: str) -> ReadResourceResult: @@ -1460,11 +1686,11 @@ async def read_resource(self, uri: str) -> ReadResourceResult: raise UserError("Server not initialized. Make sure you call `connect()` first.") session = self.session assert session is not None - from pydantic import AnyUrl - return await self._run_request_with_transport_error_redaction( "read resource", - lambda: self._maybe_serialize_request(lambda: session.read_resource(AnyUrl(uri))), + lambda: self._maybe_serialize_request( + lambda: cast(Any, session).read_resource(resource_uri(uri)) + ), ) async def cleanup(self): @@ -1485,7 +1711,11 @@ async def cleanup(self): e, ) raise - except (BaseExceptionGroup, httpx.HTTPStatusError, httpx.RequestError) as e: + except ( # type: ignore[misc] + BaseExceptionGroup, + *HTTP_STATUS_ERROR_TYPES, + *HTTP_REQUEST_ERROR_TYPES, + ) as e: selected_http_error = self._select_cleanup_transport_error(e) if selected_http_error is not None: if is_failed_connection_cleanup: @@ -1500,7 +1730,7 @@ async def cleanup(self): _get_cleanup_transport_error_message(selected_http_error), self ) ) - elif isinstance(e, httpx.RequestError): + elif is_http_request_error(e): _log_cleanup_transport_warning( get_mcp_server_log_message(_get_cleanup_transport_error_message(e), self) ) @@ -1559,6 +1789,7 @@ async def cleanup(self): finally: self.session = None self._get_session_id = None + self._v2_session_id = None if cleanup_error is not None: self._raise_mapped_transport_error(cleanup_error, None) @@ -1709,15 +1940,16 @@ class MCPServerSseParams(TypedDict): sse_read_timeout: NotRequired[float] """The timeout for the SSE connection, in seconds. Defaults to 5 minutes.""" - auth: NotRequired[httpx.Auth | None] - """Optional httpx authentication handler (e.g. ``httpx.BasicAuth``, a custom - ``httpx.Auth`` subclass for OAuth token refresh, etc.). When provided, it is - passed directly to the underlying ``httpx.AsyncClient`` used by the SSE transport. + auth: NotRequired[Any] + """Optional authentication handler for the installed MCP SDK's HTTP stack. + + Use ``httpx.Auth`` with MCP v1 or ``httpx2.Auth`` with MCP v2. """ httpx_client_factory: NotRequired[HttpClientFactory] - """Custom HTTP client factory for configuring httpx.AsyncClient behavior (e.g. - to set custom SSL certificates, proxies, or other transport options). + """Custom HTTP client factory for the installed MCP SDK's HTTP stack. + + Return ``httpx.AsyncClient`` with MCP v1 or ``httpx2.AsyncClient`` with MCP v2. """ @@ -1814,6 +2046,16 @@ def create_streams( "timeout": self.params.get("timeout", 5), "sse_read_timeout": self.params.get("sse_read_timeout", 60 * 5), } + if MCP_V2: + _validate_v2_http_auth(self.params.get("auth")) + factory = ( + self.params.get("httpx_client_factory") or _create_default_streamable_http_client + ) + kwargs["httpx_client_factory"] = _validated_v2_http_client_factory(factory) + if "auth" in self.params: + kwargs["auth"] = self.params["auth"] + return sse_client(**kwargs) + if "auth" in self.params: kwargs["auth"] = self.params["auth"] kwargs["httpx_client_factory"] = ( @@ -1846,13 +2088,15 @@ class MCPServerStreamableHttpParams(TypedDict): """Terminate on close""" httpx_client_factory: NotRequired[HttpClientFactory] - """Custom HTTP client factory for configuring httpx.AsyncClient behavior.""" + """Custom HTTP client factory for the installed MCP SDK's HTTP stack. + + Return ``httpx.AsyncClient`` with MCP v1 or ``httpx2.AsyncClient`` with MCP v2. + """ + + auth: NotRequired[Any] + """Optional authentication handler for the installed MCP SDK's HTTP stack. - auth: NotRequired[httpx.Auth | None] - """Optional httpx authentication handler (e.g. ``httpx.BasicAuth``, a custom - ``httpx.Auth`` subclass for OAuth token refresh, etc.). When provided, it is - passed directly to the underlying ``httpx.AsyncClient`` used by the Streamable HTTP - transport. + Use ``httpx.Auth`` with MCP v1 or ``httpx2.Auth`` with MCP v2. """ ignore_initialized_notification_failure: NotRequired[bool] @@ -1860,7 +2104,9 @@ class MCPServerStreamableHttpParams(TypedDict): ``notifications/initialized`` POST. Defaults to ``False``. When set to ``True``, initialized-notification failures are - logged and ignored so subsequent requests on the same transport can continue. + logged and ignored so subsequent requests on the same transport can continue. This + option requires MCP Python SDK v1; MCP v2 rejects it before connecting because its + public transport API does not expose these failures. """ @@ -1961,6 +2207,30 @@ def create_streams( "terminate_on_close": self.params.get("terminate_on_close", True), } httpx_client_factory = self.params.get("httpx_client_factory") + if MCP_V2: + if self.params.get("ignore_initialized_notification_failure", False): + raise UserError( + "ignore_initialized_notification_failure is not supported with MCP Python " + "SDK v2 because its public transport API does not expose initialized-" + "notification failures. Leave it disabled or pin mcp<2." + ) + _validate_v2_http_auth(self.params.get("auth")) + on_session_id: Callable[[str], None] | None = None + if self.session is None: + self._v2_session_id = None + + def capture_session_id(session_id: str) -> None: + self._v2_session_id = session_id + + on_session_id = capture_session_id + self._get_session_id = lambda: self._v2_session_id + return _streamablehttp_client_v2( + **kwargs, + httpx_client_factory=httpx_client_factory or _create_default_streamable_http_client, + auth=self.params.get("auth"), + on_session_id=on_session_id, + ) + if self.params.get("ignore_initialized_notification_failure", False): return _streamablehttp_client_with_transport( **kwargs, @@ -1973,23 +2243,15 @@ def create_streams( ) if "auth" in self.params: kwargs["auth"] = self.params["auth"] - return streamablehttp_client(**kwargs) + return cast( + AbstractAsyncContextManager[MCPStreamTransport], + _require_streamablehttp_client_v1()(**kwargs), + ) @asynccontextmanager async def _isolated_client_session(self): read_timeout = _client_session_read_timeout(self.client_session_timeout_seconds) - async with AsyncExitStack() as exit_stack: - transport = await exit_stack.enter_async_context(self.create_streams()) - read, write, *_ = transport - session = await exit_stack.enter_async_context( - ClientSession( - read, - write, - read_timeout, - message_handler=self.message_handler, - ) - ) - await session.initialize() + async with self._client_session_context(read_timeout) as session: yield session async def _call_tool_with_session( @@ -2001,21 +2263,20 @@ async def _call_tool_with_session( ) -> CallToolResult: if meta is None: return await session.call_tool(tool_name, arguments) - return await session.call_tool(tool_name, arguments, meta=meta) + return cast( + CallToolResult, + await cast(Any, session).call_tool(tool_name, arguments, meta=meta), + ) def _should_retry_in_isolated_session(self, exc: BaseException) -> bool: - if isinstance( - exc, - asyncio.CancelledError - | ClosedResourceError - | httpx.ConnectError - | httpx.TimeoutException, - ): + if isinstance(exc, asyncio.CancelledError | ClosedResourceError): + return True + if is_http_connect_error(exc) or is_http_timeout_error(exc): return True - if isinstance(exc, httpx.HTTPStatusError): - return exc.response.status_code >= 500 - if isinstance(exc, McpError): - return exc.error.code == httpx.codes.REQUEST_TIMEOUT + if is_http_status_error(exc): + return http_status_code(exc) >= 500 + if isinstance(exc, MCPError): + return is_mcp_timeout_error(exc) or is_mcp_connection_closed_error(exc) if isinstance(exc, BaseExceptionGroup): return bool(exc.exceptions) and all( self._should_retry_in_isolated_session(inner) for inner in exc.exceptions @@ -2140,23 +2401,23 @@ async def call_tool( backoffs_taken += 1 await asyncio.sleep(backoff) first_attempt = False - except httpx.HTTPStatusError as e: - status_code = e.response.status_code + except HTTP_STATUS_ERROR_TYPES as e: + status_code = http_status_code(e) transport_error = UserError( f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': " f"HTTP error {status_code}" ) transport_cause = _safe_transport_cause(e) - except httpx.RequestError as e: + except HTTP_REQUEST_ERROR_TYPES as e: transport_cause = _safe_transport_cause(e) - if transport_cause is not None and not isinstance(e, httpx.ConnectError): + if transport_cause is not None and not is_http_connect_error(e): raise - if isinstance(e, httpx.ConnectError): + if is_http_connect_error(e): transport_error = UserError( f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': " "Connection lost. The server may have disconnected." ) - elif isinstance(e, httpx.TimeoutException): + elif is_http_timeout_error(e): transport_error = UserError( f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': " "Connection timeout." @@ -2174,23 +2435,23 @@ async def call_tool( unsafe_http_error = _first_unretainable_transport_error(http_errors) http_error = unsafe_http_error or http_errors[0] transport_cause = _safe_transport_cause(http_error) - if isinstance(http_error, httpx.HTTPStatusError): - status_code = http_error.response.status_code + if is_http_status_error(http_error): + status_code = http_status_code(http_error) transport_error = UserError( f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': " f"HTTP error {status_code}" ) - elif isinstance(http_error, httpx.ConnectError): + elif is_http_connect_error(http_error): transport_error = UserError( f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': " "Connection lost. The server may have disconnected." ) - elif isinstance(http_error, httpx.TimeoutException): + elif is_http_timeout_error(http_error): transport_error = UserError( f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': " "Connection timeout." ) - elif isinstance(http_error, httpx.RequestError): + elif is_http_request_error(http_error): if transport_cause is not None: raise transport_error = UserError( @@ -2214,13 +2475,13 @@ def name(self) -> str: @property def session_id(self) -> str | None: - """The MCP session ID assigned by the server, or None if not yet connected - or if the server did not issue a session ID. + """The legacy MCP session ID assigned by the server, if one is available. - The session ID is stable for the lifetime of this server instance's connection. - You can persist it and pass it back via the Mcp-Session-Id request header - (params["headers"]) on a new MCPServerStreamableHttp instance to resume - the same server-side session across process restarts or stateless workers. + MCP 2026-07-28 does not use protocol sessions, so this property returns None for a + modern connection. It also returns None before connection or when a legacy server does + not issue a session ID. A legacy session ID is stable for this instance's connection and + can be passed through the Mcp-Session-Id request header when reconnecting to a server that + supports legacy session resumption. Example:: diff --git a/src/agents/mcp/util.py b/src/agents/mcp/util.py index af62873f6b..3a29bec5ba 100644 --- a/src/agents/mcp/util.py +++ b/src/agents/mcp/util.py @@ -12,17 +12,11 @@ from types import MappingProxyType from typing import TYPE_CHECKING, Any, Protocol, Union -import httpx from typing_extensions import NotRequired, TypedDict from .. import _debug from .._mcp_tool_metadata import resolve_mcp_tool_description_for_model, resolve_mcp_tool_title from ..exceptions import AgentsException, MCPToolCancellationError, ModelBehaviorError, UserError - -try: - from mcp.shared.exceptions import McpError as _McpError -except ImportError: # pragma: no cover – mcp is optional on Python < 3.10 - _McpError = None # type: ignore[assignment, misc] from ..logger import log_tool_action_error, logger from ..run_context import RunContextWrapper from ..strict_schema import ensure_strict_json_schema @@ -42,6 +36,14 @@ from ..tracing import FunctionSpanData, get_current_span, mcp_tools_span from ..util._custom_data import maybe_extract_custom_data from ..util._types import MaybeAwaitable +from ._compat import ( + MCPError, + image_mime_type, + mcp_error_message, + result_is_error, + result_structured_content, + tool_input_schema, +) from ._logging import get_mcp_server_log_message, get_mcp_server_log_name if TYPE_CHECKING: @@ -73,18 +75,19 @@ class _PrefixedToolNameCandidate: class HttpClientFactory(Protocol): - """Protocol for HTTP client factory functions. + """Protocol for MCP HTTP client factory functions. - This interface matches the MCP SDK's McpHttpClientFactory but is defined locally - to avoid accessing internal MCP SDK modules. + The factory must use the HTTP stack required by the installed MCP SDK: ``httpx`` + for MCP v1 or ``httpx2`` for MCP v2. This protocol avoids importing either SDK's + private factory type. """ def __call__( self, headers: dict[str, str] | None = None, - timeout: httpx.Timeout | None = None, - auth: httpx.Auth | None = None, - ) -> httpx.AsyncClient: ... + timeout: Any = None, + auth: Any = None, + ) -> Any: ... @dataclass @@ -532,7 +535,7 @@ def to_function_tool( effective_failure_error_function = server._get_failure_error_function( failure_error_function ) - schema, is_strict = copy.deepcopy(tool.inputSchema), False + schema, is_strict = copy.deepcopy(tool_input_schema(tool)), False # MCP spec doesn't require the inputSchema to have `properties`, but OpenAI spec does. if "properties" not in schema: @@ -620,8 +623,8 @@ async def _extract_custom_data( tool_display_name=tool_display_name, arguments=MappingProxyType(copy.deepcopy(arguments)), result_meta=cls._copy_mapping_proxy(getattr(result, "meta", None)), - structured_content=cls._copy_mapping_proxy(getattr(result, "structuredContent", None)), - is_error=getattr(result, "isError", None), + structured_content=cls._copy_mapping_proxy(result_structured_content(result)), + is_error=result_is_error(result), tool_output=copy.deepcopy(tool_output), ) return await maybe_extract_custom_data(extractor, extractor_context) @@ -724,7 +727,7 @@ async def invoke_mcp_tool( # will format them into model-visible tool errors when appropriate. raise except Exception as e: - if _McpError is not None and isinstance(e, _McpError): + if isinstance(e, MCPError): # An MCP-level error (e.g. upstream HTTP 4xx/5xx, tool not found, etc.) # is not a programming error – re-raise so the FunctionTool failure # pipeline (failure_error_function) can handle it. The default handler @@ -734,7 +737,7 @@ async def invoke_mcp_tool( logger.warning("MCP tool returned an error.") else: server_log_name = get_mcp_server_log_name(server.name) - error_text = e.error.message if hasattr(e, "error") and e.error else str(e) + error_text = mcp_error_message(e) logger.warning( "MCP tool %s on server '%s' returned an error: %s", tool_name_for_display, @@ -761,8 +764,9 @@ async def invoke_mcp_tool( # If structured content is requested and available, use it exclusively tool_output: ToolOutput - if server.use_structured_content and result.structuredContent: - tool_output = json.dumps(result.structuredContent) + structured_content = result_structured_content(result) + if server.use_structured_content and structured_content: + tool_output = json.dumps(structured_content) else: tool_output_list: list[ToolOutputItem] = [] for item in result.content: @@ -771,7 +775,8 @@ async def invoke_mcp_tool( elif item.type == "image": tool_output_list.append( ToolOutputImageDict( - type="image", image_url=f"data:{item.mimeType};base64,{item.data}" + type="image", + image_url=f"data:{image_mime_type(item)};base64,{item.data}", ) ) else: diff --git a/tests/mcp/helpers.py b/tests/mcp/helpers.py index 59a5b9a8f9..6e7d080b2d 100644 --- a/tests/mcp/helpers.py +++ b/tests/mcp/helpers.py @@ -5,14 +5,13 @@ import shutil from typing import Any -from mcp import Tool as MCPTool +from mcp import Tool as MCPToolType from mcp.types import ( CallToolResult, - Content, + ContentBlock, GetPromptResult, ListPromptsResult, ListResourcesResult, - ListResourceTemplatesResult, PromptMessage, ReadResourceResult, TextContent, @@ -23,6 +22,8 @@ from agents.mcp.util import MCPToolCustomDataExtractor, MCPToolMetaResolver, ToolFilter from agents.tool import ToolErrorFunction +from .model_compat import ListResourceTemplatesResult, Tool as MCPTool + tee = shutil.which("tee") or "" assert tee, "tee not found" @@ -70,7 +71,7 @@ def name(self) -> str: class FakeMCPServer(MCPServer): def __init__( self, - tools: list[MCPTool] | None = None, + tools: list[MCPToolType] | None = None, tool_filter: ToolFilter = None, server_name: str = "fake_mcp_server", require_approval: object | None = None, @@ -85,13 +86,13 @@ def __init__( tool_meta_resolver=tool_meta_resolver, custom_data_extractor=custom_data_extractor, ) - self.tools: list[MCPTool] = tools or [] + self.tools: list[MCPToolType] = tools or [] self.tool_calls: list[str] = [] self.tool_results: list[str] = [] self.tool_metas: list[dict[str, Any] | None] = [] self.tool_filter = tool_filter self._server_name = server_name - self._custom_content: list[Content] | None = None + self._custom_content: list[ContentBlock] | None = None self._response_meta: dict[str, Any] | None = None def add_tool(self, name: str, input_schema: dict[str, Any]): diff --git a/tests/mcp/model_compat.py b/tests/mcp/model_compat.py new file mode 100644 index 0000000000..6bd6133008 --- /dev/null +++ b/tests/mcp/model_compat.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from typing import Any, cast + +from mcp import Tool as _Tool +from mcp.types import ( + CallToolResult as _CallToolResult, + ImageContent as _ImageContent, + InitializeResult as _InitializeResult, + JSONRPCMessage as _JSONRPCMessage, + ListPromptsResult as _ListPromptsResult, + ListResourceTemplatesResult as _ListResourceTemplatesResult, + ListToolsResult as _ListToolsResult, + Resource as _Resource, + ResourceTemplate as _ResourceTemplate, + TextResourceContents as _TextResourceContents, +) + +from agents.mcp._compat import MCP_V2, MCPError + + +# MCP v1 and v2 accept their wire-format aliases at runtime, but expose different constructor +# signatures to static type checkers. Keep alias-based fixture construction in one test-only module. +class Tool(_Tool): + def __init__(self, **data: Any) -> None: + super().__init__(**data) + + +class CallToolResult(_CallToolResult): + def __init__(self, **data: Any) -> None: + super().__init__(**data) + + +class ImageContent(_ImageContent): + def __init__(self, **data: Any) -> None: + super().__init__(**data) + + +class InitializeResult(_InitializeResult): + def __init__(self, **data: Any) -> None: + super().__init__(**data) + + +def JSONRPCMessage(*args: Any, **kwargs: Any) -> Any: + return cast(Any, _JSONRPCMessage)(*args, **kwargs) + + +class ListPromptsResult(_ListPromptsResult): + def __init__(self, **data: Any) -> None: + super().__init__(**data) + + +class ListResourceTemplatesResult(_ListResourceTemplatesResult): + def __init__(self, **data: Any) -> None: + super().__init__(**data) + + +class ListToolsResult(_ListToolsResult): + def __init__(self, **data: Any) -> None: + super().__init__(**data) + + +class Resource(_Resource): + def __init__(self, **data: Any) -> None: + super().__init__(**data) + + +class ResourceTemplate(_ResourceTemplate): + def __init__(self, **data: Any) -> None: + super().__init__(**data) + + +class TextResourceContents(_TextResourceContents): + def __init__(self, **data: Any) -> None: + super().__init__(**data) + + +def create_mcp_error(code: int, message: str, data: Any = None) -> Exception: + if MCP_V2: + return cast(Exception, cast(Any, MCPError)(code=code, message=message, data=data)) + from mcp.types import ErrorData + + return cast(Exception, cast(Any, MCPError)(ErrorData(code=code, message=message, data=data))) diff --git a/tests/mcp/servers/legacy.py b/tests/mcp/servers/legacy.py new file mode 100644 index 0000000000..634c5f2a4b --- /dev/null +++ b/tests/mcp/servers/legacy.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import json +import sys + + +def send(message: dict[str, object]) -> None: + sys.stdout.write(json.dumps(message) + "\n") + sys.stdout.flush() + + +def main() -> None: + for line in sys.stdin: + message = json.loads(line) + request_id = message.get("id") + method = message.get("method") + if request_id is None: + continue + if method == "server/discover": + send( + { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32601, "message": "Method not found"}, + } + ) + elif method == "initialize": + send( + { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "protocolVersion": "2025-06-18", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "legacy-test-server", "version": "1.0"}, + }, + } + ) + elif method == "tools/list": + send( + { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "tools": [ + { + "name": "legacy_tool", + "inputSchema": {"type": "object", "properties": {}}, + } + ] + }, + } + ) + elif method == "tools/call": + send( + { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{"type": "text", "text": "legacy-result"}], + "isError": False, + }, + } + ) + else: + send( + { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32601, "message": f"Unknown method: {method}"}, + } + ) + + +if __name__ == "__main__": + main() diff --git a/tests/mcp/servers/paginated.py b/tests/mcp/servers/paginated.py index 06e706324b..c4cecda617 100644 --- a/tests/mcp/servers/paginated.py +++ b/tests/mcp/servers/paginated.py @@ -1,24 +1,42 @@ from __future__ import annotations +from importlib.metadata import version +from typing import Any + import anyio from mcp.server import Server from mcp.server.stdio import stdio_server from mcp.types import ( + CallToolResult, ListPromptsRequest, - ListPromptsResult, + ListPromptsResult as _ListPromptsResult, ListToolsRequest, - ListToolsResult, + ListToolsResult as _ListToolsResult, Prompt, TextContent, - Tool, + Tool as _Tool, ) -server = Server("paginated-test-server") +class ListPromptsResult(_ListPromptsResult): + def __init__(self, **data: Any) -> None: + super().__init__(**data) + + +class ListToolsResult(_ListToolsResult): + def __init__(self, **data: Any) -> None: + super().__init__(**data) + + +class Tool(_Tool): + def __init__(self, **data: Any) -> None: + super().__init__(**data) -@server.list_tools() # type: ignore[misc] -async def list_tools(request: ListToolsRequest) -> ListToolsResult: - cursor = request.params.cursor if request.params is not None else None + +MCP_V2 = int(version("mcp").partition(".")[0]) >= 2 + + +def tools_page(cursor: str | None) -> ListToolsResult: if cursor is None: return ListToolsResult( tools=[ @@ -41,9 +59,7 @@ async def list_tools(request: ListToolsRequest) -> ListToolsResult: raise ValueError(f"Unexpected tools cursor: {cursor}") -@server.list_prompts() # type: ignore[misc] -async def list_prompts(request: ListPromptsRequest) -> ListPromptsResult: - cursor = request.params.cursor if request.params is not None else None +def prompts_page(cursor: str | None) -> ListPromptsResult: if cursor is None: return ListPromptsResult( prompts=[Prompt(name="first_page_prompt")], @@ -58,11 +74,44 @@ async def list_prompts(request: ListPromptsRequest) -> ListPromptsResult: raise ValueError(f"Unexpected prompts cursor: {cursor}") -@server.call_tool() # type: ignore[misc] -async def call_tool(name: str, arguments: dict[str, object] | None) -> list[TextContent]: +def tool_result(name: str) -> CallToolResult: if name not in {"first_page_tool", "second_page_tool"}: raise ValueError(f"Unexpected tool: {name}") - return [TextContent(type="text", text=f"called:{name}")] + return CallToolResult(content=[TextContent(type="text", text=f"called:{name}")]) + + +if MCP_V2: + + async def list_tools_v2(_context: Any, params: Any) -> ListToolsResult: + return tools_page(params.cursor if params is not None else None) + + async def list_prompts_v2(_context: Any, params: Any) -> ListPromptsResult: + return prompts_page(params.cursor if params is not None else None) + + async def call_tool_v2(_context: Any, params: Any) -> CallToolResult: + return tool_result(params.name) + + server = Server( + "paginated-test-server", + on_list_tools=list_tools_v2, + on_list_prompts=list_prompts_v2, + on_call_tool=call_tool_v2, + ) +else: + server = Server("paginated-test-server") + + @server.list_tools() # type: ignore[attr-defined, misc] + async def list_tools_v1(request: ListToolsRequest) -> ListToolsResult: + return tools_page(request.params.cursor if request.params is not None else None) + + @server.list_prompts() # type: ignore[attr-defined, misc] + async def list_prompts_v1(request: ListPromptsRequest) -> ListPromptsResult: + return prompts_page(request.params.cursor if request.params is not None else None) + + @server.call_tool() # type: ignore[attr-defined, misc] + async def call_tool_v1(name: str, arguments: dict[str, object] | None) -> list[TextContent]: + del arguments + return tool_result(name).content # type: ignore[return-value] async def main() -> None: diff --git a/tests/mcp/test_caching.py b/tests/mcp/test_caching.py index 4465a5e057..9a3ce885fa 100644 --- a/tests/mcp/test_caching.py +++ b/tests/mcp/test_caching.py @@ -1,13 +1,14 @@ from unittest.mock import AsyncMock, call, patch import pytest -from mcp.types import ListToolsResult, PaginatedRequestParams, Tool as MCPTool +from mcp.types import PaginatedRequestParams from agents import Agent from agents.mcp import MCPServerStdio from agents.run_context import RunContextWrapper from .helpers import DummyStreamsContextManager, tee +from .model_compat import ListToolsResult, Tool as MCPTool @pytest.mark.asyncio diff --git a/tests/mcp/test_client_session_retries.py b/tests/mcp/test_client_session_retries.py index d6f2704413..b571a15c58 100644 --- a/tests/mcp/test_client_session_retries.py +++ b/tests/mcp/test_client_session_retries.py @@ -6,21 +6,20 @@ import httpx import pytest from anyio import ClosedResourceError -from mcp import ClientSession, Tool as MCPTool -from mcp.shared.exceptions import McpError +from mcp import ClientSession from mcp.types import ( CallToolResult, - ErrorData, GetPromptResult, - ListPromptsResult, - ListToolsResult, PaginatedRequestParams, Prompt, ) from agents.exceptions import UserError +from agents.mcp._compat import mcp_request_timeout_code from agents.mcp.server import MCPServerStreamableHttp, _MCPServerWithClientSession +from .model_compat import ListPromptsResult, ListToolsResult, Tool as MCPTool, create_mcp_error + if sys.version_info < (3, 11): from exceptiongroup import BaseExceptionGroup # pyright: ignore[reportMissingImports] @@ -359,9 +358,7 @@ def __init__(self, message: str = "timed out"): async def call_tool(self, tool_name, arguments, meta=None): self.call_tool_attempts += 1 - raise McpError( - ErrorData(code=httpx.codes.REQUEST_TIMEOUT, message=self.message), - ) + raise create_mcp_error(mcp_request_timeout_code(), self.message) class IsolatedRetrySession: diff --git a/tests/mcp/test_connect_disconnect.py b/tests/mcp/test_connect_disconnect.py index b001303974..167ae8bd0a 100644 --- a/tests/mcp/test_connect_disconnect.py +++ b/tests/mcp/test_connect_disconnect.py @@ -1,11 +1,11 @@ from unittest.mock import AsyncMock, patch import pytest -from mcp.types import ListToolsResult, Tool as MCPTool from agents.mcp import MCPServerStdio from .helpers import DummyStreamsContextManager, tee +from .model_compat import ListToolsResult, Tool as MCPTool @pytest.mark.asyncio diff --git a/tests/mcp/test_mcp_auth_params.py b/tests/mcp/test_mcp_auth_params.py index ebc6c1934e..ad634c83a6 100644 --- a/tests/mcp/test_mcp_auth_params.py +++ b/tests/mcp/test_mcp_auth_params.py @@ -8,8 +8,11 @@ import pytest from agents.mcp import MCPServerSse, MCPServerStreamableHttp +from agents.mcp._compat import MCP_V2 from agents.mcp.server import _create_default_streamable_http_client +pytestmark = pytest.mark.skipif(MCP_V2, reason="These assertions cover the MCP v1 HTTP stack") + class TestMCPServerSseAuthAndFactory: """Tests for auth and httpx_client_factory added to MCPServerSseParams.""" diff --git a/tests/mcp/test_mcp_pagination_integration.py b/tests/mcp/test_mcp_pagination_integration.py index 30e1f03b78..48d61230ae 100644 --- a/tests/mcp/test_mcp_pagination_integration.py +++ b/tests/mcp/test_mcp_pagination_integration.py @@ -7,6 +7,7 @@ from agents import Agent, Runner from agents.mcp import MCPServerStdio +from agents.mcp._compat import MCP_V2, result_next_cursor from ..fake_model import FakeModel from ..test_responses import get_function_tool_call, get_text_message @@ -30,14 +31,20 @@ async def test_stdio_server_auto_paginates_tools_and_prompts(): async with create_paginated_server() as server: tools = await server.list_tools() prompts = await server.list_prompts() + protocol_version = getattr(server.session, "protocol_version", None) + initialize_result = server.server_initialize_result assert [tool.name for tool in tools] == ["first_page_tool", "second_page_tool"] assert [prompt.name for prompt in prompts.prompts] == [ "first_page_prompt", "second_page_prompt", ] - assert prompts.nextCursor is None - assert prompts.meta == {"page": "first"} + assert result_next_cursor(prompts) is None + assert prompts.meta is not None + assert prompts.meta["page"] == "first" + if MCP_V2: + assert protocol_version == "2026-07-28" + assert initialize_result is None @pytest.mark.asyncio diff --git a/tests/mcp/test_mcp_resources.py b/tests/mcp/test_mcp_resources.py index 75bacc99f7..b887023769 100644 --- a/tests/mcp/test_mcp_resources.py +++ b/tests/mcp/test_mcp_resources.py @@ -5,15 +5,19 @@ import pytest from mcp.types import ( ListResourcesResult, - ListResourceTemplatesResult, + PaginatedRequestParams, ReadResourceResult, +) + +from agents.mcp import MCPServerStreamableHttp +from agents.mcp._compat import MCP_V2, resource_uri + +from .model_compat import ( + ListResourceTemplatesResult, Resource, ResourceTemplate, TextResourceContents, ) -from pydantic import AnyUrl - -from agents.mcp import MCPServerStreamableHttp @pytest.fixture @@ -54,7 +58,7 @@ async def test_list_resources_returns_result(server: MCPServerStreamableHttp): mock_session = MagicMock() expected = ListResourcesResult( resources=[ - Resource(uri=AnyUrl("file:///readme.md"), name="readme.md", mimeType="text/markdown"), + Resource(uri="file:///readme.md", name="readme.md", mimeType="text/markdown"), ] ) mock_session.list_resources = AsyncMock(return_value=expected) @@ -63,7 +67,10 @@ async def test_list_resources_returns_result(server: MCPServerStreamableHttp): result = await server.list_resources() assert result is expected - mock_session.list_resources.assert_awaited_once_with(None) + if MCP_V2: + mock_session.list_resources.assert_awaited_once_with() + else: + mock_session.list_resources.assert_awaited_once_with(None) @pytest.mark.asyncio @@ -77,7 +84,12 @@ async def test_list_resources_forwards_cursor(server: MCPServerStreamableHttp): result = await server.list_resources(cursor="tok_abc") assert result is page2 - mock_session.list_resources.assert_awaited_once_with("tok_abc") + if MCP_V2: + mock_session.list_resources.assert_awaited_once_with( + params=PaginatedRequestParams(cursor="tok_abc") + ) + else: + mock_session.list_resources.assert_awaited_once_with("tok_abc") @pytest.mark.asyncio @@ -95,7 +107,10 @@ async def test_list_resource_templates_returns_result(server: MCPServerStreamabl result = await server.list_resource_templates() assert result is expected - mock_session.list_resource_templates.assert_awaited_once_with(None) + if MCP_V2: + mock_session.list_resource_templates.assert_awaited_once_with() + else: + mock_session.list_resource_templates.assert_awaited_once_with(None) @pytest.mark.asyncio @@ -109,7 +124,12 @@ async def test_list_resource_templates_forwards_cursor(server: MCPServerStreamab result = await server.list_resource_templates(cursor="tok_xyz") assert result is page2 - mock_session.list_resource_templates.assert_awaited_once_with("tok_xyz") + if MCP_V2: + mock_session.list_resource_templates.assert_awaited_once_with( + params=PaginatedRequestParams(cursor="tok_xyz") + ) + else: + mock_session.list_resource_templates.assert_awaited_once_with("tok_xyz") @pytest.mark.asyncio @@ -119,7 +139,7 @@ async def test_read_resource_returns_result(server: MCPServerStreamableHttp): uri = "file:///readme.md" expected = ReadResourceResult( contents=[ - TextResourceContents(uri=AnyUrl(uri), text="# Hello", mimeType="text/markdown"), + TextResourceContents(uri=uri, text="# Hello", mimeType="text/markdown"), ] ) mock_session.read_resource = AsyncMock(return_value=expected) @@ -128,7 +148,7 @@ async def test_read_resource_returns_result(server: MCPServerStreamableHttp): result = await server.read_resource(uri) assert result is expected - mock_session.read_resource.assert_awaited_once_with(AnyUrl(uri)) + mock_session.read_resource.assert_awaited_once_with(resource_uri(uri)) @pytest.mark.asyncio diff --git a/tests/mcp/test_mcp_server_manager.py b/tests/mcp/test_mcp_server_manager.py index b92ca76a56..5d79b36bb6 100644 --- a/tests/mcp/test_mcp_server_manager.py +++ b/tests/mcp/test_mcp_server_manager.py @@ -9,7 +9,6 @@ GetPromptResult, ListPromptsResult, ListResourcesResult, - ListResourceTemplatesResult, ReadResourceResult, Tool as MCPTool, ) @@ -19,6 +18,8 @@ from agents.mcp._logging import get_mcp_server_log_name from agents.run_context import RunContextWrapper +from .model_compat import ListResourceTemplatesResult + class TaskBoundServer(MCPServer): def __init__(self) -> None: diff --git a/tests/mcp/test_mcp_util.py b/tests/mcp/test_mcp_util.py index b71ec8b776..e86bc63d99 100644 --- a/tests/mcp/test_mcp_util.py +++ b/tests/mcp/test_mcp_util.py @@ -7,8 +7,8 @@ import pytest from inline_snapshot import snapshot -from mcp.shared.exceptions import McpError -from mcp.types import CallToolResult, ErrorData, ImageContent, TextContent, Tool as MCPTool +from mcp import Tool as MCPToolType +from mcp.types import CallToolResult as CallToolResultType, TextContent from pydantic import BaseModel, TypeAdapter import agents._debug as _debug @@ -27,9 +27,11 @@ UserError, ) from agents.mcp import MCPServer, MCPUtil +from agents.mcp._compat import MCPError, tool_input_schema from agents.tool_context import ToolContext from .helpers import FakeMCPServer +from .model_compat import CallToolResult, ImageContent, Tool as MCPTool, create_mcp_error class Foo(BaseModel): @@ -668,7 +670,7 @@ async def call_tool( tool_name: str, arguments: dict[str, Any] | None, meta: dict[str, Any] | None = None, - ) -> CallToolResult: + ) -> CallToolResultType: if meta is not None: meta["nested"]["headers"].append("mutated") return await super().call_tool(tool_name, arguments, meta=meta) @@ -864,7 +866,7 @@ async def call_tool( arguments: dict[str, Any] | None, meta: dict[str, Any] | None = None, ): - raise McpError(ErrorData(code=-32000, message="upstream said SECRET_MCP_123")) + raise create_mcp_error(-32000, "upstream said SECRET_MCP_123") @pytest.mark.asyncio @@ -926,7 +928,7 @@ async def test_mcp_tool_returned_error_redacts_message_when_dont_log_tool_data( ctx = RunContextWrapper(context=None) tool = MCPTool(name="SECRET_MCP_TOOL_NAME", inputSchema={}) - with pytest.raises(McpError): + with pytest.raises(MCPError): await MCPUtil.invoke_mcp_tool(server, tool, ctx, "") assert "MCP tool returned an error" in caplog.text @@ -947,7 +949,7 @@ async def test_mcp_tool_returned_error_includes_message_when_tool_logging_enable ctx = RunContextWrapper(context=None) tool = MCPTool(name="test_tool_1", inputSchema={}) - with pytest.raises(McpError): + with pytest.raises(MCPError): await MCPUtil.invoke_mcp_tool(server, tool, ctx, "") assert "SECRET_MCP_123" in caplog.text @@ -1157,9 +1159,6 @@ async def test_mcp_invocation_mcp_error_reraises(caplog: pytest.LogCaptureFixtur """ caplog.set_level(logging.DEBUG) - from mcp.shared.exceptions import McpError - from mcp.types import ErrorData - class McpErrorFakeMCPServer(FakeMCPServer): async def call_tool( self, @@ -1167,7 +1166,7 @@ async def call_tool( arguments: dict[str, Any] | None, meta: dict[str, Any] | None = None, ): - raise McpError(ErrorData(code=-32000, message="upstream 422 Unprocessable Entity")) + raise create_mcp_error(-32000, "upstream 422 Unprocessable Entity") server = McpErrorFakeMCPServer() server.add_tool("search", {}) @@ -1176,7 +1175,7 @@ async def call_tool( tool = MCPTool(name="search", inputSchema={}) # invoke_mcp_tool itself should re-raise McpError - with pytest.raises(McpError): + with pytest.raises(MCPError): await MCPUtil.invoke_mcp_tool(server, tool, ctx, "{}") # Warning (not error) should be logged before re-raising @@ -1388,7 +1387,7 @@ async def test_to_function_tool_legacy_call_callable_policy_requires_approval(): def require_approval( _run_context: RunContextWrapper[Any], _agent: Agent, - _tool: MCPTool, + _tool: MCPToolType, ) -> bool: return False @@ -1412,7 +1411,7 @@ async def test_to_function_tool_callable_policy_uses_agent_and_tool(): def require_approval( run_context: RunContextWrapper[Any], agent: Agent, - tool: MCPTool, + tool: MCPToolType, ) -> bool: captured["run_context"] = run_context captured["agent"] = agent @@ -1448,7 +1447,7 @@ async def test_to_function_tool_async_callable_policy_is_awaited(): async def require_approval( _run_context: RunContextWrapper[Any], _agent: Agent, - tool: MCPTool, + tool: MCPToolType, ) -> bool: await asyncio.sleep(0) return tool.name == "async_guarded_tool" @@ -1841,7 +1840,7 @@ def test_to_function_tool_does_not_mutate_mcp_input_schema(): "properties": {}, } assert schema == {"type": "object", "description": "Test tool"} - assert tool.inputSchema == {"type": "object", "description": "Test tool"} + assert tool_input_schema(tool) == {"type": "object", "description": "Test tool"} def test_to_function_tool_failed_strict_conversion_keeps_original_schema(): @@ -1902,7 +1901,7 @@ async def call_tool( tool_name: str, arguments: dict[str, Any] | None, meta: dict[str, Any] | None = None, - ) -> CallToolResult: + ) -> CallToolResultType: """Return test result with specified content and structured content.""" self.tool_calls.append(tool_name) diff --git a/tests/mcp/test_mcp_v2_http.py b/tests/mcp/test_mcp_v2_http.py new file mode 100644 index 0000000000..cf66823a12 --- /dev/null +++ b/tests/mcp/test_mcp_v2_http.py @@ -0,0 +1,378 @@ +from __future__ import annotations + +import asyncio +import json +import socket +from typing import Any + +import httpx +import mcp +import pytest +import uvicorn +from mcp.server import Server +from mcp.types import ListToolsResult, TextContent, Tool + +from agents.exceptions import UserError +from agents.mcp import MCPServerStreamableHttp +from agents.mcp._compat import MCP_V2, create_v2_client +from agents.mcp.server import ( + _configure_v2_session_id_hook, + _create_default_streamable_http_client, + _validated_v2_http_client_factory, +) + +pytestmark = pytest.mark.skipif(not MCP_V2, reason="MCP v2 HTTP behavior") +httpx2 = pytest.importorskip("httpx2") + + +@pytest.mark.asyncio +async def test_v2_streamable_http_negotiates_modern_protocol(): + async def list_tools(_context, _params) -> ListToolsResult: + return ListToolsResult( + tools=[Tool(name="probe", input_schema={"type": "object", "properties": {}})] + ) + + app = Server("probe-server", on_list_tools=list_tools).streamable_http_app() + socket_ = socket.socket() + socket_.bind(("127.0.0.1", 0)) + socket_.listen() + port = socket_.getsockname()[1] + uvicorn_server = uvicorn.Server( + uvicorn.Config(app, log_level="error", lifespan="on", ws="none") + ) + server_task = asyncio.create_task(uvicorn_server.serve(sockets=[socket_])) + + async def wait_until_started() -> None: + while not uvicorn_server.started: + if server_task.done(): + await server_task + await asyncio.sleep(0.01) + + try: + await asyncio.wait_for(wait_until_started(), timeout=5) + server = MCPServerStreamableHttp(params={"url": f"http://127.0.0.1:{port}/mcp"}) + async with server: + tools = await server.list_tools() + protocol_version = server.session.protocol_version if server.session else None + session_id = server.session_id + + assert [tool.name for tool in tools] == ["probe"] + assert protocol_version == "2026-07-28" + assert session_id is None + finally: + uvicorn_server.should_exit = True + await server_task + + +@pytest.mark.asyncio +async def test_v2_response_hook_only_captures_legacy_initialize_session(): + captured: list[str] = [] + + def handle_request(request): + return httpx2.Response( + int(request.headers.get("x-response-status", "200")), + headers={"mcp-session-id": "legacy-session"}, + request=request, + ) + + client = httpx2.AsyncClient(transport=httpx2.MockTransport(handle_request)) + _configure_v2_session_id_hook( + client, + on_session_id=captured.append, + ) + + await client.post( + "https://example.test/mcp", + content=json.dumps({"jsonrpc": "2.0", "id": 1, "method": "server/discover"}), + ) + assert captured == [] + + with pytest.raises(httpx2.HTTPStatusError): + await client.post( + "https://example.test/mcp", + headers={"x-response-status": "503"}, + content=json.dumps({"jsonrpc": "2.0", "id": 2, "method": "initialize"}), + ) + assert captured == [] + + await client.post( + "https://example.test/mcp", + content=json.dumps({"jsonrpc": "2.0", "id": 3, "method": "initialize"}), + ) + assert captured == ["legacy-session"] + await client.aclose() + + +def test_v2_rejects_initialized_notification_tolerance_before_connecting(): + server = MCPServerStreamableHttp( + params={ + "url": "https://example.test/mcp", + "ignore_initialized_notification_failure": True, + } + ) + + with pytest.raises(UserError, match="not supported with MCP Python SDK v2"): + server.create_streams() + + +def test_v2_rejects_v1_auth_before_request(): + with pytest.raises(UserError, match="httpx2.Auth"): + _create_default_streamable_http_client(auth=httpx.BasicAuth("user", "pass")) + + +def test_v2_rejects_v1_client_factory_result(): + factory = _validated_v2_http_client_factory(lambda **kwargs: httpx.AsyncClient()) + with pytest.raises(UserError, match="httpx2.AsyncClient"): + factory() + + +def test_v2_default_factory_returns_httpx2_client(): + client = _create_default_streamable_http_client() + assert isinstance(client, httpx2.AsyncClient) + + +def test_v2_client_receives_timeout_message_handler_and_disables_cache(monkeypatch): + captured: dict[str, object] = {} + + class StubClient: + def __init__(self, transport, **kwargs): + captured["transport"] = transport + captured.update(kwargs) + + monkeypatch.setattr(mcp, "Client", StubClient) + transport = object() + handler = object() + + create_v2_client( + transport, + read_timeout_seconds=12.5, + message_handler=handler, + ) + + assert captured == { + "transport": transport, + "mode": "auto", + "cache": None, + "read_timeout_seconds": 12.5, + "message_handler": handler, + } + + +def _v2_response_for_request( + request, + *, + fail_tool_call: bool = False, + tool_status_code: int | None = None, +): + payload = json.loads(request.content) if request.content else {} + method = payload.get("method") + if method == "server/discover": + body = { + "jsonrpc": "2.0", + "id": payload["id"], + "error": {"code": -32601, "message": "Method not found"}, + } + elif method == "initialize": + body = { + "jsonrpc": "2.0", + "id": payload["id"], + "result": { + "protocolVersion": "2025-06-18", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "test", "version": "1"}, + }, + } + elif method == "notifications/initialized": + return httpx2.Response(202, request=request) + elif method == "tools/list": + body = { + "jsonrpc": "2.0", + "id": payload["id"], + "result": { + "tools": [ + { + "name": "test", + "inputSchema": {"type": "object", "properties": {}}, + } + ] + }, + } + elif method == "tools/call" and tool_status_code is not None: + return httpx2.Response(tool_status_code, request=request) + elif method == "tools/call" and fail_tool_call: + raise httpx2.ConnectError("connection dropped", request=request) + elif method == "tools/call": + body = { + "jsonrpc": "2.0", + "id": payload["id"], + "result": { + "content": [{"type": "text", "text": "ok"}], + "isError": False, + }, + } + else: + body = { + "jsonrpc": "2.0", + "id": payload.get("id"), + "error": {"code": -32601, "message": "Unknown method"}, + } + return httpx2.Response( + 200, + json=body, + headers={"content-type": "application/json"}, + request=request, + ) + + +@pytest.mark.asyncio +async def test_v2_streamable_http_retries_connect_error_on_isolated_session(): + clients: list[Any] = [] + + def factory(headers=None, timeout=None, auth=None): + fail_tool_call = not clients + + async def handler(request): + return _v2_response_for_request(request, fail_tool_call=fail_tool_call) + + client = httpx2.AsyncClient( + transport=httpx2.MockTransport(handler), + headers=headers, + timeout=timeout, + auth=auth, + ) + clients.append(client) + return client + + server = MCPServerStreamableHttp( + params={ + "url": "https://example.test/mcp", + "httpx_client_factory": factory, + }, + max_retry_attempts=1, + retry_backoff_seconds_base=0, + ) + + async with server: + result = await asyncio.wait_for(server.call_tool("test", {}), timeout=2) + + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "ok" + assert len(clients) == 2 + assert all(client.is_closed for client in clients) + + +@pytest.mark.asyncio +async def test_v2_streamable_http_retries_5xx_on_isolated_session(): + clients: list[Any] = [] + observed_statuses: list[int] = [] + + def factory(headers=None, timeout=None, auth=None): + tool_status_code = 503 if not clients else None + + async def handler(request): + return _v2_response_for_request(request, tool_status_code=tool_status_code) + + async def observe_response(response): + observed_statuses.append(response.status_code) + + client = httpx2.AsyncClient( + transport=httpx2.MockTransport(handler), + headers=headers, + timeout=timeout, + auth=auth, + event_hooks={"response": [observe_response]}, + ) + clients.append(client) + return client + + server = MCPServerStreamableHttp( + params={ + "url": "https://example.test/mcp", + "httpx_client_factory": factory, + }, + max_retry_attempts=1, + retry_backoff_seconds_base=0, + ) + + async with server: + result = await asyncio.wait_for(server.call_tool("test", {}), timeout=2) + + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "ok" + assert len(clients) == 2 + assert 503 in observed_statuses + assert all(client.is_closed for client in clients) + + +@pytest.mark.asyncio +async def test_v2_connect_cancellation_stops_pending_client_owner(monkeypatch): + client_entered = asyncio.Event() + owner_task: asyncio.Task[None] | None = None + + class BlockingClient: + async def __aenter__(self): + nonlocal owner_task + owner_task = asyncio.current_task() + client_entered.set() + await asyncio.Event().wait() + + async def __aexit__(self, exc_type, exc_value, traceback): + return False + + monkeypatch.setattr( + "agents.mcp.server.create_v2_client", + lambda *args, **kwargs: BlockingClient(), + ) + server = MCPServerStreamableHttp(params={"url": "https://example.test/mcp"}) + connect_task = asyncio.create_task(server.connect()) + await asyncio.wait_for(client_entered.wait(), timeout=2) + + connect_task.cancel() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(connect_task, timeout=2) + + assert owner_task is not None + assert owner_task.done() + assert server.session is None + + +@pytest.mark.asyncio +async def test_v2_streamable_http_preserves_outer_cancellation(): + call_started = asyncio.Event() + clients: list[Any] = [] + + def factory(headers=None, timeout=None, auth=None): + async def handler(request): + payload = json.loads(request.content) if request.content else {} + if payload.get("method") == "tools/call": + call_started.set() + await asyncio.Event().wait() + return _v2_response_for_request(request) + + client = httpx2.AsyncClient( + transport=httpx2.MockTransport(handler), + headers=headers, + timeout=timeout, + auth=auth, + ) + clients.append(client) + return client + + server = MCPServerStreamableHttp( + params={ + "url": "https://example.test/mcp", + "httpx_client_factory": factory, + }, + max_retry_attempts=1, + retry_backoff_seconds_base=0, + ) + + async with server: + call_task = asyncio.create_task(server.call_tool("test", {})) + await asyncio.wait_for(call_started.wait(), timeout=2) + call_task.cancel() + with pytest.raises(asyncio.CancelledError): + await call_task + + assert len(clients) == 1 + assert clients[0].is_closed diff --git a/tests/mcp/test_mcp_version_compat.py b/tests/mcp/test_mcp_version_compat.py new file mode 100644 index 0000000000..77bb49e0b1 --- /dev/null +++ b/tests/mcp/test_mcp_version_compat.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +from agents.mcp import MCPServerStdio +from agents.mcp._compat import MCP_V2, result_is_error + +LEGACY_SERVER_PATH = Path(__file__).parent / "servers" / "legacy.py" + + +@pytest.mark.asyncio +async def test_stdio_connects_to_legacy_server(): + server = MCPServerStdio( + name="legacy-test-server", + params={"command": sys.executable, "args": [str(LEGACY_SERVER_PATH)]}, + ) + + async with server: + tools = await server.list_tools() + result = await server.call_tool("legacy_tool", {}) + protocol_version = getattr(server.session, "protocol_version", None) + + assert [tool.name for tool in tools] == ["legacy_tool"] + assert result.content[0].type == "text" + assert result_is_error(result) is False + if MCP_V2: + assert protocol_version == "2025-06-18" + assert server.server_initialize_result is not None diff --git a/tests/mcp/test_message_handler.py b/tests/mcp/test_message_handler.py index 193815c2e7..4f93f22f40 100644 --- a/tests/mcp/test_message_handler.py +++ b/tests/mcp/test_message_handler.py @@ -1,22 +1,18 @@ from __future__ import annotations import contextlib -from typing import Union +from typing import Any import anyio import pytest from mcp.client.session import MessageHandlerFnT from mcp.shared.message import SessionMessage -from mcp.shared.session import RequestResponder from mcp.types import ( - ClientResult, Implementation, - InitializeResult, ServerCapabilities, - ServerNotification, - ServerRequest, ) +from agents.mcp._compat import MCP_V2 from agents.mcp.server import ( MCPServerSse, MCPServerStdio, @@ -24,9 +20,9 @@ _MCPServerWithClientSession, ) -HandlerMessage = Union[ # noqa: UP007 - RequestResponder[ServerRequest, ClientResult], ServerNotification, Exception -] +from .model_compat import InitializeResult + +HandlerMessage = Any class _StubClientSession: @@ -87,6 +83,7 @@ def name(self) -> str: @pytest.mark.asyncio +@pytest.mark.skipif(MCP_V2, reason="MCP v2 message handling is owned by the high-level client") async def test_client_session_receives_message_handler(monkeypatch): captured: dict[str, object] = {} diff --git a/tests/mcp/test_prompt_server.py b/tests/mcp/test_prompt_server.py index cf6254e5dd..9df2048bcd 100644 --- a/tests/mcp/test_prompt_server.py +++ b/tests/mcp/test_prompt_server.py @@ -1,13 +1,14 @@ from typing import Any import pytest -from mcp.types import ListResourcesResult, ListResourceTemplatesResult, ReadResourceResult +from mcp.types import ListResourcesResult, ReadResourceResult from agents import Agent, Runner from agents.mcp import MCPServer, MCPToolMetaResolver from ..fake_model import FakeModel from ..test_responses import get_text_message +from .model_compat import ListResourceTemplatesResult class FakeMCPPromptServer(MCPServer): diff --git a/tests/mcp/test_server_errors.py b/tests/mcp/test_server_errors.py index f28947a936..e74b2c01db 100644 --- a/tests/mcp/test_server_errors.py +++ b/tests/mcp/test_server_errors.py @@ -8,10 +8,10 @@ import httpx import pytest -from mcp.types import ListPromptsResult, ListToolsResult from agents import Agent, _debug from agents.exceptions import UserError +from agents.mcp._compat import MCP_V2 from agents.mcp.server import ( MCPServerSse, MCPServerStreamableHttp, @@ -20,6 +20,8 @@ ) from agents.run_context import RunContextWrapper +from .model_compat import ListPromptsResult, ListToolsResult + # Handle Python version compatibility for ExceptionGroups if sys.version_info < (3, 11): from exceptiongroup import BaseExceptionGroup @@ -184,7 +186,8 @@ def test_client_session_read_timeout_treats_zero_as_disabled( @pytest.mark.parametrize("timeout_seconds", [0.000001, 2.5]) def test_client_session_read_timeout_preserves_positive_value(timeout_seconds: float) -> None: - assert _client_session_read_timeout(timeout_seconds) == timedelta(seconds=timeout_seconds) + expected = timeout_seconds if MCP_V2 else timedelta(seconds=timeout_seconds) + assert _client_session_read_timeout(timeout_seconds) == expected @pytest.mark.parametrize( diff --git a/tests/mcp/test_streamable_http_client_factory.py b/tests/mcp/test_streamable_http_client_factory.py index 3e526db7b3..d92a42fe23 100644 --- a/tests/mcp/test_streamable_http_client_factory.py +++ b/tests/mcp/test_streamable_http_client_factory.py @@ -10,16 +10,24 @@ import pytest from anyio import create_memory_object_stream from mcp.shared.message import SessionMessage -from mcp.types import JSONRPCMessage, JSONRPCNotification, JSONRPCRequest +from mcp.types import JSONRPCNotification, JSONRPCRequest from agents import _debug from agents.mcp import MCPServerStreamableHttp +from agents.mcp._compat import MCP_V2 from agents.mcp.server import ( _create_default_streamable_http_client, _InitializedNotificationTolerantStreamableHTTPTransport, _streamablehttp_client_with_transport, ) +from .model_compat import JSONRPCMessage + +pytestmark = pytest.mark.skipif( + MCP_V2, + reason="These assertions cover MCP v1 streamable HTTP transport internals", +) + class TestMCPServerStreamableHttpClientFactory: """Test cases for custom httpx_client_factory parameter.""" diff --git a/tests/mcp/test_streamable_http_session_id.py b/tests/mcp/test_streamable_http_session_id.py index a98013b8f1..871b9e57db 100644 --- a/tests/mcp/test_streamable_http_session_id.py +++ b/tests/mcp/test_streamable_http_session_id.py @@ -7,6 +7,7 @@ import pytest from agents.mcp import MCPServerStreamableHttp +from agents.mcp._compat import MCP_V2 class TestStreamableHttpSessionId: @@ -53,6 +54,7 @@ def changing_callback() -> str | None: assert server.session_id == "session-2" @pytest.mark.asyncio + @pytest.mark.skipif(MCP_V2, reason="MCP v2 session IDs are captured by HTTP response hooks") async def test_connect_captures_get_session_id_callback(self): """connect() should capture the third element of the transport tuple as _get_session_id.""" server = MCPServerStreamableHttp(params={"url": "http://localhost:9999/mcp"}) diff --git a/tests/test_agent_as_tool.py b/tests/test_agent_as_tool.py index ec2c4bbc20..ebe53f3315 100644 --- a/tests/test_agent_as_tool.py +++ b/tests/test_agent_as_tool.py @@ -7,8 +7,6 @@ from typing import Any, cast import pytest -from mcp.shared.exceptions import McpError -from mcp.types import ErrorData from openai.types.responses import ResponseOutputMessage, ResponseOutputText from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall from pydantic import BaseModel, Field @@ -50,6 +48,7 @@ from agents.tool_context import ToolContext from tests.fake_model import FakeModel from tests.mcp.helpers import FakeMCPServer +from tests.mcp.model_compat import create_mcp_error from tests.test_responses import get_function_tool_call, get_text_message from tests.utils.hitl import make_function_tool_call @@ -2292,7 +2291,7 @@ async def call_tool( ): self.tool_calls.append(tool_name) del arguments, meta - raise McpError(ErrorData(code=-32000, message="synthetic upstream 422")) + raise create_mcp_error(-32000, "synthetic upstream 422") nested_server: FakeMCPServer if server == "cancelled": diff --git a/tests/test_process_model_response.py b/tests/test_process_model_response.py index f21d65911f..ff70db72ac 100644 --- a/tests/test_process_model_response.py +++ b/tests/test_process_model_response.py @@ -1,7 +1,6 @@ from typing import Any, cast import pytest -from mcp import Tool as MCPTool from openai._models import construct_type from openai.types.responses import ( ResponseApplyPatchToolCall, @@ -45,6 +44,7 @@ from agents.usage import Usage from tests.fake_model import FakeModel from tests.mcp.helpers import FakeMCPServer +from tests.mcp.model_compat import Tool as MCPTool from tests.test_responses import get_function_tool_call from tests.utils.hitl import ( RecordingEditor, diff --git a/tests/test_stream_events.py b/tests/test_stream_events.py index 453a66ea30..5cdc026f66 100644 --- a/tests/test_stream_events.py +++ b/tests/test_stream_events.py @@ -3,7 +3,6 @@ from typing import Any, cast import pytest -from mcp import Tool as MCPTool from openai._models import construct_type from openai.types.responses import ( ResponseCompletedEvent, @@ -53,6 +52,7 @@ from .fake_model import FakeModel from .mcp.helpers import FakeMCPServer +from .mcp.model_compat import Tool as MCPTool from .test_responses import get_function_tool_call, get_handoff_tool_call, get_text_message diff --git a/tests/test_tool_origin.py b/tests/test_tool_origin.py index 969b089447..6343427987 100644 --- a/tests/test_tool_origin.py +++ b/tests/test_tool_origin.py @@ -7,7 +7,6 @@ from typing import Any, TypeVar, cast import pytest -from mcp import Tool as MCPTool from openai.types.responses.response_output_item import McpCall, McpListTools, McpListToolsTool from pydantic import BaseModel @@ -35,6 +34,7 @@ from agents.run_internal.tool_execution import execute_function_tool_calls from tests.fake_model import FakeModel from tests.mcp.helpers import FakeMCPServer +from tests.mcp.model_compat import Tool as MCPTool from tests.test_responses import get_function_tool_call, get_text_message from tests.utils.factories import make_run_state, make_tool_call, roundtrip_state diff --git a/uv.lock b/uv.lock index faa0f73e57..f5f87adc89 100644 --- a/uv.lock +++ b/uv.lock @@ -1006,7 +1006,8 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, { name = "pydantic" }, - { name = "starlette" }, + { name = "starlette", version = "0.47.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "starlette", version = "1.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, { name = "typing-extensions" }, { name = "typing-inspection" }, ] @@ -1440,6 +1441,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/a8/20ed1ed79cbc2ecdf5301c0968ab7c85547212e2a7bd126ddd2d986e206e/httpcore2-2.9.1.tar.gz", hash = "sha256:4d8acbf8b306f48c9d6046591fd5ba4037d1b1b1000d140fc2c3eab1e9a0c0e2", size = 67089, upload-time = "2026-07-24T09:21:03.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/fb/46c52b781975c335a2bcf1072c7bbc007cbdc8d674217f5ee1daba2c848b/httpcore2-2.9.1-py3-none-any.whl", hash = "sha256:6182472379e855fe4221246a2bb7ecede403bc61c6798062ae1787d051ccde26", size = 82809, upload-time = "2026-07-24T09:21:01.178Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -1456,12 +1470,19 @@ wheels = [ ] [[package]] -name = "httpx-sse" -version = "0.4.1" +name = "httpx2" +version = "2.9.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6e/fa/66bd985dd0b7c109a3bcb89272ee0bfb7e2b4d06309ad7b38ff866734b2a/httpx_sse-0.4.1.tar.gz", hash = "sha256:8f44d34414bc7b21bf3602713005c5df4917884f76072479b21f68befa4ea26e", size = 12998, upload-time = "2025-06-24T13:21:05.71Z" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore2" }, + { name = "idna" }, + { name = "truststore" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/14/38128fbafd7e0ed41d874df6c9a653d47c2d111cfe59e2b4ac95161b4abd/httpx2-2.9.1.tar.gz", hash = "sha256:1932a768737e3666291582833da748cc4e563c337cf96706fccc04fa6e58764a", size = 95458, upload-time = "2026-07-24T09:21:04.972Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/25/0a/6269e3473b09aed2dab8aa1a600c70f31f00ae1349bee30658f7e358a159/httpx_sse-0.4.1-py3-none-any.whl", hash = "sha256:cba42174344c3a5b06f255ce65b350880f962d99ead85e776f23c6618a377a37", size = 8054, upload-time = "2025-06-24T13:21:04.772Z" }, + { url = "https://files.pythonhosted.org/packages/13/b8/cfd91c4ab9134d386d48f0b6ac662ff3d4be6efdee59ee1c67ebc3c0487c/httpx2-2.9.1-py3-none-any.whl", hash = "sha256:1820fe14a9ab1107bfeff39259987429450b070ec0ff38cc87eb0d8c97fdc71a", size = 91191, upload-time = "2026-07-24T09:21:02.6Z" }, ] [[package]] @@ -1494,11 +1515,11 @@ wheels = [ [[package]] name = "idna" -version = "3.10" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -1792,27 +1813,41 @@ wheels = [ [[package]] name = "mcp" -version = "1.26.0" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, + { name = "httpx2" }, { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, - { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "sse-starlette" }, - { name = "starlette" }, + { name = "starlette", version = "0.47.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "starlette", version = "1.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, { name = "typing-extensions" }, { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/33/32d4dff2c95bb5d897c3ef4c83649a08996b17b58f0a326d2495d4c81179/mcp-2.0.0.tar.gz", hash = "sha256:0f440e735c13ece8bb19bc62cf0b86f4313448432fbb77d35e14034f4e050728", size = 1662284, upload-time = "2026-07-28T13:45:32.346Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, + { url = "https://files.pythonhosted.org/packages/67/72/7d7897418912c1d12e87556630dfb7bf0eac71160e9bef8b447960804ee3/mcp-2.0.0-py3-none-any.whl", hash = "sha256:1cb4c75d2d2c7b8c1d756355e5d82a39f2822cc7f13e22a2051d7ca3592349d6", size = 349980, upload-time = "2026-07-28T13:45:28.853Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/56/9b8e1c152f61f6c6b07c4b5896c88c7d0ae90bac6ee6306f852fcc5c1eb0/mcp_types-2.0.0.tar.gz", hash = "sha256:d7d939b9285c9961ae8866ba75ef85da34d12bafe276efbf4eb6a131786d8379", size = 66632, upload-time = "2026-07-28T13:45:33.804Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/4c/c78d78c3d52b0ac594ad7cc8ef5972adfe070e3597a8a4c6ce0cd39196ea/mcp_types-2.0.0-py3-none-any.whl", hash = "sha256:6b2de797ca2797f568b79529e1b25948e34de511bcc0bd82fef1039a6d1b8eb0", size = 69649, upload-time = "2026-07-28T13:45:30.713Z" }, ] [[package]] @@ -2570,7 +2605,7 @@ requires-dist = [ { name = "griffelib", specifier = ">=2,<3" }, { name = "grpcio", marker = "extra == 'dapr'", specifier = ">=1.60.0" }, { name = "litellm", marker = "extra == 'litellm'", specifier = ">=1.83.0" }, - { name = "mcp", marker = "python_full_version >= '3.10'", specifier = ">=1.19.0,<2" }, + { name = "mcp", marker = "python_full_version >= '3.10'", specifier = ">=1.19.0,<3" }, { name = "modal", marker = "extra == 'modal'", specifier = "==1.4.3" }, { name = "numpy", marker = "python_full_version >= '3.10' and extra == 'voice'", specifier = ">=2.2.0,<3" }, { name = "openai", specifier = ">=2.45.0,<3" }, @@ -3063,20 +3098,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/f7/925f65d930802e3ea2eb4d5afa4cb8730c8dc0d2cb89a59dc4ed2fcb2d74/pydantic_core-2.41.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c173ddcd86afd2535e2b695217e82191580663a1d1928239f877f5a1649ef39f", size = 2147775, upload-time = "2025-10-14T10:23:45.406Z" }, ] -[[package]] -name = "pydantic-settings" -version = "2.10.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583, upload-time = "2025-06-24T13:26:46.841Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" }, -] - [[package]] name = "pyee" version = "12.1.1" @@ -3920,8 +3941,13 @@ wheels = [ name = "starlette" version = "0.47.2" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and python_full_version < '3.14'", + "python_full_version == '3.11.*'", + "python_full_version < '3.11'", +] dependencies = [ - { name = "anyio" }, + { name = "anyio", marker = "python_full_version < '3.14'" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/04/57/d062573f391d062710d4088fa1369428c38d51460ab6fedff920efef932e/starlette-0.47.2.tar.gz", hash = "sha256:6ae9aa5db235e4846decc1e7b79c4f346adf41e9777aebeb49dfd09bbd7023d8", size = 2583948, upload-time = "2025-07-20T17:31:58.522Z" } @@ -3929,6 +3955,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/1f/b876b1f83aef204198a42dc101613fefccb32258e5428b5f9259677864b4/starlette-0.47.2-py3-none-any.whl", hash = "sha256:c5847e96134e5c5371ee9fac6fdf1a67336d5815e09eb2a01fdb57a351ef915b", size = 72984, upload-time = "2025-07-20T17:31:56.738Z" }, ] +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", +] +dependencies = [ + { name = "anyio", marker = "python_full_version >= '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + [[package]] name = "synchronicity" version = "0.12.2" @@ -4140,6 +4181,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "types-certifi" version = "2021.10.8.3"