Skip to content

Commit 1e00f42

Browse files
committed
Load httpx2 with the HTTP transports only
The streamable-HTTP client transport (httpx2) is now imported when the first URL Client is constructed instead of at import time, so a stdio-only client (or a bare `import mcp.client`) never loads it. Tests cover the resulting import contract: the client entry points load neither the server stack nor httpx2 until they are used, and the package's lazy server-side re-exports still resolve to their defining objects. The test that patched `mcp.client.client.streamable_http_client` now patches the transport at its defining module.
1 parent 1c1b7f8 commit 1e00f42

3 files changed

Lines changed: 67 additions & 3 deletions

File tree

‎src/mcp/client/client.py‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@
5858
MessageHandlerFnT,
5959
SamplingFnT,
6060
)
61-
from mcp.client.streamable_http import streamable_http_client
6261
from mcp.client.subscriptions import ServerEvent, Subscription
6362
from mcp.client.subscriptions import listen as _listen
6463
from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair
@@ -103,6 +102,16 @@ async def connect(exit_stack: AsyncExitStack, _mode: ConnectMode, _raise_excepti
103102
return connect
104103

105104

105+
def _connect_url(url: str) -> _Connector:
106+
"""Connector for a URL string: the streamable-HTTP client transport."""
107+
# Function-level import (documented exception): `httpx2` is the heaviest client-side
108+
# dependency and only the HTTP transports need it, so a stdio-only client (or bare
109+
# `import mcp.client`) never loads it; the first URL `Client` constructed does, once.
110+
from mcp.client.streamable_http import streamable_http_client
111+
112+
return _connect_transport(streamable_http_client(url))
113+
114+
106115
def _loaded_class(module: str, name: str) -> type | None:
107116
"""Return class `name` from `module` if that module has already been imported, else `None`.
108117
@@ -422,7 +431,7 @@ def __post_init__(self) -> None:
422431
if _is_lowlevel_server(srv):
423432
self._connect = _connect_inproc(srv)
424433
elif isinstance(srv, str):
425-
self._connect = _connect_transport(streamable_http_client(srv))
434+
self._connect = _connect_url(srv)
426435
else:
427436
self._connect = _connect_transport(srv)
428437

‎tests/client/test_client.py‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
from __future__ import annotations
44

55
import contextvars
6+
import json
7+
import subprocess
8+
import sys
69
from collections.abc import AsyncIterator, Iterator
710
from contextlib import asynccontextmanager, contextmanager
811
from unittest.mock import patch
@@ -397,11 +400,52 @@ async def test_complete_with_prompt_reference(simple_server: Server):
397400

398401

399402
def test_client_with_url_initializes_streamable_http_transport():
400-
with patch("mcp.client.client.streamable_http_client") as mock:
403+
# `Client` imports the streamable-HTTP transport lazily (at construction of the
404+
# first URL client) so a stdio-only client never loads httpx2; patch its source.
405+
with patch("mcp.client.streamable_http.streamable_http_client") as mock:
401406
_ = Client("http://localhost:8000/mcp")
402407
mock.assert_called_once_with("http://localhost:8000/mcp")
403408

404409

410+
# Runs in a fresh interpreter: this process has already imported the whole SDK, so its
411+
# `sys.modules` can't show what the client entry points load on their own. Prints which of
412+
# the deferred package prefixes are loaded after the imports, then again after building a
413+
# URL Client (which constructs its transport but only connects on __aenter__).
414+
FRESH_CLIENT_IMPORT_PROBE = """
415+
import json, sys
416+
417+
import mcp
418+
import mcp.client.stdio
419+
from mcp import Client
420+
421+
DEFERRED = ("mcp.server", "httpx2", "starlette", "uvicorn", "sse_starlette", "jwt", "cryptography")
422+
423+
424+
def loaded():
425+
return sorted({p for p in DEFERRED for m in sys.modules if m == p or m.startswith(p + ".")})
426+
427+
428+
on_import = loaded()
429+
Client("http://localhost/mcp")
430+
print(json.dumps([on_import, loaded()]))
431+
"""
432+
433+
434+
def test_client_entry_points_load_the_server_stack_and_http_transports_only_when_used():
435+
"""SDK-defined: `import mcp` / `import mcp.client.stdio` / `from mcp import Client` load
436+
neither the server stack nor httpx2; constructing the first URL `Client` is what loads
437+
its streamable-HTTP transport (httpx2), and even then the server stack stays out."""
438+
# A regression hangs forever, so the bound only has to beat never (matches the suite's
439+
# other subprocess.run calls).
440+
result = subprocess.run(
441+
[sys.executable, "-c", FRESH_CLIENT_IMPORT_PROBE], capture_output=True, text=True, check=False, timeout=20
442+
)
443+
assert result.returncode == 0, result.stderr
444+
on_import, after_url_client = json.loads(result.stdout)
445+
assert on_import == []
446+
assert after_url_client == ["httpx2"]
447+
448+
405449
async def test_client_uses_transport_directly(app: MCPServer):
406450
transport = InMemoryTransport(app)
407451
async with Client(transport, mode="legacy") as client:

‎tests/test_init.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77
from inline_snapshot import snapshot
88

99
import mcp
10+
import mcp.server.session
11+
import mcp.server.stdio
1012
import mcp.shared.exceptions
13+
from mcp.client.session_group import ClientSessionGroup
1114

1215

1316
def test_exported_name_is_the_home_module_object_and_is_cached_on_the_package():
@@ -16,6 +19,14 @@ def test_exported_name_is_the_home_module_object_and_is_cached_on_the_package():
1619
assert vars(mcp)["MCPError"] is mcp.shared.exceptions.MCPError
1720

1821

22+
def test_client_and_server_reexports_are_the_defining_objects():
23+
"""SDK-defined: `mcp.ServerSession`, `mcp.stdio_server` and `mcp.ClientSessionGroup`
24+
resolve on first access to the very objects their defining modules export."""
25+
assert mcp.ServerSession is mcp.server.session.ServerSession
26+
assert mcp.stdio_server is mcp.server.stdio.stdio_server
27+
assert mcp.ClientSessionGroup is ClientSessionGroup
28+
29+
1930
def test_dir_lists_every_export_and_the_bound_submodules():
2031
"""SDK-defined: `dir(mcp)` reports all of `__all__` plus the submodules a bare import binds."""
2132
listing = set(dir(mcp))

0 commit comments

Comments
 (0)