Skip to content

Commit 082101e

Browse files
committed
Merge remote-tracking branch 'origin/main' into fix/1401-surface-transport-exceptions
2 parents 14e7118 + 31b76cb commit 082101e

7 files changed

Lines changed: 226 additions & 7 deletions

File tree

CONTRIBUTING.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,25 @@ Bug fixes for clear, reproducible issues are welcome—but still create an issue
4848
| [`help wanted`](https://github.com/modelcontextprotocol/python-sdk/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22) | Experienced contributors | Maintainers probably won't get to this |
4949
| [`ready for work`](https://github.com/modelcontextprotocol/python-sdk/issues?q=is%3Aopen+is%3Aissue+label%3A%22ready+for+work%22) | Maintainers | Triaged and ready for a maintainer to pick up |
5050

51-
Issues labeled `needs confirmation` or `needs maintainer action` are **not** ready for work—wait for maintainer input first.
51+
Issues labeled `needs confirmation` or `needs maintainer` are **not** ready for work—wait for maintainer input first.
5252

5353
Before starting, comment on the issue so we can assign it to you. This prevents duplicate effort.
5454

55+
## Issue Triage
56+
57+
Every new issue gets a first look from a maintainer within two business days. That first look is the *triage*: it means labeling the issue and deciding whether it is valid and actionable, not fixing it.
58+
59+
The core labels follow the shared [MCP SDK taxonomy](https://modelcontextprotocol.io/community/sdk-tiers#issue-triage-labels) (the repository also carries workflow labels of its own, such as `needs maintainer` and `needs decision`): one **type** (`bug`, `enhancement`, `question`), one **status** (`needs confirmation`, `needs repro`, `ready for work`, `good first issue`, `help wanted`), and — once actionable — one **priority**:
60+
61+
| Label | Meaning | Commitment |
62+
|-------|---------|------------|
63+
| `P0` | Critical: core functionality failures (connections, message exchange, tools/resources/prompts) or a High/Critical-severity security issue | resolved within 7 days |
64+
| `P1` | Significant bug affecting many users | next release |
65+
| `P2` | Moderate issue or valuable feature request | as capacity allows |
66+
| `P3` | Nice-to-have or rare edge case | opportunistic |
67+
68+
Security reports do not belong in the issue tracker; [SECURITY.md](SECURITY.md) has the private channel.
69+
5570
## Development Setup
5671

5772
1. Make sure you have Python 3.10+ installed
@@ -154,6 +169,7 @@ A few dozen lines can be reviewed in minutes. Hundreds of lines across many file
154169
2. Add tests for new functionality
155170
3. Ensure CI passes
156171
4. Address review feedback
172+
5. Dependency bound changes and new runtime dependencies follow the [Dependency Policy](DEPENDENCY_POLICY.md)
157173

158174
## Code of Conduct
159175

DEPENDENCY_POLICY.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Dependency Policy
2+
3+
`mcp` is a library that lives inside other people's environments, so its requirements are chosen to constrain your resolver as little as possible while still describing what the SDK needs.
4+
5+
## How requirements are declared
6+
7+
Every runtime dependency is a `>=` floor set to the oldest version that provides what the SDK uses, with no upper bound unless a dependency's next major is known to break the SDK. The one exception is `mcp-types`, the wire-types package released in lockstep with `mcp`: each `mcp` release requires exactly its own version of it, so it is the other half of the SDK rather than an independent constraint.
8+
9+
## When a floor moves
10+
11+
A floor is raised only when the SDK starts relying on functionality or a fix that first appeared in that version — not because the dependency published a security advisory. The `>=` bound already lets, and expects, you to run the newest release your other constraints allow, so a higher floor would only shrink the environments the SDK installs into; nor does the SDK add code to work around a dependency's vulnerability, since the fix belongs upstream and in your lockfile ([background](https://github.com/Kludex/uvicorn/discussions/2643), [python-sdk#1552](https://github.com/modelcontextprotocol/python-sdk/issues/1552)). Floor raises may ship in a minor release under the [versioning policy](VERSIONING.md) and are called out in the release notes. Adding a new runtime dependency, or moving one to its next major version, is decided in an issue before the pull request.
12+
13+
## Automated updates
14+
15+
[Dependabot](https://github.com/modelcontextprotocol/python-sdk/blob/main/.github/dependabot.yml) opens monthly, grouped pull requests for the `uv` lockfile and for GitHub Actions. These refresh the versions the SDK is developed and tested against; the requirements published to PyPI move only under the rules above.

RELEASE.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,17 @@
22

33
## Bumping Dependencies
44

5+
[`DEPENDENCY_POLICY.md`](DEPENDENCY_POLICY.md) says *when* a bound should
6+
move; this is the mechanics.
7+
58
1. Change the dependency version in `pyproject.toml`. The root `mcp` project's
69
runtime dependencies are dynamic and live under
710
`[tool.hatch.metadata.hooks.uv-dynamic-versioning].dependencies`.
8-
2. Upgrade lock with `uv lock --resolution lowest-direct`
11+
2. Regenerate the lock with `uv lock` (or `uv lock --upgrade-package <package>`
12+
to move just that package's locked version). The committed `uv.lock` is a
13+
normal (default-strategy) resolution; the `lowest-direct` resolution that
14+
proves the floors still work is applied only by its CI matrix leg at test
15+
time and is never committed.
916

1017
## Release lines
1118

@@ -104,8 +111,9 @@ hand for the same reason as above. Then ask someone to review the release.
104111
Pre-releases of the next version are cut from `main` with a PEP 440
105112
pre-release tag: `aN` for alphas, later `bN`/`rcN` for betas and release
106113
candidates. The PEP 440 suffix is what keeps `pip install mcp` on the stable
107-
version — installers only select a pre-release when it is requested explicitly (an
108-
exact pin, a specifier that names a pre-release version, or `--pre`).
114+
version — installers do not pick a pre-release for a plain `mcp` requirement while a
115+
final release satisfies it; a pre-release is opted into with an exact pin, a
116+
specifier that names a pre-release version, or `--pre`.
109117

110118
1. During a pre-release phase the README and docs pin the exact pre-release
111119
version, so update those examples first (grep the outgoing version — the

ROADMAP.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Roadmap
2+
3+
The SDK's work is organized by MCP specification revision, with one GitHub project board per revision; each item is an issue or pull request you can follow.
4+
5+
## The 2026-07-28 revision
6+
7+
v2 implements the [2026-07-28 specification](https://modelcontextprotocol.io/specification/2026-07-28) and negotiates back to every earlier revision. Board: **[python-sdk · 2026-07-28 spec](https://github.com/orgs/modelcontextprotocol/projects/42)**; the cross-SDK view is [2026-07-28 Spec Implementation](https://github.com/orgs/modelcontextprotocol/projects/41). Still open there: making advertised capabilities configurable rather than pre-computed ([#2896](https://github.com/modelcontextprotocol/python-sdk/issues/2896)).
8+
9+
## Not yet implemented
10+
11+
Of the extensions and optional client-auth mechanisms not yet implemented, those the conformance suite already exercises appear in its expected-failures baseline, [`.github/actions/conformance/expected-failures.yml`](.github/actions/conformance/expected-failures.yml), which burns down as each lands. The main ones:
12+
13+
* **Tasks extension** (`io.modelcontextprotocol/tasks`, [SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2663-tasks-extension.md)) — deferred at 2.0 because the 2026-07-28 design is wire-incompatible with the earlier in-core Tasks; tracked in [#2806](https://github.com/modelcontextprotocol/python-sdk/issues/2806).
14+
* **DPoP-bound access tokens** ([SEP-1932](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1932)) in the OAuth client.
15+
* **The workload-identity `jwt-bearer` grant** in the OAuth client.
16+
17+
Everything else — bugs and smaller features — lives in the [issue tracker](https://github.com/modelcontextprotocol/python-sdk/issues), prioritized `P0``P3`. The SDK aims to release support for each new specification revision alongside that revision.
18+
19+
## The previous major
20+
21+
`v1.x` is a maintenance line: critical bug fixes and security fixes only. Support terms are in [Versioning and support policy](VERSIONING.md#support-and-announcements); the path off it is the **[Migration Guide](https://py.sdk.modelcontextprotocol.io/migration/)**.

VERSIONING.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Versioning and support policy
2+
3+
What a version number of `mcp` promises: which changes can arrive in a minor release, which wait for a major, how deprecations are announced, and which release lines are supported.
4+
5+
## The version number
6+
7+
[Semantic Versioning](https://semver.org/) semantics in [PEP 440](https://peps.python.org/pep-0440/) syntax, taken from the git tag: in `2.X.Y`, **X** (minor) carries new functionality and every non-breaking change, **Y** (patch) carries bug fixes only, and a breaking change to the public API lands only in a new **major**. Pre-releases are cut from `main` as `aN`/`bN`/`rcN`; installers prefer final releases by default, so an unpinned `pip install mcp` stays on a stable release whenever one satisfies your requirement. `mcp` and its wire-types package `mcp-types` release in lockstep, each `mcp` requiring exactly the matching `mcp-types`.
8+
9+
## The public API
10+
11+
The promise covers every name exported by `mcp` and `mcp_types` (their `__all__`), the import paths, signatures, and behavior documented on the [documentation site](https://py.sdk.modelcontextprotocol.io/) and in its [API Reference](https://py.sdk.modelcontextprotocol.io/api/mcp/). It does not cover underscore-prefixed names, undocumented modules, or the wording of log lines, warnings, and exception messages (their types and documented raise conditions are covered). APIs labelled **provisional** (for example the middleware chain) may still change in a minor release; **experimental** APIs are opt-in previews.
12+
13+
## Breaking and non-breaking changes
14+
15+
Held for the next major:
16+
17+
* removing or renaming a public name,
18+
* changing a signature, return type, raised exception type, or documented behavior so that working code stops working,
19+
* removing a documented import path, extra, or CLI command.
20+
21+
Allowed in a minor:
22+
23+
* additions — functions, defaulted parameters, classes, fields, enum members,
24+
* changes to provisional or experimental APIs,
25+
* new deprecation warnings, and retired protocol features ceasing to work on connections that negotiate a revision without them (their Python names stay, deprecated, until a major),
26+
* raising a dependency floor the SDK needs (see the [dependency policy](DEPENDENCY_POLICY.md)) when the dependency's changes don't reach you through the SDK's API, or dropping a Python version after its upstream end-of-life — both called out in the release notes,
27+
* bug fixes, including ones that make the SDK match its documented or specified behavior.
28+
29+
## Deprecations
30+
31+
**SDK APIs** are deprecated before removal: they keep working for at least one minor release, marked with [`typing_extensions.deprecated`](https://typing-extensions.readthedocs.io/en/latest/#typing_extensions.deprecated) wherever Python can carry the marker (docstring and migration guide otherwise), and are removed only in a major. **Protocol features** the specification retires keep their implementation through the spec's deprecation window and warn with `MCPDeprecationWarning`, a `UserWarning` subclass that shows by default; what still functions depends on the revision a connection negotiated — see [Deprecated features](https://py.sdk.modelcontextprotocol.io/deprecated/).
32+
33+
## Support and announcements
34+
35+
Two lines are maintained, and only the newest release of each receives fixes:
36+
37+
* **2.x** (`main`) — bug fixes, security fixes, and features.
38+
* **1.x** ([`v1.x`](https://github.com/modelcontextprotocol/python-sdk/tree/v1.x)) — critical bug fixes and security fixes.
39+
40+
Where changes are announced:
41+
42+
* [SECURITY.md](https://github.com/modelcontextprotocol/python-sdk/blob/main/SECURITY.md) has the vulnerability reporting process.
43+
* Every release publishes notes on [GitHub Releases](https://github.com/modelcontextprotocol/python-sdk/releases).
44+
* Every breaking change between majors is documented in the [Migration Guide](https://py.sdk.modelcontextprotocol.io/migration/) before it merges.
45+
* Pull requests that make a breaking change carry the `breaking change` label.

src/mcp/client/session.py

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44
import logging
55
from collections.abc import Callable, Mapping, Sequence
66
from dataclasses import dataclass
7-
from functools import reduce
7+
from functools import cache, reduce
88
from operator import or_
9-
from types import TracebackType
10-
from typing import TYPE_CHECKING, Annotated, Any, Final, Literal, Protocol, TypeAlias, cast, overload
9+
from types import TracebackType, UnionType
10+
from typing import TYPE_CHECKING, Annotated, Any, Final, Literal, Protocol, TypeAlias, cast, get_args, overload
1111

1212
import anyio
1313
import anyio.abc
@@ -30,6 +30,7 @@
3030
from mcp_types import methods as _methods
3131
from mcp_types.version import (
3232
HANDSHAKE_PROTOCOL_VERSIONS,
33+
KNOWN_PROTOCOL_VERSIONS,
3334
LATEST_HANDSHAKE_VERSION,
3435
LATEST_MODERN_VERSION,
3536
MODERN_PROTOCOL_VERSIONS,
@@ -77,6 +78,45 @@ def _clamp_inbound_ttl(raw: dict[str, Any]) -> None:
7778
raw["ttlMs"] = 0
7879

7980

81+
@cache
82+
def _wire_fields(target: type[BaseModel] | UnionType) -> frozenset[str]:
83+
"""Top-level wire keys `target` declares (its members', for a union).
84+
85+
A `RootModel` row (e.g. an empty result carried as `RootModel[Result]`)
86+
reports its wrapped type's keys, not the pydantic-internal `root`.
87+
"""
88+
members: tuple[Any, ...] = get_args(target) if isinstance(target, UnionType) else (target,)
89+
models = [m for m in members if isinstance(m, type) and issubclass(m, BaseModel)]
90+
fields: set[str] = set()
91+
for model in models:
92+
if getattr(model, "__pydantic_root_model__", False): # a RootModel wrapper row
93+
fields |= _wire_fields(model.model_fields["root"].annotation)
94+
else:
95+
fields.update(field.alias or name for name, field in model.model_fields.items())
96+
return frozenset(fields)
97+
98+
99+
@cache
100+
def _later_revision_fields(method: str, version: str) -> frozenset[str]:
101+
"""Result keys a revision newer than `version` declares for `method` but `version` doesn't.
102+
103+
The version-free result types carry every revision's fields, so such a key
104+
(e.g. 2026-07-28 `ttlMs`/`cacheScope` on a pre-2026 session) is outside the
105+
negotiated contract yet would still parse into the model and trip that later
106+
revision's constraints. Empty at the newest known revision.
107+
"""
108+
current = _methods.SERVER_RESULTS.get((method, version))
109+
if current is None or version not in KNOWN_PROTOCOL_VERSIONS:
110+
return frozenset()
111+
newer = KNOWN_PROTOCOL_VERSIONS[KNOWN_PROTOCOL_VERSIONS.index(version) + 1 :]
112+
later: set[str] = set()
113+
for revision in newer:
114+
row = _methods.SERVER_RESULTS.get((method, revision))
115+
if row is not None:
116+
later |= _wire_fields(row)
117+
return frozenset(later) - _wire_fields(current)
118+
119+
80120
def _same_schema(a: dict[str, Any] | None, b: dict[str, Any] | None) -> bool:
81121
"""JSON equality for two output schemas.
82122
@@ -558,6 +598,11 @@ async def send_request(
558598
_methods.validate_server_result(method, version, raw)
559599
except KeyError:
560600
pass
601+
# Drop a later revision's fields (e.g. 2026-07-28 cache hints on a pre-2026
602+
# session): they are outside the negotiated contract, and the version-free
603+
# result type would otherwise apply that revision's constraints to them.
604+
if not (foreign := _later_revision_fields(method, version)).isdisjoint(raw):
605+
raw = {key: value for key, value in raw.items() if key not in foreign}
561606
if isinstance(result_type, TypeAdapter):
562607
return result_type.validate_python(raw, by_name=False)
563608
return result_type.model_validate(raw, by_name=False)

tests/client/test_session.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1759,6 +1759,75 @@ async def test_a_boolean_inbound_ttl_is_not_clamped_only_coerced_by_validation(w
17591759
assert result.ttl_ms == int(wire_ttl)
17601760

17611761

1762+
_LEGACY_HINTED_RESULTS: list[tuple[str, dict[str, Any]]] = [
1763+
("list_tools", {"tools": []}),
1764+
("list_prompts", {"prompts": []}),
1765+
("list_resources", {"resources": []}),
1766+
("list_resource_templates", {"resourceTemplates": []}),
1767+
("read_resource", {"contents": []}),
1768+
]
1769+
1770+
_LEGACY_TAGGED_RESULTS: list[tuple[str, dict[str, Any]]] = [
1771+
("call_tool", {"content": []}),
1772+
("get_prompt", {"messages": []}),
1773+
]
1774+
1775+
1776+
def _legacy_init(version: str) -> dict[str, Any]:
1777+
return InitializeResult(
1778+
protocol_version=version,
1779+
capabilities=ServerCapabilities(),
1780+
server_info=Implementation(name="mock-server", version="0.1.0"),
1781+
).model_dump(by_alias=True, mode="json", exclude_none=True)
1782+
1783+
1784+
async def _call_legacy(session: ClientSession, verb: str) -> Any:
1785+
if verb == "read_resource":
1786+
return await session.read_resource("mem://x")
1787+
if verb == "call_tool":
1788+
return await session.call_tool("t", {})
1789+
if verb == "get_prompt":
1790+
return await session.get_prompt("p")
1791+
return await getattr(session, verb)()
1792+
1793+
1794+
@pytest.mark.anyio
1795+
@pytest.mark.parametrize("version", HANDSHAKE_PROTOCOL_VERSIONS)
1796+
@pytest.mark.parametrize(("verb", "body"), _LEGACY_HINTED_RESULTS)
1797+
async def test_cache_hints_from_a_legacy_server_never_reach_the_result(
1798+
version: str, verb: str, body: dict[str, Any]
1799+
) -> None:
1800+
"""SDK-defined: on any pre-2026 session the caching fields are outside the negotiated
1801+
schema, so whatever a server puts in them - even values the 2026-07-28 enum
1802+
rejects - is dropped and the model shows its conservative defaults."""
1803+
dispatcher = _ScriptedDispatcher(_legacy_init(version), {**body, "ttlMs": -1, "cacheScope": "session"})
1804+
with anyio.fail_after(5):
1805+
async with ClientSession(dispatcher=dispatcher) as session:
1806+
await session.initialize()
1807+
result = await _call_legacy(session, verb)
1808+
assert (result.ttl_ms, result.cache_scope) == (0, "private")
1809+
assert not {"ttl_ms", "cache_scope"} & result.model_fields_set
1810+
1811+
1812+
@pytest.mark.anyio
1813+
@pytest.mark.parametrize(("verb", "body"), _LEGACY_TAGGED_RESULTS)
1814+
async def test_a_2026_result_type_tag_from_a_legacy_server_never_reaches_the_result(
1815+
verb: str, body: dict[str, Any]
1816+
) -> None:
1817+
"""SDK-defined: `resultType` is 2026-07-28 vocabulary that also feeds result-union
1818+
routing, so a tag on a pre-2026 wire (even one no union arm claims) is dropped and
1819+
the plain result is returned rather than mis-routing or failing."""
1820+
# `call_tool` re-lists tools to validate structured content; that answer trails harmlessly for `get_prompt`.
1821+
dispatcher = _ScriptedDispatcher(
1822+
_legacy_init(LATEST_HANDSHAKE_VERSION), {**body, "resultType": "task"}, {"tools": []}
1823+
)
1824+
with anyio.fail_after(5):
1825+
async with ClientSession(dispatcher=dispatcher) as session:
1826+
await session.initialize()
1827+
result = await _call_legacy(session, verb)
1828+
assert result.result_type == "complete"
1829+
1830+
17621831
@pytest.mark.anyio
17631832
async def test_session_call_tool_returns_input_required_result_when_opted_in() -> None:
17641833
"""`ClientSession.call_tool(..., allow_input_required=True)` surfaces the

0 commit comments

Comments
 (0)