Skip to content

Commit 2128b57

Browse files
committed
Resolve submodules of mcp.client, mcp.server, mcp.shared and mcp.os on attribute access
Adds a small PEP 562 __getattr__ factory (mcp.shared._lazy_submodules) and installs it on the four packages, so attribute chains that used to resolve only because the top-level package imported everything eagerly - e.g. import mcp; mcp.client.stdio.stdio_client - keep resolving now that the mcp package is lazy: touching pkg.name imports the submodule pkg.name if it exists, raising AttributeError otherwise and surfacing a submodule's own missing dependency as its real ImportError.
1 parent 622aed5 commit 2128b57

9 files changed

Lines changed: 124 additions & 0 deletions

File tree

‎src/mcp/client/__init__.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
advertise,
2222
)
2323
from mcp.client.session import ClientSession, IncomingMessage
24+
from mcp.shared._lazy_submodules import submodule_getattr as _submodule_getattr
2425

2526
__all__ = [
2627
"CacheConfig",
@@ -42,3 +43,8 @@
4243
"UnexpectedClaimedResult",
4344
"advertise",
4445
]
46+
47+
# `mcp.client.<submodule>` (stdio, sse, session_group, auth, ...) resolves by
48+
# attribute access even before that submodule was imported: the lazy `mcp`
49+
# package no longer imports them all up front. See mcp.shared._lazy_submodules.
50+
__getattr__ = _submodule_getattr(__name__)

‎src/mcp/os/__init__.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,7 @@
11
"""Platform-specific utilities for MCP."""
2+
3+
from mcp.shared._lazy_submodules import submodule_getattr as _submodule_getattr
4+
5+
# `mcp.os.posix` / `mcp.os.win32` resolve by attribute access even before they
6+
# were imported.
7+
__getattr__ = _submodule_getattr(__name__)

‎src/mcp/server/__init__.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
1+
from mcp.shared._lazy_submodules import submodule_getattr as _submodule_getattr
2+
13
from .caching import CacheHint
24
from .context import ServerRequestContext
35
from .lowlevel import NotificationOptions, Server
46
from .mcpserver import MCPServer
57
from .models import InitializationOptions
68

79
__all__ = ["CacheHint", "Server", "ServerRequestContext", "MCPServer", "NotificationOptions", "InitializationOptions"]
10+
11+
# `mcp.server.<submodule>` (stdio, session, streamable_http, auth, ...)
12+
# resolves by attribute access even before that submodule was imported:
13+
# the lazy `mcp` package no longer imports them all up front.
14+
__getattr__ = _submodule_getattr(__name__)

‎src/mcp/shared/__init__.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from mcp.shared._lazy_submodules import submodule_getattr as _submodule_getattr
2+
3+
# `mcp.shared.<submodule>` (exceptions, memory, message, ...) resolves by
4+
# attribute access even before that submodule was imported.
5+
__getattr__ = _submodule_getattr(__name__)

‎src/mcp/shared/_lazy_submodules.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Lazy submodule access for packages (PEP 562 module `__getattr__` factory).
2+
3+
A bare `import mcp` used to import most of the SDK eagerly, so attribute chains
4+
such as `mcp.client.stdio.stdio_client` resolved without ever importing the
5+
submodule explicitly. The `mcp` package is lazy now; a package that installs
6+
this `__getattr__` keeps such chains working - touching `pkg.<name>` imports the
7+
submodule `pkg.<name>` when there is one - without importing anything up front.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from collections.abc import Callable
13+
from importlib import import_module
14+
15+
16+
def submodule_getattr(package_name: str) -> Callable[[str], object]:
17+
"""Build a module `__getattr__` that imports `package_name.<attr>` on demand."""
18+
19+
def __getattr__(name: str) -> object:
20+
if name.startswith("__"):
21+
# Dunder lookups (`__test__`, `__path__`, ...) are protocol probes,
22+
# never submodules; answer them without a filesystem search.
23+
raise AttributeError(f"module {package_name!r} has no attribute {name!r}")
24+
module_name = f"{package_name}.{name}"
25+
try:
26+
return import_module(module_name)
27+
except ModuleNotFoundError as exc:
28+
if exc.name != module_name:
29+
# A submodule that exists but has a missing dependency of its
30+
# own: surface the real ImportError, not a misleading AttributeError.
31+
raise
32+
raise AttributeError(f"module {package_name!r} has no attribute {name!r}") from None
33+
34+
return __getattr__
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""A package that installs `submodule_getattr`, for tests/shared/test_lazy_submodules.py."""
2+
3+
from mcp.shared._lazy_submodules import submodule_getattr
4+
5+
__getattr__ = submodule_getattr(__name__)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""A submodule whose own import fails on a missing dependency."""
2+
3+
from importlib import import_module
4+
5+
MARKER = import_module("definitely_not_installed_dependency_xyz")
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""An importable submodule."""
2+
3+
MARKER = "ok"
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""`mcp.shared._lazy_submodules`: package attribute access imports the submodule of that name."""
2+
3+
import subprocess
4+
import sys
5+
from types import ModuleType
6+
7+
import pytest
8+
from inline_snapshot import snapshot
9+
10+
import tests.shared.lazy_submodule_fixture as fixture_pkg
11+
12+
13+
def test_package_attribute_imports_the_submodule_of_that_name():
14+
"""SDK-defined: `pkg.<name>` imports `pkg.<name>` on first touch and binds it on the package."""
15+
submodule = getattr(fixture_pkg, "fine")
16+
assert isinstance(submodule, ModuleType)
17+
assert submodule.MARKER == "ok"
18+
assert submodule is sys.modules["tests.shared.lazy_submodule_fixture.fine"]
19+
assert vars(fixture_pkg)["fine"] is submodule
20+
21+
22+
def test_missing_submodule_raises_attribute_error():
23+
"""SDK-defined: a name with no such submodule is a plain AttributeError."""
24+
with pytest.raises(AttributeError):
25+
getattr(fixture_pkg, "no_such_submodule")
26+
27+
28+
def test_dunder_probe_raises_attribute_error_without_a_submodule_search():
29+
"""SDK-defined: dunder names are protocol probes, never submodules."""
30+
with pytest.raises(AttributeError):
31+
getattr(fixture_pkg, "__no_such_dunder__")
32+
33+
34+
def test_submodule_with_a_missing_dependency_surfaces_the_real_import_error():
35+
"""SDK-defined: an existing submodule that fails to import raises its own error, not AttributeError."""
36+
with pytest.raises(ModuleNotFoundError) as exc_info:
37+
getattr(fixture_pkg, "broken")
38+
assert exc_info.value.name == "definitely_not_installed_dependency_xyz"
39+
40+
41+
def test_bare_import_mcp_still_resolves_deep_submodule_chains():
42+
"""SDK-defined: after only `import mcp`, `mcp.client.stdio.stdio_client` etc. still resolve.
43+
44+
A fresh interpreter is required: this process already imported those modules.
45+
"""
46+
probe = (
47+
"import mcp\n"
48+
"print([mcp.client.stdio.stdio_client.__name__, mcp.server.stdio.stdio_server.__name__,\n"
49+
" mcp.shared.memory.__name__, mcp.os.posix.__name__])\n"
50+
)
51+
result = subprocess.run([sys.executable, "-c", probe], capture_output=True, text=True, check=False, timeout=20)
52+
assert result.returncode == 0, result.stderr
53+
assert result.stdout == snapshot("['stdio_client', 'stdio_server', 'mcp.shared.memory', 'mcp.os.posix']\n")

0 commit comments

Comments
 (0)