- 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.
- 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.10–3.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.
- 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).
- 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.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 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.
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.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-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).
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.
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
- 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