Skip to content

Commit 31ba195

Browse files
author
Anand Mall
committed
fix(client): support forwarding custom headers to streamable_http_client
1 parent d060b36 commit 31ba195

2 files changed

Lines changed: 64 additions & 4 deletions

File tree

src/mcp/client/streamable_http.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import contextlib
66
import logging
7+
from datetime import timedelta
78
from collections.abc import AsyncGenerator, Awaitable, Callable
89
from contextlib import asynccontextmanager
910
from dataclasses import dataclass
@@ -640,16 +641,26 @@ async def terminate_session(self, client: httpx2.AsyncClient) -> None:
640641
async def streamable_http_client(
641642
url: str,
642643
*,
644+
headers: dict[str, str] | None = None,
645+
timeout: httpx2.Timeout | timedelta | None = None,
646+
auth: httpx2.Auth | None = None,
643647
http_client: httpx2.AsyncClient | None = None,
644648
terminate_on_close: bool = True,
645649
) -> AsyncGenerator[TransportStreams, None]:
646650
"""Client transport for StreamableHTTP.
647651
648652
Args:
649653
url: The MCP server endpoint URL.
654+
headers: Optional HTTP headers to include with every request, including
655+
during auth discovery. A ``User-Agent`` header set here will be
656+
forwarded to OAuth metadata discovery requests. Ignored when
657+
``http_client`` is provided.
658+
timeout: Request timeout. Ignored when ``http_client`` is provided.
659+
auth: Optional httpx2 authentication handler (e.g. OAuth). Ignored
660+
when ``http_client`` is provided.
650661
http_client: Optional pre-configured httpx2.AsyncClient. If None, a default
651-
client with recommended MCP timeouts will be created. To configure headers,
652-
authentication, or other HTTP settings, create an httpx2.AsyncClient and pass it here.
662+
client is created using ``headers``, ``timeout``, and ``auth`` if provided.
663+
To configure other HTTP settings, create an httpx2.AsyncClient and pass it here.
653664
terminate_on_close: If True, send a DELETE request to terminate the session when the context exits.
654665
655666
Yields:
@@ -665,8 +676,10 @@ async def streamable_http_client(
665676
client = http_client
666677

667678
if client is None:
668-
# Create default client with recommended MCP timeouts
669-
client = create_mcp_http_client()
679+
# Normalize timedelta → httpx2.Timeout for caller convenience
680+
if isinstance(timeout, timedelta):
681+
timeout = httpx2.Timeout(timeout.total_seconds())
682+
client = create_mcp_http_client(headers=headers, timeout=timeout, auth=auth)
670683

671684
transport = StreamableHTTPTransport(url)
672685

tests/client/test_streamable_http.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from collections.abc import AsyncIterator, Callable, Mapping
1212
from typing import Any
1313

14+
from unittest.mock import patch
1415
import anyio
1516
import httpx2
1617
import pytest
@@ -748,3 +749,49 @@ async def test_resolving_an_abandoned_request_after_the_reader_closed_is_contain
748749
_abandoned_request_context(http, send), "evt-7", None, MAX_RECONNECTION_ATTEMPTS
749750
)
750751
send.close()
752+
753+
754+
@pytest.mark.anyio
755+
async def test_custom_headers_forwarded_to_http_client() -> None:
756+
"""Headers passed to streamable_http_client() must appear in requests."""
757+
captured_requests = []
758+
759+
async def mock_transport(request: httpx2.Request) -> httpx2.Response:
760+
captured_requests.append(request)
761+
return httpx2.Response(200, content=b"{}")
762+
763+
custom_transport = httpx2.MockTransport(mock_transport)
764+
client = httpx2.AsyncClient(transport=custom_transport)
765+
766+
with patch("mcp.client.streamable_http.create_mcp_http_client", return_value=client):
767+
async with streamable_http_client(
768+
"http://localhost:8080/mcp",
769+
headers={"User-Agent": "my-client/1.0", "X-Custom": "value"},
770+
) as (read, write):
771+
pass
772+
773+
# Every captured request should carry the custom User-Agent
774+
for req in captured_requests:
775+
assert req.headers.get("user-agent") == "my-client/1.0"
776+
assert req.headers.get("x-custom") == "value"
777+
778+
779+
@pytest.mark.anyio
780+
async def test_http_client_provided_overrides_headers_param() -> None:
781+
"""When http_client is provided, headers/timeout/auth params are ignored."""
782+
custom_client = httpx2.AsyncClient(headers={"User-Agent": "explicit-client/1.0"})
783+
784+
# headers kwarg should be silently ignored — http_client wins
785+
async with streamable_http_client(
786+
"http://localhost:8080/mcp",
787+
headers={"User-Agent": "ignored/0.0"},
788+
http_client=custom_client,
789+
) as (read, write):
790+
pass # just verifying no error and no conflict
791+
792+
793+
@pytest.mark.anyio
794+
async def test_no_headers_uses_defaults() -> None:
795+
"""Omitting headers uses the same defaults as before (backward compat)."""
796+
async with streamable_http_client("http://localhost:8080/mcp") as (read, write):
797+
pass # must not raise; behavior unchanged from v1

0 commit comments

Comments
 (0)