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.
9.0 KiB
Development Guidelines
Branching Model
mainis the current stable line (v2); releases are cut from it (seeRELEASE.md).- Removing or replacing an API must be intentional, and what shipped in 2.x
is public surface. Adding a replacement API or
@deprecatedshim 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.xis 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.mddocuments v2. The v1 README lives on thev1.xbranch.
Package Management
- ONLY use uv, NEVER pip
- Installation:
uv add <package>. Exception: the root project's runtime dependencies are dynamic (the publishedmcpwheel exact-pinsmcp-types), souv addcannot edit them — add the requirement to[tool.hatch.metadata.hooks.uv-dynamic-versioning].dependenciesinpyproject.tomlby hand, then runuv lock. Dependency groups, extras, and the example packages still take plainuv add. - Running tools:
uv run --frozen <tool>. Always pass--frozenso uv doesn't rewriteuv.lockas a side effect. - Cross-version testing:
uv run --frozen --python 3.10 pytest ...to run against a specific interpreter (CI covers 3.10–3.14). - Upgrading:
uv lock --upgrade-package <package> - FORBIDDEN:
uv pip install,@latestsyntax - 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__.pydefines 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__.pybinds the client/server names andmcp.typeslazily;mcp.client.clientnever imports the server, and imports the streamable-HTTP client (httpx2) only for a URL;mcp.server.elicitationimports 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 insidestreamable_http_app()/sse_app()/custom_route(); the auth context accessor imports itsAuthenticatedUsertype inside the middleware constructor;HttpResource.readimports httpx2 in the method; andmcp_types.methodsresolves each version's wire package (mcp_types._v20*) on the first surface-map row read, never at import.docs/advanced/startup.mdstates 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
Testprefixed classes — write plain top-leveltest_*functions. Legacy files still containTest*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 (seetests/client/test_client.pyfor the canonical pattern). For narrower changes, testing the function directly is fine. Use judgment. - Test files mirror the source tree:
src/mcp/client/stdio.py→tests/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 ofsleep()+receive_nowait() - Exception:
sleep()is appropriate when testing time-based features (e.g., timeouts)
- Use
- Wrap indefinite waits (
event.wait(),stream.receive()) inanyio.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. Scopedignore::entries for upstream libraries are acceptable inpyproject.tomlwith 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-coveron the default Python. Not identical to CI: CI runs 3.10–3.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-coverPartial runs can't hit 100% (coverage tracks
tests/too), so--fail-under=0and--includescope the report.strict-no-coverhas 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'sstrict-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 bystrict-no-cover. Use for lines covered on some platforms/versions but not others.# pragma: no branch— excludes branch arcs only. coverage.py misreports the->exitarc for nestedasync withon 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.lockconsistency check, and README checks — see.pre-commit-config.yaml
Exception Handling
- Always use
logger.exception()instead oflogger.error()when catching exceptions- Don't include the exception in the message:
logger.exception("Failed")notlogger.exception(f"Failed: {e}")
- Don't include the exception in the message:
- Catch specific exceptions where possible:
- File ops:
except (OSError, PermissionError): - JSON:
except json.JSONDecodeError: - Network:
except (ConnectionError, TimeoutError):
- File ops:
- FORBIDDEN
except Exception:- unless in top-level handlers