diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index 226b0fecf9..4448a72298 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -4,6 +4,7 @@ import contextlib import logging +from datetime import timedelta from collections.abc import AsyncGenerator, Awaitable, Callable from contextlib import asynccontextmanager from dataclasses import dataclass @@ -640,6 +641,9 @@ async def terminate_session(self, client: httpx2.AsyncClient) -> None: async def streamable_http_client( url: str, *, + headers: dict[str, str] | None = None, + timeout: httpx2.Timeout | timedelta | None = None, + auth: httpx2.Auth | None = None, http_client: httpx2.AsyncClient | None = None, terminate_on_close: bool = True, ) -> AsyncGenerator[TransportStreams, None]: @@ -647,9 +651,16 @@ async def streamable_http_client( Args: url: The MCP server endpoint URL. + headers: Optional HTTP headers to include with every request, including + during auth discovery. A ``User-Agent`` header set here will be + forwarded to OAuth metadata discovery requests. Ignored when + ``http_client`` is provided. + timeout: Request timeout. Ignored when ``http_client`` is provided. + auth: Optional httpx2 authentication handler (e.g. OAuth). Ignored + when ``http_client`` is provided. http_client: Optional pre-configured httpx2.AsyncClient. If None, a default - client with recommended MCP timeouts will be created. To configure headers, - authentication, or other HTTP settings, create an httpx2.AsyncClient and pass it here. + client is created using ``headers``, ``timeout``, and ``auth`` if provided. + To configure other HTTP settings, create an httpx2.AsyncClient and pass it here. terminate_on_close: If True, send a DELETE request to terminate the session when the context exits. Yields: @@ -665,8 +676,10 @@ async def streamable_http_client( client = http_client if client is None: - # Create default client with recommended MCP timeouts - client = create_mcp_http_client() + # Normalize timedelta → httpx2.Timeout for caller convenience + if isinstance(timeout, timedelta): + timeout = httpx2.Timeout(timeout.total_seconds()) + client = create_mcp_http_client(headers=headers, timeout=timeout, auth=auth) transport = StreamableHTTPTransport(url) diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index d21f520daf..c6466e0786 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -11,6 +11,7 @@ from collections.abc import AsyncIterator, Callable, Mapping from typing import Any +from unittest.mock import patch import anyio import httpx2 import pytest @@ -748,3 +749,49 @@ async def test_resolving_an_abandoned_request_after_the_reader_closed_is_contain _abandoned_request_context(http, send), "evt-7", None, MAX_RECONNECTION_ATTEMPTS ) send.close() + + +@pytest.mark.anyio +async def test_custom_headers_forwarded_to_http_client() -> None: + """Headers passed to streamable_http_client() must appear in requests.""" + captured_requests = [] + + async def mock_transport(request: httpx2.Request) -> httpx2.Response: + captured_requests.append(request) + return httpx2.Response(200, content=b"{}") + + custom_transport = httpx2.MockTransport(mock_transport) + client = httpx2.AsyncClient(transport=custom_transport) + + with patch("mcp.client.streamable_http.create_mcp_http_client", return_value=client): + async with streamable_http_client( + "http://localhost:8080/mcp", + headers={"User-Agent": "my-client/1.0", "X-Custom": "value"}, + ) as (read, write): + pass + + # Every captured request should carry the custom User-Agent + for req in captured_requests: + assert req.headers.get("user-agent") == "my-client/1.0" + assert req.headers.get("x-custom") == "value" + + +@pytest.mark.anyio +async def test_http_client_provided_overrides_headers_param() -> None: + """When http_client is provided, headers/timeout/auth params are ignored.""" + custom_client = httpx2.AsyncClient(headers={"User-Agent": "explicit-client/1.0"}) + + # headers kwarg should be silently ignored — http_client wins + async with streamable_http_client( + "http://localhost:8080/mcp", + headers={"User-Agent": "ignored/0.0"}, + http_client=custom_client, + ) as (read, write): + pass # just verifying no error and no conflict + + +@pytest.mark.anyio +async def test_no_headers_uses_defaults() -> None: + """Omitting headers uses the same defaults as before (backward compat).""" + async with streamable_http_client("http://localhost:8080/mcp") as (read, write): + pass # must not raise; behavior unchanged from v1