1f3fbc7ca3
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.
53 lines
1.9 KiB
Python
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")] == []
|