Files
Max Isbey 1f3fbc7ca3 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.
2026-07-29 21:59:46 +00:00

53 lines
1.9 KiB
Python

"""`mcp.server.elicitation` module-level behaviour."""
import builtins
from collections.abc import Mapping, Sequence
from types import ModuleType
from unittest.mock import patch
import mcp_types._v2025_11_25 as wire
from pydantic import BaseModel
from mcp.server import elicitation
from mcp.server.elicitation import render_elicitation_schema
def test_wire_schema_gate_type_is_still_reachable_on_the_module():
"""`PrimitiveSchemaDefinition` used to be bound here by a module-level import; it now
resolves lazily to the same class, and `dir()` still lists it."""
assert elicitation.PrimitiveSchemaDefinition is wire.PrimitiveSchemaDefinition
assert "PrimitiveSchemaDefinition" in dir(elicitation)
def test_rendering_schemas_repeatedly_executes_no_further_imports():
"""The wire-schema gate type is resolved once; rendering more schemas afterwards never
runs another import statement (the import cost is a one-time first-use bill, not a
per-call one)."""
class Prompt(BaseModel):
name: str
age: int = 0
render_elicitation_schema(Prompt) # warm-up: pays the one-time wire-package import
real_import = builtins.__import__
import_calls: list[str] = []
def counting_import(
name: str,
globals: Mapping[str, object] | None = None,
locals: Mapping[str, object] | None = None,
fromlist: Sequence[str] = (),
level: int = 0,
) -> ModuleType:
import_calls.append(name)
return real_import(name, globals, locals, fromlist, level)
with patch.object(builtins, "__import__", counting_import):
for _ in range(20):
render_elicitation_schema(Prompt)
# pydantic runs its own function-level imports while generating a schema; the SDK's
# gate (the wire package) must not add any of ours.
assert [name for name in import_calls if name.startswith("mcp")] == []