|
| 1 | +# Development Guidelines |
| 2 | + |
| 3 | +## Branching Model |
| 4 | + |
| 5 | +<!-- TODO: drop this section once v2 ships and main becomes the stable line --> |
| 6 | + |
| 7 | +- `main` is currently the V2 rework. Breaking changes are expected here — when removing or |
| 8 | + replacing an API, delete it outright and document the change in |
| 9 | + `docs/migration.md`. Do not add `@deprecated` shims or backward-compat layers |
| 10 | + on `main`. |
| 11 | +- `v1.x` is the release branch for the current stable line. Backport PRs target |
| 12 | + this branch and use a `[v1.x]` title prefix. |
| 13 | +- `README.md` is frozen at v1 (a pre-commit hook rejects edits). Edit |
| 14 | + `README.v2.md` instead. |
| 15 | + |
| 16 | +## Package Management |
| 17 | + |
| 18 | +- ONLY use uv, NEVER pip |
| 19 | +- Installation: `uv add <package>` |
| 20 | +- Running tools: `uv run --frozen <tool>`. Always pass `--frozen` so uv doesn't |
| 21 | + rewrite `uv.lock` as a side effect. |
| 22 | +- Cross-version testing: `uv run --frozen --python 3.10 pytest ...` to run |
| 23 | + against a specific interpreter (CI covers 3.10–3.14). |
| 24 | +- Upgrading: `uv lock --upgrade-package <package>` |
| 25 | +- FORBIDDEN: `uv pip install`, `@latest` syntax |
| 26 | +- Don't raise dependency floors for CVEs alone. The `>=` constraint already |
| 27 | + lets users upgrade. Only raise a floor when the SDK needs functionality from |
| 28 | + the newer version, and don't add SDK code to work around a dependency's |
| 29 | + vulnerability. See Kludex/uvicorn#2643 and python-sdk #1552 for reasoning. |
| 30 | + |
| 31 | +## Code Quality |
| 32 | + |
| 33 | +- Type hints required for all code |
| 34 | +- Public APIs must have docstrings. When a public API raises exceptions a |
| 35 | + caller would reasonably catch, document them in a `Raises:` section. Don't |
| 36 | + list exceptions from argument validation or programmer error. |
| 37 | +- `src/mcp/__init__.py` defines the public API surface via `__all__`. Adding a |
| 38 | + symbol there is a deliberate API decision, not a convenience re-export. |
| 39 | +- IMPORTANT: All imports go at the top of the file — inline imports hide |
| 40 | + dependencies and obscure circular-import bugs. Only exception: when a |
| 41 | + top-level import genuinely can't work (lazy-loading optional deps, or |
| 42 | + tests that re-import a module). |
| 43 | + |
| 44 | +## Testing |
| 45 | + |
| 46 | +- Framework: `uv run --frozen pytest` |
| 47 | +- Async testing: use anyio, not asyncio |
| 48 | +- Do not use `Test` prefixed classes, use functions |
| 49 | +- IMPORTANT: Tests should be fast and deterministic. Prefer in-memory async execution; |
| 50 | + reach for threads only when necessary, and subprocesses only as a last resort. |
| 51 | +- For end-to-end behavior, an in-memory `Client(server)` is usually the |
| 52 | + cleanest approach (see `tests/client/test_client.py` for the canonical |
| 53 | + pattern). For narrower changes, testing the function directly is fine. Use |
| 54 | + judgment. |
| 55 | +- Test files mirror the source tree: `src/mcp/client/stdio.py` → |
| 56 | + `tests/client/test_stdio.py`. Add tests to the existing file for that module. |
| 57 | +- Avoid `anyio.sleep()` with a fixed duration to wait for async operations. Instead: |
| 58 | + - Use `anyio.Event` — set it in the callback/handler, `await event.wait()` in the test |
| 59 | + - For stream messages, use `await stream.receive()` instead of `sleep()` + `receive_nowait()` |
| 60 | + - Exception: `sleep()` is appropriate when testing time-based features (e.g., timeouts) |
| 61 | +- Wrap indefinite waits (`event.wait()`, `stream.receive()`) in `anyio.fail_after(5)` to prevent hangs |
| 62 | +- Pytest is configured with `filterwarnings = ["error"]`, so warnings fail |
| 63 | + tests. Don't silence warnings from your own code; fix the underlying cause. |
| 64 | + Scoped `ignore::` entries for upstream libraries are acceptable in |
| 65 | + `pyproject.toml` with a comment explaining why. |
| 66 | + |
| 67 | +### Coverage |
| 68 | + |
| 69 | +CI requires 100% (`fail_under = 100`, `branch = true`). |
| 70 | + |
| 71 | +- Full check: `./scripts/test` (~23s). Runs coverage + `strict-no-cover` on the |
| 72 | + default Python. Not identical to CI: CI runs 3.10–3.14 × {ubuntu, windows} |
| 73 | + × {locked, lowest-direct}, and some branch-coverage quirks only surface on |
| 74 | + specific matrix entries. |
| 75 | +- Targeted check while iterating (~4s, deterministic): |
| 76 | + |
| 77 | + ```bash |
| 78 | + uv run --frozen coverage erase |
| 79 | + uv run --frozen coverage run -m pytest tests/path/test_foo.py |
| 80 | + uv run --frozen coverage combine |
| 81 | + uv run --frozen coverage report --include='src/mcp/path/foo.py' --fail-under=0 |
| 82 | + # UV_FROZEN=1 propagates --frozen to the uv subprocess strict-no-cover spawns |
| 83 | + UV_FROZEN=1 uv run --frozen strict-no-cover |
| 84 | + ``` |
| 85 | + |
| 86 | + Partial runs can't hit 100% (coverage tracks `tests/` too), so `--fail-under=0` |
| 87 | + and `--include` scope the report. `strict-no-cover` has no false positives on |
| 88 | + partial runs — if your new test executes a line marked `# pragma: no cover`, |
| 89 | + even a single-file run catches it. |
| 90 | + |
| 91 | +Avoid adding new `# pragma: no cover`, `# type: ignore`, or `# noqa` comments. |
| 92 | +In tests, use `assert isinstance(x, T)` to narrow types instead of |
| 93 | +`# type: ignore`. In library code (`src/`), a `# pragma: no cover` needs very |
| 94 | +good reasoning — it usually means a test is missing. Audit before pushing: |
| 95 | + |
| 96 | +```bash |
| 97 | +git diff origin/main... | grep -E '^\+.*(pragma|type: ignore|noqa)' |
| 98 | +``` |
| 99 | + |
| 100 | +What the existing pragmas mean: |
| 101 | + |
| 102 | +- `# pragma: no cover` — line is never executed. CI's `strict-no-cover` (skipped |
| 103 | + on Windows runners) fails if it IS executed. When your test starts covering |
| 104 | + such a line, remove the pragma. |
| 105 | +- `# pragma: lax no cover` — excluded from coverage but not checked by |
| 106 | + `strict-no-cover`. Use for lines covered on some platforms/versions but not |
| 107 | + others. |
| 108 | +- `# pragma: no branch` — excludes branch arcs only. coverage.py misreports the |
| 109 | + `->exit` arc for nested `async with` on Python 3.11+ (worse on 3.14/Windows). |
| 110 | + |
| 111 | +## Breaking Changes |
| 112 | + |
| 113 | +When making breaking changes, document them in `docs/migration.md`. Include: |
| 114 | + |
| 115 | +- What changed |
| 116 | +- Why it changed |
| 117 | +- How to migrate existing code |
| 118 | + |
| 119 | +Search for related sections in the migration guide and group related changes together |
| 120 | +rather than adding new standalone sections. |
| 121 | + |
| 122 | +## Formatting & Type Checking |
| 123 | + |
| 124 | +- Format: `uv run --frozen ruff format .` |
| 125 | +- Lint: `uv run --frozen ruff check . --fix` |
| 126 | +- Type check: `uv run --frozen pyright` |
| 127 | +- Pre-commit runs all of the above plus markdownlint, a `uv.lock` consistency |
| 128 | + check, and README checks — see `.pre-commit-config.yaml` |
| 129 | + |
| 130 | +## Exception Handling |
| 131 | + |
| 132 | +- **Always use `logger.exception()` instead of `logger.error()` when catching exceptions** |
| 133 | + - Don't include the exception in the message: `logger.exception("Failed")` not `logger.exception(f"Failed: {e}")` |
| 134 | +- **Catch specific exceptions** where possible: |
| 135 | + - File ops: `except (OSError, PermissionError):` |
| 136 | + - JSON: `except json.JSONDecodeError:` |
| 137 | + - Network: `except (ConnectionError, TimeoutError):` |
| 138 | +- **FORBIDDEN** `except Exception:` - unless in top-level handlers |
0 commit comments