Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions src/mcp/client/streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -640,16 +641,26 @@ 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]:
"""Client transport for StreamableHTTP.

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:
Expand All @@ -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)

Expand Down
47 changes: 47 additions & 0 deletions tests/client/test_streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading