Files
Max Isbey ec5b2258c9 Cut import and startup cost with deferred model builds and lazy imports
Every import path now pays only for what it uses, with no public API
added or removed:

- The protocol models (mcp.types / mcp_types, incl. the JSON-RPC
  envelopes and the generated per-version wire packages) build their
  pydantic validators on first use instead of at import (defer_build),
  through one shared private base class. First-use builds are
  serialised behind a single process-wide lock, since released pydantic
  does not make concurrent first use of a deferred model thread-safe;
  this also fixes a pre-existing concurrent-first-use failure that
  reproduces on main.
- `import mcp` binds the client/server names lazily on first attribute
  access (PEP 562) instead of importing both stacks eagerly, and the
  client no longer imports the server, so client entry points stop
  loading the server, the web stack, httpx2 and cryptography.
- The web application stack (starlette's app machinery, sse_starlette,
  uvicorn) loads with the app builders that use it, and each protocol
  version's wire package loads on the first message parsed for that
  version rather than both loading at import.

On the fresh-interpreter harness `import mcp` is ~0.4x of v1 (main is
~1.6x), the client entry points ~0.6x of v1, `import mcp.server.mcpserver`
~0.7x, and time-to-ready / stdio cold start land at parity with v1. RSS
after `import mcp` is 19 MiB (v1 43.5, main 57). Steady-state per-call
latency is unchanged.

Observable-but-incidental differences (removed incidental namespace
bindings, deeper submodules no longer imported as a side effect of a
bare `import mcp`, get_type_hints needing localns= for a documented set
of callables, pre-first-use introspection) are catalogued in
docs/migration.md; ratchet tests pin the import footprints and the
concurrent-first-use safety.
2026-08-03 15:06:11 +00:00

102 lines
4.1 KiB
Python

"""The `mcp` package binds its client/server names, `mcp.types`, and its subpackages lazily (PEP 562)."""
import json
import subprocess
import sys
# Runs in a fresh interpreter: this test process imported the client/server stacks
# long ago, so only a clean one can observe first-access resolution and caching.
_LAZY_ACCESS_PROBE = """
import json, sys
import mcp
report = {
"client_is_home_object": mcp.Client is sys.modules["mcp.client.client"].Client,
"client_cached_in_namespace": "Client" in vars(mcp),
"types_is_module": mcp.types is sys.modules["mcp.types"],
"subpackage_chain": mcp.client.session.ClientSession.__name__,
"dir_lists_every_all_name": set(mcp.__all__) <= set(dir(mcp)),
"unresolvable_all_names": [n for n in mcp.__all__ if getattr(mcp, n, None) is None],
}
try:
mcp.Toool
except AttributeError as exc:
report["typo_error"] = str(exc)
print(json.dumps(report))
"""
# Runs in a fresh interpreter (each first access imports; a warm process has nothing to race).
# All threads are released at the same instant so their first-access imports genuinely overlap.
_THREADED_FIRST_ACCESS_PROBE = """
import json, sys, threading
sys.setswitchinterval(1e-6) # maximise preemption inside the imports
import mcp
names = ["Client", "ClientSession", "ClientSessionGroup", "StdioServerParameters", "stdio_client",
"ServerSession", "stdio_server", "InputRequiredRoundsExceededError", "types", "client", "server"]
barrier = threading.Barrier(len(names))
errors, resolved = [], {}
def first_access(name):
barrier.wait()
try:
resolved[name] = getattr(mcp, name).__name__
except BaseException as exc: # an import deadlock raises importlib's _DeadlockError, a RuntimeError
errors.append(f"{name}: {type(exc).__name__}: {exc}")
threads = [threading.Thread(target=first_access, args=(n,), daemon=True) for n in names]
for thread in threads:
thread.start()
for thread in threads:
thread.join(20)
print(json.dumps({"errors": errors, "hung": [t.name for t in threads if t.is_alive()],
"resolved": sorted(resolved)}))
"""
def test_concurrent_first_access_of_different_lazy_names_never_deadlocks():
"""SDK-defined regression bar: threads that first-access different lazy `mcp.<name>`s at
the same instant all resolve them. An eager `import mcp` used to serialise these imports;
the lazy resolution must not invert the import locks and raise a threaded-import deadlock."""
result = subprocess.run(
[sys.executable, "-c", _THREADED_FIRST_ACCESS_PROBE], capture_output=True, text=True, check=False, timeout=60
)
assert result.returncode == 0, result.stderr
report = json.loads(result.stdout)
assert report["errors"] == []
assert report["hung"] == []
assert report["resolved"] == sorted(
[
"Client",
"ClientSession",
"ClientSessionGroup",
"InputRequiredRoundsExceededError",
"ServerSession",
"StdioServerParameters",
"client",
"server",
"stdio_client",
"stdio_server",
"types",
]
)
def test_lazy_names_resolve_to_their_home_objects_and_cache_on_first_access():
"""SDK-defined: a lazy `mcp.<name>` is the object from its home module and is stored in the
package namespace once resolved; `mcp.types` and the `mcp.client` subpackage bind on first
access; `dir(mcp)` lists every `__all__` name and every `__all__` name resolves (so the lazy
tables cannot drift from `__all__`); and an unknown name is a plain AttributeError."""
result = subprocess.run(
[sys.executable, "-c", _LAZY_ACCESS_PROBE], capture_output=True, text=True, check=False, timeout=20
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"client_is_home_object": True,
"client_cached_in_namespace": True,
"types_is_module": True,
"subpackage_chain": "ClientSession",
"dir_lists_every_all_name": True,
"unresolvable_all_names": [],
"typo_error": "module 'mcp' has no attribute 'Toool'",
}