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

9.0 KiB
Raw Permalink Blame History

Development Guidelines

Branching Model

  • main is the current stable line (v2); releases are cut from it (see RELEASE.md).
  • Removing or replacing an API must be intentional, and what shipped in 2.x is public surface. Adding a replacement API or @deprecated shim is likewise a deliberate design choice, not bolted on for free.
  • Changes that break code written against v1 (including those softened by a backwards-compatibility shim) must be documented in docs/migration.md.
  • v1.x is the maintenance branch for the previous major. Backport PRs target it and use a [v1.x] title prefix; only critical bug fixes and security fixes land there.
  • README.md documents v2. The v1 README lives on the v1.x branch.

Package Management

  • ONLY use uv, NEVER pip
  • Installation: uv add <package>. Exception: the root project's runtime dependencies are dynamic (the published mcp wheel exact-pins mcp-types), so uv add cannot edit them — add the requirement to [tool.hatch.metadata.hooks.uv-dynamic-versioning].dependencies in pyproject.toml by hand, then run uv lock. Dependency groups, extras, and the example packages still take plain uv add.
  • Running tools: uv run --frozen <tool>. Always pass --frozen so uv doesn't rewrite uv.lock as a side effect.
  • Cross-version testing: uv run --frozen --python 3.10 pytest ... to run against a specific interpreter (CI covers 3.103.14).
  • Upgrading: uv lock --upgrade-package <package>
  • FORBIDDEN: uv pip install, @latest syntax
  • Don't raise dependency floors for CVEs alone. The >= constraint already lets users upgrade. Only raise a floor when the SDK needs functionality from the newer version, and don't add SDK code to work around a dependency's vulnerability. See Kludex/uvicorn#2643 and python-sdk #1552 for reasoning.

Code Quality

  • Type hints required for all code
  • Public APIs must have docstrings. When a public API raises exceptions a caller would reasonably catch, document them in a Raises: section. Don't list exceptions from argument validation or programmer error.
  • src/mcp/__init__.py defines the public API surface via __all__. Adding a symbol there is a deliberate API decision, not a convenience re-export.
  • IMPORTANT: All imports go at the top of the file — inline imports hide dependencies and obscure circular-import bugs. Only exception: when a top-level import genuinely can't work (lazy-loading optional deps, or tests that re-import a module), plus the deliberate startup-cost seams below — each of those local imports carries a why-comment; don't hoist them.
  • Startup-cost seams (pinned by tests/test_import_footprint.py, so a hoisted import fails a test rather than review): mcp/__init__.py binds the client/server names and mcp.types lazily; mcp.client.client never imports the server, and imports the streamable-HTTP client (httpx2) only for a URL; mcp.server.elicitation imports the 2025-era wire package inside its schema-validation gate; the two server hubs (lowlevel/server.py, mcpserver/server.py) import the HTTP web stack inside streamable_http_app() / sse_app() / custom_route(); the auth context accessor imports its AuthenticatedUser type inside the middleware constructor; HttpResource.read imports httpx2 in the method; and mcp_types.methods resolves each version's wire package (mcp_types._v20*) on the first surface-map row read, never at import. docs/advanced/startup.md states the user-facing contract.

Testing

  • When writing or reviewing tests, conform to .claude/skills/test-quality/SKILL.md — it defines the bar for naming, abstraction level, assertions, and determinism.
  • Framework: uv run --frozen pytest
  • Async testing: use anyio, not asyncio
  • Do not use Test prefixed classes — write plain top-level test_* functions. Legacy files still contain Test* classes; do NOT follow that pattern for new tests even when adding to such a file.
  • IMPORTANT: Tests should be fast and deterministic. Prefer in-memory async execution; reach for threads only when necessary, and subprocesses only as a last resort.
  • For end-to-end behavior, an in-memory Client(server) is usually the cleanest approach (see tests/client/test_client.py for the canonical pattern). For narrower changes, testing the function directly is fine. Use judgment.
  • Test files mirror the source tree: src/mcp/client/stdio.pytests/client/test_stdio.py. Add tests to the existing file for that module.
  • Avoid anyio.sleep() with a fixed duration to wait for async operations. Instead:
    • Use anyio.Event — set it in the callback/handler, await event.wait() in the test
    • For stream messages, use await stream.receive() instead of sleep() + receive_nowait()
    • Exception: sleep() is appropriate when testing time-based features (e.g., timeouts)
  • Wrap indefinite waits (event.wait(), stream.receive()) in anyio.fail_after(5) to prevent hangs
  • Pytest is configured with filterwarnings = ["error"], so warnings fail tests. Don't silence warnings from your own code; fix the underlying cause. Scoped ignore:: entries for upstream libraries are acceptable in pyproject.toml with a comment explaining why.
  • New features from the 2026-07-28 spec must have a matching test in the conformance suite that passes against this SDK (CI runs it via .github/workflows/conformance.yml). If no matching test exists, stop and tell the user so they can raise an issue on the conformance repo.

Coverage

CI requires 100% (fail_under = 100, branch = true).

  • Full check: ./scripts/test (~23s). Runs coverage + strict-no-cover on the default Python. Not identical to CI: CI runs 3.103.14 × {ubuntu, windows} × {locked, lowest-direct}, and some branch-coverage quirks only surface on specific matrix entries.

  • Targeted check while iterating (~4s, deterministic):

    uv run --frozen coverage erase
    uv run --frozen coverage run -m pytest tests/path/test_foo.py
    uv run --frozen coverage combine
    uv run --frozen coverage report --include='src/mcp/path/foo.py' --fail-under=0
    # UV_FROZEN=1 propagates --frozen to the uv subprocess strict-no-cover spawns
    UV_FROZEN=1 uv run --frozen strict-no-cover
    

    Partial runs can't hit 100% (coverage tracks tests/ too), so --fail-under=0 and --include scope the report. strict-no-cover has no false positives on partial runs — if your new test executes a line marked # pragma: no cover, even a single-file run catches it.

Avoid adding new # pragma: no cover, # type: ignore, or # noqa comments. In tests, use assert isinstance(x, T) to narrow types instead of # type: ignore. In library code (src/), a # pragma: no cover needs very good reasoning — it usually means a test is missing. Audit before pushing:

git diff origin/main... | grep -E '^\+.*(pragma|type: ignore|noqa)'

What the existing pragmas mean:

  • # pragma: no cover — line is never executed. CI's strict-no-cover (skipped on Windows runners) fails if it IS executed. When your test starts covering such a line, remove the pragma.
  • # pragma: lax no cover — excluded from coverage but not checked by strict-no-cover. Use for lines covered on some platforms/versions but not others.
  • # pragma: no branch — excludes branch arcs only. coverage.py misreports the ->exit arc for nested async with on Python 3.11+ (worse on 3.14/Windows).

Breaking Changes

When making breaking changes, document them in docs/migration.md — including changes softened by a backwards-compatibility shim. Include:

  • What changed
  • Why it changed
  • How to migrate existing code

Search for related sections in the migration guide and group related changes together rather than adding new standalone sections.

Documentation

When a change affects public API or user-visible behaviour, update the relevant page(s) under docs/ in the same PR. Docs are organised by the nav: sections in mkdocs.yml (Get started, Servers, Inside your handler, Running your server, Clients, Advanced), not by the on-disk directory names. Find the page covering the feature you touched in mkdocs.yml rather than adding a new one.

Formatting & Type Checking

  • Format: uv run --frozen ruff format .
  • Lint: uv run --frozen ruff check . --fix
  • Type check: uv run --frozen pyright
  • Pre-commit runs all of the above plus markdownlint, a uv.lock consistency check, and README checks — see .pre-commit-config.yaml

Exception Handling

  • Always use logger.exception() instead of logger.error() when catching exceptions
    • Don't include the exception in the message: logger.exception("Failed") not logger.exception(f"Failed: {e}")
  • Catch specific exceptions where possible:
    • File ops: except (OSError, PermissionError):
    • JSON: except json.JSONDecodeError:
    • Network: except (ConnectionError, TimeoutError):
  • FORBIDDEN except Exception: - unless in top-level handlers