Skip to content

Commit 1f3fbc7

Browse files
committed
Use one lazy-attribute helper for the packages, invisible to type checkers
Three implementations of lazy module attributes had grown: the hand-rolled __getattr__/__dir__ in mcp/__init__.py, the submodule fallback factory used by the four package inits, and a one-name __getattr__ in mcp.server.elicitation. They shared two problems. An unconditional module-level __getattr__ is visible to type checkers, so pyright typed every misspelled `mcp.<name>` (and `mcp.client.<name>`, ...) as `object` instead of reporting it. And the submodule fallback ran a filesystem find_spec on every attribute miss and speculatively imported whatever matched, so a name sweep (cloudpickle's whichmodule, hasattr probes) could import real submodules as a side effect, while dir(mcp.client) no longer listed submodules it would happily resolve. mcp.shared._lazy.lazy_module_attrs now serves all of them: lazy exports (a `(module, attr)` pair or a zero-argument loader, resolved once and cached in the namespace) plus known submodules (an explicit set, or the package's real submodules listed once on first need). A miss is a plain AttributeError with no search and no import, and __dir__ reports the exports and submodules. Every caller binds the pair under `if not TYPE_CHECKING:`, so attribute typos are pyright errors again while the TYPE_CHECKING mirrors keep the real names typed. The mcp.server.auth package gets the same fallback so qualified annotations such as `mcp.server.auth.provider.TokenVerifier` resolve on demand. The elicitation gate's wire-schema type is resolved through the same mechanism from a cached accessor, so validating rendered schemas no longer re-executes the wire-package import on every call.
1 parent 2a13e6d commit 1f3fbc7

13 files changed

Lines changed: 302 additions & 103 deletions

File tree

‎src/mcp/__init__.py‎

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
from importlib import import_module
21
from typing import TYPE_CHECKING
32

3+
from mcp.shared._lazy import lazy_module_attrs as _lazy_module_attrs
4+
45
# Every name below is resolved LAZILY on first access via the module
56
# `__getattr__` at the bottom of this file (PEP 562), so a bare `import mcp` no
67
# longer imports the protocol types nor the client and server stacks. Type
@@ -265,29 +266,18 @@
265266
_LAZY_MACHINERY = frozenset(
266267
{
267268
"TYPE_CHECKING",
268-
"import_module",
269269
"_LAZY_ATTRS",
270270
"_LAZY_MACHINERY",
271271
"_LAZY_SUBMODULES",
272272
"_MCP_TYPES_NAMES",
273-
"__getattr__",
274-
"__dir__",
273+
"_lazy_module_attrs",
275274
}
276275
)
277276

278-
279-
def __getattr__(name: str) -> object:
280-
lazy = _LAZY_ATTRS.get(name)
281-
if lazy is not None:
282-
module_name, attr = lazy
283-
value = getattr(import_module(module_name), attr)
284-
# Cache on the module: every later access is a plain namespace lookup.
285-
globals()[name] = value
286-
return value
287-
if name in _LAZY_SUBMODULES:
288-
return import_module(f"{__name__}.{name}")
289-
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
290-
291-
292-
def __dir__() -> list[str]:
293-
return sorted((globals().keys() - _LAZY_MACHINERY) | _LAZY_ATTRS.keys() | _LAZY_SUBMODULES)
277+
# Runtime-only: a type checker takes the real bindings from the TYPE_CHECKING
278+
# imports above and must not also see a `__getattr__(name) -> object`, which
279+
# would type every misspelled `mcp.<name>` as `object` instead of reporting it.
280+
if not TYPE_CHECKING:
281+
__getattr__, __dir__ = _lazy_module_attrs(
282+
__name__, globals(), exports=_LAZY_ATTRS, submodules=_LAZY_SUBMODULES, hidden=_LAZY_MACHINERY
283+
)

‎src/mcp/client/__init__.py‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""MCP Client module."""
22

3+
from typing import TYPE_CHECKING
4+
35
from mcp.client._input_required import InputRequiredRoundsExceededError
46
from mcp.client._transport import Transport
57
from mcp.client.caching import (
@@ -21,7 +23,7 @@
2123
advertise,
2224
)
2325
from mcp.client.session import ClientSession, IncomingMessage
24-
from mcp.shared._lazy_submodules import submodule_getattr as _submodule_getattr
26+
from mcp.shared._lazy import lazy_module_attrs as _lazy_module_attrs
2527

2628
__all__ = [
2729
"CacheConfig",
@@ -46,5 +48,7 @@
4648

4749
# `mcp.client.<submodule>` (stdio, sse, session_group, auth, ...) resolves by
4850
# 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__)
51+
# package no longer imports them all up front (see mcp.shared._lazy). Runtime
52+
# only, so a type checker still flags a misspelled `mcp.client.<name>`.
53+
if not TYPE_CHECKING:
54+
__getattr__, __dir__ = _lazy_module_attrs(__name__, globals())

‎src/mcp/os/__init__.py‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
"""Platform-specific utilities for MCP."""
22

3-
from mcp.shared._lazy_submodules import submodule_getattr as _submodule_getattr
3+
from typing import TYPE_CHECKING
4+
5+
from mcp.shared._lazy import lazy_module_attrs as _lazy_module_attrs
46

57
# `mcp.os.posix` / `mcp.os.win32` resolve by attribute access even before they
6-
# were imported.
7-
__getattr__ = _submodule_getattr(__name__)
8+
# were imported. Runtime only, so a type checker still flags a typo.
9+
if not TYPE_CHECKING:
10+
__getattr__, __dir__ = _lazy_module_attrs(__name__, globals())

‎src/mcp/server/__init__.py‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,26 @@
1-
from mcp.shared._lazy_submodules import submodule_getattr as _submodule_getattr
1+
from typing import TYPE_CHECKING
2+
3+
from mcp.shared._lazy import lazy_module_attrs as _lazy_module_attrs
24

35
from .caching import CacheHint
46
from .context import ServerRequestContext
57
from .lowlevel import NotificationOptions, Server
68
from .mcpserver import MCPServer
79
from .models import InitializationOptions
810

11+
if TYPE_CHECKING:
12+
# Submodules named by qualified annotations elsewhere in the SDK (e.g.
13+
# `mcp.server.streamable_http_manager.StreamableHTTPSessionManager`); the
14+
# mirror lets type checkers follow the chain the runtime `__getattr__`
15+
# resolves. Add to it when spelling a new qualified annotation.
16+
from . import auth as auth
17+
from . import streamable_http_manager as streamable_http_manager
18+
919
__all__ = ["CacheHint", "Server", "ServerRequestContext", "MCPServer", "NotificationOptions", "InitializationOptions"]
1020

1121
# `mcp.server.<submodule>` (stdio, session, streamable_http, auth, ...)
1222
# 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__)
23+
# the lazy `mcp` package no longer imports them all up front. Runtime only,
24+
# so a type checker still flags a misspelled `mcp.server.<name>`.
25+
if not TYPE_CHECKING:
26+
__getattr__, __dir__ = _lazy_module_attrs(__name__, globals())

‎src/mcp/server/auth/__init__.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,20 @@
11
"""MCP OAuth server authorization components."""
2+
3+
from typing import TYPE_CHECKING
4+
5+
from mcp.shared._lazy import lazy_module_attrs as _lazy_module_attrs
6+
7+
if TYPE_CHECKING:
8+
# Submodules named by qualified annotations elsewhere in the SDK
9+
# (`mcp.server.auth.provider.TokenVerifier`, ...): mirrored so type
10+
# checkers follow the chain the runtime `__getattr__` resolves.
11+
from . import provider as provider
12+
from . import settings as settings
13+
14+
# `mcp.server.auth.<submodule>` (settings, provider, routes, ...) resolves by
15+
# attribute access without importing the auth stack up front, so qualified
16+
# annotations such as `mcp.server.auth.provider.TokenVerifier` evaluate on
17+
# demand in `typing.get_type_hints`. Runtime only, so a type checker still
18+
# flags a misspelled `mcp.server.auth.<name>`.
19+
if not TYPE_CHECKING:
20+
__getattr__, __dir__ = _lazy_module_attrs(__name__, globals())

‎src/mcp/server/elicitation.py‎

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
from __future__ import annotations
44

5-
from typing import Any, Generic, Literal, TypeVar
5+
from functools import cache
6+
from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar
67

78
from mcp_types import RequestId
89
from pydantic import BaseModel, ValidationError
@@ -11,6 +12,11 @@
1112
from typing_extensions import TypeAliasType
1213

1314
from mcp.server.session import ServerSession
15+
from mcp.shared._lazy import lazy_module_attrs as _lazy_module_attrs
16+
17+
if TYPE_CHECKING:
18+
# Explicit re-export: the name resolves lazily on this module at runtime.
19+
from mcp_types._v2025_11_25 import PrimitiveSchemaDefinition as PrimitiveSchemaDefinition
1420

1521
ElicitSchemaModelT = TypeVar("ElicitSchemaModelT", bound=BaseModel)
1622

@@ -68,21 +74,30 @@ def default_schema(self, schema: core_schema.WithDefaultSchema) -> JsonSchemaVal
6874
return result
6975

7076

77+
@cache
78+
def _primitive_schema_definition() -> type[PrimitiveSchemaDefinition]:
79+
"""The spec-valid property schema the elicitation gate validates against.
80+
81+
Its home is the internal `mcp_types._v2025_11_25` wire package (~50 ms of
82+
pydantic model construction), so it is imported on the first elicitation
83+
schema instead of at module import - once: this accessor is cached, so no
84+
later call re-runs the import.
85+
"""
86+
from mcp_types._v2025_11_25 import PrimitiveSchemaDefinition
87+
88+
return PrimitiveSchemaDefinition
89+
90+
7191
def _validate_rendered_properties(json_schema: dict[str, Any]) -> None:
7292
"""Reject any `properties` entry the spec's `PrimitiveSchemaDefinition` won't accept.
7393
7494
Catches whatever the renderer let through that isn't spec-valid: bare
7595
`list[str]` (no enum), multi-primitive unions, nested models.
7696
"""
77-
# The gate's source of truth is the internal surface package's spec-valid
78-
# property schema. Imported here rather than at module top on purpose: the
79-
# generated package costs ~100 ms of pydantic model construction, and only
80-
# servers that actually elicit should pay for it, at first use.
81-
from mcp_types._v2025_11_25 import PrimitiveSchemaDefinition
82-
97+
primitive_schema_definition = _primitive_schema_definition()
8398
for field_name, prop in json_schema.get("properties", {}).items():
8499
try:
85-
PrimitiveSchemaDefinition.model_validate(prop)
100+
primitive_schema_definition.model_validate(prop)
86101
except ValidationError:
87102
raise TypeError(
88103
f"Elicitation schema field {field_name!r} rendered as {prop!r}, "
@@ -193,19 +208,12 @@ async def elicit_url(
193208
raise ValueError(f"Unexpected elicitation action: {result.action}")
194209

195210

196-
def __getattr__(name: str) -> Any:
197-
# `PrimitiveSchemaDefinition` used to be bound in this module by a
198-
# module-level import of the internal wire package; that import now
199-
# happens on first use (inside `_validate_rendered_properties`) so that
200-
# importing this module does not build the wire schema. Keep the attribute
201-
# resolving lazily for existing references to it.
202-
if name == "PrimitiveSchemaDefinition":
203-
from mcp_types._v2025_11_25 import PrimitiveSchemaDefinition
204-
205-
globals()[name] = PrimitiveSchemaDefinition
206-
return PrimitiveSchemaDefinition
207-
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
208-
209-
210-
def __dir__() -> list[str]:
211-
return sorted({*globals(), "PrimitiveSchemaDefinition"})
211+
# `PrimitiveSchemaDefinition` used to be bound in this module by a module-level
212+
# import of the internal wire package; that import now happens on the first
213+
# rendered schema (`_primitive_schema_definition`) so importing this module does
214+
# not build the wire package. The name still resolves lazily for existing
215+
# references to it. Runtime only: the TYPE_CHECKING import above types it.
216+
if not TYPE_CHECKING:
217+
__getattr__, __dir__ = _lazy_module_attrs(
218+
__name__, globals(), exports={"PrimitiveSchemaDefinition": _primitive_schema_definition}
219+
)

‎src/mcp/shared/__init__.py‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1-
from mcp.shared._lazy_submodules import submodule_getattr as _submodule_getattr
1+
from typing import TYPE_CHECKING
2+
3+
from mcp.shared._lazy import lazy_module_attrs as _lazy_module_attrs
24

35
# `mcp.shared.<submodule>` (exceptions, memory, message, ...) resolves by
4-
# attribute access even before that submodule was imported.
5-
__getattr__ = _submodule_getattr(__name__)
6+
# attribute access even before that submodule was imported. Runtime only,
7+
# so a type checker still flags a misspelled `mcp.shared.<name>`.
8+
if not TYPE_CHECKING:
9+
__getattr__, __dir__ = _lazy_module_attrs(__name__, globals())

‎src/mcp/shared/_lazy.py‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""Lazy module attributes (PEP 562) shared by the SDK's package inits.
2+
3+
`import mcp` no longer walks the SDK's whole module graph; each package instead
4+
resolves what it exports the first time it is asked for it. `lazy_module_attrs`
5+
builds the `__getattr__`/`__dir__` pair for such a module from two kinds of
6+
lazy names:
7+
8+
* `exports` - a re-exported object, described as `("home.module", "attr")`
9+
or as a zero-argument loader, imported/loaded on first access and then
10+
cached in the module namespace, so every later lookup is a plain attribute
11+
read;
12+
* `submodules` - the package's own submodules, imported when the attribute
13+
chain reaches them (`import mcp` then `mcp.client.stdio` used to work as a
14+
side effect of eager imports; this keeps such chains resolving). Pass the
15+
set explicitly, or `None` to use the package's real submodules (listed
16+
once, on first need).
17+
18+
A name that is neither raises `AttributeError` immediately: no filesystem
19+
search and no speculative import on a miss. Callers bind the pair under
20+
`if not TYPE_CHECKING:` so a static type checker never sees a module-level
21+
`__getattr__` (it would type every misspelled attribute as `object` instead of
22+
reporting it) while the mirrored `TYPE_CHECKING` imports keep the real names
23+
typed.
24+
"""
25+
26+
from __future__ import annotations
27+
28+
import sys
29+
from collections.abc import Callable, Mapping
30+
from importlib import import_module
31+
32+
__all__ = ["lazy_module_attrs"]
33+
34+
Loader = tuple[str, str] | Callable[[], object]
35+
"""How a lazy export is produced: `("home.module", "attr")` or a zero-arg loader."""
36+
37+
_HELPER_NAMES = frozenset({"__getattr__", "__dir__"})
38+
"""The two functions this helper adds to a module; never reported by its `__dir__`."""
39+
40+
DEFAULT_HIDDEN = frozenset({"TYPE_CHECKING", "_lazy_module_attrs"})
41+
"""The conventional machinery names a caller binds to install the pair (hidden from `dir()`)."""
42+
43+
44+
def lazy_module_attrs(
45+
module_name: str,
46+
module_globals: dict[str, object],
47+
*,
48+
exports: Mapping[str, Loader] | None = None,
49+
submodules: frozenset[str] | None = None,
50+
hidden: frozenset[str] = DEFAULT_HIDDEN,
51+
) -> tuple[Callable[[str], object], Callable[[], list[str]]]:
52+
"""Build a module's `__getattr__` and `__dir__` for lazily-resolved names.
53+
54+
Args:
55+
module_name: The `__name__` of the module the pair is installed on.
56+
module_globals: That module's `globals()`; resolved exports are cached
57+
in it, so `__getattr__` runs at most once per name.
58+
exports: Lazy re-exports, name -> `("home.module", "attr")` or a
59+
zero-argument loader. Resolved on first access.
60+
submodules: Submodule names reachable as attributes without an
61+
explicit import. `None` (the default) means the package's real
62+
submodules, discovered once on first need; a frozenset (possibly
63+
empty) means exactly those.
64+
hidden: Namespace entries that are lazy-loading machinery and must not
65+
appear in `dir(module)` (defaults to the two conventional binding
66+
names, `TYPE_CHECKING` and `_lazy_module_attrs`).
67+
68+
Returns:
69+
The `(__getattr__, __dir__)` pair to bind in the module namespace.
70+
"""
71+
export_map: Mapping[str, Loader] = exports or {}
72+
known_submodules = submodules
73+
74+
def submodule_names() -> frozenset[str]:
75+
# An explicit set, or the real submodules of the package (one directory
76+
# listing, cached; dunder entries such as `__main__` are not attributes).
77+
# A plain module (no `__path__`) has no submodules.
78+
nonlocal known_submodules
79+
if known_submodules is None:
80+
# pkgutil is imported for the one listing rather than at module top,
81+
# keeping this helper (and so a bare `import mcp`) as light as possible.
82+
import pkgutil
83+
84+
search_path = getattr(sys.modules[module_name], "__path__", None)
85+
known_submodules = frozenset(
86+
info.name
87+
for info in (pkgutil.iter_modules(search_path) if search_path else ())
88+
if not info.name.startswith("__")
89+
)
90+
return known_submodules
91+
92+
def __getattr__(name: str) -> object:
93+
loader = export_map.get(name)
94+
if loader is not None:
95+
if callable(loader):
96+
value = loader()
97+
else:
98+
home_module, attr = loader
99+
value = getattr(import_module(home_module), attr)
100+
elif not name.startswith("__") and name in submodule_names():
101+
# The import system also binds the submodule on the package, so
102+
# this too runs at most once per name.
103+
value = import_module(f"{module_name}.{name}")
104+
else:
105+
raise AttributeError(f"module {module_name!r} has no attribute {name!r}")
106+
module_globals[name] = value # cache: later accesses never reach __getattr__
107+
return value
108+
109+
def __dir__() -> list[str]:
110+
return sorted((module_globals.keys() - hidden - _HELPER_NAMES) | export_map.keys() | submodule_names())
111+
112+
return __getattr__, __dir__

‎src/mcp/shared/_lazy_submodules.py‎

Lines changed: 0 additions & 34 deletions
This file was deleted.

0 commit comments

Comments
 (0)