ec5b2258c9
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.
102 lines
4.1 KiB
Python
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'",
|
|
}
|