Skip to content

Commit 5004eda

Browse files
refactor: Remove header mutation in streamable_http_client
Remove client.headers.update() call that was unnecessarily mutating user-provided httpx.AsyncClient instances. The mutation was defensive but unnecessary since: 1. All transport methods pass headers explicitly to httpx requests 2. httpx merges request headers with client defaults, with request headers taking precedence 3. HTTP requests are identical with or without the mutation 4. Not mutating respects user's client object integrity Add comprehensive test coverage for header behavior: - Verify client headers are not mutated after use - Verify MCP protocol headers override httpx defaults in requests - Verify custom and MCP headers coexist correctly in requests All existing tests pass, confirming no behavior change to actual HTTP requests.
1 parent e0f7807 commit 5004eda

File tree

2 files changed

+123
-17
lines changed

2 files changed

+123
-17
lines changed

src/mcp/client/streamable_http.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -622,9 +622,6 @@ async def streamable_http_client(
622622
# Create transport with extracted configuration
623623
transport = StreamableHTTPTransport(url, headers_dict, timeout, sse_read_timeout, auth)
624624

625-
# Sync client headers with transport's merged headers (includes MCP protocol requirements)
626-
client.headers.update(transport.request_headers)
627-
628625
async with anyio.create_task_group() as tg:
629626
try:
630627
logger.debug(f"Connecting to StreamableHTTP endpoint: {url}")

tests/shared/test_streamable_http.py

Lines changed: 123 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1891,7 +1891,7 @@ async def test_close_sse_stream_callback_not_provided_for_old_protocol_version()
18911891

18921892

18931893
@pytest.mark.anyio
1894-
async def test_streamablehttp_client_receives_priming_event(
1894+
async def test_streamable_http_client_receives_priming_event(
18951895
event_server: tuple[SimpleEventStore, str],
18961896
) -> None:
18971897
"""Client should receive priming event (resumption token update) on POST SSE stream."""
@@ -1902,7 +1902,7 @@ async def test_streamablehttp_client_receives_priming_event(
19021902
async def on_resumption_token_update(token: str) -> None:
19031903
captured_resumption_tokens.append(token)
19041904

1905-
async with streamablehttp_client(f"{server_url}/mcp") as (
1905+
async with streamable_http_client(f"{server_url}/mcp") as (
19061906
read_stream,
19071907
write_stream,
19081908
_,
@@ -1943,7 +1943,7 @@ async def test_server_close_sse_stream_via_context(
19431943
"""Server tool can call ctx.close_sse_stream() to close connection."""
19441944
_, server_url = event_server
19451945

1946-
async with streamablehttp_client(f"{server_url}/mcp") as (
1946+
async with streamable_http_client(f"{server_url}/mcp") as (
19471947
read_stream,
19481948
write_stream,
19491949
_,
@@ -1964,7 +1964,7 @@ async def test_server_close_sse_stream_via_context(
19641964

19651965

19661966
@pytest.mark.anyio
1967-
async def test_streamablehttp_client_auto_reconnects(
1967+
async def test_streamable_http_client_auto_reconnects(
19681968
event_server: tuple[SimpleEventStore, str],
19691969
) -> None:
19701970
"""Client should auto-reconnect with Last-Event-ID when server closes after priming event."""
@@ -1980,7 +1980,7 @@ async def message_handler(
19801980
if isinstance(message.root, types.LoggingMessageNotification): # pragma: no branch
19811981
captured_notifications.append(str(message.root.params.data))
19821982

1983-
async with streamablehttp_client(f"{server_url}/mcp") as (
1983+
async with streamable_http_client(f"{server_url}/mcp") as (
19841984
read_stream,
19851985
write_stream,
19861986
_,
@@ -2009,13 +2009,13 @@ async def message_handler(
20092009

20102010

20112011
@pytest.mark.anyio
2012-
async def test_streamablehttp_client_respects_retry_interval(
2012+
async def test_streamable_http_client_respects_retry_interval(
20132013
event_server: tuple[SimpleEventStore, str],
20142014
) -> None:
20152015
"""Client MUST respect retry field, waiting specified ms before reconnecting."""
20162016
_, server_url = event_server
20172017

2018-
async with streamablehttp_client(f"{server_url}/mcp") as (
2018+
async with streamable_http_client(f"{server_url}/mcp") as (
20192019
read_stream,
20202020
write_stream,
20212021
_,
@@ -2040,7 +2040,7 @@ async def test_streamablehttp_client_respects_retry_interval(
20402040

20412041

20422042
@pytest.mark.anyio
2043-
async def test_streamablehttp_sse_polling_full_cycle(
2043+
async def test_streamable_http_sse_polling_full_cycle(
20442044
event_server: tuple[SimpleEventStore, str],
20452045
) -> None:
20462046
"""End-to-end test: server closes stream, client reconnects, receives all events."""
@@ -2056,7 +2056,7 @@ async def message_handler(
20562056
if isinstance(message.root, types.LoggingMessageNotification): # pragma: no branch
20572057
all_notifications.append(str(message.root.params.data))
20582058

2059-
async with streamablehttp_client(f"{server_url}/mcp") as (
2059+
async with streamable_http_client(f"{server_url}/mcp") as (
20602060
read_stream,
20612061
write_stream,
20622062
_,
@@ -2088,7 +2088,7 @@ async def message_handler(
20882088

20892089

20902090
@pytest.mark.anyio
2091-
async def test_streamablehttp_events_replayed_after_disconnect(
2091+
async def test_streamable_http_events_replayed_after_disconnect(
20922092
event_server: tuple[SimpleEventStore, str],
20932093
) -> None:
20942094
"""Events sent while client is disconnected should be replayed on reconnect."""
@@ -2104,7 +2104,7 @@ async def message_handler(
21042104
if isinstance(message.root, types.LoggingMessageNotification): # pragma: no branch
21052105
notification_data.append(str(message.root.params.data))
21062106

2107-
async with streamablehttp_client(f"{server_url}/mcp") as (
2107+
async with streamable_http_client(f"{server_url}/mcp") as (
21082108
read_stream,
21092109
write_stream,
21102110
_,
@@ -2136,7 +2136,7 @@ async def message_handler(
21362136

21372137

21382138
@pytest.mark.anyio
2139-
async def test_streamablehttp_multiple_reconnections(
2139+
async def test_streamable_http_multiple_reconnections(
21402140
event_server: tuple[SimpleEventStore, str],
21412141
):
21422142
"""Verify multiple close_sse_stream() calls each trigger a client reconnect.
@@ -2156,7 +2156,7 @@ async def test_streamablehttp_multiple_reconnections(
21562156
async def on_resumption_token(token: str) -> None:
21572157
resumption_tokens.append(token)
21582158

2159-
async with streamablehttp_client(f"{server_url}/mcp") as (read_stream, write_stream, _):
2159+
async with streamable_http_client(f"{server_url}/mcp") as (read_stream, write_stream, _):
21602160
async with ClientSession(read_stream, write_stream) as session:
21612161
await session.initialize()
21622162

@@ -2216,7 +2216,7 @@ async def message_handler(
22162216
if isinstance(message.root, types.ResourceUpdatedNotification): # pragma: no branch
22172217
received_notifications.append(str(message.root.params.uri))
22182218

2219-
async with streamablehttp_client(f"{server_url}/mcp") as (
2219+
async with streamable_http_client(f"{server_url}/mcp") as (
22202220
read_stream,
22212221
write_stream,
22222222
_,
@@ -2247,3 +2247,112 @@ async def message_handler(
22472247
assert "http://notification_2/" in received_notifications, (
22482248
f"Should receive notification 2 after reconnect, got: {received_notifications}"
22492249
)
2250+
2251+
2252+
@pytest.mark.anyio
2253+
async def test_streamable_http_client_does_not_mutate_provided_client(
2254+
basic_server: None, basic_server_url: str
2255+
) -> None:
2256+
"""Test that streamable_http_client does not mutate the provided httpx client's headers."""
2257+
# Create a client with custom headers
2258+
original_headers = {
2259+
"X-Custom-Header": "custom-value",
2260+
"Authorization": "Bearer test-token",
2261+
}
2262+
2263+
async with httpx.AsyncClient(headers=original_headers, follow_redirects=True) as custom_client:
2264+
# Use the client with streamable_http_client
2265+
async with streamable_http_client(f"{basic_server_url}/mcp", http_client=custom_client) as (
2266+
read_stream,
2267+
write_stream,
2268+
_,
2269+
):
2270+
async with ClientSession(read_stream, write_stream) as session:
2271+
result = await session.initialize()
2272+
assert isinstance(result, InitializeResult)
2273+
2274+
# Verify client headers were not mutated with MCP protocol headers
2275+
# If accept header exists, it should still be httpx default, not MCP's
2276+
if "accept" in custom_client.headers: # pragma: no branch
2277+
assert custom_client.headers.get("accept") == "*/*"
2278+
# MCP content-type should not have been added
2279+
assert custom_client.headers.get("content-type") != "application/json"
2280+
2281+
# Verify custom headers are still present and unchanged
2282+
assert custom_client.headers.get("X-Custom-Header") == "custom-value"
2283+
assert custom_client.headers.get("Authorization") == "Bearer test-token"
2284+
2285+
2286+
@pytest.mark.anyio
2287+
async def test_streamable_http_client_mcp_headers_override_defaults(
2288+
context_aware_server: None, basic_server_url: str
2289+
) -> None:
2290+
"""Test that MCP protocol headers override httpx.AsyncClient default headers."""
2291+
# httpx.AsyncClient has default "accept: */*" header
2292+
# We need to verify that our MCP accept header overrides it in actual requests
2293+
2294+
async with httpx.AsyncClient(follow_redirects=True) as client:
2295+
# Verify client has default accept header
2296+
assert client.headers.get("accept") == "*/*"
2297+
2298+
async with streamable_http_client(f"{basic_server_url}/mcp", http_client=client) as (
2299+
read_stream,
2300+
write_stream,
2301+
_,
2302+
):
2303+
async with ClientSession(read_stream, write_stream) as session:
2304+
await session.initialize()
2305+
2306+
# Use echo_headers tool to see what headers the server actually received
2307+
tool_result = await session.call_tool("echo_headers", {})
2308+
assert len(tool_result.content) == 1
2309+
assert isinstance(tool_result.content[0], TextContent)
2310+
headers_data = json.loads(tool_result.content[0].text)
2311+
2312+
# Verify MCP protocol headers were sent (not httpx defaults)
2313+
assert "accept" in headers_data
2314+
assert "application/json" in headers_data["accept"]
2315+
assert "text/event-stream" in headers_data["accept"]
2316+
2317+
assert "content-type" in headers_data
2318+
assert headers_data["content-type"] == "application/json"
2319+
2320+
2321+
@pytest.mark.anyio
2322+
async def test_streamable_http_client_preserves_custom_with_mcp_headers(
2323+
context_aware_server: None, basic_server_url: str
2324+
) -> None:
2325+
"""Test that both custom headers and MCP protocol headers are sent in requests."""
2326+
custom_headers = {
2327+
"X-Custom-Header": "custom-value",
2328+
"X-Request-Id": "req-123",
2329+
"Authorization": "Bearer test-token",
2330+
}
2331+
2332+
async with httpx.AsyncClient(headers=custom_headers, follow_redirects=True) as client:
2333+
async with streamable_http_client(f"{basic_server_url}/mcp", http_client=client) as (
2334+
read_stream,
2335+
write_stream,
2336+
_,
2337+
):
2338+
async with ClientSession(read_stream, write_stream) as session:
2339+
await session.initialize()
2340+
2341+
# Use echo_headers tool to verify both custom and MCP headers are present
2342+
tool_result = await session.call_tool("echo_headers", {})
2343+
assert len(tool_result.content) == 1
2344+
assert isinstance(tool_result.content[0], TextContent)
2345+
headers_data = json.loads(tool_result.content[0].text)
2346+
2347+
# Verify custom headers are present
2348+
assert headers_data.get("x-custom-header") == "custom-value"
2349+
assert headers_data.get("x-request-id") == "req-123"
2350+
assert headers_data.get("authorization") == "Bearer test-token"
2351+
2352+
# Verify MCP protocol headers are also present
2353+
assert "accept" in headers_data
2354+
assert "application/json" in headers_data["accept"]
2355+
assert "text/event-stream" in headers_data["accept"]
2356+
2357+
assert "content-type" in headers_data
2358+
assert headers_data["content-type"] == "application/json"

0 commit comments

Comments
 (0)