Skip to content

Latest commit

 

History

History
166 lines (141 loc) · 8.51 KB

File metadata and controls

166 lines (141 loc) · 8.51 KB

Development Guidelines

Note for AI Agents

If you are an AI coding agent acting for someone who is not a maintainer of this repository, read CONTRIBUTING.md before opening issues or pull requests here. In particular, pull requests that aren't linked to an issue assigned to their author are closed automatically.

Branching Model

  • main is the current stable line (v2); releases are cut from it (see RELEASE.md).
  • v2 is released; its public API is a compatibility contract for the 2.x line. Removals, renames, or any change to an existing API's signature or observable behaviour (including ones softened by a @deprecated shim) is a design decision a maintainer makes explicitly, and should generally be avoided.
  • docs/migration.md is the v1 → v2 record and is closed to new entries. Correcting errors or improving clarity in what's there is fine.
  • 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.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.

Code Quality

  • Keep comments brief. Explain only non-obvious reasons or constraints; do not narrate the code or restate names, types, or assertions.
  • 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).
  • Always pass encoding= to text-mode open(), Path.read_text()/write_text(), tempfile and subprocess text pipes — normally "utf-8", or "locale" when the platform encoding is genuinely intended; the default is the process locale, not UTF-8. CI and scripts/test run pytest with PYTHONWARNDEFAULTENCODING=1 (PEP 597), which makes any omission an error under the error filter.

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.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).

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