diff --git a/.github/ISSUE_TEMPLATE/bug.yaml b/.github/ISSUE_TEMPLATE/bug.yaml
index 617ff9872e..42b2d4b16b 100644
--- a/.github/ISSUE_TEMPLATE/bug.yaml
+++ b/.github/ISSUE_TEMPLATE/bug.yaml
@@ -11,13 +11,26 @@ body:
id: checks
attributes:
label: Initial Checks
- description: Just making sure you're using the latest version of MCP Python SDK.
+ description: >
+ Both the 2.x stable line and the 1.x maintenance line are supported, and only
+ the newest release of each line receives fixes.
options:
- - label: I confirm that I'm using the latest version of MCP Python SDK
+ - label: I confirm that I'm using the newest release of my line (the latest 2.x, or the latest 1.x if I'm still on v1)
required: true
- label: I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue
required: true
+ - type: dropdown
+ id: release-line
+ attributes:
+ label: Release line
+ description: Which major version of the SDK are you using?
+ options:
+ - 2.x (current stable)
+ - 1.x (maintenance line, v1.x branch)
+ validations:
+ required: true
+
- type: textarea
id: description
attributes:
diff --git a/.github/ISSUE_TEMPLATE/v2-feedback.yaml b/.github/ISSUE_TEMPLATE/v2-feedback.yaml
index 35ed633d5d..cfa6996989 100644
--- a/.github/ISSUE_TEMPLATE/v2-feedback.yaml
+++ b/.github/ISSUE_TEMPLATE/v2-feedback.yaml
@@ -1,15 +1,15 @@
name: v2 feedback
description: Bugs, API friction, or docs gaps in v2 of the SDK
title: "[v2] "
-labels: ["v2-alpha"]
+labels: ["v2"]
body:
- type: markdown
attributes:
value: |
- Thanks for trying v2. Anything that broke, surprised you, or slowed you down is useful — API feedback is explicitly welcome while v2 is in pre-release.
+ Anything in v2 that broke, surprised you, or slowed you down is useful, including API friction and docs gaps.
- Docs: https://py.sdk.modelcontextprotocol.io/v2/ · Migration from v1: https://py.sdk.modelcontextprotocol.io/v2/migration/
+ Docs: https://py.sdk.modelcontextprotocol.io/ · Migration from v1: https://py.sdk.modelcontextprotocol.io/migration/
- type: textarea
id: what
diff --git a/.github/actions/conformance/client.py b/.github/actions/conformance/client.py
index 0784ef190d..18e59a8ac6 100644
--- a/.github/actions/conformance/client.py
+++ b/.github/actions/conformance/client.py
@@ -11,16 +11,18 @@
--spec-version is omitted the harness picks per-scenario (LATEST_SPEC_VERSION
for active scenarios, DRAFT_PROTOCOL_VERSION for draft-only ones).
- Server URL as last CLI argument (sys.argv[1])
- - Must exit 0 within 30 seconds
+ - Must exit 0 within the harness --timeout (CI passes 60s; the default is 30s)
Scenarios:
initialize - Connect, initialize, list tools, close
tools_call - Connect, call add_numbers(a=5, b=3), close
sse-retry - Connect, call test_reconnection, close
json-schema-ref-no-deref - Connect, list tools (no $ref deref)
+ json-schema-2020-12-preservation - List tools, echo the focal inputSchema back verbatim
request-metadata - Connect with all callbacks; client stamps _meta
http-standard-headers - Connect, call a tool (Mcp-* headers checked)
http-invalid-tool-headers - List tools, call every surfaced tool (x-mcp-header filter)
+ http-custom-headers - Replay the harness's toolCalls (x-mcp-header -> Mcp-Param-*)
elicitation-sep1034-client-defaults - Elicitation with default accept callback
sep-2322-client-request-state - Drive the MRTR auto-loop (SEP-2322)
auth/client-credentials-jwt - Client credentials with private_key_jwt
@@ -252,6 +254,21 @@ async def run_json_schema_ref_no_deref(server_url: str) -> None:
await client.list_tools()
+@register("json-schema-2020-12-preservation")
+async def run_json_schema_2020_12_preservation(server_url: str) -> None:
+ """List tools, then echo the focal tool's inputSchema back verbatim (SEP-1613 / SEP-2106).
+
+ The harness diffs what the client round-trips through `json_schema_echo` against its
+ fixture to detect 2020-12 keywords ($schema, $defs, $anchor, additionalProperties,
+ allOf/anyOf, if/then/else) being stripped while parsing tools/list. Unlike
+ json-schema-ref-no-deref, this mock is version-aware, so client_mode() applies.
+ """
+ async with Client(server_url, mode=client_mode()) as client:
+ listed = await client.list_tools()
+ focal = next(tool for tool in listed.tools if tool.name == "json_schema_2020_12_tool")
+ await client.call_tool("json_schema_echo", {"schema": focal.input_schema})
+
+
@register("tools_call")
async def run_tools_call(server_url: str) -> None:
"""Connect, list tools, call add_numbers(a=5, b=3), close."""
diff --git a/.github/actions/conformance/expected-failures.yml b/.github/actions/conformance/expected-failures.yml
index aa31cb757b..4379d116d5 100644
--- a/.github/actions/conformance/expected-failures.yml
+++ b/.github/actions/conformance/expected-failures.yml
@@ -10,7 +10,23 @@
# scenarios start passing and MUST be removed from this list (the runner fails
# on stale entries), so the baseline burns down per milestone.
-client: []
+client:
+ # SEP-1932 (DPoP): the SDK's OAuth client does not implement DPoP proofs.
+ # The entries are per-check (conformance #406) because both scenarios
+ # pass their non-DPoP checks (discovery, token acquisition, request
+ # flow) live.
+ - auth/dpop:sep-1932-client-token-request-proof
+ - auth/dpop:sep-1932-client-dpop-auth-scheme
+ - auth/dpop:sep-1932-client-fresh-proof
+ - auth/dpop-nonce:sep-1932-client-token-request-proof
+ - auth/dpop-nonce:sep-1932-client-dpop-auth-scheme
+ - auth/dpop-nonce:sep-1932-client-fresh-proof
+ - auth/dpop-nonce:sep-1932-client-as-nonce
+ - auth/dpop-nonce:sep-1932-client-rs-nonce
+ # Workload identity federation: the OAuth client does not implement the
+ # urn:ietf:params:oauth:grant-type:jwt-bearer grant (it answers with
+ # authorization_code). Per-check for the same reason.
+ - auth/wif-jwt-bearer:wif-grant-type
server:
# SEP-2663 (io.modelcontextprotocol/tasks): the SDK does not implement the
diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml
index dd132698dd..0222296888 100644
--- a/.github/workflows/conformance.yml
+++ b/.github/workflows/conformance.yml
@@ -15,21 +15,9 @@ permissions:
env:
# Pinned conformance harness package spec (passed verbatim to `npx --yes`).
- # Use a published version, e.g. @modelcontextprotocol/conformance@0.2.0-alpha.7.
# Bump deliberately and reconcile both
# .github/actions/conformance/expected-failures*.yml files in the same change.
- #
- # Temporarily pinned to the pkg.pr.new build of conformance main@4944b268
- # (0.2.0-alpha.8, which includes #372: fail checks whose prerequisite is
- # missing instead of skipping them) — alpha.8 is not published to npm yet.
- # Pinned by commit SHA so the tarball cannot move under us;
- # CONFORMANCE_PKG_SHA256 pins the bytes and the fetch-and-verify step below
- # downloads, checks the digest, and repoints CONFORMANCE_PKG at the
- # verified local copy. Repin to the next published @modelcontextprotocol/
- # conformance release (>=0.2.0-alpha.8) once it ships, then drop
- # CONFORMANCE_PKG_SHA256 and the fetch-and-verify steps.
- CONFORMANCE_PKG: "https://pkg.pr.new/@modelcontextprotocol/conformance@4944b268"
- CONFORMANCE_PKG_SHA256: "0f70c035782d319d72ab427653c5275db5c50429d59fae0241a645b33aeda1a7"
+ CONFORMANCE_PKG: "@modelcontextprotocol/conformance@0.2.0-alpha.11"
jobs:
server-conformance:
@@ -45,19 +33,6 @@ jobs:
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
- - name: Fetch and verify conformance harness
- # Only when CONFORMANCE_PKG is a URL: download, check the recorded
- # sha256, and re-point CONFORMANCE_PKG at the verified local tarball.
- # When CONFORMANCE_PKG is a registry spec, this step is a no-op (npm's
- # own integrity check applies).
- run: |
- case "$CONFORMANCE_PKG" in
- https://*)
- curl -fsSL "$CONFORMANCE_PKG" -o /tmp/conformance.tgz
- echo "$CONFORMANCE_PKG_SHA256 /tmp/conformance.tgz" | sha256sum -c -
- echo "CONFORMANCE_PKG=file:/tmp/conformance.tgz" >> "$GITHUB_ENV"
- ;;
- esac
- run: uv sync --frozen --all-extras --package mcp-everything-server
- name: Run server conformance (active suite)
run: >-
@@ -117,19 +92,6 @@ jobs:
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
- - name: Fetch and verify conformance harness
- # Only when CONFORMANCE_PKG is a URL: download, check the recorded
- # sha256, and re-point CONFORMANCE_PKG at the verified local tarball.
- # When CONFORMANCE_PKG is a registry spec, this step is a no-op (npm's
- # own integrity check applies).
- run: |
- case "$CONFORMANCE_PKG" in
- https://*)
- curl -fsSL "$CONFORMANCE_PKG" -o /tmp/conformance.tgz
- echo "$CONFORMANCE_PKG_SHA256 /tmp/conformance.tgz" | sha256sum -c -
- echo "CONFORMANCE_PKG=file:/tmp/conformance.tgz" >> "$GITHUB_ENV"
- ;;
- esac
# --compile-bytecode: without it, ~40 concurrently spawned interpreters
# race to byte-compile site-packages during the timing-sensitive window.
- run: uv sync --frozen --all-extras --package mcp --compile-bytecode
diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml
index 6da800a727..334ba818c8 100644
--- a/.github/workflows/deploy-docs.yml
+++ b/.github/workflows/deploy-docs.yml
@@ -3,8 +3,10 @@ name: Deploy Docs
on:
push:
branches:
+ # main is the sole deployer of the combined site (v2 at / and /v2/, v1.x
+ # at /v1/); the v1.x branch has no deploy workflow. A v1.x docs change is
+ # published by the next main deploy or a manual workflow_dispatch here.
- main
- - v1.x
paths:
- docs/**
# docs pages include their code blocks from these files via `--8<--`, so a
@@ -48,7 +50,7 @@ jobs:
enable-cache: true
version: 0.9.5
- - name: Build combined docs (v1.x at /, main at /v2/)
+ - name: Build combined docs (main at / and /v2/, v1.x at /v1/)
run: bash scripts/build-docs.sh site
- name: Configure Pages
diff --git a/.github/workflows/shared.yml b/.github/workflows/shared.yml
index b113d87c3f..541fc7bb54 100644
--- a/.github/workflows/shared.yml
+++ b/.github/workflows/shared.yml
@@ -51,7 +51,7 @@ jobs:
- name: mcp-types installs and imports standalone
run: |
uv run --isolated --no-project --with ./src/mcp-types python -c \
- "import mcp_types, mcp_types.jsonrpc, mcp_types.methods, mcp_types.version, mcp_types.v2025_11_25, mcp_types.v2026_07_28"
+ "import mcp_types, mcp_types.jsonrpc, mcp_types.methods, mcp_types.version, mcp_types._v2025_11_25, mcp_types._v2026_07_28"
test:
name: test (${{ matrix.python-version }}, ${{ matrix.dep-resolution.name }}, ${{ matrix.os }})
diff --git a/AGENTS.md b/AGENTS.md
index 43fbb887d4..2812ed6d17 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,16 +2,16 @@
## Branching Model
-
-
-- `main` is currently the V2 rework.
-- Breaking changes are expected here — removing or replacing an API must be
- intentional. Adding a replacement API or `@deprecated` shim must likewise be
- a deliberate design choice, not bolted on for free.
-- Breaking changes (including those softened by a backwards-compatibility
- shim) must be documented in `docs/migration.md`.
-- `v1.x` is the release branch for the current stable line. Backport PRs target
- this branch and use a `[v1.x]` title prefix.
+- `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.
## Package Management
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index a36dedd8da..b0fb9fa57b 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -76,11 +76,11 @@ uv tool install pre-commit --with pre-commit-uv --force-reinstall
| Change Type | Target Branch | Example |
|-------------|---------------|---------|
- | New features, breaking changes | `main` | New APIs, refactors |
+ | New features and fixes for v2 | `main` | New APIs, refactors |
| Security fixes for v1 | `v1.x` | Critical patches |
- | Bug fixes for v1 | `v1.x` | Non-breaking fixes |
+ | Critical bug fixes for v1 | `v1.x` | Backports of severe bugs |
- > **Note:** `main` is the v2 development branch. Breaking changes are welcome on `main`. The `v1.x` branch receives only security and critical bug fixes.
+ > **Note:** `main` is the current stable line (v2). The `v1.x` branch is the previous major's maintenance line and receives only security and critical bug fixes.
2. Create a new branch from your chosen base branch
diff --git a/README.md b/README.md
index 3822976640..1850141b67 100644
--- a/README.md
+++ b/README.md
@@ -13,18 +13,18 @@
-> [!CAUTION]
-> **This README documents v2 of the MCP Python SDK — a pre-release (alpha/beta) line under active development. Do not use v2 in production.** Pre-releases are published to PyPI as `2.0.0aN` / `2.0.0bN`, and **each pre-release may contain breaking changes from the previous one**. Pin an exact version and expect to update your code when you bump the pin.
+> [!NOTE]
+> **This is v2 of the MCP Python SDK, the current stable release line.** It is a major rework of the SDK, both to support the [2026-07-28 MCP specification](https://modelcontextprotocol.io/specification/2026-07-28) (and every earlier revision) and to fix long-standing architectural issues. Coming from v1? See [What's new in v2](https://py.sdk.modelcontextprotocol.io/whats-new/) for the tour of what changed and the [migration guide](https://py.sdk.modelcontextprotocol.io/migration/) for every breaking change.
>
-> **v1.x is the only stable release line and remains recommended for production.** It lives on the [`v1.x` branch](https://github.com/modelcontextprotocol/python-sdk/tree/v1.x) and continues to receive critical bug fixes and security patches; see [the v1.x README](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/README.md) for its documentation. `pip` and `uv` don't select a pre-release unless you explicitly request one, so existing installs are unaffected. **If your package depends on `mcp`, add a `<2` upper bound to your version constraint (for example `mcp>=1.27,<2`) before the stable release lands.**
+> **Not ready to migrate?** v1.x lives on the [`v1.x` branch](https://github.com/modelcontextprotocol/python-sdk/tree/v1.x), continues to receive critical bug fixes and security patches, and is documented at . Since `pip install mcp` now installs 2.x, keep a `<2` upper bound on your requirement (for example `mcp>=1.28,<2`) until you've migrated.
>
-> v2 is a major rework of the SDK, both to support the [2026-07-28 MCP specification release](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) and to fix long-standing architectural issues. See [What's new in v2](https://py.sdk.modelcontextprotocol.io/v2/whats-new/) for the tour of what changed, and the [migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/) for every breaking change. Stable v2 is targeted for 2026-07-27, alongside the spec release. Try the pre-releases and [tell us what breaks](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml), or discuss in [#python-sdk-dev on the MCP Contributors Discord](https://discord.gg/6CSzBmMkjX).
+> Something rough, confusing, or broken? [Open an issue](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml) or find us in [#python-sdk-dev on the MCP Contributors Discord](https://discord.gg/6CSzBmMkjX).
## Documentation
-**The documentation lives at .**
+**The documentation lives at .**
-It has a [Get started guide](https://py.sdk.modelcontextprotocol.io/v2/get-started/), [What's new in v2](https://py.sdk.modelcontextprotocol.io/v2/whats-new/), the [API reference](https://py.sdk.modelcontextprotocol.io/v2/api/mcp/), and the [migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/).
+It has a [Get started guide](https://py.sdk.modelcontextprotocol.io/get-started/), [What's new in v2](https://py.sdk.modelcontextprotocol.io/whats-new/), the [API reference](https://py.sdk.modelcontextprotocol.io/api/mcp/), and the [migration guide](https://py.sdk.modelcontextprotocol.io/migration/).
## What is MCP?
@@ -41,10 +41,10 @@ Python 3.10+.
## Installation
```bash
-uv add "mcp[cli]==2.0.0b1" # or: pip install "mcp[cli]==2.0.0b1"
+uv add "mcp[cli]" # or: pip install "mcp[cli]"
```
-The pin matters while v2 is in pre-release: an unpinned install resolves to the latest stable v1.x, which this README does not describe. Check [PyPI](https://pypi.org/project/mcp/#history) for the newest pre-release, and use `uv run --with "mcp==2.0.0b1"` for one-off commands.
+The `cli` extra adds the `mcp` command-line tool (`mcp dev`, `mcp run`, `mcp install`) on top of the SDK; install plain `mcp` if you don't need it. For one-off commands, `uv run --with "mcp[cli]" mcp ...` works without a project.
## A server in 15 lines
@@ -82,7 +82,7 @@ Call `add` with `a=1`, `b=2` and you get `3` back.
Notice what you did **not** write: no JSON Schema (`a: int, b: int` _is_ the schema), no request parsing, no validation code, no protocol handling. Two type-hinted Python functions and a docstring.
-[Get started](https://py.sdk.modelcontextprotocol.io/v2/get-started/) takes it from here.
+[Get started](https://py.sdk.modelcontextprotocol.io/get-started/) takes it from here.
## A client in 10 lines
@@ -122,7 +122,7 @@ This project is licensed under the MIT License. See the [LICENSE](https://github
[python-badge]: https://img.shields.io/pypi/pyversions/mcp.svg
[python-url]: https://www.python.org/downloads/
[docs-badge]: https://img.shields.io/badge/docs-python--sdk-blue.svg
-[docs-url]: https://py.sdk.modelcontextprotocol.io/v2/
+[docs-url]: https://py.sdk.modelcontextprotocol.io/
[protocol-badge]: https://img.shields.io/badge/protocol-modelcontextprotocol.io-blue.svg
[protocol-url]: https://modelcontextprotocol.io
[spec-badge]: https://img.shields.io/badge/spec-spec.modelcontextprotocol.io-blue.svg
diff --git a/RELEASE.md b/RELEASE.md
index f86da2ea67..58b7fb48f5 100644
--- a/RELEASE.md
+++ b/RELEASE.md
@@ -7,63 +7,125 @@
`[tool.hatch.metadata.hooks.uv-dynamic-versioning].dependencies`.
2. Upgrade lock with `uv lock --resolution lowest-direct`
-## Major or Minor Release
-
-Stable releases are cut from the `v1.x` branch. Create a GitHub release via UI
-with the tag being `vX.Y.Z` where `X.Y.Z` is the version and the release title
-being the same, and **set the tag's target to the `v1.x` branch** — the UI
-defaults to `main`, which is the v2 rework, and a v1 tag created there would
-publish the v2 codebase as a stable release. Then ask someone to review the
-release.
-
-The package version will be set automatically from the tag.
-
-## v2 Pre-releases
-
-v2 pre-releases are cut from `main` with a PEP 440 pre-release tag: `v2.0.0aN`
-for alphas, later `bN`/`rcN` for betas and release candidates.
-
-A release publishes two distributions, `mcp` and `mcp-types`, at the same
-version, and the `mcp` wheel exact-pins `mcp-types`. Before the first release
-that includes both, the `mcp-types` PyPI project must be given the same
-trusted publisher as `mcp` (this repository, workflow `publish-pypi.yml`,
-environment `release`) and the same owners — without it the `mcp-types`
-upload is rejected. If only some of the files upload, fix the cause and re-run
-the publish job — `skip-existing` makes it skip whatever already landed. The
-`Development Status` classifier in both `pyproject.toml` files is permanently
-`5 - Production/Stable`; it is not bumped as part of any release.
-
-1. Update the pre-release version examples in `README.md` and the docs
- (grep the outgoing version — the pins live in the README Installation
- section, `docs/index.md`, `docs/get-started/installation.md`, and `docs/get-started/real-host.md`) so the tagged
- commit — and therefore the README PyPI publishes — names the version
- being released. When entering a new phase (alpha → beta → rc), update
- the banner wording too.
-2. Check the full test matrix is green on the release commit. The publish
- workflow re-runs the checks and blocks publishing until they pass, so a
- red leg there means re-running the failed jobs on the Publishing run.
-3. Create the release as a pre-release, passing the exact commit verified in
- step 2 as `--target` (otherwise the tag is created from whatever `main`'s
- HEAD is by then). The tagged commit determines everything about the
+## Release lines
+
+Two branches ship, and the package version comes from the git tag
+(`uv-dynamic-versioning`). Publishing a GitHub release runs `publish-pypi.yml`
+**from the tagged commit**, so the workflow that fires is the tagged branch's
+own: a `main` tag builds and publishes two distributions (`mcp` and
+`mcp-types`, lock-stepped via `Requires-Dist: mcp-types=={{ version }}`), and a
+`v1.x` tag builds and publishes `mcp` only.
+
+| Line | Branch | Tag | GitHub release flags |
+| ---------------------------- | ------ | ------------------------- | ------------------------------------- |
+| Current stable | `main` | `v2.X.Y` | not a pre-release; becomes **Latest** |
+| Maintenance (previous major) | `v1.x` | `v1.X.Y` | not a pre-release; **not** Latest |
+| Pre-releases | `main` | `v2.X.YaN` / `bN` / `rcN` | **Pre-release** ticked, never Latest |
+
+The `Development Status` classifier in both `pyproject.toml` files is
+permanently `5 - Production/Stable`; it is not bumped as part of any release.
+The `mcp-types` PyPI project carries the same trusted publisher as `mcp` (this
+repository, workflow `publish-pypi.yml`, environment `release`). For a release
+cut from `main`, if only some of the four files upload, fix the cause and
+re-run the publish job — its `skip-existing` setting makes it skip whatever
+already landed (the `v1.x` workflow publishes a single distribution and has no
+such setting).
+
+## Stable release from `main` (`v2.X.Y`)
+
+The stable line's README and docs carry no version pin (`pip install "mcp[cli]"`
+installs the newest stable release), so a routine stable release needs no
+pin-flip commit; the exception is the first stable release of a new major,
+whose pre-release banner and pins are replaced by that flip. `README.md` at the
+tagged commit is the PyPI long description, so any README fix has to merge
+before the tag.
+
+1. Check the full test matrix is green on the release commit. The publish
+ workflow re-runs the same checks and blocks publishing until they pass, so a
+ red leg there means re-running the failed jobs on the Publishing run — but
+ verify green before creating the release rather than discovering red after
+ the tag exists.
+2. Freeze `main` from that commit until the tag exists: the release is created
+ with an explicit `--target`, and nothing else should land in between.
+3. Create the release NOT as a pre-release, passing the verified commit as
+ `--target` (otherwise the tag is created from whatever `main`'s HEAD is by
+ then). It becomes GitHub "Latest", and PyPI's default `pip install mcp`
+ version moves to it. The tagged commit determines everything about the
release — the workflows that run and the package metadata (readme,
classifiers) that gets published — so it must contain the current release
tooling, not just pass tests. `--target` is ignored if the tag already
exists: when re-creating a release, delete the old tag first and
- double-check where the new tag points. The pre-release flag keeps GitHub's
- "Latest" badge and `/releases/latest` pointing at the stable v1.x line:
+ double-check where the new tag points.
+
+ ```shell
+ gh release create v2.X.Y --title v2.X.Y --target --notes-file
+ ```
+
+4. Curate the release notes: the highlights, anything known-incomplete, and
+ links to the docs and migration guide, above a `## What's Changed` list.
+ Generate that list with the release UI's "Generate release notes" (setting
+ its **Previous tag** to the previous release on this line by hand — the
+ auto-picked baseline is the newest tag, which may sit on the other line), or
+ assemble the whole body in the file passed to `--notes-file`. Use absolute
+ URLs (relative links don't resolve in GitHub release bodies).
+5. If a stable release turns out to be broken, yank it on PyPI and release the
+ fix as the next patch version. Never delete a release from PyPI — version
+ numbers cannot be reused. Yank `mcp` and `mcp-types` together (they are one
+ release), and set the yank reason and the GitHub release notes to point at
+ the replacement version, since yanking doesn't stop `==` pins from installing
+ the broken version.
+
+## Maintenance release from `v1.x` (`v1.X.Y`)
+
+Land the `[v1.x]`-prefixed backport PRs (and any README banner update, which is
+the README PyPI shows for that version), verify the branch tip green, then
+create the release the same way with two differences:
+
+- **The tag's target is the verified commit on the `v1.x` branch.** The UI and
+ CLI default the target to `main`, which is the v2 codebase — a v1 tag created
+ there would publish v2 code as a v1 stable release. Pass the exact commit
+ verified green in the previous step rather than the branch name, for the
+ same moving-HEAD reason as above.
+- **It must not take "Latest" back from the 2.x line.** The UI ticks "Set as
+ the latest release" by default for the newest non-pre-release; untick it, or
+ pass `--latest=false`, and afterwards confirm `/releases/latest` still names
+ the newest v2 tag. If it slipped, `gh release edit v1.X.Y --latest=false`
+ fixes it — release metadata only, no re-cut.
+
+```shell
+gh release create v1.X.Y --title v1.X.Y --target --latest=false --notes-file
+```
+
+When generating notes, set **Previous tag** to the previous `v1.*` release by
+hand for the same reason as above. Then ask someone to review the release.
+
+## Pre-releases from `main`
+
+Pre-releases of the next version are cut from `main` with a PEP 440
+pre-release tag: `aN` for alphas, later `bN`/`rcN` for betas and release
+candidates. The PEP 440 suffix is what keeps `pip install mcp` on the stable
+version — installers only select a pre-release when it is requested explicitly (an
+exact pin, a specifier that names a pre-release version, or `--pre`).
+
+1. During a pre-release phase the README and docs pin the exact pre-release
+ version, so update those examples first (grep the outgoing version — the
+ pins live in the README Installation section, `docs/index.md`,
+ `docs/get-started/installation.md`, and `docs/get-started/real-host.md`) so
+ the tagged commit — and therefore the README PyPI publishes — names the
+ version being released. When entering a new phase (alpha → beta → rc →
+ stable), update the banner wording too; the stable phase drops the pins.
+2. Check the full test matrix is green on the release commit, as above.
+3. Create the release as a pre-release, passing the verified commit as
+ `--target`. The pre-release flag keeps GitHub's "Latest" badge and
+ `/releases/latest` on the newest stable release:
```shell
- gh release create v2.0.0aN --prerelease --title v2.0.0aN --target
+ gh release create v2.X.YbN --prerelease --title v2.X.YbN --target
```
-4. Curate the release notes instead of relying on auto-generated ones: what
- changed since the previous pre-release, what is known-incomplete, the
- install line (`pip install mcp==2.0.0aN`), and a link to the migration
- guide. Use the absolute URL
- (`https://github.com/modelcontextprotocol/python-sdk/blob/main/docs/migration.md`)
- because relative links don't resolve in GitHub release bodies.
-5. If a pre-release turns out to be broken, yank it on PyPI and cut the next
- one. Never delete a release from PyPI — version numbers cannot be reused.
- Yanking doesn't stop `==` pins from installing the broken version, so set
- the yank reason (and edit the GitHub release notes) to point at the
- replacement version.
+4. Curate the release notes: what changed since the previous pre-release, what
+ is known-incomplete, the install line (`pip install mcp==2.X.YbN`), and a
+ link to the migration guide, with absolute URLs.
+5. If a pre-release turns out to be broken, yank both `mcp` and `mcp-types` on PyPI
+ and cut the next one, pointing the yank reason and the GitHub release notes
+ at the replacement version.
diff --git a/SECURITY.md b/SECURITY.md
index e8b51cc08d..5a69875124 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -4,12 +4,17 @@ Thank you for helping keep the Model Context Protocol and its ecosystem secure.
## Supported Versions
-Security fixes are released for the most recent stable (v1.x) release line.
-
-v2 pre-releases (`2.0.0aN`, …) are development snapshots: fixes land only in
-the newest pre-release, and already-published pre-releases are not patched. If
-you are testing the v2 line, track the latest pre-release; for production use,
-stay on the latest stable release.
+| Version | Line | Support |
+| ---------------------------------------- | ----------------------- | ------------------------------------------- |
+| 2.x (newest release) | current stable (`main`) | bug fixes, security fixes, new features |
+| 1.x newest release (`v1.x` branch) | maintenance | critical bug fixes and security fixes |
+| older 1.x releases, and all pre-releases | unsupported | upgrade to the newest 1.x release or to 2.x |
+
+Only the newest release of a supported line receives fixes, so reproduce against
+it before reporting. If your project depends on `mcp` and is not yet ready for
+2.x, keep a `<2` upper bound on your `mcp` requirement and follow the
+[migration guide](https://py.sdk.modelcontextprotocol.io/migration/) when you
+migrate.
## Reporting Security Issues
diff --git a/docs/advanced/apps.md b/docs/advanced/apps.md
index 87e260f014..a60c997b42 100644
--- a/docs/advanced/apps.md
+++ b/docs/advanced/apps.md
@@ -20,7 +20,7 @@ then come back.
## A clock with a face
-```python title="server.py" hl_lines="18 21 29 31"
+```python title="server.py" hl_lines="19 22 30 32"
--8<-- "docs_src/apps/tutorial001.py"
```
@@ -51,7 +51,7 @@ The model reads `content`; the iframe is for humans. A UI-capable host still fee
the text result to the model, and a text-only client gets *only* that. So the
canonical pattern is one tool, two answers. Look at `get_time` again:
-```python title="server.py" hl_lines="22-26"
+```python title="server.py" hl_lines="23-27"
--8<-- "docs_src/apps/tutorial001.py"
```
diff --git a/docs/advanced/extensions.md b/docs/advanced/extensions.md
index 0358ba5a83..de7937fc75 100644
--- a/docs/advanced/extensions.md
+++ b/docs/advanced/extensions.md
@@ -128,19 +128,26 @@ The same file's `main()` is the whole client story, both halves of it:
The one interceptive hook. Override `intercept_tool_call` to observe, short-circuit,
or veto a tool call:
-```python title="server.py" hl_lines="18-25"
+```python title="server.py" hl_lines="17-24"
--8<-- "docs_src/extensions/tutorial005.py"
```
* `params` is the validated `CallToolRequestParams`: you get `params.name` and
- `params.arguments` without touching raw JSON.
-* `call_next(ctx)` runs the rest of the chain. Return its result unchanged (observe),
- return something else (replace), or raise an `MCPError` (refuse).
+ `params.arguments` without touching raw JSON. It is also what decides which
+ tool call runs: passing a rewritten context through `call_next` changes what
+ the handler observes on `ctx`, not the tool invocation. Wire-level request
+ rewriting belongs to [Middleware](middleware.md).
+* `call_next(ctx)` runs the rest of the chain and returns the handler's result.
+ Return it unchanged (observe), return something else (replace), or raise an
+ `MCPError` (refuse). Whatever you return is serialized like any handler
+ result, including the 2026-era `serverInfo` identity stamp, so a
+ short-circuiting interceptor never produces an anonymous or off-schema
+ response.
* With several extensions, interceptors nest in registration order: the first
extension in `extensions=[...]` is outermost.
* The default implementation is a pass-through, and a server whose extensions never
- override this hook installs **no** middleware at all. You don't pay for what
- you don't use.
+ override this hook keeps the bare `tools/call` handler untouched. You don't
+ pay for what you don't use.
The hook wraps `tools/call` and nothing else. For every-message concerns, use
[Middleware](middleware.md). That is what it is for.
@@ -151,7 +158,7 @@ A **client extension** is the same contract from the consuming side: a bundle of
client-side behaviour behind one identifier. Pass instances to
`Client(extensions=[...])` and call tools normally:
-```python title="client.py" hl_lines="67-69"
+```python title="client.py" hl_lines="66-68"
--8<-- "docs_src/extensions/tutorial006.py"
```
@@ -181,7 +188,7 @@ client = Client(mcp, extensions=[advertise("com.example/search")])
Subclass `ClientExtension` and override only what you need. Three contribution
kinds, each with a default: `settings()`, `claims()`, and `notifications()`.
-```python title="client.py" hl_lines="18-19 44-45 47-48"
+```python title="client.py" hl_lines="17-18 43-44 46-47"
--8<-- "docs_src/extensions/tutorial006.py"
```
@@ -219,12 +226,12 @@ claimed shape reaching a session-tier caller raises `UnexpectedClaimedResult`.
### Extension verbs
An extension's own request methods need no client-side registration. A vendor request
-type subclasses `mcp_types.Request` and goes through `client.session.send_request`,
+type subclasses `mcp.types.Request` and goes through `client.session.send_request`,
as in [Serving your own methods](#serving-your-own-methods). One addition: when a
params key must ride the `Mcp-Name` header (extension specs such as tasks require
this for their verbs), the request type declares `name_param`:
-```python title="client.py" hl_lines="23-26 47-48"
+```python title="client.py" hl_lines="22-25 46-47"
--8<-- "docs_src/extensions/tutorial007.py"
```
diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md
index 26df8f6123..083e03cd61 100644
--- a/docs/advanced/low-level-server.md
+++ b/docs/advanced/low-level-server.md
@@ -14,7 +14,7 @@ For everything else, stay on `MCPServer`.
This is the `search_books` tool that **[Tools](../servers/tools.md)** writes in nine lines of `@mcp.tool()`, with the sugar removed:
-```python title="server.py" hl_lines="23 27 33"
+```python title="server.py" hl_lines="22 26 32"
--8<-- "docs_src/lowlevel/tutorial001.py"
```
@@ -80,7 +80,7 @@ That generalises. An exception raised from a low-level handler is **always** a p
`on_call_tool` is the single entry point for every tool on the server. You route on `params.name`:
-```python title="server.py" hl_lines="39-44"
+```python title="server.py" hl_lines="38-43"
--8<-- "docs_src/lowlevel/tutorial002.py"
```
@@ -91,7 +91,7 @@ That generalises. An exception raised from a low-level handler is **always** a p
Declare `output_schema` on the `Tool` and put `structured_content` on the result. Both are yours:
-```python title="server.py" hl_lines="20-24 37"
+```python title="server.py" hl_lines="19-23 36"
--8<-- "docs_src/lowlevel/tutorial003.py"
```
@@ -102,10 +102,13 @@ Call it and the result carries both representations:
"content": [{"type": "text", "text": "Found 3 books matching 'dune'."}],
"structuredContent": {"matches": 3, "query": "dune"},
"isError": false,
- "resultType": "complete"
+ "resultType": "complete",
+ "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "Bookshop", "version": "2.0.0"}}
}
```
+The `_meta` block is the server's identity stamp: the SDK adds it to every 2026-era result, with the `version` from the constructor (a server that sets none reports an empty string). A server that must not identify itself can strip the key with a middleware, which owns the results it returns.
+
The server never compares the two fields. This SDK's `Client` does: return `structured_content` that doesn't satisfy the `output_schema` you declared and `call_tool` raises a `RuntimeError` that starts with `Invalid structured content returned by tool search_books` and goes on to quote the `jsonschema` failure. Promising a schema is cheap; keeping it is on you. The whole ladder of return types and schemas is in **[Structured Output](../servers/structured-output.md)**.
## `_meta`: for the application, not the model
@@ -114,7 +117,7 @@ The server never compares the two fields. This SDK's `Client` does: return `stru
Use it for record IDs, trace IDs, anything your UI needs and your prompt doesn't:
-```python title="server.py" hl_lines="38"
+```python title="server.py" hl_lines="37"
--8<-- "docs_src/lowlevel/tutorial004.py"
```
@@ -141,7 +144,7 @@ No `resources`, no `prompts`: there is nothing to back them. Pass `on_list_promp
`Server` is generic in the type its lifespan yields. Annotate it once and the object is typed everywhere it surfaces:
-```python title="server.py" hl_lines="25-27 45-46 51"
+```python title="server.py" hl_lines="24-26 44-45 50"
--8<-- "docs_src/lowlevel/tutorial005.py"
```
diff --git a/docs/advanced/middleware.md b/docs/advanced/middleware.md
index 4e57bae82d..5d80927441 100644
--- a/docs/advanced/middleware.md
+++ b/docs/advanced/middleware.md
@@ -5,18 +5,20 @@ A **middleware** is one async function that wraps every message your server rece
You write it as `async (ctx, call_next)` and append it to `server.middleware`. That is the whole API.
!!! warning
- `Server.middleware` is marked **provisional** in the source. The signature and semantics are
- expected to change before v2 is final. Use it to *observe*: timing, logging, tracing.
- Do not make it the foundation your server stands on.
+ The middleware list is marked **provisional** in the source: its signature and semantics may
+ change in a 2.x minor release. Use it to *observe* (timing, logging, tracing) and to
+ *refuse* messages; do not make it the foundation your server stands on.
-This is a **low-level `Server`** feature. `MCPServer` does not expose a middleware list.
-If `Server(name, on_call_tool=...)` is new to you, read **[The low-level Server](low-level-server.md)** first.
+`MCPServer` takes the list at construction (`MCPServer(name, middleware=[...])`) and exposes it as
+`mcp.middleware`; the low-level `Server` exposes the same list as `server.middleware`. The example
+below uses the low-level `Server`; if `Server(name, on_call_tool=...)` is new to you, read
+**[The low-level Server](low-level-server.md)** first.
## A timing middleware
One server, one tool, one middleware that logs how long each message took:
-```python title="server.py" hl_lines="40-46 50"
+```python title="server.py" hl_lines="39-45 49"
--8<-- "docs_src/middleware/tutorial001.py"
```
@@ -57,12 +59,19 @@ In increasing order of how much you should hesitate:
* **Observe.** Time it, count it, log it. The example above.
* **Refuse.** Raise an `MCPError` *instead of* calling `call_next(ctx)` and that one message is
- answered with a JSON-RPC error. The connection stays up; the next message goes through.
+ answered with a JSON-RPC error. The connection stays up; the next message goes through. This is
+ how a server gates `subscriptions/listen` per caller:
+ **[Deciding who may watch](../handlers/subscriptions.md#deciding-who-may-watch)** on the
+ Subscriptions page walks through it.
* **Rewrite.** `ctx` is a dataclass: `await call_next(dataclasses.replace(ctx, params=...))`
hands the rest of the chain different params than the client sent. Never do this to
`initialize`: the result the client gets back is built from your rewritten params, but the
server commits its connection state from the original wire params. The two sides can finish
the handshake disagreeing about what they negotiated.
+* **Answer.** Return a result without calling `call_next(ctx)` and it goes to the client as
+ your response. `call_next` hands you the finished wire form, and the pipeline never patches
+ what you return, so the whole envelope is yours: on a 2026-era connection that includes the
+ `serverInfo` `_meta` stamp, which the SDK adds to handler results but not to yours.
!!! check
`initialize` is one of the things middleware wraps, and it is the *only* hook you get
@@ -94,8 +103,8 @@ don't think about it. It is a no-op until you install an exporter, and it has it
## Recap
-* A middleware is `async (ctx, call_next) -> result`, appended to `server.middleware` on the
- low-level `Server`.
+* A middleware is `async (ctx, call_next) -> result`, passed as `MCPServer(middleware=[...])` (or
+ appended to `mcp.middleware`), and appended to `server.middleware` on the low-level `Server`.
* It wraps **every** inbound message (`server/discover`, `initialize`, requests, notifications,
unknown methods) and runs outermost-first.
* `ctx.request_id is None` is how you tell a notification from a request.
diff --git a/docs/advanced/pagination.md b/docs/advanced/pagination.md
index 381f7fae04..9f807a8e61 100644
--- a/docs/advanced/pagination.md
+++ b/docs/advanced/pagination.md
@@ -10,7 +10,7 @@ Pagination is for the server whose resource list is really a database: thousands
## A server that pages
-```python title="server.py" hl_lines="13 16-17"
+```python title="server.py" hl_lines="12 15-16"
--8<-- "docs_src/pagination/tutorial001.py"
```
@@ -38,7 +38,7 @@ The tenth page comes back with `next_cursor` set to `None`. Done.
Every `list_*` method on `Client` (`list_tools`, `list_resources`, `list_resource_templates`, `list_prompts`) takes a `cursor=` keyword. Draining a paged list is one `while True`:
-```python title="client.py" hl_lines="27-33"
+```python title="client.py" hl_lines="26-32"
--8<-- "docs_src/pagination/tutorial002.py"
```
diff --git a/docs/client/caching.md b/docs/client/caching.md
index dc4ae97acc..5d83cfa92a 100644
--- a/docs/client/caching.md
+++ b/docs/client/caching.md
@@ -25,7 +25,7 @@ Out of the box every result says `ttlMs: 0, cacheScope: "private"`: immediately
On the low-level `Server`, handlers build their results by hand, and `ttl_ms` / `cache_scope` are just fields on the result models. A handler that sets them explicitly always wins over the constructor map, field by field:
-```python title="server.py" hl_lines="11 17"
+```python title="server.py" hl_lines="10 16"
--8<-- "docs_src/caching/tutorial002.py"
```
@@ -39,7 +39,7 @@ One caveat on paginated lists: the protocol requires the **same `cacheScope` on
On a 2026-07-28 session, `Client` honors the hints for you: it has a built-in response cache, on by default. A result that arrives carrying a `ttlMs` is stored, and an identical call within that TTL is served from the cache with no round trip. A result that carries *no* hint is not cached: hint-less results get `CacheConfig.default_ttl_ms`, which defaults to `0` (immediately stale), so a server that declares nothing sees exactly the call-for-call traffic it always did.
-```python title="client.py" hl_lines="34 36 39"
+```python title="client.py" hl_lines="33 35 38"
--8<-- "docs_src/caching/tutorial003.py"
```
@@ -51,7 +51,7 @@ Four calls, three fetches. The second call found a fresh entry and never reached
One rule sits above `"use"`: **calls carrying `meta` always reach the server.** A request with `meta` set (a progress token, tracing fields) expects a wire request, so under `cache_mode="use"` it is treated as `"refresh"`: the cache read is skipped, and the fetched result still replaces the cached entry. `"bypass"` and an explicit `"refresh"` behave as they always do.
-To turn caching off entirely, construct with `Client(server, cache=False)`: every call is a round trip again, and `cache_mode`, while still accepted, does nothing.
+To turn caching off entirely, construct with `Client(server, cache=None)`: every call is a round trip again, and `cache_mode`, while still accepted, does nothing.
Scope is honored automatically too: `"private"` entries are keyed to the cache's *partition* (below), while `"public"` ones may opt into wider sharing. And **notifications beat TTL** for the exact entries they name: a `list_changed` notification evicts the matching cached listing, and `resources/updated` evicts the cached read stored under exactly its URI, however fresh they were. On a 2026-07-28 connection those notifications arrive on a `subscriptions/listen` stream you open with `client.listen(...)`, and eviction completes before your watcher sees the event; **[Subscriptions](subscriptions.md)** is that page.
@@ -114,4 +114,4 @@ Clients on pre-2026 protocol versions never see either field; the SDK strips the
* A handler that sets the fields on its result overrides the map, per field.
* `"public"` is a promise that the result is identical for every caller. It is not access control.
* `Client` honors the hints automatically: its response cache is on by default, serves fresh entries instead of refetching, and caches nothing for servers (or sessions) that provide no hints.
-* Per call, `cache_mode="refresh"` refetches and `"bypass"` skips the cache; `cache=False` at construction turns it off entirely.
+* Per call, `cache_mode="refresh"` refetches and `"bypass"` skips the cache; `cache=None` at construction turns it off entirely.
diff --git a/docs/client/callbacks.md b/docs/client/callbacks.md
index 6b4e934cf9..5f1dd1948a 100644
--- a/docs/client/callbacks.md
+++ b/docs/client/callbacks.md
@@ -19,7 +19,7 @@ That is the server half, and the **[Elicitation](../handlers/elicitation.md)** p
## The elicitation callback
-```python title="client.py" hl_lines="7-11 17-18"
+```python title="client.py" hl_lines="6-10 16-17"
--8<-- "docs_src/client_callbacks/tutorial002.py"
```
@@ -55,7 +55,7 @@ result.content # [TextContent(type='text', text='Card issued to Ada Lovelace.')
One `tools/call` from you, one `elicitation/create` back from the server, answered by your function, all inside a single tool call.
!!! info
- `mode="legacy"` on line 17 is doing real work. By default `Client(...)` negotiates the modern
+ `mode="legacy"` on the `Client(...)` call is doing real work. By default `Client(...)` negotiates the modern
protocol path, and that path has no back-channel for server-to-client requests: `ctx.elicit`
fails before your callback ever runs. The transport doesn't decide that; the negotiated
protocol does, in-memory and over a URL alike. Pin `mode="legacy"` whenever your client has
@@ -133,9 +133,9 @@ Pass them to `Client(...)` exactly like `elicitation_callback`.
Two more. Neither declares anything.
-`logging_callback` receives every `notifications/message` a server sends, as `LoggingMessageNotificationParams` (`level`, `logger`, `data`). Protocol logging is itself deprecated by the 2026-07-28 spec (**[Logging](../handlers/logging.md)** has what to do instead), so this callback exists for the servers that still emit it.
+`logging_callback` receives the `notifications/message` a server sends, as `LoggingMessageNotificationParams` (`level`, `logger`, `data`). Protocol logging is itself deprecated by the 2026-07-28 spec (**[Logging](../handlers/logging.md)** has what to do instead), so this callback exists for the servers that still emit it. On a 2026-era connection the callback alone gets you nothing, because 2026 servers send log messages only to requests that opt in: pass `log_level="info"` (or another level) to `Client(...)` to stamp that opt-in on every request and receive that level and above. Pre-2026 servers ignore it and keep their `logging/setLevel` behavior.
-`message_handler` is the catch-all: every server notification reaches it (as well as its specific callback), and on a stream-backed transport so does every transport-level `Exception`. The one pattern worth knowing is `if isinstance(message, Exception): raise message`, so a broken connection fails loudly instead of vanishing.
+`message_handler` is the catch-all: every server notification the session surfaces reaches it (as well as its specific callback), and on a stream-backed transport so does every transport-level `Exception`. Two never do: `notifications/cancelled` is applied by the SDK rather than surfaced, and a subscription acknowledgment for a live `listen()` stream is consumed by that stream. Annotate the parameter with `IncomingMessage` (`ServerNotification | Exception`, exported from `mcp.client`). The one pattern worth knowing is `if isinstance(message, Exception): raise message`, so a broken connection fails loudly instead of vanishing.
## Recap
diff --git a/docs/client/index.md b/docs/client/index.md
index ae47508359..1e1df3c01b 100644
--- a/docs/client/index.md
+++ b/docs/client/index.md
@@ -30,7 +30,7 @@ Everything else on this page is identical across all three. Headers, subprocesse
Four read-only properties, populated the moment you enter the block:
-* `client.server_info`: the server's identity. `server_info.name` here is `"Bookshop"`, `server_info.version` is whatever the server reports.
+* `client.server_info`: the server's identity, or `None` for a 2026-era server that does not report one (python-sdk servers do by default). `server_info.name` here is `"Bookshop"`, `server_info.version` is whatever the server reports.
* `client.server_capabilities`: what the server can do (`tools`, `resources`, `prompts`, `completions`, ...). A capability the server doesn't have is `None`.
* `client.protocol_version`: the protocol version the two sides agreed on. Here it is `"2026-07-28"`.
* `client.instructions`: the server's `instructions=` string, or `None` if it didn't set one.
@@ -135,7 +135,7 @@ A tool that raises does **not** raise in your client. It comes back as an ordina
The resource verbs come in pairs: two ways to list, one way to read.
-```python title="client.py" hl_lines="23-32"
+```python title="client.py" hl_lines="22-31"
--8<-- "docs_src/client/tutorial004.py"
```
@@ -174,7 +174,7 @@ A host hands those messages straight to the model. That is the whole feature.
A server with a completion handler can autocomplete prompt and resource-template arguments as the user types.
-```python title="client.py" hl_lines="28-32"
+```python title="client.py" hl_lines="27-31"
--8<-- "docs_src/client/tutorial006.py"
```
@@ -187,7 +187,7 @@ The answer is in `result.completion.values`. Type `"p"` and the server comes bac
Every `list_*` method takes a `cursor=` keyword and every result carries a `next_cursor`. When `next_cursor` is `None`, you have everything.
-```python title="client.py" hl_lines="23-31"
+```python title="client.py" hl_lines="22-30"
--8<-- "docs_src/client/tutorial007.py"
```
@@ -202,7 +202,7 @@ There is one constructor flag built for that: `Client(mcp, raise_exceptions=True
## Recap
* `Client(x)` connects in-memory to a server object, over Streamable HTTP to a URL string, and over anything else via a transport.
-* `async with` is the whole lifecycle. Inside it, `server_info`, `server_capabilities`, `protocol_version` and `instructions` are already populated.
+* `async with` is the whole lifecycle. Inside it, `server_capabilities` and `protocol_version` are already populated; `server_info` and `instructions` are too when the server provides them.
* `list_tools()` gives you each tool's `name`, `title`, `description` and `input_schema`.
* `call_tool()` returns `content` for the model, `structured_content` for your code, and `is_error`. A raising tool is a result, not an exception.
* `content` is a union of block types; narrow with `isinstance` before reading.
diff --git a/docs/client/oauth-clients.md b/docs/client/oauth-clients.md
index e446eeb997..cd7de35626 100644
--- a/docs/client/oauth-clients.md
+++ b/docs/client/oauth-clients.md
@@ -83,7 +83,7 @@ The first time `Client` sends a request, the server answers `401`. The provider
After that it is quiet. Tokens come out of storage, an expired access token is refreshed with the refresh token, and only when none of that works does it run the flow again.
-You wrote none of it. Three keyword arguments remain (`timeout`, `client_metadata_url` and `validate_resource_url`), and this file needs none of them. `client_metadata_url` is the one worth knowing about; it gets its own section below.
+You wrote none of it. Two keyword arguments remain (`client_metadata_url` and `validate_resource_url`), and this file needs neither. `client_metadata_url` is the one worth knowing about; it gets its own section below.
### Try it
@@ -112,7 +112,7 @@ A nightly job, a CI step, another service. There is no browser and nobody to cli
What changed:
* No `OAuthClientMetadata`, no handlers. You pass `client_id` and `client_secret`; the provider builds a minimal `client_credentials` registration around them and skips dynamic registration entirely.
-* `scopes` is a space-separated string, the OAuth wire format.
+* `scope` is a space-separated string, the OAuth wire format.
* Everything downstream is identical: the same `TokenStorage`, the same `httpx2.AsyncClient(auth=...)`, the same `streamable_http_client`.
By default the secret travels as HTTP Basic auth on the token request (`client_secret_basic`). Pass `token_endpoint_auth_method="client_secret_post"` to put it in the form body instead. Some authorization servers only accept one of the two.
@@ -131,7 +131,7 @@ There is one more no-human situation: the client belongs to an enterprise whose
## When it fails
-When the OAuth flow goes wrong, the provider raises an `OAuthFlowError` from `mcp.client.auth`. It has two subclasses. `OAuthRegistrationError` means the authorization server refused to register you. `OAuthTokenError` means the token endpoint said no. One `except OAuthFlowError:` covers discovery, registration, authorization, and exchange.
+When the OAuth flow goes wrong, the provider raises an `OAuthFlowError` from `mcp.client.auth`. It has two subclasses. `OAuthRegistrationError` means registration did not yield a client you can use: the authorization server refused to register you, or it did register you but with credentials this flow cannot use (for instance an authentication method it does not implement). `OAuthTokenError` means a token could not be obtained: the token endpoint said no, or a stored client record carries an authentication method this client cannot apply, which is reported while building the token request rather than sent. One `except OAuthFlowError:` covers discovery, registration, authorization, and exchange.
Not everything is a flow error. The network can still fail; those are ordinary `httpx2` exceptions and pass through untouched.
diff --git a/docs/client/session-groups.md b/docs/client/session-groups.md
index c7a1434fb1..70ac02e859 100644
--- a/docs/client/session-groups.md
+++ b/docs/client/session-groups.md
@@ -42,7 +42,7 @@ Create a `ClientSessionGroup` and call **`connect_to_server`** once per server:
You fix this at the group, not at the servers. Pass a function of `(name, server_info)` and the group runs it on every name it registers:
-```python title="client.py" hl_lines="8-9 16"
+```python title="client.py" hl_lines="7-8 15"
--8<-- "docs_src/session_groups/tutorial004.py"
```
@@ -64,7 +64,7 @@ Run it again. `print(sorted(group.tools))` now shows both:
`connect_to_server` returns the `ClientSession` it opened. Keep it if you ever want that server gone: `await group.disconnect_from_server(session)` removes its tools, resources, and prompts from the group.
-If you already hold a connected `ClientSession` (`Client.session` is one), hand it to `await group.connect_with_session(server_info, session)` instead of opening a new transport. It aggregates the same way. The group never closes a session it didn't open.
+If you already hold a connected `ClientSession` (`Client.session` is one), hand it to `await group.connect_with_session(server_info, session)` instead of opening a new transport. It aggregates the same way. The group never closes a session it didn't open. `server_info` names the server for component prefixes; on a 2026-era connection `client.server_info` can be `None` (identity is optional), so pass your own `Implementation(name=..., version=...)` in that case.
## The classic handshake
diff --git a/docs/client/subscriptions.md b/docs/client/subscriptions.md
index fc7d01308d..bf5b0a36d8 100644
--- a/docs/client/subscriptions.md
+++ b/docs/client/subscriptions.md
@@ -8,7 +8,7 @@ This page is the client end: opening the stream, watching it beside your main fl
A subscription is one context manager. Entering it sends the request, with your keyword arguments as the subscription filter, and waits for the server's acknowledgment, so the stream is live by the time the block starts.
-```python title="client.py" hl_lines="16 19 29"
+```python title="client.py" hl_lines="15 18 28"
--8<-- "docs_src/subscriptions/tutorial003.py"
```
@@ -20,7 +20,7 @@ Duplicate events waiting to be consumed collapse into one, and refetching still
Two more properties of the handle:
-* `sub.honored` is the filter the server acknowledged: a `SubscriptionFilter` with the fields you passed, read as attributes (`sub.honored.prompts_list_changed`). `MCPServer` honors every kind you ask for, so it echoes your request back. A server that narrows the filter (see the [filter warning](../handlers/subscriptions.md#only-what-was-asked-for) on the server page) acknowledges less, and an honored kind may still never fire.
+* `sub.honored` is the filter the server acknowledged: a `SubscriptionFilter` with the fields you passed, read as attributes (`sub.honored.prompts_list_changed`). `MCPServer` honors every kind you ask for, so it echoes your request back. A server that supports fewer kinds acknowledges less, and an honored kind may still never fire. A server may also refuse the whole request rather than acknowledge it (see [Deciding who may watch](../handlers/subscriptions.md#deciding-who-may-watch) on the server page), which surfaces as the request's error.
* `sub.subscription_id` is the listen request's id, the one stamped on every frame of this stream. Several subscriptions can be open at once, each demultiplexed by its own id.
## Watching without blocking
diff --git a/docs/get-started/installation.md b/docs/get-started/installation.md
index 5113eba269..728a86cfd0 100644
--- a/docs/get-started/installation.md
+++ b/docs/get-started/installation.md
@@ -2,39 +2,32 @@
The Python SDK is on PyPI as [`mcp`](https://pypi.org/project/mcp/). It requires **Python 3.10+**.
-These docs describe **v2**, which is in beta, so the version pin is not optional yet:
+These docs describe **v2**, the current stable release line:
=== "uv"
```bash
- uv add "mcp[cli]==2.0.0b1"
+ uv add "mcp[cli]"
```
=== "pip"
```bash
- pip install "mcp[cli]==2.0.0b1"
+ pip install "mcp[cli]"
```
-!!! warning "Why the pin"
- Installers never select a pre-release unless you name one, so an unpinned `uv add "mcp[cli]"`
- gives you the latest **v1.x** release, which these docs do not describe. Check the
- [release history](https://pypi.org/project/mcp/#history) for the newest beta before you copy
- the line above.
-
- The same applies to one-off commands: `uv run --with "mcp==2.0.0b1" ...`, not `uv run --with mcp ...`.
-
- If your *package* depends on `mcp`, add a `<2` upper bound (for example `mcp>=1.27,<2`) before
- the stable v2 lands so the major version bump doesn't surprise you.
+!!! note "Coming from v1?"
+ v2 is a major version with breaking changes; the **[Migration Guide](../migration.md)**
+ covers every one. If your *package* depends on `mcp` and isn't ready to migrate, keep a
+ `<2` upper bound (for example `mcp>=1.28,<2`) so an unpinned resolve stays on the 1.x line.
## What gets installed
You don't need to know any of this to use the SDK, but if you're wondering what each dependency is for:
-* `mcp-types`: every protocol type (requests, results, content blocks) as its own package, versioned in lockstep with the SDK. Every `from mcp_types import ...` in these docs is this package.
+* `mcp-types`: every protocol type (requests, results, content blocks) as its own package, versioned in lockstep with the SDK. Code that depends on `mcp` imports it through the `mcp.types` alias (every `from mcp.types import ...` in these docs); import `mcp_types` directly only in a project that installs `mcp-types` without the SDK.
* [`anyio`](https://anyio.readthedocs.io/): the async runtime. The whole SDK is written against anyio, so it runs on either `asyncio` or `trio`.
-* [`pydantic`](https://docs.pydantic.dev/): what every `mcp_types` model is built on, plus all schema generation and validation.
-* [`pydantic-settings`](https://docs.pydantic.dev/latest/concepts/pydantic_settings/): server configuration via `MCP_*` environment variables and `.env` files.
+* [`pydantic`](https://docs.pydantic.dev/): what every `mcp.types` model is built on, plus all schema generation and validation.
* [`httpx2`](https://pypi.org/project/httpx2/): the HTTP client behind the Streamable HTTP and SSE *client* transports, with server-sent events support built in.
* [`starlette`](https://www.starlette.io/), [`uvicorn`](https://www.uvicorn.org/), [`sse-starlette`](https://pypi.org/project/sse-starlette/), and [`python-multipart`](https://pypi.org/project/python-multipart/): the HTTP *server* transports.
* [`jsonschema`](https://pypi.org/project/jsonschema/): validates a tool's structured output against its declared output schema.
diff --git a/docs/get-started/real-host.md b/docs/get-started/real-host.md
index 159c1f56f7..d31fb5caea 100644
--- a/docs/get-started/real-host.md
+++ b/docs/get-started/real-host.md
@@ -23,17 +23,12 @@ That is the last line of Python on this page. From here down it is all host conf
Every host below gets the same command:
```bash
-uv run --with "mcp[cli]==2.0.0b1" mcp run /absolute/path/to/server.py
+uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py
```
-One command for all of them because `uv run --with` resolves the pinned SDK into a fresh environment on the spot: it works from any directory, needs no project and no virtual environment to activate, and always gets the exact `mcp` version these docs describe. That matters here more than anywhere else, because a host launches your server from *its* working directory with a near-empty environment, not from your shell.
+One command for all of them because `uv run --with` resolves the SDK into a fresh environment on the spot: it works from any directory and needs no project and no virtual environment to activate. That matters here more than anywhere else, because a host launches your server from *its* working directory with a near-empty environment, not from your shell.
-It is also the command `mcp install` writes into Claude Desktop's config for you (below), so what you type by hand and what the tool generates agree.
-
-!!! warning "The version pin is not optional"
- v2 of this SDK is in beta, and installers never select a pre-release unless you name one. An
- unpinned `--with "mcp[cli]"` gives you the latest **v1.x**, which these docs do not describe.
- Use the exact pin from **[Installation](installation.md)**.
+It is also the command `mcp install` writes into Claude Desktop's config for you (below), so what you type by hand and what the tool generates agree, apart from the exact version pin the tool adds.
!!! tip "If a host can't find `uv`"
A host spawns your server with a minimal `PATH`, and `uv` may not be on it. Replace the bare
@@ -74,7 +69,7 @@ There is nothing to be mystified by. This is the entry it writes:
"run",
"--frozen",
"--with",
- "mcp[cli]==2.0.0b1",
+ "mcp[cli]==2.0.0",
"mcp",
"run",
"/absolute/path/to/server.py"
@@ -84,12 +79,12 @@ There is nothing to be mystified by. This is the entry it writes:
}
```
-That's the launch command from the section above with two additions: the absolute path to `uv`, and `--frozen` so `uv` never rewrites a lockfile it happens to be near. It lands in `claude_desktop_config.json`, which lives at:
+That's the launch command from the section above with three additions: the absolute path to `uv`, `--frozen` so `uv` never rewrites a lockfile it happens to be near, and an exact pin to the `mcp` version you have installed. It lands in `claude_desktop_config.json`, which lives at:
* **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
* **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
-You can write that file by hand. `mcp install` exists so you don't make the two classic mistakes (a relative path, a missing version pin) while doing it.
+You can write that file by hand. `mcp install` exists so you don't make the classic mistake (a relative path) while doing it.
Fully quit Claude Desktop (not just its window) and reopen it.
@@ -107,7 +102,7 @@ Fully quit Claude Desktop (not just its window) and reopen it.
There is no file to edit. Register the server with the `claude` CLI; everything after `--` is the launch command.
```bash
-claude mcp add bookshop -- uv run --with "mcp[cli]==2.0.0b1" mcp run /absolute/path/to/server.py
+claude mcp add bookshop -- uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py
```
Run `/mcp` inside a Claude Code session to confirm `bookshop` is connected and its tools are listed.
@@ -121,7 +116,7 @@ Create `.cursor/mcp.json` in your project root.
"mcpServers": {
"bookshop": {
"command": "uv",
- "args": ["run", "--with", "mcp[cli]==2.0.0b1", "mcp", "run", "/absolute/path/to/server.py"]
+ "args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"]
}
}
}
@@ -139,7 +134,7 @@ Create `.vscode/mcp.json` in your project root.
"bookshop": {
"type": "stdio",
"command": "uv",
- "args": ["run", "--with", "mcp[cli]==2.0.0b1", "mcp", "run", "/absolute/path/to/server.py"]
+ "args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"]
}
}
}
@@ -156,7 +151,7 @@ Two differences from Cursor's file, and they are the only two: the wrapper key i
Before you touch any host config, run the launch command yourself:
```bash
-uv run --with "mcp[cli]==2.0.0b1" mcp run /absolute/path/to/server.py
+uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py
```
Nothing prints, and it doesn't return. That silence is correct: a stdio server is waiting for a host to speak first on stdin (`Ctrl-C` to stop it). A traceback or an immediate exit is the real bug, and now you can read it instead of guessing at it through a host.
@@ -165,7 +160,7 @@ Once that command sits and waits, what's left is almost always one of three thin
* **A relative path.** The host launches your server from *its* working directory, not the one you registered from. `server.py` where `/absolute/path/to/server.py` is needed is the single most common failure. If the host can't find `uv` either, that path has to be absolute too.
* **The host is still running its old config.** Hosts read their config at launch. Claude Desktop in particular has to be *fully quit* (not just its window closed) and reopened before an edit to `claude_desktop_config.json` takes effect.
-* **Something reached stdout.** On stdio, stdout *is* the protocol. One stray `print()` and the host reads a corrupt message and drops the connection. Log with the `logging` module, which writes to stderr. **[Logging](../handlers/logging.md)** has the whole story.
+* **Something reached stdout outside the diverted window.** On stdio, stdout *is* the protocol. The SDK diverts flushed stray output to stderr while serving, but output flushed to stdout before then (a wrapper script echoing, an import-time `print()` in an unbuffered process), or a buffered `print()` drained at interpreter exit, hands the host a corrupt message and it drops the connection. Log with the default `logging` configuration, whose stderr handler flushes each record; custom handlers must also avoid stdout. **[Logging](../handlers/logging.md)** has the whole story.
Claude Desktop keeps a log per server: `mcp-server-.log` is your server's stderr, next to `mcp.log` for connections, under `~/Library/Logs/Claude` on macOS and `%APPDATA%\Claude\logs` on Windows.
@@ -174,8 +169,8 @@ For anything past those three, **[Troubleshooting](../troubleshooting.md)** is t
## Recap
* A **host** (Claude Desktop, an IDE) runs an MCP client that launches your server as a child process over stdio. Connecting means giving it one launch command.
-* That command is `uv run --with "mcp[cli]==2.0.0b1" mcp run /absolute/path/to/server.py`: version-pinned, no venv to activate, works from any directory. The pin is mandatory while v2 is in beta.
-* **Claude Desktop** is the one host `mcp install` configures for you. It writes that same command (plus the absolute path to `uv`) into `claude_desktop_config.json`, so you never have to.
+* That command is `uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py`: no venv to activate, works from any directory.
+* **Claude Desktop** is the one host `mcp install` configures for you. It writes that same command (plus the absolute path to `uv`, `--frozen`, and an exact pin to the version you have installed) into `claude_desktop_config.json`, so you never have to.
* **Claude Code** is `claude mcp add bookshop -- `. **Cursor** is `.cursor/mcp.json` under `mcpServers`. **VS Code** is `.vscode/mcp.json` under `servers`, each entry with a `type`.
* Absolute paths everywhere, restart the host after editing its config, and never let anything but the SDK write to stdout.
diff --git a/docs/get-started/testing.md b/docs/get-started/testing.md
index 82a113cfb1..9abd281ceb 100644
--- a/docs/get-started/testing.md
+++ b/docs/get-started/testing.md
@@ -40,7 +40,7 @@ Now the test:
import pytest
from inline_snapshot import snapshot
from mcp import Client
-from mcp_types import CallToolResult, TextContent
+from mcp.types import CallToolResult, TextContent
from server import mcp
@@ -59,6 +59,8 @@ async def client(): # (2)!
@pytest.mark.anyio
async def test_call_add_tool(client: Client):
result = await client.call_tool("add", {"a": 1, "b": 2})
+ # Drop the server identity stamp in `_meta`; it is not what this test is about.
+ result.meta = None
assert result == snapshot(
CallToolResult(
content=[TextContent(type="text", text="3")],
diff --git a/docs/handlers/dependencies.md b/docs/handlers/dependencies.md
index 509b2635f0..b347e1b7d6 100644
--- a/docs/handlers/dependencies.md
+++ b/docs/handlers/dependencies.md
@@ -138,7 +138,7 @@ That's the right default for a precondition: no answer, no order. When declining
Elicitation is one of the three questions a resolver can ask, and the multi-round-trip flow allows no others. The other two go to the **client** rather than the user: return `Sample(...)` to run an LLM call through the client (a `sampling/createMessage` request), or `ListRoots()` to fetch the client's current roots. Neither has an accept/decline outcome; the consumer annotates the result type directly, `CreateMessageResult` (`CreateMessageResultWithTools` when the request carries `tools` or `tool_choice`) or `ListRootsResult`:
-```python title="server.py" hl_lines="11-16 22"
+```python title="server.py" hl_lines="10-15 21"
--8<-- "docs_src/dependencies/tutorial004.py"
```
diff --git a/docs/handlers/elicitation.md b/docs/handlers/elicitation.md
index 3f3f5a6c07..c9a0a4fabc 100644
--- a/docs/handlers/elicitation.md
+++ b/docs/handlers/elicitation.md
@@ -128,7 +128,7 @@ Look at the second tool. When your server learns the out-of-band flow finished (
Servers ask. Clients answer by passing an **`elicitation_callback`** to `Client(...)`:
-```python title="client.py" hl_lines="7-8 19"
+```python title="client.py" hl_lines="6-7 18"
--8<-- "docs_src/elicitation/tutorial003.py"
```
diff --git a/docs/handlers/logging.md b/docs/handlers/logging.md
index 945aa60d5e..6f6c839314 100644
--- a/docs/handlers/logging.md
+++ b/docs/handlers/logging.md
@@ -33,8 +33,11 @@ For a **stdio** server this question matters more than usual. The host launched
The standard library already does the right thing: log output goes to `sys.stderr` by default. Your `logger.info(...)` lines land in the terminal (or wherever the host collects the subprocess's stderr), and the protocol stream stays clean.
!!! tip
- Never `print()` in a stdio server. `print` writes to **stdout**, and stdout *is* the wire: one stray
- line and the client is trying to parse it as JSON-RPC.
+ Don't `print()` in a stdio server. `print` writes to **stdout**, and stdout belongs to the protocol.
+ While serving, the SDK diverts stdout that is actually *flushed* to stderr, so it can't corrupt the
+ wire, but a `print()` in a block-buffered process usually sits unflushed in `sys.stdout`'s buffer
+ until the interpreter drains it at exit, straight onto the protocol stream. Even when it is diverted,
+ the line lands raw among the log output, with no level, no logger name, and no way to filter it.
`logger.debug("got here")` is the same one line of effort and goes to the right place.
@@ -72,7 +75,7 @@ went to standard error: the terminal, not the wire.
* The MCP protocol's logging capability is deprecated by the 2026-07-28 spec and not replaced. Don't build on it.
* `logger = logging.getLogger(__name__)` at module level, `logger.info(...)` in the tool. That's the whole pattern.
* Log output never reaches the model. Only the value you `return` does.
-* Standard error is yours; stdout belongs to the protocol. Never `print()` in a stdio server.
+* Standard error is yours; stdout belongs to the protocol. The SDK diverts flushed stray stdout to stderr while serving, but an unflushed `print()` can still drain onto the wire at exit, and diverted lines arrive unlabeled; use `logging`, whose handler flushes every record.
* `MCPServer(..., log_level="DEBUG")` sets the level, and a logging configuration you made first is left alone.
Telling connected clients that something on your server changed (the tool list, a resource) is **[Subscriptions](subscriptions.md)**.
diff --git a/docs/handlers/multi-round-trip.md b/docs/handlers/multi-round-trip.md
index e08903444b..1d5b9f52c6 100644
--- a/docs/handlers/multi-round-trip.md
+++ b/docs/handlers/multi-round-trip.md
@@ -21,7 +21,7 @@ That's the whole protocol. Every leg is an ordinary request from the client to t
On `@mcp.tool()` you rarely build this by hand: declare a dependency that asks the user (`Elicit`), samples the client's LLM (`Sample`), or lists its roots (`ListRoots`) and the SDK returns the `InputRequiredResult` for you; that form is the **[Dependencies](dependencies.md)** page. The two forms don't mix: a call has one `input_responses`/`request_state` channel, so a tool that uses `Resolve(...)` parameters cannot also return `InputRequiredResult` from its body. A declared `InputRequiredResult` return is rejected at registration (`InvalidSignature`), and an undeclared one fails the call at runtime. The manual form is the **low-level** `Server`, whose `on_call_tool` handler is allowed to return either result type:
-```python title="server.py" hl_lines="44-47"
+```python title="server.py" hl_lines="43-46"
--8<-- "docs_src/mrtr/tutorial001.py"
```
@@ -35,7 +35,7 @@ Everything else in that file (the explicit `input_schema`, the hand-built `CallT
`tools/call` is not special: at 2026-07-28 a server may answer `prompts/get` and `resources/read` the same way. On `MCPServer`, an `@mcp.prompt()` function — or an `@mcp.resource()` **template** function — returns the `InputRequiredResult` itself and reads the retry's answers off the context:
-```python title="server.py" hl_lines="21 23 25"
+```python title="server.py" hl_lines="20 22 24"
--8<-- "docs_src/mrtr/tutorial004.py"
```
@@ -51,7 +51,7 @@ Everything else in that file (the explicit `input_schema`, the hand-built `CallT
Register the callbacks the server might ask for (`elicitation_callback`, `sampling_callback`, `list_roots_callback`) and call the tool. When an `InputRequiredResult` arrives, `Client` dispatches each entry in `input_requests` to the matching callback, retries with the answers and the echoed `request_state`, and keeps going until a `CallToolResult` comes back:
-```python title="client.py" hl_lines="12 13"
+```python title="client.py" hl_lines="11 12"
--8<-- "docs_src/mrtr/tutorial003.py"
```
@@ -76,7 +76,7 @@ The auto-loop is enough for a single-process client. Own the loop instead when:
Drop to the underlying session, where `allow_input_required=True` hands you the union directly:
-```python title="client.py" hl_lines="13 14 20"
+```python title="client.py" hl_lines="12 13 19"
--8<-- "docs_src/mrtr/tutorial002.py"
```
diff --git a/docs/handlers/sampling-and-roots.md b/docs/handlers/sampling-and-roots.md
index 6174f42585..f7c192f05b 100644
--- a/docs/handlers/sampling-and-roots.md
+++ b/docs/handlers/sampling-and-roots.md
@@ -11,7 +11,7 @@ Both still work, on every protocol version the SDK speaks. But read the warning
A resolver returns `Sample(...)` and the tool receives the completion, through the same dependency mechanism that runs `Elicit` in **[Dependencies](dependencies.md)**:
-```python title="server.py" hl_lines="11-16 20"
+```python title="server.py" hl_lines="10-15 19"
--8<-- "docs_src/sampling_and_roots/tutorial001.py"
```
@@ -24,7 +24,7 @@ A resolver returns `Sample(...)` and the tool receives the completion, through t
Roots are the folders the client says the server may operate on. They are informational guidance, not an access-control mechanism. A resolver returns `ListRoots()`:
-```python title="server.py" hl_lines="11-12 16"
+```python title="server.py" hl_lines="10-11 15"
--8<-- "docs_src/sampling_and_roots/tutorial002.py"
```
diff --git a/docs/handlers/subscriptions.md b/docs/handlers/subscriptions.md
index 85b9632786..4fbf7e9ffb 100644
--- a/docs/handlers/subscriptions.md
+++ b/docs/handlers/subscriptions.md
@@ -43,27 +43,28 @@ Two things the stream is *not*:
* **It is not a replay log.** A dropped stream is gone, and events published while nobody was connected are not queued. Clients re-listen and refetch.
* **It is not the 2025 path.** Clients that called `resources/subscribe` are served by `ctx.session.send_resource_updated(uri)`. The `notify_*` methods reach `subscriptions/listen` streams only.
-!!! warning
- Don't publish sensitive per-user URIs through `notify_resource_updated` on a multi-tenant
- server. Any client may name any URI in its filter, and `MCPServer` honors it. The exposure
- is narrow but real: a subscriber learns that a URI it can guess changed, and when. It never
- learns content, and it cannot probe what exists, because an unknown URI is honored too and
- simply never fires. To narrow the filter per client today, serve the method with your own
- handler on the low-level `Server` and acknowledge a smaller filter than the client asked
- for; the acknowledgment is how the client learns what it actually got.
-
-!!! warning "Streamable HTTP only, for now"
- `subscriptions/listen` needs a transport that can stream a request's response, which today
- means streamable HTTP. Over stdio a 2026-07-28 connection rejects the method with
- METHOD_NOT_FOUND, even though `server/discover` advertises the subscription capabilities
- there. Serving it over stdio is planned; the open-stream semantics for that transport are
- not built yet.
+## Deciding who may watch
+
+By default every requested kind and URI is honored: any caller may watch any URI you publish. Nothing consults your read handler, because nobody is reading — a caller your `files://{name}` handler would turn away can still open a stream on `files://payroll.csv` and learn that it changed, and when. It never learns content, and it cannot probe what exists, because an unknown URI is honored too and simply never fires. Narrow but real, so gate it before you publish per-user URIs from a multi-tenant server.
+
+The gate is a middleware. It sees the `subscriptions/listen` request before the SDK acknowledges it and refuses when the caller asks for anything they may not read:
+
+```python title="server.py" hl_lines="19-26 29"
+--8<-- "docs_src/subscriptions/tutorial006.py"
+```
+
+* `ctx.params` is the raw request, so the middleware validates it into `SubscriptionsListenRequestParams` itself and reads the filter the client asked for.
+* Refusal is a raised `MCPError` before `call_next(ctx)`: the client gets that error and no stream, and the connection carries on. Keep the message uniform, naming no URI, so a refusal never confirms which URIs are protected.
+* One `can_access(user, uri)` answers both questions. The resource handler asks it on `resources/read`; the middleware asks it on `subscriptions/listen`. Swap the table for a database or your RBAC system and both stay in step.
+* The decision holds for the stream's lifetime. There is no per-event re-check, so if a caller's access can lapse mid-stream (an expiring token), end that caller's connection when it does.
+
+The full middleware contract, including what else it wraps and why it is marked provisional, is on **[Middleware](../advanced/middleware.md)**.
## The client end
Here is a client on the other side of that stream, following the board:
-```python title="client.py" hl_lines="16"
+```python title="client.py" hl_lines="15"
--8<-- "docs_src/subscriptions/tutorial003.py"
```
@@ -126,7 +127,7 @@ async def tools_reloaded() -> None:
Down on the low-level `Server` there is no pre-wired anything, and the same parts assemble in three lines:
-```python title="server.py" hl_lines="9-10 48"
+```python title="server.py" hl_lines="8-9 47"
--8<-- "docs_src/subscriptions/tutorial002.py"
```
diff --git a/docs/index.md b/docs/index.md
index 8aa1a5b671..3d10fc9bca 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,9 +1,9 @@
# MCP Python SDK
-!!! info "You are viewing the in-development v2 documentation"
- For the current stable release, see the [v1.x documentation](https://py.sdk.modelcontextprotocol.io/).
- New to v2, or coming from v1? **[What's new in v2](whats-new.md)** is the five-minute tour of what changed.
- Trying v2? [Tell us what you find](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml) — it is the most useful thing you can do for the SDK right now.
+!!! info "This documents v2, the current stable release line"
+ New to v2, or coming from v1? **[What's new in v2](whats-new.md)** is the five-minute tour of what changed, and the **[Migration Guide](migration.md)** covers every breaking change.
+ Still on v1.x? Its documentation lives at the [v1.x docs](https://py.sdk.modelcontextprotocol.io/v1/).
+ Something rough or confusing? [Tell us](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml).
The **Model Context Protocol (MCP)** lets applications provide context to LLMs in a standardized way, separating the concern of *providing* context from the LLM interaction itself.
@@ -22,22 +22,17 @@ Python 3.10+.
=== "uv"
```bash
- uv add "mcp[cli]==2.0.0b1"
+ uv add "mcp[cli]"
```
=== "pip"
```bash
- pip install "mcp[cli]==2.0.0b1"
+ pip install "mcp[cli]"
```
The `[cli]` extra gives you the `mcp` command; you'll want it for development.
-
-!!! warning "Pin the version while v2 is in beta"
- Installers never select a pre-release unless you name one, so an unpinned `uv add "mcp[cli]"`
- gives you the latest **v1.x** release, which this documentation does not describe. Check
- [PyPI](https://pypi.org/project/mcp/#history) for the newest beta before you copy the line
- above. See [Installation](get-started/installation.md) for the details.
+See [Installation](get-started/installation.md) for what each dependency is for.
## Example
@@ -98,5 +93,5 @@ You wrote two Python functions with type hints and a docstring. The SDK does the
* Migrating from v1? Start with the **[Migration Guide](migration.md)**.
* Hunting for an exact signature? The **[API Reference](api/mcp/index.md)** is generated from the source.
* Reading with an LLM? This documentation is also published in the [llms.txt](https://llmstxt.org/) format:
- [llms.txt](https://py.sdk.modelcontextprotocol.io/v2/llms.txt) is an index of the pages, and
- [llms-full.txt](https://py.sdk.modelcontextprotocol.io/v2/llms-full.txt) contains every page in a single file.
+ [llms.txt](https://py.sdk.modelcontextprotocol.io/llms.txt) is an index of the pages, and
+ [llms-full.txt](https://py.sdk.modelcontextprotocol.io/llms-full.txt) contains every page in a single file.
diff --git a/docs/migration.md b/docs/migration.md
index 876608db70..b094d79f84 100644
--- a/docs/migration.md
+++ b/docs/migration.md
@@ -4,9 +4,14 @@ This guide covers the breaking changes introduced in v2 of the MCP Python SDK an
Version 2 of the MCP Python SDK introduces several breaking changes to improve the API, align with the MCP specification, and provide better type safety.
+!!! note "Not ready to migrate yet?"
+ The v1.x maintenance line keeps receiving critical bug fixes and security patches, and its
+ documentation is at [/v1/](https://py.sdk.modelcontextprotocol.io/v1/). If your package depends
+ on `mcp`, keep a `<2` upper bound until you've migrated.
+
## Find your changes
-Every section heading below names the API it affects, so searching this page for the symbol your code uses is the fastest route to the change that broke it.
+Every section heading below names the API it affects, so searching this page for the symbol your code uses is the fastest route to the change that broke it. The guide lists changes only: an SDK API not mentioned here behaves as it did in v1, and the "what did not change" summaries — [`MCPServer`](#what-is-unchanged-on-mcpserver), [lowlevel `Server`](#lowlevel-server-what-did-not-change), and [auth](#unchanged-auth-surfaces) — spell out the surfaces most migrators stop to check.
### Changes almost every project hits
@@ -14,10 +19,12 @@ Every section heading below names the API it affects, so searching this page for
|---|---|---|
| `FastMCP` renamed to `MCPServer` | `ModuleNotFoundError: No module named 'mcp.server.fastmcp'` | [`FastMCP` renamed](#fastmcp-renamed-to-mcpserver) |
| Fields renamed from camelCase to snake_case | `AttributeError: 'Tool' object has no attribute 'inputSchema'` | [snake_case fields](#field-names-changed-from-camelcase-to-snake_case) |
-| `mcp.types` moved to the `mcp-types` package | `ModuleNotFoundError: No module named 'mcp.types'` | [`mcp.types` moved](#mcptypes-moved-to-the-mcp-types-package) |
+| `mcp.types` names removed | `ImportError: cannot import name 'Content' from 'mcp.types'` | [Removed types](#removed-type-aliases-and-classes) |
| `McpError` renamed to `MCPError` | `ImportError: cannot import name 'McpError' from 'mcp'` | [`McpError` renamed](#mcperror-renamed-to-mcperror) |
| Resource URIs are `str`, not `AnyUrl` | `AttributeError: 'str' object has no attribute 'host'` | [URI type](#resource-uri-type-changed-from-anyurl-to-str) |
+| Message unions (`ServerNotification`, `JSONRPCMessage`, ...) are plain unions, not `RootModel` | `AttributeError: 'LoggingMessageNotification' object has no attribute 'root'` | [`RootModel` → unions](#replace-rootmodel-by-union-types-with-typeadapter-validation) |
| `streamablehttp_client` removed | `ImportError: cannot import name 'streamablehttp_client'` | [`streamablehttp_client`](#streamablehttp_client-removed) |
+| `httpx` and `httpx-sse` replaced by `httpx2` | `ModuleNotFoundError: No module named 'httpx'`, or `TypeError: Invalid "auth" argument` from `httpx.AsyncClient(auth=provider)` | [`httpx2` swap](#httpx-and-httpx-sse-replaced-by-httpx2) |
| `Client` defaults to `mode='auto'` | servers log an unexpected `server/discover` request | [`mode='auto'`](#client-defaults-to-modeauto) |
| Transport parameters moved off the `MCPServer` constructor | `TypeError: MCPServer.__init__() got an unexpected keyword argument 'port'` | [constructor parameters](#transport-specific-parameters-moved-from-mcpserver-constructor-to-runapp-methods) |
| Sync handlers run on a worker thread | `asyncio.get_running_loop()` in a `def` handler raises `RuntimeError` | [worker threads](#sync-handler-functions-now-run-on-a-worker-thread) |
@@ -99,11 +106,13 @@ The SDK now depends on [`httpx2`](https://pypi.org/project/httpx2/) instead of
`httpx`) with server-sent events support built in, so the separate `httpx-sse`
dependency is gone.
-The swap itself does not change any SDK signatures - `streamable_http_client`
-and `sse_client` accept the same arguments as elsewhere in v2 - but the client
-type they expect is now `httpx2.AsyncClient`. If you construct your own client to pass as
-`http_client` (or build an `httpx2.Auth` subclass for `auth`), import from
-`httpx2`:
+The swap changes types, not parameter lists: `streamable_http_client` and `sse_client`
+keep their keyword arguments (covered, with the removed `streamablehttp_client` alias and the
+`get_session_id` callback, under [Transports](#transports)), and only the objects they take
+become `httpx2` types — the pre-built `http_client` you hand `streamable_http_client`,
+`sse_client`'s `auth=` (an `httpx2.Auth`, the base class `OAuthClientProvider` now uses), and
+the client a custom `httpx_client_factory` returns. Import from `httpx2` when building any of
+them:
**Before (v1):**
@@ -125,21 +134,45 @@ http_client = httpx2.AsyncClient(follow_redirects=True)
changes. To consume SSE directly, use `httpx2.EventSource` (or
`AsyncClient.sse()`) instead of the `httpx-sse` helpers.
+mcp no longer installs `httpx` at all. If your own code imports `httpx` and relied on mcp
+v1 to pull it in, that import now fails with
+`ModuleNotFoundError: No module named 'httpx'` — a traceback that never mentions mcp. Either
+add `httpx` to your own dependencies (the two packages install side by side; only objects
+handed to the SDK via `http_client=` or `auth=` have to be `httpx2` types) or port those
+calls to `httpx2`, whose `Client` and `AsyncClient` are drop-in replacements.
+
Exception handlers need the same rename: the SDK now raises `httpx2`
exceptions (`httpx2.ConnectError`, `httpx2.HTTPStatusError`, and so on), and
-this failure mode is silent. `httpx` usually stays installed as a transitive
-dependency of other packages, so an old `except httpx.ConnectError:` block
+this failure mode is silent. If `httpx` is still installed — your own code or another
+package depends on it — an old `except httpx.ConnectError:` block
keeps importing fine and simply never matches again. Audit `except httpx.`
-clauses and `isinstance` checks along with the imports. The same identity
-split applies to objects: `httpx` and `httpx2` types are not interchangeable
-at runtime, so an `httpx.AsyncClient` passed as `http_client` degrades in
-subtle ways (server-initiated messages stop arriving) instead of raising
-immediately.
+clauses and `isinstance` checks along with the imports, and switch test fixtures in the
+same change: `pytest.raises(httpx.ConnectError)`, an `httpx.MockTransport`, or a test-only
+`httpx.Auth` subclass all target the wrong types once the code under test moves to `httpx2`.
+The same identity split applies to objects: `httpx` and `httpx2` types are not
+interchangeable at runtime, so an `httpx.AsyncClient` passed as `http_client` degrades in
+subtle ways (server-initiated messages stop arriving) instead of raising immediately.
+
+Retry and error-classification logic keyed to HTTP status codes needs a look too: through
+the SDK's client, timeouts and non-2xx responses surface as `MCPError` with JSON-RPC codes,
+not `408`s or `httpx.HTTPStatusError` — see the client request timeouts section
+(`REQUEST_TIMEOUT`, `-32001`) under [Clients](#clients) and
+[Streamable HTTP: non-2xx responses now surface as per-request JSON-RPC errors](#streamable-http-non-2xx-responses-now-surface-as-per-request-json-rpc-errors).
+
+The SDK's own auth providers made the same move: `OAuthClientProvider`,
+`ClientCredentialsOAuthProvider`, `PrivateKeyJWTOAuthProvider`, and
+`IdentityAssertionOAuthProvider` now subclass `httpx2.Auth` (v1: `httpx.Auth`),
+so the client you attach one to must be an `httpx2.AsyncClient`. Unlike the
+silent `http_client` degradation, this direction fails loudly at
+construction: `httpx.AsyncClient(auth=provider)` raises
+`TypeError: Invalid "auth" argument`. See [OAuth clients](client/oauth-clients.md)
+for the `httpx2.AsyncClient(auth=...)` wiring.
The client also identifies itself differently: the default User-Agent is now
`python-httpx2/`, and log lines come from the `httpx2` and
`httpcore2.*` loggers, so a `logging.getLogger("httpx")` or
-`logging.getLogger("httpcore")` suppression no longer matches anything.
+`logging.getLogger("httpcore")` suppression no longer matches anything — target
+`logging.getLogger("httpx2")` and `logging.getLogger("httpcore2")` instead.
Telemetry integrations keyed to the `httpx` module (such as OpenTelemetry's
httpx instrumentation) stop seeing the SDK's traffic as well.
@@ -158,8 +191,8 @@ in `httpx2`; build an `ssl.SSLContext` and configure it instead.
Both commands run your server through a fresh `uv run --with ...` environment. In v1 the
`mcp` requirement in that command was unpinned, so the spawned environment resolved to the
-newest stable release rather than the version you had installed; with a v2 pre-release
-installed, `mcp dev server.py` built a v1 environment that could not import a v2 server.
+newest stable release rather than the version you had installed; while v2 was in
+pre-release, `mcp dev server.py` built a v1 environment that could not import a v2 server.
Both commands now pin the requirement to the version you are running
(`mcp==`). Source builds and other unpublished versions, which have
nothing on PyPI to pin to, keep the unpinned form.
@@ -168,16 +201,38 @@ nothing on PyPI to pin to, keep the unpinned form.
### `mcp.types` moved to the `mcp-types` package
-The protocol wire types now live in a standalone distribution, `mcp-types`, imported as
-`mcp_types`. Its only runtime dependencies are `pydantic` and `typing-extensions`, so code
-that just needs to (de)serialize MCP traffic can install it without the full SDK. The `mcp` package depends on `mcp-types` and
-continues to re-export the type names at the top level, so `from mcp import Tool` is
-unchanged. Only the `mcp.types` submodule and `mcp.shared.version` were removed. The
-package's API reference is at [`mcp_types`](api/mcp_types/index.md).
-
-**Why:** keeping the wire types in their own package lets tooling and lightweight clients
-depend on the protocol schema without pulling in `httpx2`, `starlette`, `uvicorn`, and the
-rest of the server/transport stack.
+The protocol wire types now live in a standalone distribution, `mcp-types` (import package
+`mcp_types`). Its only runtime dependencies are `pydantic` and `typing-extensions`, so code
+that just needs to (de)serialize MCP traffic can install it without the full SDK. Its API
+reference is at [`mcp_types`](api/mcp_types/index.md).
+
+**If your project depends on `mcp`, nothing changes for you.** `import mcp.types`,
+`from mcp.types import ...`, `from mcp import types`, and `import mcp` followed by
+`mcp.types.Tool` all keep working: `mcp.types` is a permanent alias that mirrors `mcp_types`
+exactly (every name is the same object), and `mcp.types.version` mirrors
+`mcp_types.version` the same way. Keep importing through `mcp` — the package you actually
+depend on — rather than writing `import mcp_types`, which would reach past your declared
+dependency into a transitive one. The old `mcp.shared.version` module was removed; import the
+version registry from `mcp.types.version` instead. The top-level `from mcp import Tool`
+re-exports are unchanged too.
+
+**Import `mcp_types` directly only in a project that depends on `mcp-types` without the
+SDK.** That is the point of the split: tooling and lightweight clients can depend on the
+protocol schema without pulling in `httpx2`, `starlette`, `uvicorn`, and the rest of the
+server/transport stack.
+
+Names that no longer exist (listed under
+[Removed type aliases and classes](#removed-type-aliases-and-classes)) fail on import or
+attribute access with an ordinary `ImportError` / `AttributeError`; the table below names each
+replacement.
+
+The supported import surface is the package plus its `jsonrpc`, `methods`, and `version`
+submodules, and each has both spellings: `mcp.types` / `mcp_types`, `mcp.types.jsonrpc` /
+`mcp_types.jsonrpc`, `mcp.types.methods` / `mcp_types.methods`, and `mcp.types.version` /
+`mcp_types.version` (each `mcp.types` module mirrors its `mcp_types` counterpart, name for
+name, the same objects). Underscore-prefixed submodules (`mcp_types._types`, and the generated
+per-protocol-version packages `mcp_types._v2025_11_25` / `mcp_types._v2026_07_28`) are internal
+validators with unstable class names; don't import from them, under either spelling.
**Before (v1):**
@@ -186,19 +241,23 @@ from mcp.types import Tool, Resource
from mcp.shared.version import LATEST_PROTOCOL_VERSION
```
-**After (v2):**
+**After (v2), depending on `mcp`:**
+
+```python
+from mcp.types import Tool, Resource # unchanged
+from mcp.types.version import LATEST_PROTOCOL_VERSION
+```
+
+**After (v2), depending only on `mcp-types` (no SDK):**
```python
from mcp_types import Tool, Resource
from mcp_types.version import LATEST_PROTOCOL_VERSION
-
-# Names `mcp` already re-exported at the top level are unchanged:
-from mcp import Tool, Resource
```
### Removed type aliases and classes
-The following type aliases and classes have been removed from `mcp_types`:
+The following type aliases and classes have been removed from the protocol types (`mcp.types` / `mcp_types`):
| Removed | Replacement |
|---------|-------------|
@@ -221,13 +280,13 @@ from mcp.types import Content, ResourceReference, Cursor
**After (v2):**
```python
-from mcp_types import ContentBlock, ResourceTemplateReference
+from mcp.types import ContentBlock, ResourceTemplateReference
# Use `str` instead of `Cursor` for pagination cursors
```
### Field names changed from camelCase to snake_case
-All Pydantic model fields in `mcp_types` now use snake_case names for Python attribute access. The JSON wire format is unchanged — traffic the SDK sends still uses camelCase via Pydantic aliases, but your own `model_dump()` calls now need `by_alias=True` to produce it.
+All Pydantic model fields in the protocol types now use snake_case names for Python attribute access. The JSON wire format is unchanged — traffic the SDK sends still uses camelCase via Pydantic aliases, but your own `model_dump()` calls now need `by_alias=True` to produce it.
**Before (v1):**
@@ -287,7 +346,7 @@ In v1, MCP protocol types were configured with `extra="allow"`: unknown fields p
In v2, MCP types silently ignore extra fields. Unknown constructor keyword arguments and unknown keys in wire data are dropped during validation — no error is raised, and the values do not round-trip:
```python
-from mcp_types import CallToolRequestParams
+from mcp.types import CallToolRequestParams
params = CallToolRequestParams(
name="my_tool",
@@ -323,7 +382,7 @@ resource = Resource(name="test", uri=AnyUrl("users/me")) # Would fail validatio
**After (v2):**
```python
-from mcp_types import Resource
+from mcp.types import Resource
# Plain strings accepted
resource = Resource(name="test", uri="users/me") # Works
@@ -393,7 +452,7 @@ actual_notification = notification.root
**After (v2):**
```python
-from mcp_types import client_request_adapter, server_notification_adapter
+from mcp.types import client_request_adapter, server_notification_adapter
# Using TypeAdapter.validate_python()
request = client_request_adapter.validate_python(data)
@@ -417,6 +476,11 @@ await session.send_request(ClientRequest(PingRequest()), EmptyResult)
```python
await session.send_notification(InitializedNotification())
await session.send_request(PingRequest(), EmptyResult)
+
+# Params are constructed as before; only the outer wrapper is gone
+await session.send_notification(
+ CancelledNotification(params=CancelledNotificationParams(request_id=request_id, reason="timeout"))
+)
```
**Available adapters:**
@@ -431,7 +495,24 @@ await session.send_request(PingRequest(), EmptyResult)
| `ServerResult` | `server_result_adapter` |
| `JSONRPCMessage` | `jsonrpc_message_adapter` |
-All adapters are exported from `mcp_types`.
+All adapters are exported from `mcp.types`.
+
+These are ordinary `X | Y` unions of the concrete pydantic classes, so `isinstance(msg, ServerNotification)`, `isinstance(msg, LoggingMessageNotification)`, and `match`/`case` on the member classes keep working (unlike `ElicitationResult`, which became a `TypeAliasType` — see [`isinstance()` checks against `ElicitationResult` raise `TypeError`](#isinstance-checks-against-elicitationresult-raise-typeerror)).
+
+Values the SDK hands you are the member instances themselves, so delete `.root` accesses. A `message_handler`, for example, now receives the notification directly (v1 code fails with `AttributeError: 'LoggingMessageNotification' object has no attribute 'root'`):
+
+```python
+# Before (v1)
+if isinstance(message, ServerNotification):
+ if isinstance(message.root, LoggingMessageNotification):
+ print(message.root.params.data)
+
+# After (v2)
+if isinstance(message, LoggingMessageNotification):
+ print(message.params.data)
+```
+
+Custom transports and `EventStore` implementations follow the same rule: `mcp.shared.message.SessionMessage` takes the member directly (`SessionMessage(JSONRPCNotification(...))`, not `SessionMessage(JSONRPCMessage(JSONRPCNotification(...)))`), and raw JSON parses with `jsonrpc_message_adapter.validate_json(raw)` instead of `JSONRPCMessage.model_validate_json(raw)`.
### `RequestParams.Meta` replaced with `RequestParamsMeta` TypedDict
@@ -470,7 +551,7 @@ attribute access. The JSON wire format is unchanged.
### `SUPPORTED_PROTOCOL_VERSIONS` deprecated; `LATEST_PROTOCOL_VERSION` changed meaning
-`SUPPORTED_PROTOCOL_VERSIONS` is deprecated — it's now the union of `HANDSHAKE_PROTOCOL_VERSIONS` (initialize-handshake versions) and `MODERN_PROTOCOL_VERSIONS` (per-request-envelope versions). If you were using it to mean "versions the initialize handshake accepts", switch to `HANDSHAKE_PROTOCOL_VERSIONS`. Named scalars derived from these tuples are now exported alongside them — `LATEST_HANDSHAKE_VERSION`, `LATEST_MODERN_VERSION`, `OLDEST_SUPPORTED_VERSION` — so prefer those over indexing the tuples directly. All of these live in `mcp_types.version` (previously `mcp.shared.version`): `from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS`.
+`SUPPORTED_PROTOCOL_VERSIONS` is deprecated — it's now the union of `HANDSHAKE_PROTOCOL_VERSIONS` (initialize-handshake versions) and `MODERN_PROTOCOL_VERSIONS` (per-request-envelope versions). If you were using it to mean "versions the initialize handshake accepts", switch to `HANDSHAKE_PROTOCOL_VERSIONS`. Named scalars derived from these tuples are now exported alongside them — `LATEST_HANDSHAKE_VERSION`, `LATEST_MODERN_VERSION`, `OLDEST_SUPPORTED_VERSION` — so prefer those over indexing the tuples directly. All of these live in `mcp.types.version` (an alias of `mcp_types.version`; previously `mcp.shared.version`): `from mcp.types.version import HANDSHAKE_PROTOCOL_VERSIONS`.
`LATEST_PROTOCOL_VERSION` also changed value and meaning. In v1 it was `"2025-11-25"`, the version the client offered during initialization. In v2 it is the newest revision the SDK speaks in any era, currently `"2026-07-28"`, which the initialize handshake cannot negotiate. If you offered it in a hand-built `initialize` request or compared the negotiated version against it, use `LATEST_HANDSHAKE_VERSION` instead. These tuples really are tuples now (`SUPPORTED_PROTOCOL_VERSIONS` was a `list` in v1), so list-only operations such as concatenating with a list raise `TypeError`.
@@ -521,18 +602,49 @@ raise McpError(ErrorData(code=INVALID_REQUEST, message="bad input"))
```python
from mcp.shared.exceptions import MCPError
-from mcp_types import INVALID_REQUEST
+from mcp.types import INVALID_REQUEST
raise MCPError(INVALID_REQUEST, "bad input")
# or, if you already have an ErrorData:
raise MCPError.from_error_data(error_data)
```
+### `JSONRPCError.id` is now `RequestId | None`
+
+In v1 `JSONRPCError.id` was typed `str | int`, so an error response with `"id": null` failed validation even though JSON-RPC 2.0 allows it (the id is null when the receiver could not determine the request id, e.g. a parse error). In v2 the field is `RequestId | None`: still required, but `None` is accepted, and `jsonrpc_message_adapter` parses a null-id error into a `JSONRPCError`.
+
+**Before (v1):**
+
+```python
+from mcp.types import JSONRPCMessage
+
+# Raised ValidationError: id could not be None
+JSONRPCMessage.model_validate(
+ {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "Parse error"}}
+)
+```
+
+**After (v2):**
+
+```python
+from mcp.types import PARSE_ERROR, ErrorData, JSONRPCError, jsonrpc_message_adapter
+
+message = jsonrpc_message_adapter.validate_python(
+ {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "Parse error"}}
+)
+assert isinstance(message, JSONRPCError) and message.id is None
+
+# Constructing one: `id` is required but nullable
+JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=PARSE_ERROR, message="Parse error"))
+```
+
+Delete any shim that accepted or synthesized null-id error responses. Code that assumed `error.id` was always a `str | int` must now handle `None`, and tests that pinned v1's rejection of `"id": null` now fail because validation succeeds.
+
## MCPServer (formerly FastMCP)
### `FastMCP` renamed to `MCPServer`
-The `FastMCP` class has been renamed to `MCPServer` to better reflect its role as the main server class in the SDK. This is a simple rename with no functional changes to the class itself.
+The `FastMCP` class has been renamed to `MCPServer` to better reflect its role as the main server class in the SDK. Beyond the name and import path, the changes to the class are covered in the sections that follow, and [What is unchanged on `MCPServer`](#what-is-unchanged-on-mcpserver) lists the everyday surface that carries over as-is.
**Before (v1):**
@@ -555,10 +667,23 @@ mcp = MCPServer("Demo")
All submodules under `mcp.server.fastmcp.*` are now under `mcp.server.mcpserver.*` with the same structure. Common imports:
- `Image`, `Audio` — from `mcp.server.mcpserver` (or `.utilities.types`)
-- `UserMessage`, `AssistantMessage` — from `mcp.server.mcpserver.prompts.base`
+- `Icon` — from `mcp.server.mcpserver` or `mcp.types` (not a top-level `mcp` export); its `mimeType` field is now `mime_type` per the [snake_case renames](#field-names-changed-from-camelcase-to-snake_case), though the `mimeType=` kwarg still constructs
+- `Message`, `UserMessage`, `AssistantMessage` — from `mcp.server.mcpserver.prompts.base`
- `ToolError`, `ResourceError` — from `mcp.server.mcpserver.exceptions`
- `MCPServerError` (renamed from `FastMCPError`) — from `mcp.server.mcpserver.exceptions`
+### What is unchanged on `MCPServer`
+
+Beyond the changes covered in this section, the everyday `FastMCP` surface carries over to `MCPServer` as-is:
+
+- **Decorators.** `@mcp.tool()`, `@mcp.resource()`, `@mcp.prompt()`, and `@mcp.completion()` take the same arguments and handler signatures as v1. The lowlevel [`on_completion` reshape](#lowlevel-server-decorator-based-handlers-replaced-with-constructor-on_-params) applies only to the lowlevel `Server`; a high-level `@mcp.completion()` handler is still called as `(ref, argument, context)`.
+- **Tool return handling.** A returned `CallToolResult` (including an `Annotated[CallToolResult, YourModel]` output schema, and `_meta`) is passed through, `Image` and `Audio` convert to content blocks as before, ready-made content blocks are kept as-is, and dict, list, scalar, and model returns are wrapped into `content` and `structured_content` by the same rules.
+- **Listing and registration methods.** `list_tools()`, `list_resources()`, `list_resource_templates()`, and `list_prompts()` return the same lists and are still what the protocol handlers call, so subclass overrides still take effect. `add_tool()`, `add_resource()`, and `add_prompt()` are unchanged.
+- **Helpers.** `Image.to_image_content()`, `Audio.to_audio_content()`, and the prompt `Message`, `UserMessage`, and `AssistantMessage` classes.
+- **Lifespan.** The `lifespan=` constructor argument and `ctx.request_context.lifespan_context` work as before, and the class is still generic over the lifespan result: `FastMCP[MyState]` becomes `MCPServer[MyState]`. (`Context`'s own type parameters did change; see [`RequestContext` type parameters simplified](#requestcontext-type-parameters-simplified).)
+- **Tool internals.** `Tool`, `Tool.from_function()`, `FuncMetadata`, `ArgModelBase`, and `func_metadata()` keep their v1 shapes; the one change is the now-required `context` argument to `Tool.run()`, described [below](#mcpservercall_tool-read_resource-get_prompt-now-accept-a-context-parameter).
+- **Auxiliary import paths.** `TransportSecuritySettings` (`mcp.server.transport_security`) and `AcceptedElicitation`/`DeclinedElicitation`/`CancelledElicitation` (`mcp.server.elicitation`) have not moved; the server auth surface is inventoried under [Unchanged auth surfaces](#unchanged-auth-surfaces).
+
### Default server name changed from `FastMCP` to `mcp-server`
A server constructed without a name now defaults to `mcp-server` instead of `FastMCP`. This is the name reported to clients as `serverInfo.name` in the initialize result, so it is visible in client UIs, logs, and monitoring. Nothing raises when this changes; the migrated server simply reports a different identity.
@@ -606,6 +731,14 @@ mcp = MCPServer("Demo", instructions="You answer questions about the weather.")
Keep `name` positional and pass everything else by keyword.
+### Unversioned servers report an empty version
+
+In v1, a server constructed without a `version` reported the installed `mcp`
+package's version as its own in the `initialize` result's `serverInfo`. In v2
+it reports an empty string instead: the SDK's version is not your server's
+version. Pass `version="..."` to `Server(...)` or `MCPServer(...)` to identify
+your server properly. The field is display-only; nothing breaks either way.
+
### `mount_path` parameter removed from MCPServer
The `mount_path` parameter has been removed from `MCPServer.__init__()`, `MCPServer.run()`, `MCPServer.run_sse_async()`, and `MCPServer.sse_app()`. It was also removed from the `Settings` class.
@@ -614,17 +747,19 @@ This parameter was redundant because the SSE transport already handles sub-path
### Transport-specific parameters moved from MCPServer constructor to run()/app methods
-Transport-specific parameters have been moved from the `MCPServer` constructor to the `run()`, `sse_app()`, and `streamable_http_app()` methods. This provides better separation of concerns - the constructor now only handles server identity and authentication, while transport configuration is passed when starting the server.
+Transport-specific parameters have been moved off the `MCPServer` constructor and onto `run()`, `sse_app()`, and `streamable_http_app()`, so transport configuration is passed when starting or building the server. The rest of the constructor is unchanged: identity (`name`, `instructions`, `website_url`, `icons`, plus the newly added positional `title`, `description`, and `version` covered [above](#mcpserver-constructor-title-description-and-version-added-to-the-positional-parameters)), authentication (`auth`, `token_verifier`, `auth_server_provider`), `lifespan`, `dependencies`, `tools`, `debug`, `log_level`, and the `warn_on_duplicate_*` flags; the new keyword-only parameters (`resources`, `extensions`, `resource_security`, `request_state_security`, `cache_hints`, `subscriptions`, `middleware`) are additive.
**Parameters moved:**
-- `host`, `port` - HTTP server binding
-- `sse_path`, `message_path` - SSE transport paths
-- `streamable_http_path` - StreamableHTTP endpoint path
-- `json_response`, `stateless_http` - StreamableHTTP behavior
-- `max_request_body_size` - StreamableHTTP request-body limit
-- `event_store`, `retry_interval` - StreamableHTTP event handling
-- `transport_security` - DNS rebinding protection
+- `host`, `port` - HTTP server binding, on `run()` only. The app factories have no `port` (`streamable_http_app(port=...)` raises `TypeError`; a mounted app binds wherever the outer ASGI server does) but do take `host` (default `"127.0.0.1"`), used only to decide whether DNS rebinding protection auto-enables (see the note below)
+- `sse_path`, `message_path` - SSE transport paths, on `run(transport="sse", ...)` and `sse_app()`
+- `streamable_http_path` - StreamableHTTP endpoint path, on `run(transport="streamable-http", ...)` and `streamable_http_app()`
+- `json_response`, `stateless_http` - StreamableHTTP behavior, same two places; each also removes a server-to-client channel, see [Server-initiated sampling, elicitation, and roots raise `NoBackChannelError`](#server-initiated-sampling-elicitation-and-roots-raise-nobackchannelerror)
+- `max_request_body_size` - StreamableHTTP request-body limit, same two places
+- `event_store`, `retry_interval` - StreamableHTTP event handling, same two places
+- `transport_security` - DNS rebinding protection, on `run()` for both HTTP transports and on both app methods
+
+`run()` is `@overload`ed per transport, so type checkers validate the keywords each transport accepts (`transport="stdio"` takes none); at runtime the HTTP transports raise `TypeError` on an unrecognised keyword when they start.
**Before (v1):**
@@ -647,7 +782,7 @@ from mcp.server.mcpserver import MCPServer
# Transport params passed to run()
mcp = MCPServer("Demo")
-mcp.run(transport="streamable-http", json_response=True, stateless_http=True)
+mcp.run(transport="streamable-http", host="0.0.0.0", port=9000, json_response=True, stateless_http=True)
# Or for SSE
mcp = MCPServer("Server")
@@ -656,25 +791,59 @@ mcp.run(transport="sse", host="0.0.0.0", port=9000, sse_path="/events")
**For mounted apps:**
-When mounting in a Starlette app, pass transport params to the app methods:
+When mounting in a Starlette app, pass transport params to `streamable_http_app()`. As in v1, the host app's lifespan must enter `mcp.session_manager.run()` — a mounted sub-app's own lifespan never runs, so nothing else starts the session manager:
```python
+import contextlib
+
# Before (v1)
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("App", json_response=True)
-app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())])
# After (v2)
from mcp.server.mcpserver import MCPServer
mcp = MCPServer("App")
-app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app(json_response=True))])
+
+
+# Unchanged from v1: the host app's lifespan runs the session manager
+@contextlib.asynccontextmanager
+async def lifespan(app: Starlette):
+ async with mcp.session_manager.run():
+ yield
+
+
+# Before (v1)
+app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lifespan)
+
+# After (v2)
+app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app(json_response=True))], lifespan=lifespan)
```
-**Note:** DNS rebinding protection is automatically enabled when `host` is `127.0.0.1`, `localhost`, or `::1`. This now happens in `sse_app()` and `streamable_http_app()` instead of the constructor.
+Without `lifespan=lifespan` the app starts but every request to the mounted path fails with `RuntimeError: Task group is not initialized. Make sure to use run().` — `mcp.session_manager` is the same public property v1's `FastMCP.session_manager` was, and it still exists only after `streamable_http_app()` has been called, so build the routes at module level and touch the manager only inside the lifespan. See [Add to an existing app](run/asgi.md) for the full pattern, including several servers in one app.
+
+v2 has no settings object that carries transport configuration, so if the module that configures the server is not the one that builds the ASGI app, carry the keywords yourself, e.g. `build_app = functools.partial(mcp.streamable_http_app, json_response=True, stateless_http=True)` next to the server definition and `Mount("/", app=build_app())` wherever it is mounted.
+
+**Note:** DNS rebinding protection is automatically enabled when `host` is `127.0.0.1`, `localhost`, or `::1` and no `transport_security` is passed. This now happens in `sse_app()` and `streamable_http_app()` instead of the constructor, and because those default to `host="127.0.0.1"`, a mounted app has protection on until you configure it. The auto-allowlist entries are `host:port` patterns (`127.0.0.1:*`, `localhost:*`, `[::1]:*`), so a request whose `Host` header carries no port (some in-process test clients send a bare `Host: localhost`) is rejected with `421 Invalid Host header`. To serve a real hostname, pass `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` (from `mcp.server.transport_security`); [Deploy & scale](run/deploy.md) and [Troubleshooting](troubleshooting.md) cover the allowlist and the `421` in detail.
+
+`Settings` (what `mcp.settings` holds) now has only the constructor-owned fields: `debug`, `log_level`, the `warn_on_duplicate_*` flags, `dependencies`, `lifespan`, and `auth`. If you were mutating transport values via `mcp.settings` after construction (e.g. `mcp.settings.port = 9000`), pass them to `run()` / `sse_app()` / `streamable_http_app()` instead: assigning a removed field now raises `ValueError: "Settings" object has no field "port"`. `settings.lifespan` is read once, at construction, so reassigning it afterwards has no effect. Once `streamable_http_app()` has been called, the values it was built with live on the runtime objects (e.g. `mcp.session_manager.stateless`, `mcp.session_manager.json_response`).
-If you were mutating these via `mcp.settings` after construction (e.g., `mcp.settings.port = 9000`), pass them to `run()` / `sse_app()` / `streamable_http_app()` instead — these fields no longer exist on `Settings`. The `debug` and `log_level` parameters remain on the constructor.
+### `MCP_*` environment variables and `.env` files are no longer read
+
+The `Settings` docstring advertised configuration via `MCP_*` environment variables and a `.env` file (e.g. `MCP_DEBUG=true`), but constructor arguments have always taken precedence, so those environment variables never took effect. `Settings` is now a plain Pydantic model rather than a `pydantic-settings` `BaseSettings`, and `pydantic-settings` is no longer a dependency of the SDK.
+
+If you want environment-driven configuration, read the environment yourself and pass the values to the constructor:
+
+```python
+import os
+
+from mcp.server.mcpserver import MCPServer
+
+mcp = MCPServer("Demo", debug=os.environ.get("MCP_DEBUG") == "true")
+```
+
+If your own code uses `pydantic-settings`, add it to your project's dependencies directly.
### Streamable HTTP request bodies are limited to 4 MiB
@@ -697,6 +866,17 @@ When serving streamable HTTP (stateful or `stateless_http=True`), the server's `
Lifespans that set up process-wide state (connection pools, caches, background tasks) are unaffected — they now run once instead of per session/request. If your lifespan was acquiring per-connection resources, move that acquisition into the handler body; per-connection cleanup belongs on the connection's `exit_stack` (a public way to reach it from high-level `@mcp.tool()` handlers is planned).
+### Streamable HTTP: session manager, `EventStore`, and stateless mode unchanged
+
+Beyond the constructor parameters that moved to `run()`/`streamable_http_app()` and the lifespan change above, the server-side Streamable HTTP machinery is as in v1:
+
+- `mcp.server.streamable_http` still exports the `EventStore` ABC (`store_event()`, `replay_events_after()`), `EventMessage`, `EventCallback`, `EventId`, and `StreamId` with unchanged signatures; a custom `EventStore` keeps importing `JSONRPCMessage` from `mcp.types`, unchanged.
+- `StreamableHTTPSessionManager` keeps its constructor and its `run()` / `handle_request()` methods (see [Lowlevel `Server`: what did not change](#lowlevel-server-what-did-not-change)); its `stateless=` parameter is unrelated to the removed [`Server.run(stateless=)` flag](#serverrun-no-longer-takes-a-stateless-flag).
+- `mcp.session_manager` still returns the manager once `streamable_http_app()` has been called, with the same `stateless`, `json_response`, `event_store`, and `retry_interval` attributes.
+- `stateless_http=True` still serves each request with a fresh transport, no `Mcp-Session-Id`, and no state carried between requests; `ctx.close_sse_stream()` and `ctx.close_standalone_sse_stream()` are still available on the handler `Context`.
+
+Only private attributes moved: `mcp._mcp_server` is now `mcp._lowlevel_server` (see [Registering lowlevel handlers from `MCPServer`](#registering-lowlevel-handlers-from-mcpserver)), and `_session_manager` now lives on that lowlevel `Server`. Prefer the public `mcp.session_manager` property to either.
+
### `MCPServer.get_context()` removed
`MCPServer.get_context()` has been removed. Context is now injected by the framework and passed explicitly — there is no ambient ContextVar to read from.
@@ -709,7 +889,7 @@ Lifespans that set up process-wide state (connection pools, caches, background t
@mcp.tool()
async def my_tool(x: int) -> str:
ctx = mcp.get_context()
- await ctx.info("Processing...")
+ await ctx.report_progress(1, 2)
return str(x)
```
@@ -720,7 +900,7 @@ from mcp.server.mcpserver import Context
@mcp.tool()
async def my_tool(x: int, ctx: Context) -> str:
- await ctx.info("Processing...")
+ await ctx.report_progress(1, 2)
return str(x)
```
@@ -788,8 +968,11 @@ enforce the spec's egress rule: an undeclared capability (form-mode `elicitation
or `tool_choice`) fails the call with a `-32021`
`MISSING_REQUIRED_CLIENT_CAPABILITY` JSON-RPC error instead of sending a
request the client cannot handle. This applies on 2025-11-25 sessions with a
-live back-channel too; a session with no back-channel keeps failing with its
-no-back-channel error. To migrate, declare the capability: the SDK client
+live back-channel too; a pre-`2026-07-28` session with no back-channel
+(stateless HTTP, or streamable HTTP with `json_response=True`) keeps failing
+with its no-back-channel error. At `2026-07-28` a resolver never uses a
+back-channel — it answers with an `InputRequiredResult` — so the `-32021`
+check applies there unconditionally. To migrate, declare the capability: the SDK client
declares `elicitation`, `sampling`, and `roots` when the matching callback is
set, and `sampling.tools` needs an explicit
`Client(sampling_capabilities=SamplingCapability(tools=...))`. Direct
@@ -812,12 +995,62 @@ elicitation required, invalid parameters). For tool *execution* failures the
calling LLM should see and react to, raise any other exception or return
`CallToolResult(is_error=True, ...)` directly; that path is unchanged.
+The client sees this change too. `Client.call_tool()` and
+`ClientSession.call_tool()` raise on a JSON-RPC error response, so a tool that
+rejects with `MCPError` now raises `MCPError` on the calling side (`code`,
+`message`, and `data` intact) instead of returning a `CallToolResult` with
+`isError=True` and the message in `content`:
+
+```python
+# Before (v1)
+result = await session.call_tool("book_flight", {"date": "yesterday"})
+if result.isError:
+ ... # error text is in result.content
+
+# After (v2)
+try:
+ result = await client.call_tool("book_flight", {"date": "yesterday"})
+except MCPError as e:
+ ... # e.code, e.message, e.data
+```
+
### Resource not found returns `-32602` and resource lookups raise typed exceptions (SEP-2164)
Reading a missing resource now returns JSON-RPC error code `-32602` (invalid params) with the requested URI in `error.data` (`{"uri": ...}`), per [SEP-2164](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2164). Previously the server returned code `0` with no `data`. Clients can now reliably distinguish not-found from other errors; a template handler that raises `ResourceNotFoundError` (from `mcp.server.mcpserver.exceptions`) produces this same response.
The underlying lookups now raise typed exceptions instead of `ValueError`. `ResourceManager.get_resource()` raises `ResourceNotFoundError` when no resource or template matches the URI, and `ResourceTemplate.create_resource()` raises `ResourceError` when the template function fails. Neither subclasses `ValueError`, so callers catching `ValueError` should switch to `ResourceNotFoundError` / `ResourceError` (both importable from `mcp.server.mcpserver.exceptions`; `ResourceNotFoundError` subclasses `ResourceError`).
+### `Resource` classes reject unknown keyword arguments
+
+The `Resource` base class now sets `extra="forbid"`, so every resource class — `TextResource`, `BinaryResource`, `FunctionResource`, `FileResource`, `HttpResource`, `DirectoryResource`, and your own subclasses — raises `ValidationError` on an unrecognised keyword argument instead of silently dropping it. Previously a typo'd or since-removed parameter (such as `FileResource(is_binary=...)`, below) was accepted and ignored. Remove any stray keyword arguments; if a subclass needs to accept arbitrary extras, set its own `model_config = ConfigDict(extra="allow")`.
+
+### `FileResource.is_binary` replaced by `encoding`
+
+`FileResource` used to take `is_binary: bool` and guess its default from `mime_type` (`text/*` → text, anything else → bytes). Two problems fell out of that: `is_binary=False` could not actually be set — `False` doubled as the "not given" sentinel, so `mime_type="application/json"` always came back as a base64 blob — and text reads used `Path.read_text()` with no encoding, i.e. the platform locale (cp1252 on Windows).
+
+The field is now `encoding: str | None`. A string means "decode with this encoding and serve as text"; `None` means "read bytes and serve as a blob". When omitted it defaults to the `charset` declared in `mime_type` if there is one, otherwise `"utf-8-sig"` for textual mime types (`text/*`, `application/json`, `application/xml`, and any `+json`/`+xml` suffix) and `None` for everything else, so JSON and XML files are now served as text without any configuration. `utf-8-sig` is plain UTF-8 that also drops a byte-order mark if the file has one; a declared `charset=` is used as-is.
+
+Passing the removed `is_binary=` argument now raises a `ValidationError` at construction (see the section above) rather than being silently ignored. A misspelled `encoding` also fails at construction rather than on the first read.
+
+Two edge cases to check. A non-UTF-8 file with a *newly* textual mime type (say a UTF-16 `application/xml`) previously shipped byte-exact as a blob and now fails to decode. And an existing `text/*` file that was only readable through your platform's locale encoding (v1 decoded these with the locale, not UTF-8) now fails too. In both cases set `encoding` to the file's real encoding, or `encoding=None` to serve the bytes as a blob.
+
+**Before (v1):**
+
+```python
+FileResource(uri="file:///logo.png", path=logo, mime_type="image/png", is_binary=True)
+FileResource(uri="file:///notes.txt", path=notes) # text, decoded with the locale encoding
+```
+
+**After (v2):**
+
+```python
+FileResource(uri="file:///logo.png", path=logo, mime_type="image/png") # bytes, from mime_type
+FileResource(uri="file:///notes.txt", path=notes) # text, decoded as UTF-8 (BOM tolerated)
+FileResource(uri="file:///data.json", path=data, mime_type="application/json") # now text, not a blob
+```
+
+Pass `encoding=None` to force a blob, or `encoding="latin-1"` (etc.) to decode a text file that isn't UTF-8.
+
### Resource templates: matching behavior changes
Resource template matching has been rewritten with [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) support.
@@ -880,10 +1113,25 @@ not be omitted, and needs no default.)
**Static URIs with Context-only handlers now error.** A non-template
URI paired with a handler that takes only a `Context` parameter
previously registered but was silently unreachable (the resource
-could never be read). This now raises `ValueError` at decoration time.
-Context injection for static resources is not supported — use a
-template with at least one variable or access context through other
-means.
+could never be read). This now raises `ValueError` at decoration time
+— resource `Context` injection is only wired up for templates. What to
+do instead depends on why the handler wanted the context. For lifespan
+or application state (what you would read from
+`ctx.request_context.lifespan_context`), a static handler is an
+ordinary function, so read that state from a module-level object (or
+closure) that your `lifespan` populates. For anything on the request
+itself (logging, progress, the session), keep the `Context` parameter
+and add a template variable so the handler registers as a template; an
+optional query variable is enough, and a plain `notes://recent` read
+still matches with the default filled in:
+
+```python
+@mcp.resource("notes://recent{?limit}")
+async def recent_notes(ctx: Context, limit: int = 10) -> str: ...
+```
+
+Such a resource is advertised by `resources/templates/list` rather than
+`resources/list`.
See [URI templates](servers/uri-templates.md) for the full template syntax,
security configuration, and filesystem safety utilities.
@@ -908,17 +1156,46 @@ await ctx.log(level="info", data="hello")
Positional calls (`await ctx.info("hello")`) are unaffected.
+These helpers are themselves deprecated by [SEP-2577](#roots-sampling-and-logging-methods-deprecated-sep-2577) and emit `mcp.MCPDeprecationWarning` on every call, so treat the rename as a keep-it-working fix rather than a migration target: nothing in-protocol replaces pushing log messages to the client, so log with the standard `logging` module instead (see [Logging](handlers/logging.md)) and use `ctx.report_progress()` for progress the client should see.
+
+### `Context.client_id` removed
+
+`Context.client_id` has been removed. It never returned an authenticated client identity: it echoed a non-standard `client_id` key from the request's `_meta`, which nothing in the SDK or the MCP spec populates, so it was `None` unless a caller injected `meta={"client_id": ...}` by hand. The name also collided with the OAuth `client_id`, which is what callers usually mean by "the client".
+
+If you were reading a custom `_meta` key, read it from the meta dict directly. If you want the authenticated OAuth client, use the access token:
+
+```python
+# Before (v1)
+client_id = ctx.client_id
+
+# After (v2) — the raw _meta key, if you were setting it yourself
+meta = ctx.request_context.meta
+client_id = meta.get("client_id") if meta else None
+
+# After (v2) — the authenticated OAuth client (usually what you want)
+from mcp.server.auth.middleware.auth_context import get_access_token
+
+token = get_access_token()
+client_id = token.client_id if token else None
+```
+
### `ProgressContext` and `progress()` context manager removed
The `mcp.shared.progress` module (`ProgressContext`, `Progress`, and the `progress()` context manager) has been removed. This module had no real-world adoption — all users send progress notifications via `Context.report_progress()` or `session.send_progress_notification()` directly.
+The replacement is `Context.report_progress(progress, total=None, message=None)` in an `MCPServer` handler, or `ctx.session.report_progress(progress, total, message)` from a lowlevel `Server` handler. Two differences from `ProgressContext.progress(amount, message)`:
+
+- **`report_progress` takes the absolute current value, not a delta.** `ProgressContext.progress(amount)` accumulated into a running total, so calling `p.progress(10)` twice reported `20`. Passing the same deltas to `report_progress` reports `10` twice — progress that jitters instead of increasing, with no error. Keep the running total yourself.
+- `progress()` raised `ValueError` when the request carried no progress token; `report_progress` is a no-op when the caller did not request progress. The optional `message=` argument is unchanged.
+
**Before (v1):**
```python
from mcp.shared.progress import progress
with progress(ctx, total=100) as p:
- await p.progress(25)
+ await p.progress(25, message="step 1") # running total: 25
+ await p.progress(25) # running total: 50
```
**After — use `Context.report_progress()` (recommended):**
@@ -926,20 +1203,19 @@ with progress(ctx, total=100) as p:
```python
@mcp.tool()
async def my_tool(x: int, ctx: Context) -> str:
- await ctx.report_progress(25, 100)
+ await ctx.report_progress(25, 100, message="step 1")
+ await ctx.report_progress(50, 100) # absolute value, not a delta
return "done"
```
-**After — use `session.send_progress_notification()` (low-level):**
+**After — lowlevel `Server`:**
```python
-await session.send_progress_notification(
- progress_token=progress_token,
- progress=25,
- total=100,
-)
+await ctx.session.report_progress(50, 100, message="halfway")
```
+`ctx.session.report_progress()` also works on the in-process `Client(server)` path (see [Testing utilities](#testing-utilities)); `ctx.session.send_progress_notification(progress_token, progress, total, message)` remains for code that reads `ctx.meta["progress_token"]` itself, and takes the same absolute-value `progress`.
+
### `Context.elicit()` schema gate validates the rendered schema
`Context.elicit()` (and `elicit_with_validation()`) now render the schema first and validate each property against the spec's `PrimitiveSchemaDefinition`, raising `TypeError` at the call site for anything outside it. `Optional[T]` fields render as `{"type": ...}` with the field omitted from `required` (previously the non-spec `anyOf` shape). A bare `list[str]` field is rejected because it renders without the required enum items; use `list[Literal[...]]` or `list[str]` with `json_schema_extra` supplying the items. Unions of multiple primitives (e.g. `int | str`) and nested models are rejected.
@@ -958,7 +1234,7 @@ if isinstance(result, AcceptedElicitation):
... # result.data is a Confirm
```
-Narrowing on `result.action` (`"accept"` / `"decline"` / `"cancel"`) is unaffected.
+Narrowing on `result.action` (`"accept"` / `"decline"` / `"cancel"`) is unaffected. The `TypeError` is specific to `TypeAliasType` aliases like `ElicitationResult`; the `mcp.types` message unions (`ClientRequest`, `ServerNotification`, `JSONRPCMessage`, ...) are ordinary unions and stay `isinstance`-compatible (see [Replace `RootModel` by union types with `TypeAdapter` validation](#replace-rootmodel-by-union-types-with-typeadapter-validation)).
### Registering lowlevel handlers from `MCPServer`
@@ -980,7 +1256,7 @@ In v2, the lowlevel `Server` supports arbitrary request handlers directly via `a
```python
from mcp.server import ServerRequestContext
-from mcp_types import EmptyResult, SetLevelRequestParams, SubscribeRequestParams
+from mcp.types import EmptyResult, SetLevelRequestParams, SubscribeRequestParams
async def handle_set_logging_level(ctx: ServerRequestContext, params: SetLevelRequestParams) -> EmptyResult:
@@ -1001,6 +1277,29 @@ mcp._lowlevel_server.add_request_handler("resources/subscribe", SubscribeRequest
## Lowlevel Server
+### Lowlevel `Server`: what did not change
+
+Handler registration, signatures, and return values changed (the sections below); the serving scaffolding around them keeps its v1 import paths and call shapes:
+
+- `server.run(read_stream, write_stream, initialization_options)`, including `raise_exceptions=` (narrowed, see [transport errors no longer re-raised](#lowlevel-serverrunraise_exceptionstrue-transport-errors-no-longer-re-raised)). Only the `stateless=` flag is gone (see [`Server.run()` no longer takes a `stateless` flag](#serverrun-no-longer-takes-a-stateless-flag)).
+- `server.create_initialization_options(notification_options=..., experimental_capabilities=...)`, `server.get_capabilities(...)` (its arguments are now optional), and `NotificationOptions(prompts_changed=, resources_changed=, tools_changed=)`. Both methods gained an optional `extensions=` argument. `create_initialization_options()` is still how you build the `InitializationOptions` passed to `run()`; the only value that differs is `server_version` (see [Unversioned servers report an empty version](#unversioned-servers-report-an-empty-version)).
+- `InitializationOptions` (`from mcp.server import InitializationOptions`, also `mcp.server.models`) gained optional `title`/`description` fields; `NotificationOptions` is importable from `mcp.server` and `mcp.server.lowlevel` as before.
+- `lifespan=` keeps its contract — an async-context-manager factory that receives the `Server` and whose yielded value handlers read as `ctx.lifespan_context` — but is now keyword-only (see [constructor parameters are now keyword-only](#lowlevel-server-constructor-parameters-are-now-keyword-only)) and, under streamable HTTP, entered once at manager startup (see [Streamable HTTP: lifespan now entered once at manager startup](#streamable-http-lifespan-now-entered-once-at-manager-startup)).
+- Server-side transports keep their v1 signatures: `mcp.server.stdio.stdio_server()`, `mcp.server.sse.SseServerTransport(endpoint)` (`connect_sse` / `handle_post_message`), and `mcp.server.streamable_http_manager.StreamableHTTPSessionManager`; the one stdio behavior change is [`stdio_server` keeps the protocol streams on private descriptors](#stdio_server-keeps-the-protocol-streams-on-private-descriptors).
+- Import paths: `from mcp.server import Server` (preferred), `from mcp.server.lowlevel import Server`, and `from mcp.server.lowlevel.server import Server` all resolve; only the `request_ctx` contextvar left `mcp.server.lowlevel.server` (see [`request_context` property removed](#lowlevel-server-request_context-property-removed)). `mcp.server.lowlevel.helper_types.ReadResourceContents` still exists (it is `MCPServer.read_resource()`'s return type), but lowlevel `on_read_resource` handlers return `ReadResourceResult` (see [automatic return value wrapping removed](#lowlevel-server-automatic-return-value-wrapping-removed)).
+
+So a v1 `main()` carries over untouched:
+
+```python
+async def main() -> None:
+ async with stdio_server() as (read_stream, write_stream):
+ await server.run(
+ read_stream,
+ write_stream,
+ server.create_initialization_options(notification_options=NotificationOptions(tools_changed=True)),
+ )
+```
+
### Lowlevel `Server`: decorator-based handlers replaced with constructor `on_*` params
The lowlevel `Server` class no longer uses decorator methods for handler registration. Instead, handlers are passed as `on_*` keyword arguments to the constructor.
@@ -1026,7 +1325,7 @@ async def handle_call_tool(name: str, arguments: dict):
```python
from mcp.server import Server, ServerRequestContext
-from mcp_types import (
+from mcp.types import (
CallToolRequestParams,
CallToolResult,
ListToolsResult,
@@ -1075,13 +1374,13 @@ All handlers receive `ctx: ServerRequestContext` as the first argument. The seco
| `@server.progress_notification()` | `on_progress` | `ProgressNotificationParams` | `None` |
| — | `on_roots_list_changed` | `NotificationParams \| None` | `None` |
-All `params` and return types are importable from `mcp_types`.
+All `params` and return types are importable from `mcp.types`.
**Notification handlers:**
```python
from mcp.server import Server, ServerRequestContext
-from mcp_types import ProgressNotificationParams
+from mcp.types import ProgressNotificationParams
async def handle_progress(ctx: ServerRequestContext, params: ProgressNotificationParams) -> None:
@@ -1204,7 +1503,7 @@ async def call_tool(name: str, arguments: dict):
```python
from mcp.server import Server
-from mcp_types import CallToolResult, TextContent
+from mcp.types import CallToolResult, TextContent
async def handle_call_tool(ctx, params) -> CallToolResult:
@@ -1306,17 +1605,27 @@ server.middleware.append(logging_middleware)
The method and the raw inbound params are `ctx.method` and `ctx.params` (`params` is `None` when the message carries none). Middleware runs before params validation and also wraps unknown methods. To rewrite the method or params before the handler runs, pass an adjusted context through: `await call_next(replace(ctx, params=...))`.
+**Note:** `Server.middleware` and the `ServerMiddleware` / `CallNext` / `HandlerResult` types in `mcp.server.context` are marked provisional in the source — their signature and semantics may change — so use middleware to observe (log, time, trace) rather than as a foundation. See [Middleware](advanced/middleware.md).
+
### Lowlevel `Server.run(raise_exceptions=True)`: transport errors no longer re-raised
`raise_exceptions=True` now only governs handler exceptions: an exception raised by an `on_*` handler propagates out of `run()`. The JSON-RPC error response is still written to the client first, regardless of the flag.
Previously it also re-raised exceptions yielded by the transport onto the read stream (e.g. JSON parse errors). Those are now debug-logged and dropped regardless of `raise_exceptions`. If you relied on `run()` exiting on a transport-level parse error, that no longer happens.
+### Cancelled requests are no longer answered
+
+In v1, when the peer sent `notifications/cancelled` for an in-flight request, the receiving side interrupted the handler and answered the request anyway with a JSON-RPC error, `{"code": 0, "message": "Request cancelled"}` - and `0` is not a defined JSON-RPC error code. The 2026-07-28 transport specifications (stdio, streamable HTTP) say a server **MUST NOT** send any further messages for a cancelled request; the older cancellation pattern already said it **SHOULD NOT**. The sender is expected to stop waiting once it cancels, so that error response has been removed: a cancelled request now produces no response at all - no result, and no error - even if the handler runs to completion or fails afterwards. This applies to both seats (the server for cancelled client requests, and the client for cancelled server-initiated requests such as sampling or elicitation).
+
+The one deliberate exception is the 2025-era streamable HTTP transport (`StreamableHTTPServerTransport`), whose wire can end a request's stream only with a response for that id (and stores that response so a resuming client's replay terminates too). Under the 2025 rule, a SHOULD NOT, that transport now terminates a cancelled request with a valid `-32800` error (`mcp.server.streamable_http.REQUEST_CANCELLED`, mirroring LSP's `RequestCancelled`) in place of the old `0`. Nothing else answers.
+
+Nothing changes for callers of the built-in client: abandoning a call (cancelling the awaiting task, or a per-request timeout) never waited for that response. If you send `notifications/cancelled` by hand while still awaiting the call, the call now receives nothing on most transports (over 2025-era streamable HTTP it fails with `REQUEST_CANCELLED`); stop awaiting it yourself, or use a per-request timeout.
+
### `Server.run()` no longer takes a `stateless` flag
The `stateless: bool` parameter on the lowlevel `Server.run()` has been removed. Stateless serving is now a property of how the connection is constructed (the streamable-HTTP manager builds a born-ready `Connection` per request), not a flag the loop driver inspects.
-Server-initiated requests that have no channel to travel on now raise `NoBackChannelError` (an `MCPError` subclass) — the same exception regardless of why the channel is absent. In v1 there was no dedicated exception for this case: the transport silently dropped the outbound message and the awaiting call stalled.
+Server-initiated requests that have no channel to travel on — a legacy session against a `stateless_http=True` server, the request-scoped channel of a stateful legacy session against a `json_response=True` server, or any connection negotiated at 2026-07-28 — now raise `NoBackChannelError` instead of stalling as they did in v1 (the transport silently dropped the outbound message), so a stateless-HTTP or JSON-mode `ctx.elicit()` that used to hang now fails fast; see [Server-initiated sampling, elicitation, and roots raise `NoBackChannelError`](#server-initiated-sampling-elicitation-and-roots-raise-nobackchannelerror) for the exception and the migration paths.
### Lowlevel `Server`: `request_context` property removed
@@ -1338,7 +1647,7 @@ async def handle_call_tool(name: str, arguments: dict):
```python
from mcp.server import ServerRequestContext
-from mcp_types import CallToolRequestParams, CallToolResult, TextContent
+from mcp.types import CallToolRequestParams, CallToolResult, TextContent
async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
@@ -1385,6 +1694,8 @@ server_ctx: ServerRequestContext[LifespanContextT, RequestT]
One field is newly optional: `request_id` is now `RequestId | None` (in v1 it was always a `RequestId`). The same context class is passed to notification handlers, where `request_id` is `None`, so code that forwards `ctx.request_id` as a definite `RequestId` needs a `None` check to satisfy type checkers.
+`ClientRequestContext` (importable from `mcp.client` or `mcp.client.context`) is smaller: a keyword-only dataclass with just `session: ClientSession`, `request_id: RequestId`, and `meta: RequestParamsMeta | None` — no `lifespan_context` or `request` on the client side. Its `request_id` is always a concrete `RequestId`, since the context is only built for the server-initiated `sampling`, `elicitation`, and `roots` requests it is passed to.
+
The high-level `Context` class (injected into `@mcp.tool()` etc.) similarly dropped its `ServerSessionT` parameter: `Context[ServerSessionT, LifespanContextT, RequestT]` → `Context[LifespanContextT, RequestT]`. Both remaining parameters have defaults, so bare `Context` is usually sufficient:
**Before (v1):**
@@ -1401,11 +1712,21 @@ async def my_tool(ctx: Context) -> str: ...
async def my_tool(ctx: Context[MyLifespanState]) -> str: ...
```
+The parametrized `Context[MyLifespanState]` annotation currently works only on `@mcp.tool()` handlers. On `@mcp.prompt()` and templated `@mcp.resource("scheme://{param}")` handlers, annotate the parameter as bare `Context` for now: these handlers are wrapped in `pydantic.validate_call`, which re-validates the injected `Context` into a fresh `Context[MyLifespanState]` detached from the request, so the first access to `ctx.request_id`, `ctx.session`, or `ctx.request_context` raises `ValueError: Context is not available outside of a request` (the client sees an internal server error, or `Error creating resource from template ...`). Bare `Context` still exposes `ctx.request_context.lifespan_context`; only its static type is lost.
+
### `ServerSession` is now a thin proxy (no longer a `BaseSession`)
-`ServerSession` no longer subclasses `BaseSession`. It is now a small per-request proxy that exposes `send_request`, `send_notification`, the typed convenience helpers (`create_message`, `elicit_form`, `send_log_message`, `send_tool_list_changed`, ...), `client_params`, `protocol_version`, and `check_client_capability`. The receive loop, `initialize` handling, and per-request task isolation that previously lived in `ServerSession` have moved to `JSONRPCDispatcher` and `ServerRunner`.
+`ServerSession` no longer subclasses `BaseSession`. It is now a small per-request proxy that exposes `send_request`, `send_notification`, the typed convenience helpers — `create_message`, `elicit` / `elicit_form` / `elicit_url`, `send_elicit_complete`, `list_roots`, `send_log_message`, `send_resource_updated`, `send_resource_list_changed` / `send_tool_list_changed` / `send_prompt_list_changed`, `send_ping`, `send_progress_notification`, and the new `report_progress` — plus `check_client_capability` and the read-only `client_params`, `client_capabilities`, `protocol_version`, and `can_send_request` properties. The receive loop, `initialize` handling, and per-request task isolation that previously lived in `ServerSession` have moved to `JSONRPCDispatcher` and `ServerRunner`.
+
+The helpers keep their v1 signatures, so calls through `ctx.session` are source-compatible: `send_notification(notification, related_request_id=None)`, `send_log_message(level, data, logger=None, related_request_id=None)` (now [SEP-2577-deprecated](#roots-sampling-and-logging-methods-deprecated-sep-2577)), `send_progress_notification(progress_token, progress, total=None, message=None, related_request_id=None)`, `related_request_id=` on `elicit_form` / `elicit_url` / `send_elicit_complete`, and `metadata=ServerMessageMetadata(related_request_id=...)` on `send_request` (used by `create_message`). As in v1, a present `related_request_id` routes the message onto that request's own stream (the POST response in streamable HTTP) and an absent one uses the connection's standalone stream — the one 2026-era exception being `send_log_message`, whose delivery is gated and request-scoped by the spec there (see [Log messages are delivered only to requests that opt in](#log-messages-are-delivered-only-to-requests-that-opt-in)). Two adjustments: `send_resource_updated(uri)` accepts `str | AnyUrl`, and `send_notification` takes the notification model itself — the `types.ServerNotification(...)` wrapper is gone with the other `RootModel` unions (`await session.send_notification(types.ResourceListChangedNotification())`; see [Replace `RootModel` by union types with `TypeAdapter` validation](#replace-rootmodel-by-union-types-with-typeadapter-validation)).
-`ServerSession` is normally constructed for you by `Server.run()` and reached via `ctx.session` in handlers, so most servers are unaffected. If you were constructing or subclassing it directly:
+Behavior changes:
+
+- **A new `ServerSession` proxy is built for every inbound message.** In v1 one `ServerSession` lived for the whole connection, and servers commonly keyed per-client state on `ctx.session` identity (a `WeakKeyDictionary[ServerSession, ...]`, `id(ctx.session)`, a set of captured sessions to notify later). In v2 each request and notification gets a fresh proxy over the same connection, so those idioms silently misbehave: a session-keyed dict never finds an earlier key, and a broadcast set grows by one entry per request, sending duplicates. Key on something connection-stable instead — on stateful streamable HTTP the `mcp-session-id` request header names the transport session (read it via `ctx.headers` on `MCPServer` or `ctx.request.headers` in a lowlevel handler); on stdio there is one connection per process. The per-connection object the proxies share is `mcp.server.connection.Connection` (`state`, `session_id`, `exit_stack`), which is not currently reachable from `ctx`.
+- **A captured `ctx.session` stays usable after the handler returns.** The proxy holds the connection, not the request, so a background task can keep calling `send_resource_updated()` / `send_tool_list_changed()` on it while the client stays connected; with `related_request_id` omitted these ride the standalone stream as in v1 — except on a 2026-era connection, where change notifications are dropped and belong on the subscription bus instead ([change notifications travel only on `subscriptions/listen` streams](#change-notifications-travel-only-on-subscriptionslisten-streams)). A request-scoped send is only meaningful while that request is in flight — once the handler returns, that stream is closed and the message is dropped with a debug log.
+- **Notifications after the connection has closed are dropped instead of raising.** In v1 the notification helpers raised `anyio.ClosedResourceError`/`anyio.BrokenResourceError` on a dead connection, and broadcast loops used that exception to prune sessions. In v2 the send returns normally (the drop is debug-logged), so probe with a request instead: `await session.send_ping()` raises `MCPError` once the connection has closed. On a 2026-07-28 connection, though, every server-initiated request raises `NoBackChannelError` (an `MCPError`) regardless, so a ping is a liveness probe only on connections negotiated at 2025-11-25 or earlier.
+
+`ServerSession` is normally constructed for you by `Server.run()` and reached via `ctx.session` in handlers, so beyond the behavior changes above, most servers are unaffected. If you were constructing or subclassing it directly:
**Constructor change:**
@@ -1462,7 +1783,7 @@ result = await ctx.session.elicit_form(
)
```
-Positional callers (`session.elicit_form(message, schema)`) are unaffected. `elicit_url()` already used snake_case parameters in v1; only `elicit()` and `elicit_form()` changed.
+Positional callers (`session.elicit_form(message, schema)`) are unaffected, and so are the return types: `elicit()`, `elicit_form()`, and `elicit_url()` still return `ElicitResult` (`action` of `"accept"`/`"decline"`/`"cancel"` plus `content`), and `create_message()` still returns `CreateMessageResult` (or `CreateMessageResultWithTools` when `tools`/`tool_choice` are passed). `elicit_url()` already used snake_case parameters in v1; only `elicit()` and `elicit_form()` changed.
## Clients
@@ -1470,11 +1791,11 @@ Positional callers (`session.elicit_form(message, schema)`) are unaffected. `eli
In v1, connecting to a server always performed the `initialize` handshake. In v2, `Client` defaults to `mode='auto'`: on enter it probes `server/discover` and, if the server doesn't support it, falls back to the `initialize` handshake. Pass `mode='legacy'` to force the initialize handshake and reproduce v1's pre-2026 connection sequence (the per-request wire shape still differs from v1; see [Every outbound request now carries a `_meta` envelope](#every-outbound-request-now-carries-a-_meta-envelope-opentelemetry-is-on-by-default)), or pass a modern protocol-version string (e.g. `mode='2026-07-28'`) to pin a version without probing.
-The probe is transport-independent: v2 servers answer it over stdio (and any other stream-pair transport) as well as streamable HTTP, so `mode='auto'` lands on `2026-07-28` against a v2 server on every transport. If your stdio workflow relies on server-initiated requests (sampling, push elicitation), pass `mode='legacy'` — a 2026-07-28 connection refuses them on every transport.
+The probe is transport-independent: v2 servers answer it over stdio (and any other stream-pair transport) as well as streamable HTTP, so `mode='auto'` lands on `2026-07-28` against a v2 server on every transport. If your stdio workflow relies on server-initiated requests (sampling, push elicitation, roots), pass `mode='legacy'` — a 2026-07-28 connection refuses them on every transport with `NoBackChannelError` (see [Server-initiated sampling, elicitation, and roots raise `NoBackChannelError`](#server-initiated-sampling-elicitation-and-roots-raise-nobackchannelerror)).
-For an in-process `Client(server)` (where `server` is a `Server` or `MCPServer` instance), `mode='auto'` dispatches calls directly through `DirectDispatcher` with no JSON-RPC framing. Pass `mode='legacy'` if you need the in-memory JSON-RPC transport that v1 used.
+For an in-process `Client(server)` (where `server` is a `Server` or `MCPServer` instance), `mode='auto'` dispatches calls directly through `DirectDispatcher` with no JSON-RPC framing. Pass `mode='legacy'` if you need the in-memory JSON-RPC transport that v1 used — or if the server pushes sampling, elicitation, or roots requests, which the default 2026-07-28 in-process connection refuses with `NoBackChannelError` even when the matching callback is set (see the section linked above). `mode` is a `Client` argument only: a lowlevel `ClientSession` you `initialize()` yourself always performs the pre-2026 handshake, and `ClientSession.discover()` is the explicit 2026-07-28 entry point.
-`Client.send_ping()` is deprecated (ping is removed in 2026-07-28); pin `mode='legacy'` if you need it.
+`Client.send_ping()` is deprecated (ping is removed in 2026-07-28) and emits `mcp.MCPDeprecationWarning` when called; pin `mode='legacy'` if you need it. The lowlevel `ClientSession.send_ping()` carries no deprecation marker.
### `ClientSession.get_server_capabilities()` replaced by era-neutral accessors
@@ -1498,7 +1819,7 @@ version = session.protocol_version
The raw handshake result is also retained: `session.initialize_result` is set after `initialize()` (≤2025-11-25 servers — including `stateless_http=True` servers, which still answer `initialize`); `session.discover_result` is set after `discover()` (2026-07-28+ servers). At most one is non-`None`.
-On the high-level `Client`, `client.server_capabilities`, `client.server_info`, and `client.protocol_version` are non-nullable inside the context manager. `client.instructions` remains `str | None` since the server may omit it. (The lowlevel `ClientSession` still lets you call methods before any handshake, as in v1; `Client` always connects on enter — by default it probes `server/discover` and falls back to the initialize handshake.)
+On the high-level `Client`, `client.server_capabilities` and `client.protocol_version` are non-nullable inside the context manager. `client.instructions` remains `str | None` since the server may omit it, and `client.server_info` is `Implementation | None`: on 2026-era connections identity is optional wire metadata, so a server that does not report it reads as `None`. (The lowlevel `ClientSession` still lets you call methods before any handshake, as in v1; `Client` always connects on enter — by default it probes `server/discover` and falls back to the initialize handshake.)
### `cursor` parameter removed from `ClientSession` list methods
@@ -1509,7 +1830,7 @@ The deprecated `cursor` parameter has been removed from the following `ClientSes
- `list_prompts()`
- `list_tools()`
-Use `params=PaginatedRequestParams(cursor=...)` instead.
+Each method now takes a single keyword-only argument, `params: PaginatedRequestParams | None = None`. Pass `params=PaginatedRequestParams(cursor=...)` to continue from a `next_cursor`; omit `params` for the first page.
**Before (v1):**
@@ -1521,12 +1842,26 @@ result = await session.list_tools(cursor="next_page_token")
**After (v2):**
```python
-from mcp_types import PaginatedRequestParams
+from mcp.types import PaginatedRequestParams
result = await session.list_resources(params=PaginatedRequestParams(cursor="next_page_token"))
result = await session.list_tools(params=PaginatedRequestParams(cursor="next_page_token"))
```
+To walk every page, feed each result's `next_cursor` back in until it comes back `None`:
+
+```python
+tools = []
+cursor = None
+while True:
+ page = await session.list_tools(params=PaginatedRequestParams(cursor=cursor))
+ tools.extend(page.tools)
+ if (cursor := page.next_cursor) is None:
+ break
+```
+
+The high-level `Client` (including the `Client(server)` replacement described under [Testing utilities](#testing-utilities)) does not accept `params=` — passing it raises `TypeError`. Its list methods keep pagination as a plain keyword, `await client.list_tools(cursor="next_page_token")`, alongside `meta=` and a per-call `cache_mode=` (`"use"` by default, or `"refresh"`/`"bypass"`) for the client's built-in [response cache](client/caching.md); `client.session.list_tools(params=...)` reaches the underlying `ClientSession` if you want the `params` form.
+
### `args` parameter removed from `ClientSessionGroup.call_tool()`
The deprecated `args` parameter has been removed from `ClientSessionGroup.call_tool()`. Use `arguments` instead.
@@ -1595,7 +1930,7 @@ To migrate, replace `timedelta(...)` with plain seconds, or mechanically append
### Client request timeouts now raise `-32001` (`REQUEST_TIMEOUT`) instead of `408`
-A client request that exceeds `read_timeout_seconds` still raises the SDK's protocol error (`MCPError`, previously `McpError`), but the error code changed from the HTTP status `408` (`httpx.codes.REQUEST_TIMEOUT`) to the JSON-RPC code `-32001` (`REQUEST_TIMEOUT`, importable from `mcp_types`), matching the TypeScript SDK. The message changed too: v1 said `"Timed out while waiting for response to ClientRequest. Waited 5.0 seconds."`, v2 says `"Request 'tools/call' timed out"`. `MCPError.error` still exists, so a migrated `e.error.code == 408` check runs without error and silently never matches; timeouts fall through to whatever generic-error handling follows. Code that matched on the old message text breaks too. Compare against `REQUEST_TIMEOUT` instead.
+A client request that exceeds `read_timeout_seconds` still raises the SDK's protocol error (`MCPError`, previously `McpError`), but the error code changed from the HTTP status `408` (`httpx.codes.REQUEST_TIMEOUT`) to the JSON-RPC code `-32001` (`REQUEST_TIMEOUT`, importable from `mcp.types`), matching the TypeScript SDK. The message changed too: v1 said `"Timed out while waiting for response to ClientRequest. Waited 5.0 seconds."`, v2 says `"Request 'tools/call' timed out"`. `MCPError.error` still exists, so a migrated `e.error.code == 408` check runs without error and silently never matches; timeouts fall through to whatever generic-error handling follows. Code that matched on the old message text breaks too. Compare against `REQUEST_TIMEOUT` instead.
**Before (v1):**
@@ -1616,7 +1951,7 @@ except McpError as e:
```python
from mcp.shared.exceptions import MCPError
-from mcp_types import REQUEST_TIMEOUT # -32001
+from mcp.types import REQUEST_TIMEOUT # -32001
try:
result = await client.call_tool("slow_tool", {})
@@ -1627,32 +1962,104 @@ except MCPError as e:
raise
```
-`e.error.code` also still works; `e.code` is the v2 convenience property. `mcp.types` no longer exists, so the constant comes from `mcp_types`. The example uses the high-level `Client`; `ClientSession.call_tool()` raises the same `MCPError`.
+`e.error.code` also still works; `e.code` is the v2 convenience property. The constant is importable from `mcp.types` (or from `mcp_types` in a project that uses that package without the SDK). The example uses the high-level `Client`; `ClientSession.call_tool()` raises the same `MCPError`.
### `ClientSession` now runs on `JSONRPCDispatcher`; `BaseSession` removed
-`ClientSession`'s public surface is unchanged — same constructor apart from timeout parameters (see [Timeouts take `float` seconds instead of `timedelta`](#timeouts-take-float-seconds-instead-of-timedelta)), typed methods, manual `initialize()`, and async context-manager lifecycle — but `BaseSession`, the v1 receive loop underneath it, is removed with no shim. The engine now lives in `JSONRPCDispatcher` (`mcp.shared.jsonrpc_dispatcher`). To customize client behavior, use the `ClientSession` constructor callbacks, or pass a pre-built dispatcher via the new keyword-only `dispatcher=` constructor argument (e.g. a `DirectDispatcher` for in-process embedding).
+`ClientSession`'s public surface is unchanged — same constructor apart from timeout parameters (see [Timeouts take `float` seconds instead of `timedelta`](#timeouts-take-float-seconds-instead-of-timedelta)), typed methods, manual `initialize()`, and async context-manager lifecycle — but `BaseSession`, the v1 receive loop underneath it, is removed with no shim. The engine now lives in `JSONRPCDispatcher` (`mcp.shared.jsonrpc_dispatcher`). To customize client behavior, use the `ClientSession` constructor callbacks, or pass a pre-built dispatcher via the new keyword-only `dispatcher=` constructor argument (e.g. a `DirectDispatcher` for in-process embedding). Passing one of the SDK's own dispatchers (`JSONRPCDispatcher`, or `DirectDispatcher` from `mcp.shared.direct_dispatcher`) is the supported use; the `Dispatcher` protocol's `run()` lifecycle (`mcp.shared.dispatcher`) is documented as provisional, so treat a hand-written implementation as experimental.
Behavior changes:
-- **Callbacks and notifications now run concurrently.** In v1 the receive loop processed one inbound message at a time, so callbacks ran inline and in order. Now each delivery starts in arrival order but runs as its own task. Server-initiated request callbacks (`sampling`, `elicitation`, `roots`) no longer block other traffic, may themselves send requests without deadlocking, and are interrupted if the server sends `notifications/cancelled` (the request is then answered with an error). Notification callbacks (`logging_callback`, `progress_callback`, `message_handler`) may interleave, and a `progress_callback` may run after the request it reports on has returned; there is no built-in bound on concurrent deliveries. Transport-level errors reach `message_handler` the same way, and a `message_handler` that raises is logged rather than fatal to the session. Callbacks that need strict sequencing must coordinate themselves.
-- **Timeouts**: a timed-out or abandoned request is now followed by `notifications/cancelled`, so the server stops the handler instead of leaving it running.
+- **Callbacks and notifications now run concurrently.** In v1 the receive loop processed one inbound message at a time, so callbacks ran inline and in order. Now each delivery starts in arrival order but runs as its own task. Server-initiated request callbacks (`sampling`, `elicitation`, `roots`) no longer block other traffic, may themselves send requests without deadlocking, and are interrupted if the server sends `notifications/cancelled` (no response is sent for the cancelled request). Notification callbacks (`logging_callback`, `progress_callback`, `message_handler`) may interleave, and a `progress_callback` may run after the request it reports on has returned; there is no built-in bound on concurrent deliveries. Transport-level errors reach `message_handler` the same way, and a `message_handler` that raises is logged rather than fatal to the session. Callbacks that need strict sequencing must coordinate themselves.
+- **Notification routing is unchanged.** Each server notification is still delivered to its typed callback first — `logging_callback` for log messages, the per-request `progress_callback` whose `progressToken` matches a request you issued (`progress_callback=` still stamps `params._meta.progressToken` with the outbound request id) — and then teed to `message_handler`. `notifications/cancelled` is applied by the dispatcher and never surfaced, also as in v1.
+- **Cancellation now reaches the server.** Cancelling the task or cancel scope awaiting a request (e.g. `anyio.move_on_after()` around `session.call_tool(...)`), or a request hitting its read timeout, now sends `notifications/cancelled` for that request, so the server interrupts the handler instead of leaving it running; v1 sent nothing ([#2507](https://github.com/modelcontextprotocol/python-sdk/issues/2507)). A test that pinned the v1 gap with a strict `xfail` now passes — drop the marker. The cancelled peer no longer answers at all (v1 sent `ErrorData(code=0, message="Request cancelled")`); the one exception, the 2025-era streamable HTTP transport's `-32800` terminator, is discarded like the v1 error since the caller's waiter is already gone — see [Cancelled requests are no longer answered](#cancelled-requests-are-no-longer-answered). There is no public request-id or cancel handle (v1's private `session._request_id` went with `BaseSession`): cancel the awaiting task or scope and the dispatcher sends the cancel for you.
- **A raising request callback** is answered with `code=0` and the exception text; v1 flattened every callback exception to `INVALID_PARAMS`. For a specific error response, return `ErrorData` (unchanged) or raise `MCPError`. One carve-out: pydantic's `ValidationError` is still answered with `INVALID_PARAMS`, as in v1.
- **`send_request` before entering the context manager** raises `RuntimeError` immediately; v1 wrote to the transport and hung until the timeout. After the connection has closed it raises `MCPError` (`CONNECTION_CLOSED`) instead. `send_notification` before entry still works.
- **`send_notification` after the connection has closed is dropped with a debug log instead of raising.** In v1 the send raised `anyio.BrokenResourceError` (peer gone) or `anyio.ClosedResourceError` (session torn down), and this applied to the typed helpers (`send_roots_list_changed`, `send_progress_notification`) too. Code that used the exception as its disconnect signal should probe with a request instead (`send_request` still raises `MCPError` after close, see above) or scope the sending task to the session's lifetime.
-- **`send_notification` no longer takes `related_request_id`, and `send_request` no longer accepts `ServerMessageMetadata`.** No client transport ever serialized these hints; progress and response correlation via `progressToken` and the request id is unaffected.
-- **Client callbacks now receive `mcp.client.ClientRequestContext`** (its `request_id` is always populated); the `mcp.shared.context.RequestContext` generic is deleted. Annotations spelled `RequestContext[ClientSession, Any]` become `ClientRequestContext` (details in [`RequestContext` type parameters simplified](#requestcontext-type-parameters-simplified)).
+- **`send_notification` no longer takes `related_request_id`, and `send_request` no longer accepts `ServerMessageMetadata`.** No client transport ever serialized these hints; progress and response correlation via `progressToken` and the request id is unaffected. This is client-side only: the server's `ServerSession` helpers keep `related_request_id` (see [`ServerSession` is now a thin proxy](#serversession-is-now-a-thin-proxy-no-longer-a-basesession)).
+- **Client callbacks now receive `mcp.client.ClientRequestContext`** (its `request_id` is always populated); the `mcp.shared.context.RequestContext` generic is deleted. Annotations spelled `RequestContext[ClientSession, Any]` become `ClientRequestContext` (details in [`RequestContext` type parameters simplified](#requestcontext-type-parameters-simplified)). Otherwise the callback surface is unchanged: the `sampling_callback=`, `elicitation_callback=`, `list_roots_callback=`, `logging_callback=`, and `message_handler=` keywords; the `SamplingFnT`, `ElicitationFnT`, `ListRootsFnT`, `LoggingFnT`, and `MessageHandlerFnT` protocols (still in `mcp.client.session`); and the params/result types (`CreateMessageRequestParams` → `CreateMessageResult | CreateMessageResultWithTools | ErrorData`, `ElicitRequestParams` → `ElicitResult | ErrorData`, `ListRootsResult | ErrorData` — returning `ErrorData` is not new). The `mcp.client.session`, `mcp.client.stdio`, `mcp.client.sse`, and `mcp.client.streamable_http` module paths are unchanged too, so `unittest.mock.patch` string targets still resolve.
+- **`message_handler` no longer receives requests.** Server-initiated requests are answered by the typed callbacks (`sampling_callback`, `elicitation_callback`, `list_roots_callback`), so the handler's parameter is now `IncomingMessage = ServerNotification | Exception`, exported from `mcp.client`. Replace the hand-written v1 union `RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception` with `IncomingMessage`; `RequestResponder` is gone (below), so the old annotation no longer imports. Delivered notifications are the concrete member instances rather than the v1 `RootModel` wrapper, so drop `.root` (`message.params`, not `message.root.params`); see [Replace `RootModel` by union types with `TypeAdapter` validation](#replace-rootmodel-by-union-types-with-typeadapter-validation).
+
+The `mcp.shared.session` module is gone. `RequestResponder` is removed — `respond()`, the cancellation-tracking members (`cancel()`, the `cancelled` and `in_flight` properties, the `on_complete` constructor argument) and `BaseSession._in_flight` have no replacement; inbound cancellation is handled by `JSONRPCDispatcher`. `ProgressFnT` now lives only in `mcp.shared.dispatcher`, and `RequestId` in `mcp.types`. The module's generic typing helpers (`SendRequestT`, `SendResultT`, `SendNotificationT`, `ReceiveRequestT`, `ReceiveResultT`, `ReceiveNotificationT`) went with it and have no re-export — the sessions are no longer generic; `ClientSession.send_request` takes a concrete request model plus a result model class (or `pydantic.TypeAdapter`), so an override that needs a type parameter can declare its own `TypeVar` bound to `pydantic.BaseModel`.
+
+Subclassing `ClientSession` remains a valid interception point: every typed helper routes through `send_request`, and the notification helpers through `send_notification`, so overriding those two still sees that traffic (the 2026-era `discover()`/`send_discover()` are the exception — they call the dispatcher directly). For wire-level interception, use the `dispatcher=` argument instead (with the caveat above on hand-written dispatchers).
+
+Migrating a request callback is a signature-only change (sampling and roots callbacks have the same shape):
+
+**Before (v1):**
+
+```python
+async def elicitation_callback(
+ context: RequestContext[ClientSession, Any], params: types.ElicitRequestParams
+) -> types.ElicitResult | types.ErrorData: ...
+```
+
+**After (v2):**
+
+```python
+from mcp.client import ClientRequestContext
+from mcp.types import ElicitRequestParams, ElicitResult, ErrorData
-`mcp.shared.session` is now a compatibility module: `ProgressFnT` is re-exported (its home is `mcp.shared.dispatcher`), and `RequestResponder` remains as a typing-only stub so `MessageHandlerFnT` annotations keep importing. `RequestResponder.respond()` no longer exists, and neither do the cancellation-tracking members (`cancel()`, the `cancelled` and `in_flight` properties, the `on_complete` constructor argument) or `BaseSession._in_flight`; inbound cancellation is handled by `JSONRPCDispatcher`.
+
+async def elicitation_callback(
+ context: ClientRequestContext, params: ElicitRequestParams
+) -> ElicitResult | ErrorData: ...
+```
### Experimental Tasks support removed
-Tasks ([SEP-1686](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1686)) have been removed from the MCP specification and are no longer part of this SDK. The `mcp.client.experimental`, `mcp.server.experimental`, `mcp.shared.experimental`, and `mcp.server.lowlevel.experimental` modules have been removed, along with the `experimental` properties on `ClientSession`, `ServerSession`, `Server`, and `ServerRequestContext`. The corresponding `Task*` types remain in `mcp_types` as types-only definitions, except the `TaskExecutionMode` alias, whose literal is now inlined on `ToolExecution.task_support`.
+Tasks ([SEP-1686](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1686)) have been removed from the MCP specification and are no longer part of this SDK. The `mcp.client.experimental`, `mcp.server.experimental`, `mcp.shared.experimental`, and `mcp.server.lowlevel.experimental` modules have been removed, along with the `experimental` properties on `ClientSession`, `ServerSession`, `Server`, and `ServerRequestContext`. The corresponding `Task*` types remain in `mcp.types` as types-only definitions, except the `TaskExecutionMode` alias, whose literal is now inlined on `ToolExecution.task_support`.
The 2026-07-28 revision reintroduces Tasks as an official extension: [SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663), `io.modelcontextprotocol/tasks`, redesigned around polling (`tasks/get`) instead of a blocking `tasks/result`. This SDK does not implement the extension yet.
+There is no drop-in replacement for the tasks runtime (`server.experimental.enable_tasks()`, `ctx.experimental.run_task()`, `ServerTaskContext`, and the client's `session.experimental.call_tool_as_task()` / `poll_task()` / `get_task_result()`); the port depends on what the code used tasks for.
+
+**Status updates on a long-running tool.** Run the work inline in the tool handler and replace `ServerTaskContext.update_status()` with progress reporting: `ctx.report_progress(progress, total, message)` on `MCPServer`, or `ctx.session.report_progress(...)` in a lowlevel handler (a no-op when the caller did not request progress). The client no longer creates a task and polls `tasks/get`; it passes `progress_callback=` to `call_tool()` and receives `notifications/progress` while the single call is in flight.
+
+**Before (v1):**
+
+```python
+# server: hand the work to the task runtime and report status from inside it
+async def work(task: ServerTaskContext) -> types.CallToolResult:
+ await task.update_status("Processing step 1...")
+ ...
+
+result = await ctx.experimental.run_task(work)
+
+# client: create the task, poll its status, then fetch the result
+result = await session.experimental.call_tool_as_task("long_running_task", arguments={}, ttl=60000)
+async for status in session.experimental.poll_task(result.task.taskId):
+ print(status.statusMessage)
+task_result = await session.experimental.get_task_result(result.task.taskId, CallToolResult)
+```
+
+**After (v2):**
+
+```python
+# server
+@mcp.tool()
+async def long_running_task(ctx: Context) -> str:
+ await ctx.report_progress(1, total=3, message="Processing step 1...")
+ ...
+ return "Task completed!"
+
+# client
+async def on_progress(progress: float, total: float | None, message: str | None) -> None:
+ print(message)
+
+result = await client.call_tool("long_running_task", {}, progress_callback=on_progress)
+```
+
+**Gathering user input mid-work** (`task.elicit()`, `task.create_message()`). Don't port these to inline `ctx.elicit()` / `ctx.session.create_message()` calls: those are server-initiated requests, refused with `NoBackChannelError` on 2026-07-28 connections (the default for an in-process `Client(server)`). Use the resolver dependencies (`Elicit`, `Sample`) or return an `InputRequiredResult` — both work on every protocol version, and `Client.call_tool()` retries the `InputRequiredResult` rounds automatically; see [Multi-round-trip requests](handlers/multi-round-trip.md) and [Server-initiated sampling, elicitation, and roots raise `NoBackChannelError`](#server-initiated-sampling-elicitation-and-roots-raise-nobackchannelerror). The client's existing `elicitation_callback` / `sampling_callback` serve both eras.
+
+**Detached work** (create the task now, fetch its result on a later connection or after a client restart) has no v2 equivalent until the SEP-2663 extension is implemented.
+
+Also drop `execution=ToolExecution(taskSupport=types.TASK_REQUIRED)` from tool definitions: the `TASK_REQUIRED` / `TASK_OPTIONAL` / `TASK_FORBIDDEN` constants are gone from `mcp.types` (`ToolExecution.task_support` takes the plain `"required"` / `"optional"` / `"forbidden"` literal), and no v2 client or server reads the field.
+
## Transports
+Server-side transport entry points (`stdio_server()`, `SseServerTransport`, `StreamableHTTPSessionManager`) keep their v1 import paths and signatures (see [Lowlevel `Server`: what did not change](#lowlevel-server-what-did-not-change)), so the sections below are client-side apart from [`stdio_server` keeps the protocol streams on private descriptors](#stdio_server-keeps-the-protocol-streams-on-private-descriptors); the other server-side transport changes ([lifespan entered once](#streamable-http-lifespan-now-entered-once-at-manager-startup), the [4 MiB request-body limit](#streamable-http-request-bodies-are-limited-to-4-mib)) sit under MCPServer.
+
### `streamablehttp_client` removed
The deprecated `streamablehttp_client` function has been removed. Use `streamable_http_client` instead.
@@ -1696,11 +2103,19 @@ async with http_client:
v1's internal client set `follow_redirects=True`; set it explicitly when supplying your own `httpx2.AsyncClient` to preserve that behavior.
+`streamable_http_client` itself keeps a small signature — `streamable_http_client(url, *, http_client=None, terminate_on_close=True)` — and now yields a 2-tuple (next section). The removed function's other parameters map onto the client you build:
+
+- `headers`, `timeout`, `sse_read_timeout`, `auth`: set them on the `httpx2.AsyncClient` as above. `streamablehttp_client` defaulted to `httpx.Timeout(30, read=300)`; a bare `httpx2.AsyncClient()` falls back to httpx2's flat 5-second timeout, too short for the long-lived GET stream, so set `timeout=httpx2.Timeout(30, read=300)` (as shown) to keep v1's values. Omitting `http_client` still gives you a default client with those timeouts and `follow_redirects=True`.
+- `httpx_client_factory`: gone with no replacement — call your factory yourself and pass the result as `http_client`.
+- `terminate_on_close`: unchanged (default `True`).
+
+Client-side stream resumption is also unchanged: the transport reconnects a dropped GET stream with `Last-Event-ID` on its own, and `session.send_request(..., metadata=ClientMessageMetadata(resumption_token=..., on_resumption_token_update=...))` (from `mcp.shared.message`) works as in v1.
+
### `get_session_id` callback removed from `streamable_http_client`
The `get_session_id` callback (third element of the returned tuple) has been removed from `streamable_http_client`. The function now returns a 2-tuple `(read_stream, write_stream)` instead of a 3-tuple.
-The `GetSessionIdCallback` type alias is gone as well, so `from mcp.client.streamable_http import GetSessionIdCallback` now raises `ImportError`. Drop the annotation, or inline `Callable[[], str | None]` if your own wrapper code still needs the type.
+The `GetSessionIdCallback` type alias is gone as well, so `from mcp.client.streamable_http import GetSessionIdCallback` now raises `ImportError`. Drop the annotation, or inline `Callable[[], str | None]` if your own wrapper code still needs the type. The `StreamableHTTPTransport.get_session_id()` method that backed the callback is removed too.
If you need to capture the session ID (e.g., for session resumption testing), you can use httpx2 event hooks to capture it from the response headers:
@@ -1746,11 +2161,15 @@ async with http_client:
session_id = captured_session_ids[0] if captured_session_ids else None
```
+The hook fires on every response the client sees, so `captured_session_ids` gains one entry per response carrying an `mcp-session-id` header (all the same value on one connection; if you reuse an `httpx2.AsyncClient` across reconnects, take the last entry). A hook can also be appended to an existing client: `client.event_hooks["response"].append(capture_session_id)`.
+
+`terminate_on_close` still defaults to `True`, so `streamable_http_client` sends its own `DELETE` for the session on exit; if your test deletes the session itself, pass `terminate_on_close=False`, or the transport's follow-up `DELETE` hits an already-terminated session and logs a `Session termination failed: 404` warning.
+
### `StreamableHTTPTransport` parameters removed
The `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters have been removed from `StreamableHTTPTransport`. Configure these on the `httpx2.AsyncClient` instead (see example above).
-Note: `sse_client` retains its `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters — only the streamable HTTP transport changed.
+`sse_client` is unchanged apart from the `httpx2` retyping: it still takes `url`, `headers`, `timeout`, `sse_read_timeout`, `httpx_client_factory` (which must now return an `httpx2.AsyncClient`), `auth` (now `httpx2.Auth | None`), and `on_session_created`. Only the streamable HTTP transport dropped its transport-level parameters; `StreamableHTTPTransport(url)` now takes just the URL.
### `StreamableHTTPTransport.protocol_version` attribute removed
@@ -1797,7 +2216,7 @@ while True:
```python
from mcp import ClientSession, MCPError
from mcp.client.streamable_http import streamable_http_client
-from mcp_types import INVALID_REQUEST # -32600
+from mcp.types import INVALID_REQUEST # -32600
async with streamable_http_client(url) as (read, write):
async with ClientSession(read, write) as session:
@@ -1855,12 +2274,120 @@ group (spawned with `start_new_session=True`); the `getpgid()` lookup and the
per-process terminate/kill fallback are gone. The win32 utilities logger is now
named `mcp.os.win32.utilities` (was `client.stdio.win32`).
+### `stdio_server` keeps the protocol streams on private descriptors
+
+While serving, the stdio transport moves the wire to private descriptors and points
+fd 0 at the null device and fd 1 at stderr, restoring both on exit. Subprocesses and
+handler code can no longer read protocol bytes or write into the stream (the
+[#671](https://github.com/modelcontextprotocol/python-sdk/issues/671) fix). Ordinary
+servers have nothing to do, and code that inspects or manipulates fd 0/1 directly
+during a session now sees the diversions, not the wire.
+
+One pattern needs migrating: watchdog threads that watch fd 0 to detect a vanished
+client (a POSIX-specific pattern; `select.poll` does not exist on Windows). The null
+device does not behave like the old pipe: it never reports `POLLHUP` or `POLLERR`,
+and it reports readable immediately and permanently (`POLLIN` from `poll()` on Linux,
+plus `POLLOUT` under the default event mask; ready from `select()`; and macOS can
+report `POLLNVAL` for devices). A watcher waiting for `POLLHUP` or `POLLERR` is
+silently disarmed; a watcher that treats any event as "client gone" now fires at
+startup instead of never. Watch the parent process instead: on POSIX, exit
+when `os.getppid()` changes, which happens when the client dies because orphaned
+processes are reparented. That works on both v1 and v2 and does not depend on
+descriptor layout.
+
+Also new: a second concurrent `stdio_server()` on the process's default streams now
+raises `RuntimeError` instead of silently contending for stdin, a configuration that
+never worked (there is one stdin).
+
+Also worth knowing: a child process that streams large output to its inherited
+stdout now streams it into the client's stderr channel. Capture output you do not
+want in the client's logs, and be aware that a client which never drains its stderr
+pipe applies back-pressure to the server (true of stderr logging on v1 as well).
+
### WebSocket transport removed
The WebSocket transport has been removed: `mcp.client.websocket.websocket_client`, `mcp.server.websocket.websocket_server`, and the `ws` optional dependency extra (`mcp[ws]`) no longer exist. WebSocket was never part of the MCP specification. Use the streamable HTTP transport instead (`mcp.client.streamable_http.streamable_http_client` on the client, `streamable_http_app()` on the server), which supports bidirectional communication with server-to-client streaming over standard HTTP.
## OAuth and server auth
+### Unchanged auth surfaces
+
+Most of the auth API carries over from v1; if a survey of your `mcp.client.auth` /
+`mcp.server.auth` usage only turns up the changes documented in the sections below, that is
+expected. In particular:
+
+- **OAuth client core.** `OAuthClientProvider` keeps its v1 constructor apart from the
+ [removed `timeout`](#timeout-parameter-removed-from-oauthclientprovider) and the
+ [`AuthorizationCodeResult`-returning `callback_handler`](#oauth-callback_handler-returns-authorizationcoderesult),
+ and gains an optional `validate_resource_url` callback for overriding the RFC 8707 resource
+ check: `OAuthClientProvider(server_url, client_metadata, storage, redirect_handler=None,
+ callback_handler=None, client_metadata_url=None, validate_resource_url=None)`. `PKCEParameters`,
+ `TokenStorage`, and the exceptions exported by `mcp.client.auth` (`OAuthFlowError`,
+ `OAuthTokenError`, `OAuthRegistrationError`) are unchanged; import `OAuthTokenError` from
+ `mcp.client.auth`, since `mcp.client.auth.extensions.client_credentials` no longer happens to
+ re-export it. `provider.context` (`OAuthContext`: `current_tokens`, `token_expiry_time`,
+ `is_token_valid()`, `can_refresh_token()`, `clear_tokens()`) also carries over, but remains an
+ internal object with no stability guarantee.
+- **Client-credentials extension.** `ClientCredentialsOAuthProvider`,
+ `PrivateKeyJWTOAuthProvider`, `SignedJWTParameters`, and `static_assertion_provider` in
+ `mcp.client.auth.extensions.client_credentials` keep their v1 signatures apart from the
+ [`scopes=` → `scope=` rename](#scopes-renamed-to-scope-on-the-client-credentials-providers)
+ and the [`RFC7523OAuthClientProvider`/`JWTParameters` removal](#rfc7523oauthclientprovider-and-jwtparameters-removed).
+ Their base class is now `httpx2.Auth` (see
+ [`httpx` and `httpx-sse` replaced by `httpx2`](#httpx-and-httpx-sse-replaced-by-httpx2)),
+ and `token_endpoint_auth_method="client_secret_post"` changes the token request body (see
+ [`client_secret_post` token requests now include `client_id`](#client_secret_post-token-requests-now-include-client_id)).
+- **Discovery and registration helpers.** `mcp.client.auth.utils` keeps its v1 helpers
+ (`build_protected_resource_metadata_discovery_urls`,
+ `build_oauth_authorization_server_metadata_discovery_urls`, the `handle_*_response`
+ coroutines, `extract_field_from_www_auth`/`extract_scope_from_www_auth`,
+ `get_client_metadata_scopes`), retyped from `httpx` to `httpx2` request/response objects.
+ The additions — `union_scopes`, `validate_metadata_issuer`,
+ `validate_authorization_response_iss`, `credentials_match_issuer`, and an optional
+ `client_grant_types` on `get_client_metadata_scopes` — are new, not renames.
+- **Resource-server surface.** `TokenVerifier`, `AccessToken`, and
+ `OAuthAuthorizationServerProvider` (`mcp.server.auth.provider`), `AuthSettings`,
+ `create_auth_routes`/`create_protected_resource_routes`,
+ `BearerAuthBackend`/`RequireAuthMiddleware`, `AuthContextMiddleware`/`get_access_token`,
+ `mcp.shared.auth`, `mcp.shared.auth_utils`, and `MCPServer`'s
+ `auth=`/`auth_server_provider=`/`token_verifier=` keywords all carry over. `AccessToken` has
+ had optional `subject` and `claims` fields since v1.27.2, so a subclass that existed only to
+ add them can be dropped. The SDK-hosted authorization server changes only per
+ [Stricter client authentication at `/token` and `/revoke`](#stricter-client-authentication-at-token-and-revoke),
+ plus the additive [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990)
+ identity-assertion pieces (`AuthSettings(identity_assertion_enabled=True)` /
+ `create_auth_routes(..., identity_assertion_enabled=True)` and the overridable
+ `OAuthAuthorizationServerProvider.exchange_identity_assertion`, which rejects the grant by
+ default). The `mcp.shared.auth` metadata models keep their fields, with the additions covered
+ in the sections below.
+
+### `RFC7523OAuthClientProvider` and `JWTParameters` removed
+
+`RFC7523OAuthClientProvider` (deprecated since 1.23.0) and its `JWTParameters` model have been
+removed from `mcp.client.auth.extensions.client_credentials`. The provider implemented the
+[RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) §2.1 `jwt-bearer` *authorization grant*
+with an SDK-minted or prebuilt JWT, which no MCP auth extension specifies. Replace it with the
+purpose-built provider for the flow you actually run:
+
+- Machine-to-machine with a client secret
+ ([`io.modelcontextprotocol/oauth-client-credentials`](https://modelcontextprotocol.io/extensions/auth/oauth-client-credentials)):
+ `ClientCredentialsOAuthProvider(server_url=..., storage=..., client_id=..., client_secret=...)`.
+- Machine-to-machine authenticating with a JWT instead of a secret (same extension, RFC 7523 §2.2
+ `private_key_jwt` client authentication on the `client_credentials` grant, which is the mode the
+ extension actually specifies for JWTs): `PrivateKeyJWTOAuthProvider(server_url=...,
+ storage=..., client_id=..., assertion_provider=...)`. Build the assertion with
+ `SignedJWTParameters(issuer=..., subject=..., signing_key=...).create_assertion_provider()`
+ (replaces `JWTParameters` signing fields), or wrap a prebuilt JWT with
+ `static_assertion_provider(token)` (replaces `JWTParameters(assertion=...)`).
+- Presenting an enterprise ID-JAG under the `jwt-bearer` grant
+ ([SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990)):
+ `IdentityAssertionOAuthProvider` in `mcp.client.auth.extensions.identity_assertion`.
+
+The provider's third mode — the interactive `authorization_code` flow with `private_key_jwt`
+client authentication on the token exchange — has no replacement and is intentionally dropped; it
+was never exercised by the test suite and no MCP auth extension specifies it. If you depended on
+it, open an issue describing the deployment.
+
### OAuth metadata URLs no longer gain a trailing slash
`OAuthMetadata`, `ProtectedResourceMetadata`, and `OAuthClientMetadata` now set
@@ -1916,6 +2443,53 @@ async def callback_handler() -> AuthorizationCodeResult:
Forward the `iss` query parameter from the redirect so the validation can run: omitting it makes the flow fail with `OAuthFlowError` against servers that advertise `authorization_response_iss_parameter_supported`, and silently skips the check for servers that send `iss` without advertising it.
+### `scopes=` renamed to `scope=` on the client-credentials providers
+
+`ClientCredentialsOAuthProvider` and `PrivateKeyJWTOAuthProvider` took the requested scope as a keyword named `scopes`, even though the value is a single space-separated string, not a list. The parameter is now `scope`, matching the RFC 6749 wire parameter, `OAuthClientMetadata.scope`, and the newer `IdentityAssertionOAuthProvider`.
+
+**Before (v1):**
+
+```python
+ClientCredentialsOAuthProvider(..., scopes="read write")
+```
+
+**After (v2):**
+
+```python
+ClientCredentialsOAuthProvider(..., scope="read write")
+```
+
+### `client_secret_post` token requests now include `client_id`
+
+With `token_endpoint_auth_method="client_secret_post"`, the token request body now carries both `client_id` and `client_secret`, as [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) §2.3.1 requires; v1 sent only `client_secret`. The authorization-code and refresh requests already carried `client_id`, so the observable difference is the `client_credentials` exchange sent by `ClientCredentialsOAuthProvider(..., token_endpoint_auth_method="client_secret_post")` (plus `resource`/`scope` when configured):
+
+```text
+# v1
+grant_type=client_credentials&client_secret=SECRET
+# v2
+grant_type=client_credentials&client_id=CLIENT_ID&client_secret=SECRET
+```
+
+Authorization servers that require both parameters answered the v1 request with `401 invalid_client`, so under v1 this provider effectively only worked with the default `client_secret_basic`. Drop any manual `client_id` injection or a test that pinned the 401 — the exchange now succeeds as configured.
+
+### `timeout` parameter removed from `OAuthClientProvider`
+
+`OAuthClientProvider` no longer accepts a `timeout` argument, and `OAuthContext.timeout` is gone. The value was stored but never read, so it never bounded anything — removing it changes nothing at runtime.
+
+**Before (v1):**
+
+```python
+provider = OAuthClientProvider(server_url, client_metadata, storage, timeout=120.0)
+```
+
+**After (v2):**
+
+```python
+provider = OAuthClientProvider(server_url, client_metadata, storage)
+```
+
+If you passed `timeout` to bound how long you wait for the user to complete authorization, apply that bound where you actually wait — inside your `redirect_handler`/`callback_handler`, e.g. `with anyio.fail_after(120): ...`. The full v2 constructor (v1's parameters minus `timeout`, plus a new optional `validate_resource_url` callback) is listed under [Unchanged auth surfaces](#unchanged-auth-surfaces).
+
### Client rejects authorization server metadata with a mismatched `issuer`
During OAuth discovery, `OAuthClientProvider` now validates that the authorization server
@@ -2010,6 +2584,27 @@ client_metadata = OAuthClientMetadata(
Under OIDC, omitting `application_type` defaults to `"web"`, which an authorization server may reject for the `localhost` redirect URIs native clients use; sending `"native"` avoids that. Non-OIDC servers ignore the parameter.
+### `OAuthClientInformationFull` no longer subclasses `OAuthClientMetadata`, and parses server-substituted metadata
+
+`OAuthClientMetadata` is the registration request a client sends; `OAuthClientInformationFull` is the authorization server's record of a registered client, parsed from its Dynamic Client Registration response. In v1 the second inherited from the first, which typed the response as though it had to be a request this SDK would send. It does not: [RFC 7591 §3.2.1](https://datatracker.ietf.org/doc/html/rfc7591#section-3.2.1) lets the server "reject or replace any of the client's requested metadata values submitted during the registration and substitute them with suitable values", and real servers return an `application_type` outside OIDC Registration's `web`/`native`, an explicit `null`, a `token_endpoint_auth_method` the SDK does not implement, or an empty `redirect_uris`. The inherited strict types turned each of those into a `ValidationError` on a 2xx response - after the server had already provisioned the client, so the registration was discarded and orphaned.
+
+The two are now siblings over a shared `OAuthClientMetadataBase`. `OAuthClientMetadata` keeps its strict types (the SDK still refuses to *send* an unregistered `application_type`), while `OAuthClientInformationFull` accepts what a server may echo:
+
+```python
+# v1
+class OAuthClientInformationFull(OAuthClientMetadata): ...
+
+# v2
+class OAuthClientMetadata(OAuthClientMetadataBase): ... # request: strict
+class OAuthClientInformationFull(OAuthClientMetadataBase): ... # server record: tolerant
+```
+
+On `OAuthClientInformationFull`, `application_type` and `token_endpoint_auth_method` are now `str | None`, `grant_types` is `list[str]`, and `redirect_uris` is optional (`list[AnyUrl] | None`, no minimum length). `client_id` is now required (`str`): [RFC 7591 §3.2.1](https://datatracker.ietf.org/doc/html/rfc7591#section-3.2.1) makes it mandatory in the response, and a record of a registered client without one was never meaningful. Code that only reads these fields is unaffected. Code that relied on `isinstance(client_info, OAuthClientMetadata)`, or passed an `OAuthClientInformationFull` where an `OAuthClientMetadata` is expected, must reference the record type directly. `validate_scope()` and `validate_redirect_uri()` moved with the record: they are methods of `OAuthClientInformationFull` (the type authorization-server code holds) and are no longer available on `OAuthClientMetadata`.
+
+A registration response the server sends is no longer rejected on these fields: a member serialized as a placeholder - an explicit `null`, or `""` - reads as an omitted key, so its default applies. Whether a substituted value is usable is judged where it matters, not at parse. When Dynamic Client Registration completes with credentials the authorization-code flow cannot use - a `token_endpoint_auth_method` other than `none`, `client_secret_post`, or `client_secret_basic` (including `private_key_jwt`, whose assertion that flow has no key to sign), or a secret-based method for which the server issued no `client_secret` - the client raises `OAuthRegistrationError` naming the problem, before the record is stored or authorization begins. Separately, a stored or pre-registered record carrying a method the SDK does not know at all raises `OAuthTokenError` when it reaches the token exchange; `private_key_jwt` on such a record does not raise there, so `PrivateKeyJWTOAuthProvider`, which signs its assertion only in the client-credentials exchange, still recovers from a rejected refresh by exchanging afresh.
+
+The SDK's own registration endpoint now returns all registered metadata in its 201 response (RFC 7591 §3.2.1) - including the client's `application_type`, which v1 dropped from the echo (silently reporting the default in place of a client's `"web"`), and `client_secret_expires_at` (`0` when the secret never expires) whenever a `client_secret` is issued. It also now answers a `private_key_jwt` registration with `400 invalid_client_metadata` rather than confirming a method it authenticates no requests with.
+
### Stricter client authentication at `/token` and `/revoke`
v2 hardens client authentication on SDK-hosted authorization servers (`create_auth_routes`) in two ways. Both apply automatically; server code only needs changing if you hand-provision client records.
@@ -2056,9 +2651,11 @@ LEGACY_CLIENT = OAuthClientInformationFull(
Results returned from server handlers are now validated against the negotiated protocol version's schema before being sent. A result that does not conform raises on the server side and the client receives an `INTERNAL_ERROR` response. The case most existing code will hit is `Tool.inputSchema`: the spec requires it to contain `"type": "object"`, so an empty `{}` is now rejected.
+Validation runs when the result is serialized onto the wire, not when the model is constructed: `Tool(name="t", input_schema={})` still constructs, so a fixture that builds such a tool only fails once a `tools/list` handler returns it. Your handler returns normally; the server then logs the `pydantic.ValidationError` (`handler for 'tools/list' returned an invalid result`) and answers the request with `INTERNAL_ERROR`, so the failure shows up on the client, not at the line that built the model.
+
### Client validates inbound traffic against the protocol schema
-`ClientSession` now validates server requests, notifications, and results against the negotiated protocol version's schema before parsing them into `mcp_types` models. Spec-invalid server output that the previous monolith parse tolerated may now raise `pydantic.ValidationError` from `list_tools()`, `call_tool()`, and similar calls. `_meta` remains the sanctioned place for result extras (and `experimental` for capability extras).
+`ClientSession` now validates server requests, notifications, and results against the negotiated protocol version's schema before parsing them into `mcp.types` models. Spec-invalid server output that the previous monolith parse tolerated may now raise `pydantic.ValidationError` from `list_tools()`, `call_tool()`, and similar calls. `_meta` remains the sanctioned place for result extras (and `experimental` for capability extras).
### Unknown request methods now return `-32601` (Method not found)
@@ -2110,7 +2707,9 @@ async with Client(server) as client:
result = await client.call_tool("my_tool", {"x": 1})
```
-`Client` accepts the same callback parameters the old helper did (`sampling_callback`, `list_roots_callback`, `logging_callback`, `message_handler`, `elicitation_callback`, `client_info`), keeps `raise_exceptions` for surfacing server-side errors and `read_timeout_seconds` (now a plain `float` of seconds rather than a `timedelta`; see [Timeouts take `float` seconds instead of `timedelta`](#timeouts-take-float-seconds-instead-of-timedelta)), and adds `mode` to control version negotiation (`'auto'` by default; `'legacy'` reproduces v1's initialize-only handshake).
+`Client` accepts the same callback parameters the old helper did (`sampling_callback`, `list_roots_callback`, `logging_callback`, `message_handler`, `elicitation_callback`, `client_info`), keeps `raise_exceptions` for surfacing server-side errors and `read_timeout_seconds` (now a plain `float` of seconds rather than a `timedelta`; see [Timeouts take `float` seconds instead of `timedelta`](#timeouts-take-float-seconds-instead-of-timedelta)), and adds `mode` to control version negotiation (`'auto'` by default; `'legacy'` reproduces v1's initialize-only handshake). Its method signatures are not identical to `ClientSession`'s: the `list_*()` methods paginate with a plain `cursor=` keyword rather than `params=PaginatedRequestParams(...)` (see [`cursor` parameter removed from `ClientSession` list methods](#cursor-parameter-removed-from-clientsession-list-methods)).
+
+One consequence to plan for: unlike the old helper, `Client(server)` negotiates 2026-07-28 by default, where server-initiated requests are refused. A v1 test that drove `ctx.elicit()`, `ctx.session.create_message()`, or `list_roots()` through the helper now fails with `NoBackChannelError` even with the callbacks set. Pin the era — `Client(server, mode="legacy", sampling_callback=..., elicitation_callback=..., list_roots_callback=...)` — or port the handler to a resolver dependency; see [Server-initiated sampling, elicitation, and roots raise `NoBackChannelError`](#server-initiated-sampling-elicitation-and-roots-raise-nobackchannelerror).
If you need direct access to the underlying `ClientSession` and memory streams (e.g., for low-level transport testing), `create_client_server_memory_streams` is still available in `mcp.shared.memory`:
@@ -2136,6 +2735,20 @@ One behavioral caveat when moving progress-reporting handlers onto `Client(serve
## Deprecations
+Every deprecation below is a runtime warning as well as a type-checker one: deprecated methods and helpers emit `mcp.MCPDeprecationWarning` on each call, and the deprecated `Server(...)` constructor parameters (`on_set_logging_level`, `on_roots_list_changed`, `on_progress`) emit it at construction time. The category subclasses `UserWarning`, not `DeprecationWarning`, so it is visible by default; [Deprecated features](deprecated.md) has the full list and each replacement.
+
+Under pytest's `filterwarnings = ["error"]`, that warning becomes an exception at the first deprecated call. Inside an `@mcp.tool()` handler the exception is caught like any other and returned as `CallToolResult(is_error=True)` (`Error executing tool ...: The logging capability is deprecated as of 2026-07-28 (SEP-2577).`), which reads as a failing tool rather than a warning. Keep the warnings visible but non-fatal with:
+
+```toml
+[tool.pytest.ini_options]
+filterwarnings = [
+ "error",
+ "default::mcp.MCPDeprecationWarning",
+]
+```
+
+Use `"ignore::mcp.MCPDeprecationWarning"` (or the `warnings.filterwarnings` call [below](#roots-sampling-and-logging-methods-deprecated-sep-2577)) to silence them instead, and wrap a test that deliberately exercises a deprecated path in `pytest.warns(MCPDeprecationWarning)`.
+
### Client resource-subscription methods deprecated (SEP-2575)
[SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2575) removes `resources/subscribe` and `resources/unsubscribe` from the 2026-07-28 wire; per-URI subscriptions travel in the `subscriptions/listen` filter instead. The client verbs now carry `typing_extensions.deprecated`:
@@ -2143,7 +2756,7 @@ One behavioral caveat when moving progress-reporting handlers onto `Client(serve
- `Client.subscribe_resource()` / `Client.unsubscribe_resource()`
- `ClientSession.subscribe_resource()` / `ClientSession.unsubscribe_resource()`
-They keep working against 2025-era servers; a 2026-07-28 server answers them with `-32601` (method not found). Migrate to the listen driver:
+Calling them emits `mcp.MCPDeprecationWarning`. They keep working against 2025-era servers — where they are still the only way to watch a resource, so code that talks to 2025-11-25 (or earlier) servers should keep calling them and filter the warning rather than migrate. A 2026-07-28 server answers them with `-32601` (method not found); on those connections migrate to the listen driver, `Client.listen()`:
```python
async with client.listen(resource_subscriptions=["board://sprint"]) as sub:
@@ -2151,12 +2764,14 @@ async with client.listen(resource_subscriptions=["board://sprint"]) as sub:
...
```
-See the [Subscriptions](client/subscriptions.md#watching-the-stream) page under Clients for the full client-side contract (typed events, the honored filter, clean end vs `SubscriptionLost`).
+On a bare `ClientSession` (no high-level `Client`), the same stream is `listen(session, resource_subscriptions=[...])` from `mcp.client.subscriptions` — the function `Client.listen()` wraps — which requires a 2026-07-28 connection and raises `ListenNotSupportedError` on an older one. See the [Subscriptions](client/subscriptions.md#watching-the-stream) page under Clients for the full client-side contract (typed events, the honored filter, clean end vs `SubscriptionLost`).
### Roots, Sampling, and Logging methods deprecated (SEP-2577)
[SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) deprecates the Roots, Sampling, and Logging features as of the 2026-07-28 spec. The deprecation is advisory only: there are no wire-level changes, capability negotiation is unchanged, and every method keeps working for sessions negotiating 2025-11-25 and earlier.
+The deprecation and the back-channel are separate axes. Sampling, roots, and push elicitation are server-initiated *requests*, so on a connection negotiated at 2026-07-28 — including the default in-process `Client(server)` — `create_message()`, `list_roots()`, and `elicit()` / `elicit_form()` raise `NoBackChannelError` rather than working with a warning; the resolver markers `Sample`, `ListRoots`, and `Elicit` are the era-portable form (see [Server-initiated sampling, elicitation, and roots raise `NoBackChannelError`](#server-initiated-sampling-elicitation-and-roots-raise-nobackchannelerror)).
+
The user-facing methods for these features now carry `typing_extensions.deprecated`, so type checkers, IDEs, and the runtime surface a deprecation warning where they are called:
- Sampling: `ServerSession.create_message()`, `ClientPeer.sample()`
@@ -2165,7 +2780,7 @@ The user-facing methods for these features now carry `typing_extensions.deprecat
Registering a handler for a deprecated capability is deprecated too. The `Server.__init__` parameters `on_set_logging_level` (Logging) and `on_roots_list_changed` (Roots) are now split out into a `typing_extensions.deprecated` overload, so passing either is flagged by type checkers and emits `mcp.MCPDeprecationWarning` at construction time. `on_progress` follows the same pattern (see below). The non-deprecated overload omits these parameters, so the common case stays warning-free.
-The runtime warning is emitted as `mcp.MCPDeprecationWarning`, which subclasses `UserWarning` (not `DeprecationWarning`) so it is visible by default. To silence it, filter that category:
+To silence the warnings in code, filter the category:
```python
import warnings
@@ -2186,8 +2801,71 @@ On the server side, prefer the new dispatcher-agnostic `ServerSession.report_pro
Everything below this heading describes behavior that only activates on connections
negotiated at protocol 2026-07-28 or later. Migrated v1 code talking to 2025-11-25 (or
-earlier) peers is unaffected. It is collected here so the rest of this guide stays
-focused on the v1-to-v2 upgrade itself.
+earlier) peers is unaffected — the notable exception being an in-process `Client(server)`,
+which negotiates 2026-07-28 by default (first subsection below). It is collected here so the
+rest of this guide stays focused on the v1-to-v2 upgrade itself.
+
+### Server-initiated sampling, elicitation, and roots raise `NoBackChannelError`
+
+The 2026-07-28 protocol has no server-initiated requests, so a handler that reaches back to the client mid-request — `ctx.elicit()`, `ctx.elicit_url()`, `ctx.session.create_message()`, `ctx.session.list_roots()`, or any other `ServerSession` request helper — raises `NoBackChannelError` on such a connection instead of sending. An in-process `Client(server)` negotiates 2026-07-28 by default (see [`Client` defaults to `mode='auto'`](#client-defaults-to-modeauto)), so the first smoke test of an unchanged v1 sampling or elicitation tool fails, and setting `sampling_callback=` / `elicitation_callback=` on the client changes nothing because no request ever reaches the client.
+
+`NoBackChannelError` lives in `mcp.shared.exceptions` and subclasses `MCPError` (code `-32600`, message `Cannot send '': this transport context has no back-channel for server-initiated requests.`). Raised inside an `@mcp.tool()` it reaches the client as a top-level JSON-RPC error, not `CallToolResult(is_error=True)` — see [`MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error](#mcperror-raised-from-an-mcptool-handler-now-surfaces-as-a-json-rpc-error) — and the [Troubleshooting](troubleshooting.md) page walks through the client-side traceback. The same exception is raised on a legacy session against a `stateless_http=True` server, and on the request-scoped channel of a stateful legacy session against a `json_response=True` server (a JSON body carries exactly one response, so a mid-request `ctx.elicit()` cannot ride it; the session's standalone `GET` stream still carries unrelated messages) — both places v1 dropped the message and stalled ([`Server.run()` no longer takes a `stateless` flag](#serverrun-no-longer-takes-a-stateless-flag)). Notifications never raise it: `send_log_message()`, `send_tool_list_changed()`, and the other notification helpers are dropped with a debug log where no channel exists (and the change-notification helpers are dropped on every 2026-era connection, channel or not — see [change notifications travel only on `subscriptions/listen` streams](#change-notifications-travel-only-on-subscriptionslisten-streams)), and `UrlElicitationRequiredError` from a tool is unaffected (it is an error response, not a request).
+
+Two ways to migrate:
+
+- **Keep the push behavior for now** by connecting at a pre-2026 version: `Client(server, mode="legacy", sampling_callback=..., elicitation_callback=...)` reproduces v1's `initialize` handshake, in-process included; a lowlevel `ClientSession` you `initialize()` yourself already negotiates a 2025-era version, so hand-rolled test harnesses are unaffected. Sampling and roots stay deprecated on this path ([SEP-2577](#roots-sampling-and-logging-methods-deprecated-sep-2577)).
+- **Port to the era-portable form**: return the question instead of pushing it — a `Resolve(...)`-backed parameter whose resolver returns `Elicit`, `Sample`, or `ListRoots` (all in `mcp.server.mcpserver`). The SDK elicits directly on a legacy connection and drives the `InputRequiredResult` multi-round trip at 2026-07-28, with one tool body for both eras; see [Dependencies](handlers/dependencies.md), [Multi-round-trip requests](handlers/multi-round-trip.md), and [Serving legacy clients](run/legacy-clients.md).
+
+**Before (v1):**
+
+```python
+@mcp.tool()
+async def book_table(date: str, ctx: Context) -> str:
+ result = await ctx.elicit(f"Book a table for {date}?", schema=Confirmation)
+ if result.action == "accept" and result.data.confirm:
+ return f"Booked for {date}."
+ return "No booking made."
+```
+
+**After (v2), era-portable:**
+
+```python
+from typing import Annotated
+
+from mcp.server.mcpserver import Elicit, Resolve
+
+
+async def ask_to_confirm(date: str) -> Elicit[Confirmation]:
+ return Elicit(f"Book a table for {date}?", Confirmation)
+
+
+@mcp.tool()
+async def book_table(date: str, answer: Annotated[Confirmation, Resolve(ask_to_confirm)]) -> str:
+ if answer.confirm:
+ return f"Booked for {date}."
+ return "No booking made."
+```
+
+The client's same `elicitation_callback` answers both; the resolver lets the server *return* the question instead of pushing it.
+
+### Log messages are delivered only to requests that opt in
+
+At 2026-07-28 the deprecated logging capability changes shape: `logging/setLevel` is gone, and log delivery becomes a per-request opt-in. A server MUST NOT send `notifications/message` for a request whose `_meta` lacks `io.modelcontextprotocol/logLevel`, and when the key is present it sends only entries at or above that level, on that request's own stream. So on a 2026-era connection the request-scoped log calls — `ctx.info(...)` and friends on `MCPServer`'s `Context`, `ctx.session.send_log_message(...)`, `Context.log(...)` — are silently dropped (debug-logged) unless the request opted in, and dropped when they fall below the requested level; `Connection.log(...)`, which has no request to opt in, never sends there. Nothing changes on 2025-11-25 and earlier connections.
+
+The most visible consequence is the in-process `Client(server)`, which negotiates 2026-07-28 by default: a `logging_callback` that used to receive every message now receives nothing until the client opts in. `Client` grows a `log_level` argument for exactly this, stamped as the reserved `_meta` key on every modern request:
+
+```python
+async with Client(server, logging_callback=on_log, log_level="info") as client:
+ await client.call_tool("chatty", {}) # info and above reach `on_log`
+```
+
+`log_level=None` (the default) means no opt-in — a `logging_callback` alone is not one — and a single request can override the client-wide default by supplying the key in its own `meta=` (e.g. `meta={LOG_LEVEL_META_KEY: "debug"}` from `mcp_types`). The opt-in is what the spec calls for on 2026-era servers generally, not just this SDK's. Because 2026 log delivery is request-scoped by construction, `related_request_id` on `send_log_message` no longer selects the standalone stream there: whatever is delivered rides the requesting stream.
+
+### Change notifications travel only on `subscriptions/listen` streams
+
+On a 2026-07-28 connection, `notifications/tools/list_changed`, `notifications/prompts/list_changed`, `notifications/resources/list_changed`, and `notifications/resources/updated` reach a client only through a `subscriptions/listen` stream it opened — the spec forbids sending a notification type a subscription did not request. The v1-style session helpers (`ctx.session.send_tool_list_changed()`, `send_prompt_list_changed()`, `send_resource_list_changed()`, `send_resource_updated(uri)`) push a bare copy onto the connection's standalone channel instead, so on such a connection they are dropped with a debug log: silently on streamable HTTP (there is no standalone channel), and on stdio, where earlier v2 releases wrote the bare notification to the shared pipe, it is now dropped too. On pre-2026 connections the helpers behave as in v1.
+
+Migrate to publishing on the subscription bus, which stamps and filters per stream: `await ctx.notify_tools_changed()`, `notify_prompts_changed()`, `notify_resources_changed()`, and `notify_resource_updated(uri)` on `MCPServer`'s `Context`, or `await bus.publish(...)` on a low-level `Server`'s own `SubscriptionBus` — see [Subscriptions](handlers/subscriptions.md). A stream only ever receives the kinds and URIs the server acknowledged for it; to gate per caller which subscriptions may be opened, refuse `subscriptions/listen` in a middleware (`MCPServer(middleware=[...])`), covered on the same page.
### Servers validate `Mcp-Param-*` headers against the request body ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243))
diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md
index 221a87dc41..9ef19a7cf7 100644
--- a/docs/protocol-versions.md
+++ b/docs/protocol-versions.md
@@ -68,10 +68,10 @@ A pin is a promise *you* make: you already know the server speaks that version.
A pin is not a discovery. Print `client.server_info` and the price is right there:
```text
- name='' title=None version='' description=None website_url=None icons=None
+ None
```
- The client never asked the server who it is, so `server_info` is a blank. `client.server_capabilities`
+ The client never asked the server who it is, so `server_info` is `None`. `client.server_capabilities`
is the same story: every capability is `None`. Tool calls still work (the protocol needs none of it);
code that reads `server_capabilities` to decide what to offer does not.
@@ -87,7 +87,7 @@ ValueError: mode must be 'legacy', 'auto', or one of ['2026-07-28']; got '2025-0
The probe is cheap, but it is still a round trip you pay on every reconnect, and the answer almost never changes.
-So keep it. After an `auto` connection, `client.session.discover_result` holds the exact `DiscoverResult` the server sent: its `supported_versions`, its `capabilities`, its `server_info`, its `instructions`. Hand it back as `prior_discover=` the next time:
+So keep it. After an `auto` connection, `client.session.discover_result` holds the exact `DiscoverResult` the server sent: its `supported_versions`, its `capabilities`, its `instructions`, and the identity the server stamped into the result's `_meta`. Hand it back as `prior_discover=` the next time:
```python title="client.py" hl_lines="15 17"
--8<-- "docs_src/protocol_versions/tutorial004.py"
@@ -112,7 +112,7 @@ The second connection made **zero** negotiation round trips and still knows exac
| --- | --- | --- |
| `Client(target)` | one `server/discover` probe; the `initialize` handshake if it fails | the newest version both sides speak, whichever era |
| `Client(target, mode="legacy")` | the `initialize` handshake | a handshake-era version; server-initiated requests work |
-| `Client(target, mode="2026-07-28")` | none | that version, pinned, with a blank `server_info` |
+| `Client(target, mode="2026-07-28")` | none | that version, pinned, with `server_info` as `None` |
| `Client(target, mode="2026-07-28", prior_discover=saved)` | none | that version, pinned, *and* the identity you saved last time |
## Recap
@@ -121,7 +121,7 @@ The second connection made **zero** negotiation round trips and still knows exac
* `mode="auto"` is the default: probe, fall back. Leave it alone unless one of the other three rows describes you.
* `client.protocol_version` is always the answer to "what did I get?".
* `mode="legacy"` forces the handshake. It is what you need for server-initiated requests: sampling, push elicitation, `message_handler`.
-* A version pin (`mode="2026-07-28"`) sends no negotiation traffic at all, at the cost of a blank `server_info`.
+* A version pin (`mode="2026-07-28"`) sends no negotiation traffic at all, at the cost of `client.server_info` being `None`.
* `prior_discover=` pays that cost back: save `client.session.discover_result`, reconnect with it, get both.
A modern connection has no push channel, so how does a 2026 server ask you a question mid-call? It returns it: **[Multi-round-trip requests](handlers/multi-round-trip.md)**.
diff --git a/docs/run/deploy.md b/docs/run/deploy.md
index 7cec58163b..24f25c2019 100644
--- a/docs/run/deploy.md
+++ b/docs/run/deploy.md
@@ -76,7 +76,7 @@ A **[multi-round-trip](../handlers/multi-round-trip.md)** tool needs something t
Here is a tool that asks before it acts, on a server that configures nothing:
-```python title="server.py" hl_lines="15 21"
+```python title="server.py" hl_lines="14 20"
--8<-- "docs_src/deploy/tutorial002.py"
```
@@ -111,7 +111,7 @@ The two rounds are two independent HTTP requests, and several ordinary things se
The fix is one argument. It has **two** halves.
-```python title="server.py" hl_lines="3 13 15"
+```python title="server.py" hl_lines="1 12 14"
--8<-- "docs_src/deploy/tutorial003.py"
```
diff --git a/docs/run/index.md b/docs/run/index.md
index f92090fee4..dbea20d0fe 100644
--- a/docs/run/index.md
+++ b/docs/run/index.md
@@ -39,31 +39,7 @@ python server.py
Nothing prints, and it doesn't return. It is waiting on stdin for a host to speak first.
-That also means stdout **is the wire**. A stray `print()` corrupts the stream; the `logging` module writes to stderr and is the right tool. That story is in **[Logging](../handlers/logging.md)**.
-
-On Windows, the same rule applies to child processes your tools start. A child
-that inherits the stdio server's stdin can block behind the server's protocol
-reader. If your tool starts a subprocess and you do not intend to feed it input,
-redirect the child's stdin:
-
-```python
-import asyncio
-import subprocess
-import sys
-
-
-async def run_script() -> tuple[bytes, bytes]:
- process = await asyncio.create_subprocess_exec(
- sys.executable,
- "script.py",
- stdin=subprocess.DEVNULL,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- )
- return await process.communicate()
-```
-
-The matching troubleshooting entry is **[My stdio tool hangs when it starts a subprocess on Windows](../troubleshooting.md#my-stdio-tool-hangs-when-it-starts-a-subprocess-on-windows)**.
+That also means stdout **is the wire**. While serving, the SDK moves the wire to a private descriptor and diverts output that is *flushed* to stdout (a subprocess writing to its inherited stdout, a flushed `print()`) to stderr, where it can't corrupt the stream. Output flushed to stdout *before* serving begins (a wrapper script echoing, an unbuffered import-time print) still lands on the wire, and so does a `print()` that stays buffered until the interpreter drains it at exit. For output you actually want, the `logging` module is the right tool: its handler flushes each record to stderr as it happens. That story is in **[Logging](../handlers/logging.md)**.
### Try it
@@ -89,7 +65,7 @@ Each transport has its own keyword arguments, all on `run()`:
* `host` / `port`: where to listen. Defaults `127.0.0.1` and `8000`.
* `streamable_http_path`: where the MCP endpoint lives. Default `/mcp`.
-* `json_response=True`: answer with plain JSON instead of an SSE stream.
+* `json_response=True`: answer each POST with a single JSON body instead of an SSE stream. That body has room for the response and nothing else, so a tool that calls back into the client mid-request (`ctx.elicit()`, sampling) raises `NoBackChannelError` on this leg, and notifications tied to the in-flight call (progress from `ctx.report_progress()`, per-call log messages) are dropped; the standalone `GET` stream still carries unrelated ones.
* `stateless_http=True`: a fresh transport per request, no session tracking.
* `max_request_body_size`: largest accepted POST body in bytes. Defaults to 4 MiB; larger requests
receive HTTP 413 before parsing or session creation. Raise it only when legitimate MCP messages
diff --git a/docs/run/legacy-clients.md b/docs/run/legacy-clients.md
index c7a1096db6..a1c0f76007 100644
--- a/docs/run/legacy-clients.md
+++ b/docs/run/legacy-clients.md
@@ -72,6 +72,13 @@ Two things about it matter more than what it does.
**It costs both server-to-client channels on that leg.** A session that lives for one `POST` has no stream for the server to push a request down and no standalone stream for it to push notifications down. Every server-initiated request raises `NoBackChannelError`: `ctx.elicit()`, the retired sampling and roots calls (**[Deprecated features](../deprecated.md)**), and, yes, `Resolve` asking a *legacy* client its question. Notifications don't even get an error; they are silently dropped.
+!!! note
+ `json_response=True` is not that knob, but it takes half the same cost on *every* legacy
+ session: a `POST` answered with one JSON body has no stream for the request-scoped channel,
+ so a mid-request `ctx.elicit()` raises the same `NoBackChannelError` and notifications tied to
+ the request are dropped. The session's standalone stream is untouched: unrelated notifications
+ still arrive.
+
!!! check
Do the wrong thing. `reserve` is the exact tool that just served both clients. Deploy it with
`stateless_http=True`, connect the same two clients over HTTP, and call it from each.
@@ -100,7 +107,7 @@ Tools, resources, prompts, structured output, progress, errors: none of them car
There is exactly one thing left, and it is **change notifications**, because the two eras listen on different pipes:
* A `2026-07-28` client opens a `subscriptions/listen` stream and reads the subscriptions bus. `ctx.notify_resource_updated()` (and `notify_tools_changed()`, `notify_prompts_changed()`, `notify_resources_changed()`) publish there, and *only* there. **[Subscriptions](../handlers/subscriptions.md)** is that page.
-* A legacy client reads the standalone stream its session keeps open. `ctx.session.send_resource_updated()` (and `send_tool_list_changed()` and friends) write to the *connection* that carried the request: for a legacy session, that is its standalone stream. For a modern HTTP request there is no such channel, and the notification is quietly dropped.
+* A legacy client reads the standalone stream its session keeps open. `ctx.session.send_resource_updated()` (and `send_tool_list_changed()` and friends) write to the *connection* that carried the request: for a legacy session, that is its standalone stream. A modern connection has no place for it: over HTTP there is no such channel, and over stdio the four change-notification kinds ride `subscriptions/listen` streams only, so on a modern connection the notification is quietly dropped.
Over HTTP, neither call reaches the other era's clients. To tell everyone, call both:
diff --git a/docs/servers/completions.md b/docs/servers/completions.md
index b7b8750fcd..1d7eca8e2c 100644
--- a/docs/servers/completions.md
+++ b/docs/servers/completions.md
@@ -21,7 +21,7 @@ Nothing here is about completions yet.
Add **one** function decorated with `@mcp.completion()`:
-```python title="server.py" hl_lines="22-30"
+```python title="server.py" hl_lines="21-29"
--8<-- "docs_src/completions/tutorial002.py"
```
@@ -91,7 +91,7 @@ You didn't list `completions` anywhere. The SDK saw the handler and declared the
That's what `context` is for. It carries the arguments the user has **already resolved**:
-```python title="server.py" hl_lines="9-12 35-39"
+```python title="server.py" hl_lines="8-11 34-38"
--8<-- "docs_src/completions/tutorial003.py"
```
diff --git a/docs/servers/handling-errors.md b/docs/servers/handling-errors.md
index 0cb0a7df32..4262f586a7 100644
--- a/docs/servers/handling-errors.md
+++ b/docs/servers/handling-errors.md
@@ -41,7 +41,7 @@ The model is the one calling your tool. It picked the arguments. So a tool error
Now swap `ValueError` for `MCPError`.
-```python title="server.py" hl_lines="1 3 15"
+```python title="server.py" hl_lines="1 3 14"
--8<-- "docs_src/handling_errors/tutorial002.py"
```
@@ -56,7 +56,7 @@ Now swap `ValueError` for `MCPError`.
* There is **no result**. No `content`, no `is_error`: nothing for the model to read.
* The **host** application gets the error instead, the same way it would if the tool didn't exist at all.
-* `code`, `message`, and `data` arrive intact. `INVALID_PARAMS` is `-32602`; `mcp_types` exports it and the other JSON-RPC error codes (`INVALID_REQUEST`, `INTERNAL_ERROR`, ...) as constants so you never type a magic number.
+* `code`, `message`, and `data` arrive intact. `INVALID_PARAMS` is `-32602`; `mcp.types` exports it and the other JSON-RPC error codes (`INVALID_REQUEST`, `INTERNAL_ERROR`, ...) as constants so you never type a magic number.
!!! check
Same lookup, same miss, but now the call *raises* on the client side instead of returning:
@@ -127,7 +127,7 @@ It means a whole class of `raise` statements you don't write: don't re-validate
* The deciding question: *could a smarter model have avoided this?* Yes -> exception. No -> `MCPError`.
* `ResourceNotFoundError` from a resource handler -> the protocol's `-32602`, with the URI in `data`.
* Bad arguments are rejected against the schema before your function runs; you don't `raise` for those.
-* `from mcp import MCPError`; the error-code constants come from `mcp_types`.
+* `from mcp import MCPError`; the error-code constants come from `mcp.types`.
Errors handled. That is everything a server *exposes*. What every handler can read, and do back to the client while it runs, is the next section: **[Inside your handler](../handlers/index.md)**.
diff --git a/docs/servers/media.md b/docs/servers/media.md
index df23966078..8655b77f12 100644
--- a/docs/servers/media.md
+++ b/docs/servers/media.md
@@ -29,7 +29,7 @@ Two things to notice:
* `structured_content` is `None`. An `Image` is content for the model to look at, not data for the application to parse: there is no output schema. (Contrast **[Structured Output](structured-output.md)**, where the return annotation *is* the schema.)
!!! info
- `ImageContent` and `AudioContent` live in `mcp_types`, right next to the `TextContent`
+ `ImageContent` and `AudioContent` live in `mcp.types`, right next to the `TextContent`
that a plain `str` result becomes (**[Tools](tools.md)**). A tool result is a list of content blocks; `Image` and `Audio` are
the shortest way to produce the two binary kinds.
@@ -85,7 +85,7 @@ A suffix it doesn't recognise falls back to `application/octet-stream`.
An `Icon` is metadata, not content. It doesn't carry the image; it points at one with a URI, and a client may fetch it and show it next to your server's name, a tool, a resource, or a prompt.
-```python title="server.py" hl_lines="5-6 8 11 17"
+```python title="server.py" hl_lines="4-5 7 10 16"
--8<-- "docs_src/media/tutorial004.py"
```
@@ -97,9 +97,10 @@ The same `icons=[...]` keyword is accepted by `MCPServer(...)`, `@mcp.tool()`, `
### Where a client sees them
-Icons travel with whatever they decorate. The server's arrive when the client connects, on `client.server_info`:
+Icons travel with whatever they decorate. The server's arrive when the client connects, on `client.server_info` (optional on 2026-era connections, so narrow it first):
```python
+assert client.server_info is not None # python-sdk servers identify themselves by default
client.server_info.icons # [Icon(src="https://example.com/brand-kit.png", mime_type="image/png", sizes=["48x48"])]
```
diff --git a/docs/servers/tools.md b/docs/servers/tools.md
index 8b7ee05721..5b728cb782 100644
--- a/docs/servers/tools.md
+++ b/docs/servers/tools.md
@@ -142,7 +142,7 @@ There is nothing else to configure.
Everything the SDK infers, you can override in the decorator:
-```python title="server.py" hl_lines="8-11"
+```python title="server.py" hl_lines="7-10"
--8<-- "docs_src/tools/tutorial005.py"
```
diff --git a/docs/servers/uri-templates.md b/docs/servers/uri-templates.md
index 6cda30eb30..406a8fda6a 100644
--- a/docs/servers/uri-templates.md
+++ b/docs/servers/uri-templates.md
@@ -215,7 +215,7 @@ return the protocol types yourself.
For fixed URIs, keep a registry and dispatch on exact match:
-```python title="server.py" hl_lines="18 22 28"
+```python title="server.py" hl_lines="17 21 27"
--8<-- "docs_src/uri_templates/tutorial004.py"
```
@@ -229,7 +229,7 @@ The template engine `MCPServer` uses lives in `mcp.shared.uri_template`
and works on its own. You get the same parsing and matching; you wire
up the routing and security policy yourself.
-```python title="server.py" hl_lines="14-17 23-26 30 34 46"
+```python title="server.py" hl_lines="13-16 22-25 29 33 45"
--8<-- "docs_src/uri_templates/tutorial005.py"
```
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index 1279448359..75a6652ecc 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -137,45 +137,10 @@ There is no error string for this, which is exactly why it is hard to search. Th
* **Is the tool on the `mcp` the host is running?** A second `MCPServer(...)` in another module is a different, empty server. Check which object the host's command actually imports.
* **Did two tools share a name?** Then one of them is gone. Look for `Tool already exists:` in the server log.
* **Is the host's list stale?** Adding a tool after startup only reaches clients that handle `notifications/tools/list_changed`. Restarting the host is the blunt fix.
-* **Did something write to `stdout`?** On a stdio transport, stdout *is* the protocol: one stray `print()` and the host drops the connection, which some hosts render as a server with nothing in it. Log with the `logging` module instead. The rest of the host-side checklist is on **[Connect to a real host](get-started/real-host.md)**.
+* **Did something write to `stdout` outside the diverted window?** While serving, the SDK diverts *flushed* stray stdout to stderr (best-effort: an environment that replaces the standard streams is served as-is), but output flushed to stdout earlier (a wrapper script echoing, an import-time `print()` in an unbuffered process) or a buffered `print()` drained at interpreter exit lands on the protocol stream, and one junk line can make the host drop the connection, which some hosts render as a server with nothing in it. Log with the `logging` module instead. The rest of the host-side checklist is on **[Connect to a real host](get-started/real-host.md)**.
An "invalid" tool name is *not* on that list: a non-conforming name logs a warning but the tool is registered and listed anyway.
-## My stdio tool hangs when it starts a subprocess on Windows
-
-Your server is running over `stdio`, and a tool starts another process with
-`asyncio.create_subprocess_exec`, `asyncio.create_subprocess_shell`, or
-`subprocess.Popen`. The tool call never returns on Windows, while the same code
-works over an HTTP transport.
-
-The child inherited the server's stdin. In a stdio server, stdin is the protocol
-pipe and the server is already waiting on it for the next JSON-RPC message. A
-Python child process on Windows can block during startup when it inherits that
-same pipe.
-
-If you do not intend to send input to the child, redirect its stdin:
-
-```python
-import asyncio
-import subprocess
-import sys
-
-
-async def run_script() -> tuple[bytes, bytes]:
- process = await asyncio.create_subprocess_exec(
- sys.executable,
- "script.py",
- stdin=subprocess.DEVNULL,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- )
- return await process.communicate()
-```
-
-Use the same idea with `subprocess.Popen(..., stdin=subprocess.DEVNULL)`. Also
-capture or redirect the child's stdout. The stdio server's stdout is the MCP
-wire, so a child that writes there can corrupt the connection.
-
## `MCPError: Server returned an error response`
The server refused the HTTP request outright, with a body that is not JSON-RPC, so the python `Client` has nothing better to show you than this stand-in.
@@ -326,7 +291,7 @@ async def main() -> None:
!!! info
`-32021` is `MISSING_REQUIRED_CLIENT_CAPABILITY`, one of three error codes the 2026-07-28
spec adds. None of them is an exception class: they all arrive as `MCPError`, and
- `e.error.code` is where to look. `mcp_types` exports the constants. The other two are
+ `e.error.code` is where to look. `mcp.types` exports the constants. The other two are
`-32020` `HEADER_MISMATCH` (an HTTP header disagrees with the request body it accompanies)
and `-32022` `UNSUPPORTED_PROTOCOL_VERSION` (the request named a version this server does not
speak). A conforming SDK client cannot produce either, so if you see one, look at whatever is
@@ -340,7 +305,7 @@ You see this one from `ctx.elicit()` on a legacy connection, and on any connecti
## `MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.`
-Your handler tried to reach the client mid-request, on a connection where nothing can carry a request from the server. There are exactly two ways to be on one.
+Your handler tried to reach the client mid-request, on a connection whose call has no channel that can carry a request from the server. There are three server configurations that put a call there.
**A `2026-07-28` connection: any transport, always.** The modern protocol has no server-initiated requests at all, so the server refuses before anything is sent. `ctx.elicit()` inside a tool is the classic way to meet this (on the very first in-memory test, since `Client(server)` negotiates `2026-07-28` without being asked), and passing `elicitation_callback=` changes nothing, because no request ever reaches the client for it to answer:
@@ -364,20 +329,23 @@ mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport
--8<-- "docs_src/troubleshooting/tutorial008.py"
```
+**A legacy connection on a `json_response=True` server.** The `POST` is answered with one JSON body, and one body carries only the response, so the request-scoped stream a mid-request `ctx.elicit()` needs does not exist here either. The session, its `Mcp-Session-Id`, and its standalone stream are all still there; only the request-scoped channel is gone.
+
The message names the method it could not send. `NoBackChannelError` is the class the server raises, but the wire carries only the base `MCPError`, so the sentence above is your traceback's last line, not the class name.
-The fix is the same for both: don't reach back mid-call. Move the question into a **resolver** (or return an `InputRequiredResult` yourself) and it becomes part of the *response*, which every connection can carry:
+For a `2026-07-28` client the fix is the same on all three: don't reach back mid-call. Move the question into a **resolver** (or return an `InputRequiredResult` yourself) and it becomes part of the *response*, which every connection can carry:
```python title="server.py" hl_lines="15-17 21"
--8<-- "docs_src/troubleshooting/tutorial007.py"
```
-Same question, same `elicitation_callback` on the client. The difference is under the hood: a resolver lets the server *return* the question from the call instead of pushing it, so nothing ever flows server-to-client. **[Elicitation](handlers/elicitation.md)** covers resolvers; **[Multi-round-trip requests](handlers/multi-round-trip.md)** covers what happens on the wire.
+Same question, same `elicitation_callback` on the client. The difference is under the hood: a resolver lets the server *return* the question from the call instead of pushing it, so nothing ever flows server-to-client. That rescues every `2026-07-28` client, whichever of the three configurations the server is in. A *legacy* client is not rescued by the rewrite alone: `2025-11-25` has no way to return a question, so on a legacy connection the resolver still sends `elicitation/create` down the request-scoped channel, and still needs a server that keeps it — neither `stateless_http=True` nor `json_response=True`. **[Elicitation](handlers/elicitation.md)** covers resolvers; **[Multi-round-trip requests](handlers/multi-round-trip.md)** covers what happens on the wire.
!!! check
The tool with `ctx.elicit()` is not wrong, it is *pre-2026*. Connect with `mode="legacy"`
- (the classic `initialize` handshake, spec `2025-11-25` and earlier) to a server that is not
- `stateless_http=True`, and it works, because the server-to-client channel exists there.
+ (the classic `initialize` handshake, spec `2025-11-25` and earlier) to a server that is neither
+ `stateless_http=True` nor `json_response=True`, and it works, because the server-to-client
+ channel exists there.
**[Protocol versions](protocol-versions.md)** is the page on what each version has.
## `MCPError: Invalid or expired requestState`
@@ -442,6 +410,6 @@ mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key
* One 421, three spellings: `Server returned an error response` (the python `Client`), `421 Misdirected Request` / `Invalid Host header` (everything else), `Invalid Host header: ` (the server log). Fix: `transport_security=TransportSecuritySettings(allowed_hosts=[...])`.
* `Task group is not initialized` -> a mounted app whose host lifespan never entered `mcp.session_manager.run()`.
* `Session not found` -> the server restarted; reconnect.
-* `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()` needs a server-to-client channel: a `2026-07-28` connection never has one, and `stateless_http=True` takes away the legacy one. Use a resolver. Its neighbour `Method not found` is a request for a method the other side's protocol revision doesn't have.
+* `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()` needs a server-to-client channel: a `2026-07-28` connection never has one, `stateless_http=True` takes away the legacy one, and `json_response=True` takes away the request-scoped one. Use a resolver (a legacy client also needs a server that keeps the channel). Its neighbour `Method not found` is a request for a method the other side's protocol revision doesn't have.
* `Client did not declare the form elicitation capability ...` and `Elicitation not supported` -> the client is missing `elicitation_callback=`.
* `Invalid or expired requestState` never says why on the wire. The server log does; `unknown key` means share `RequestStateSecurity(keys=[...])` across workers.
diff --git a/docs/whats-new.md b/docs/whats-new.md
index de29f0aefc..bc1bfd6c56 100644
--- a/docs/whats-new.md
+++ b/docs/whats-new.md
@@ -4,13 +4,10 @@ Two things happened at once in v2. The **SDK was rebuilt**: a new engine under b
This page is the tour of both halves, one section per headline, each ending in the page that owns the topic. It is not the porting manual. That is the **[Migration Guide](migration.md)**: every breaking change, with before and after code.
-!!! note "v2 is a beta"
- `pip install mcp` still installs v1.x: you opt into v2 with an exact version pin, and the
- API can still move before the stable release, which lands alongside the spec release.
- **[Installation](get-started/installation.md)** has the copy-paste install line and the
- pinning rules. And if anything in v2 breaks, surprises, or slows you down,
- [tell us](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml):
- while v2 is in beta, that is the most useful thing you can send us.
+!!! note "v2 is the stable line"
+ `pip install mcp` installs 2.x, and **[Installation](get-started/installation.md)** has the
+ copy-paste install line. If anything in v2 breaks, surprises, or slows you down,
+ [tell us](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml).
## The SDK: v1 to v2
@@ -44,7 +41,7 @@ v1 handed you three nested layers: a transport context manager yielding raw stre
--8<-- "docs_src/client/tutorial001.py"
```
-`Client` takes a server object (in memory, no transport: the testing story), a URL (Streamable HTTP), or any transport context manager such as `stdio_client(...)`. Entering `async with` connects and negotiates the protocol version, whichever era the server speaks; `client.server_info`, `client.server_capabilities`, and `client.protocol_version` are simply there afterwards. The sampling and elicitation callbacks you registered in v1 still work (their bodies see the same snake_case attribute rename as everything else on this page), they now also answer the 2026-style requests-inside-results (below), and they run concurrently instead of one at a time. `ClientSession` is still underneath for anyone who wants the low-level surface, and `client.session` hands it to you; it moved too (it runs on the new dispatcher engine, and some of its own signatures changed), so read the **[Migration Guide](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)** before you drop down.
+`Client` takes a server object (in memory, no transport: the testing story), a URL (Streamable HTTP), or any transport context manager such as `stdio_client(...)`. Entering `async with` connects and negotiates the protocol version, whichever era the server speaks; `client.server_capabilities` and `client.protocol_version` are simply there afterwards, and `client.server_info` is too when the server identifies itself (it is `Implementation | None` now, since 2026-era identity is optional). The sampling and elicitation callbacks you registered in v1 still work (their bodies see the same snake_case attribute rename as everything else on this page), they now also answer the 2026-style requests-inside-results (below), and they run concurrently instead of one at a time. `ClientSession` is still underneath for anyone who wants the low-level surface, and `client.session` hands it to you; it moved too (it runs on the new dispatcher engine, and some of its own signatures changed), so read the **[Migration Guide](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)** before you drop down.
**[The Client](client/index.md)** introduces it, **[Client transports](client/transports.md)** covers the three connection forms, **[Client callbacks](client/callbacks.md)** covers the callbacks themselves, and **[Testing](get-started/testing.md)** shows the in-memory pattern that replaces v1's `create_connected_server_and_client_session()` helper.
@@ -117,7 +114,7 @@ Underneath, the v1 `BaseSession` receive loop was replaced by a dispatcher engin
### The wire types moved to `mcp-types`, and every field is snake_case
-The protocol types now live in their own distribution, `mcp-types`, imported as `mcp_types`. It depends on nothing but pydantic and typing-extensions, so a gateway, a proxy, or a code generator can consume MCP's wire shapes without installing an HTTP stack. `mcp` depends on it at an exact version and re-exports the common names, so `from mcp import Tool` still works; `import mcp.types` does not.
+The protocol types now live in their own distribution, `mcp-types`. It depends on nothing but pydantic and typing-extensions, so a gateway, a proxy, or a code generator can consume MCP's wire shapes without installing an HTTP stack: such a project installs `mcp-types` and imports `mcp_types`. `mcp` itself depends on that package at an exact version and re-exposes it, so code that depends on the SDK keeps writing `import mcp.types as types` and `from mcp.types import Tool` (a permanent alias, every name the same object) and declares only its one real dependency, `mcp`. The rule of thumb: import through whichever package you actually depend on.
On those types, every Python attribute is now snake_case: `result.is_error`, `tool.input_schema`, `listing.next_cursor`. The JSON on the wire is camelCase, exactly as before; only the attribute spelling changed. Two stricter defaults ride along: unknown fields are ignored instead of round-tripped (put extras in `_meta`), and both sides validate traffic against the protocol version they negotiated. See the **[Migration Guide](migration.md#field-names-changed-from-camelcase-to-snake_case)** for the rename table.
@@ -146,7 +143,7 @@ Each of these is a section in the **[Migration Guide](migration.md)**:
* The **WebSocket transport**, both sides, and the `mcp[ws]` extra. It was never part of the MCP specification.
* The **experimental Tasks** API (`mcp.*.experimental`). 2026-07-28 moves tasks out of the core protocol and into an official extension ([SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663)), which this SDK does not implement yet.
-* `mcp.types`, `mcp.shared.version`, and `mcp.shared.progress` as import paths.
+* `mcp.shared.version`, `mcp.shared.progress`, and `mcp.shared.session` (with the `RequestResponder` stub v1 `message_handler` annotations imported) as import paths. (`mcp.types` is *not* removed: it remains as a permanent alias for the standalone `mcp_types` package.)
* The deprecated `streamablehttp_client` spelling, and the `get_session_id` callback from `streamable_http_client` (which now yields exactly two streams).
* `McpError`, renamed **`MCPError`** with a direct `(code, message, data)` constructor.
* `MCPServer.get_context()`, `mount_path=`, and the lowlevel `Server`'s decorator methods, ContextVar, and handler dicts.
@@ -191,12 +188,13 @@ That file is the pitch in one place: one server, one `Resolve`-backed tool, and
### Change notifications become one stream
-At 2026-07-28 the standalone HTTP GET stream and `resources/subscribe` are replaced by `subscriptions/listen`: the client opens one long-lived stream and names the notification kinds it wants. `MCPServer` serves it out of the box; you publish with `await ctx.notify_resource_updated(uri)` (and `notify_tools_changed()`, and so on), and multi-replica deployments plug in a shared `SubscriptionBus`. On the client (since `2.0.0b2`), `async with client.listen(...)` opens the stream: the filter goes in as keyword arguments, typed change events come back, and `sub.honored` is the subset the server agreed to deliver. One honest caveat: over stdio the server does not serve the stream yet.
+At 2026-07-28 the standalone HTTP GET stream and `resources/subscribe` are replaced by `subscriptions/listen`: the client opens one long-lived stream and names the notification kinds it wants. `MCPServer` serves it out of the box; you publish with `await ctx.notify_resource_updated(uri)` (and `notify_tools_changed()`, and so on), a middleware can refuse a listen request per caller, and multi-replica deployments plug in a shared `SubscriptionBus`. On the client, `async with client.listen(...)` opens the stream: the filter goes in as keyword arguments, typed change events come back, and `sub.honored` is the subset the server agreed to deliver.
**[Subscriptions](handlers/subscriptions.md)** covers publishing and serving, **[its Clients twin](client/subscriptions.md)** the watching end, and **[Deploy & scale](run/deploy.md)** the bus.
### The rest, quickly
+* **Identity is optional, per-message metadata.** The request-side `clientInfo` `_meta` key is optional (the required pair is `protocolVersion` + `clientCapabilities`), and `serverInfo` moved out of the `server/discover` result body: servers stamp it into every 2026-era result's `_meta` instead ([spec #3002](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3002)). The SDK always stamps; `client.server_info` is `None` when a server does not identify itself (for example, a middleware stripped the key). **[The low-level Server](advanced/low-level-server.md)** shows the stamp on the wire.
* **Requests are routable without parsing bodies.** Modern HTTP requests carry `Mcp-Method` (and, for the three tool-ish calls, `Mcp-Name`); a tool input-schema property annotated with `x-mcp-header` is mirrored into an `Mcp-Param-*` header and cross-checked by the server ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)). Gateways and rate limiters can route on headers alone; the **[Migration Guide](migration.md#servers-validate-mcp-param-headers-against-the-request-body-sep-2243)** has the rules.
* **Results carry cache hints.** List and read results declare `ttlMs` and `cacheScope` ([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549)); you set them per method with `cache_hints=`, and `Client` honors them with a built-in response cache. A server that sends no hints (every pre-2026 server) sees identical, uncached traffic. **[Caching hints](client/caching.md)**.
* **Extensions are first class.** Servers and clients declare optional capability bundles under reverse-DNS identifiers ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)); the built-in `Apps` extension (MCP Apps) is the reference. **[Extensions](advanced/extensions.md)** and **[MCP Apps](advanced/apps.md)**.
@@ -207,5 +205,5 @@ At 2026-07-28 the standalone HTTP GET stream and `resources/subscribe` are repla
## Upgrading from v1?
* The **[Migration Guide](migration.md)** is the complete, exact list of what to change; this page was the why.
-* **v1.x is not going anywhere.** It stays the stable line, with critical fixes and security patches, and nothing about the 2026-07-28 spec release breaks it. If you publish a library that depends on `mcp`, add an upper bound (for example `mcp>=1.27,<2`) so stable v2 does not surprise your users.
+* **v1.x is not going anywhere.** It moves to maintenance, keeps getting critical fixes and security patches, and nothing about the 2026-07-28 spec release breaks it; its docs live at [/v1/](https://py.sdk.modelcontextprotocol.io/v1/). If you publish a library that depends on `mcp` and are not ready to migrate, keep an upper bound (for example `mcp>=1.28,<2`) so an unpinned resolve stays on 1.x.
* Something rough, confusing, or broken? **[File v2 feedback](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml)**; it all gets read.
diff --git a/docs_src/caching/tutorial002.py b/docs_src/caching/tutorial002.py
index 6bbfec9e27..e1722e81f7 100644
--- a/docs_src/caching/tutorial002.py
+++ b/docs_src/caching/tutorial002.py
@@ -1,8 +1,7 @@
from typing import Any
-from mcp_types import ListToolsResult, PaginatedRequestParams, Tool
-
from mcp.server import CacheHint, Server, ServerRequestContext
+from mcp.types import ListToolsResult, PaginatedRequestParams, Tool
TOOLS = [Tool(name="forecast", input_schema={"type": "object"})]
diff --git a/docs_src/caching/tutorial003.py b/docs_src/caching/tutorial003.py
index 29c168c9f6..9ff3c36101 100644
--- a/docs_src/caching/tutorial003.py
+++ b/docs_src/caching/tutorial003.py
@@ -1,11 +1,10 @@
from dataclasses import dataclass
from typing import Any
-from mcp_types import ListToolsResult, PaginatedRequestParams, Tool
-
from mcp import Client
from mcp.client import CacheConfig
from mcp.server import CacheHint, Server, ServerRequestContext
+from mcp.types import ListToolsResult, PaginatedRequestParams, Tool
@dataclass
diff --git a/docs_src/client/tutorial003.py b/docs_src/client/tutorial003.py
index 1aeab63a49..bf74c46748 100644
--- a/docs_src/client/tutorial003.py
+++ b/docs_src/client/tutorial003.py
@@ -1,8 +1,8 @@
-from mcp_types import TextContent
from pydantic import BaseModel
from mcp import Client
from mcp.server import MCPServer
+from mcp.types import TextContent
mcp = MCPServer("Bookshop")
diff --git a/docs_src/client/tutorial004.py b/docs_src/client/tutorial004.py
index fddcde90a5..b0d62a7714 100644
--- a/docs_src/client/tutorial004.py
+++ b/docs_src/client/tutorial004.py
@@ -1,7 +1,6 @@
-from mcp_types import TextResourceContents
-
from mcp import Client
from mcp.server import MCPServer
+from mcp.types import TextResourceContents
mcp = MCPServer("Bookshop")
diff --git a/docs_src/client/tutorial006.py b/docs_src/client/tutorial006.py
index b76b6a0f11..370e0b79ef 100644
--- a/docs_src/client/tutorial006.py
+++ b/docs_src/client/tutorial006.py
@@ -1,7 +1,6 @@
-from mcp_types import Completion, CompletionArgument, CompletionContext, PromptReference, ResourceTemplateReference
-
from mcp import Client
from mcp.server import MCPServer
+from mcp.types import Completion, CompletionArgument, CompletionContext, PromptReference, ResourceTemplateReference
mcp = MCPServer("Bookshop")
diff --git a/docs_src/client/tutorial007.py b/docs_src/client/tutorial007.py
index 594b052020..c5c918bc63 100644
--- a/docs_src/client/tutorial007.py
+++ b/docs_src/client/tutorial007.py
@@ -1,7 +1,6 @@
-from mcp_types import Tool
-
from mcp import Client
from mcp.server import MCPServer
+from mcp.types import Tool
mcp = MCPServer("Bookshop")
diff --git a/docs_src/client_callbacks/tutorial002.py b/docs_src/client_callbacks/tutorial002.py
index 2bae985d60..a37fbd635b 100644
--- a/docs_src/client_callbacks/tutorial002.py
+++ b/docs_src/client_callbacks/tutorial002.py
@@ -1,7 +1,6 @@
-from mcp_types import ElicitRequestParams, ElicitResult
-
from mcp import Client
from mcp.client import ClientRequestContext
+from mcp.types import ElicitRequestParams, ElicitResult
async def handle_elicitation(
diff --git a/docs_src/client_callbacks/tutorial003.py b/docs_src/client_callbacks/tutorial003.py
index c7a269a36d..0ce615a6ff 100644
--- a/docs_src/client_callbacks/tutorial003.py
+++ b/docs_src/client_callbacks/tutorial003.py
@@ -1,8 +1,8 @@
-from mcp_types import ClientCapabilities, ElicitationCapability, RootsCapability, SamplingCapability
from pydantic import BaseModel
from mcp.server import MCPServer
from mcp.server.mcpserver import Context
+from mcp.types import ClientCapabilities, ElicitationCapability, RootsCapability, SamplingCapability
mcp = MCPServer("Library")
diff --git a/docs_src/client_callbacks/tutorial004.py b/docs_src/client_callbacks/tutorial004.py
index 20c9b81870..1c5fc2a601 100644
--- a/docs_src/client_callbacks/tutorial004.py
+++ b/docs_src/client_callbacks/tutorial004.py
@@ -1,7 +1,7 @@
-from mcp_types import CreateMessageRequestParams, CreateMessageResult, ListRootsResult, Root, TextContent
from pydantic import FileUrl
from mcp.client import ClientRequestContext
+from mcp.types import CreateMessageRequestParams, CreateMessageResult, ListRootsResult, Root, TextContent
async def handle_sampling(
diff --git a/docs_src/completions/tutorial002.py b/docs_src/completions/tutorial002.py
index 471527792b..01ec02c5cb 100644
--- a/docs_src/completions/tutorial002.py
+++ b/docs_src/completions/tutorial002.py
@@ -1,6 +1,5 @@
-from mcp_types import Completion, CompletionArgument, CompletionContext, PromptReference, ResourceTemplateReference
-
from mcp.server import MCPServer
+from mcp.types import Completion, CompletionArgument, CompletionContext, PromptReference, ResourceTemplateReference
mcp = MCPServer("GitHub Explorer")
diff --git a/docs_src/completions/tutorial003.py b/docs_src/completions/tutorial003.py
index 3cbe21bcd6..13897a5e3a 100644
--- a/docs_src/completions/tutorial003.py
+++ b/docs_src/completions/tutorial003.py
@@ -1,6 +1,5 @@
-from mcp_types import Completion, CompletionArgument, CompletionContext, PromptReference, ResourceTemplateReference
-
from mcp.server import MCPServer
+from mcp.types import Completion, CompletionArgument, CompletionContext, PromptReference, ResourceTemplateReference
mcp = MCPServer("GitHub Explorer")
diff --git a/docs_src/dependencies/tutorial004.py b/docs_src/dependencies/tutorial004.py
index ff55e5ce15..d08cc53b40 100644
--- a/docs_src/dependencies/tutorial004.py
+++ b/docs_src/dependencies/tutorial004.py
@@ -1,9 +1,8 @@
from typing import Annotated
-from mcp_types import CreateMessageResult, SamplingMessage, TextContent
-
from mcp.server import MCPServer
from mcp.server.mcpserver import Resolve, Sample
+from mcp.types import CreateMessageResult, SamplingMessage, TextContent
mcp = MCPServer("Bookshop")
diff --git a/docs_src/deploy/tutorial002.py b/docs_src/deploy/tutorial002.py
index 8b61aacac1..bb92fd9099 100644
--- a/docs_src/deploy/tutorial002.py
+++ b/docs_src/deploy/tutorial002.py
@@ -1,6 +1,5 @@
-from mcp_types import ElicitRequest, ElicitRequestFormParams, ElicitResult, InputRequiredResult
-
from mcp.server.mcpserver import Context, MCPServer
+from mcp.types import ElicitRequest, ElicitRequestFormParams, ElicitResult, InputRequiredResult
CONFIRM = ElicitRequest(
params=ElicitRequestFormParams(
diff --git a/docs_src/deploy/tutorial003.py b/docs_src/deploy/tutorial003.py
index 8d9d126c0c..f7ffc2e2a1 100644
--- a/docs_src/deploy/tutorial003.py
+++ b/docs_src/deploy/tutorial003.py
@@ -1,6 +1,5 @@
-from mcp_types import ElicitRequest, ElicitRequestFormParams, ElicitResult, InputRequiredResult
-
from mcp.server.mcpserver import Context, MCPServer, RequestStateSecurity
+from mcp.types import ElicitRequest, ElicitRequestFormParams, ElicitResult, InputRequiredResult
CONFIRM = ElicitRequest(
params=ElicitRequestFormParams(
diff --git a/docs_src/elicitation/tutorial003.py b/docs_src/elicitation/tutorial003.py
index f6bb4020b6..c63c3e00b7 100644
--- a/docs_src/elicitation/tutorial003.py
+++ b/docs_src/elicitation/tutorial003.py
@@ -1,7 +1,6 @@
-from mcp_types import ElicitRequestParams, ElicitRequestURLParams, ElicitResult
-
from mcp import Client
from mcp.client import ClientRequestContext
+from mcp.types import ElicitRequestParams, ElicitRequestURLParams, ElicitResult
async def handle_elicitation(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
diff --git a/docs_src/extensions/tutorial004.py b/docs_src/extensions/tutorial004.py
index 7ad32052d2..d3e2ef3bf8 100644
--- a/docs_src/extensions/tutorial004.py
+++ b/docs_src/extensions/tutorial004.py
@@ -1,9 +1,9 @@
from collections.abc import Sequence
from typing import Any, Literal
-import mcp_types as types
from pydantic import Field
+import mcp.types as types
from mcp import Client
from mcp.client import advertise
from mcp.server.context import ServerRequestContext
diff --git a/docs_src/extensions/tutorial005.py b/docs_src/extensions/tutorial005.py
index 61ec6c76bc..05b5210c79 100644
--- a/docs_src/extensions/tutorial005.py
+++ b/docs_src/extensions/tutorial005.py
@@ -1,11 +1,10 @@
import logging
from typing import Any
-from mcp_types import CallToolRequestParams
-
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
from mcp.server.extension import Extension
from mcp.server.mcpserver import MCPServer
+from mcp.types import CallToolRequestParams
logger = logging.getLogger(__name__)
diff --git a/docs_src/extensions/tutorial006.py b/docs_src/extensions/tutorial006.py
index 05ffbcb9d6..88592af99e 100644
--- a/docs_src/extensions/tutorial006.py
+++ b/docs_src/extensions/tutorial006.py
@@ -1,8 +1,7 @@
from collections.abc import Sequence
from typing import Any, Literal
-import mcp_types as types
-
+import mcp.types as types
from mcp import Client
from mcp.client import ClaimContext, ClientExtension, ResultClaim
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
diff --git a/docs_src/extensions/tutorial007.py b/docs_src/extensions/tutorial007.py
index 37706ca219..182fc8f61b 100644
--- a/docs_src/extensions/tutorial007.py
+++ b/docs_src/extensions/tutorial007.py
@@ -1,8 +1,7 @@
from collections.abc import Sequence
from typing import Any, Literal
-import mcp_types as types
-
+import mcp.types as types
from mcp import Client
from mcp.client import advertise
from mcp.server.context import ServerRequestContext
diff --git a/docs_src/handling_errors/tutorial002.py b/docs_src/handling_errors/tutorial002.py
index b45c67e967..52c3a261de 100644
--- a/docs_src/handling_errors/tutorial002.py
+++ b/docs_src/handling_errors/tutorial002.py
@@ -1,7 +1,6 @@
-from mcp_types import INVALID_PARAMS
-
from mcp import MCPError
from mcp.server import MCPServer
+from mcp.types import INVALID_PARAMS
mcp = MCPServer("Bookshop")
diff --git a/docs_src/identity_assertion/tutorial001.py b/docs_src/identity_assertion/tutorial001.py
index 3012f1ed17..afcd537896 100644
--- a/docs_src/identity_assertion/tutorial001.py
+++ b/docs_src/identity_assertion/tutorial001.py
@@ -9,7 +9,7 @@
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
-IDP_SIGNING_KEY = "the-enterprise-idp-signing-key"
+IDP_SIGNING_KEY = "the-enterprise-idp-signing-key-for-this-demo"
class InMemoryTokenStorage:
diff --git a/docs_src/identity_assertion/tutorial002.py b/docs_src/identity_assertion/tutorial002.py
index d537069f18..8b0dd599b0 100644
--- a/docs_src/identity_assertion/tutorial002.py
+++ b/docs_src/identity_assertion/tutorial002.py
@@ -21,7 +21,7 @@
ISSUER = "https://auth.example.com/"
MCP_SERVER = "http://localhost:8001/mcp"
IDP_ISSUER = "https://idp.example.com"
-IDP_SIGNING_KEY = "the-enterprise-idp-signing-key"
+IDP_SIGNING_KEY = "the-enterprise-idp-signing-key-for-this-demo"
REGISTERED_CLIENTS = {
"finance-agent": OAuthClientInformationFull(
diff --git a/docs_src/legacy_clients/tutorial001.py b/docs_src/legacy_clients/tutorial001.py
index 2f8b1191e4..2090201f91 100644
--- a/docs_src/legacy_clients/tutorial001.py
+++ b/docs_src/legacy_clients/tutorial001.py
@@ -1,12 +1,12 @@
from typing import Annotated
-from mcp_types import ElicitRequestParams, ElicitResult
from pydantic import BaseModel
from mcp import Client
from mcp.client import ClientRequestContext
from mcp.server import MCPServer
from mcp.server.mcpserver import AcceptedElicitation, Elicit, ElicitationResult, Resolve
+from mcp.types import ElicitRequestParams, ElicitResult
mcp = MCPServer("Bookshop")
diff --git a/docs_src/lowlevel/tutorial001.py b/docs_src/lowlevel/tutorial001.py
index 999c707f25..3b96aa2af4 100644
--- a/docs_src/lowlevel/tutorial001.py
+++ b/docs_src/lowlevel/tutorial001.py
@@ -1,4 +1,5 @@
-from mcp_types import (
+from mcp.server import Server, ServerRequestContext
+from mcp.types import (
CallToolRequestParams,
CallToolResult,
ListToolsResult,
@@ -7,8 +8,6 @@
Tool,
)
-from mcp.server import Server, ServerRequestContext
-
SEARCH_BOOKS = Tool(
name="search_books",
description="Search the catalog by title or author.",
diff --git a/docs_src/lowlevel/tutorial002.py b/docs_src/lowlevel/tutorial002.py
index d3033f6013..97eb4c4a6a 100644
--- a/docs_src/lowlevel/tutorial002.py
+++ b/docs_src/lowlevel/tutorial002.py
@@ -1,4 +1,5 @@
-from mcp_types import (
+from mcp.server import Server, ServerRequestContext
+from mcp.types import (
CallToolRequestParams,
CallToolResult,
ListToolsResult,
@@ -7,8 +8,6 @@
Tool,
)
-from mcp.server import Server, ServerRequestContext
-
SEARCH_BOOKS = Tool(
name="search_books",
description="Search the catalog by title or author.",
diff --git a/docs_src/lowlevel/tutorial003.py b/docs_src/lowlevel/tutorial003.py
index f350397006..682848588f 100644
--- a/docs_src/lowlevel/tutorial003.py
+++ b/docs_src/lowlevel/tutorial003.py
@@ -1,4 +1,5 @@
-from mcp_types import (
+from mcp.server import Server, ServerRequestContext
+from mcp.types import (
CallToolRequestParams,
CallToolResult,
ListToolsResult,
@@ -7,8 +8,6 @@
Tool,
)
-from mcp.server import Server, ServerRequestContext
-
SEARCH_BOOKS = Tool(
name="search_books",
description="Search the catalog by title or author.",
@@ -38,4 +37,4 @@ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) ->
)
-server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool)
+server = Server("Bookshop", version="2.0.0", on_list_tools=list_tools, on_call_tool=call_tool)
diff --git a/docs_src/lowlevel/tutorial004.py b/docs_src/lowlevel/tutorial004.py
index 18b0bef8f6..cb8dfe4e26 100644
--- a/docs_src/lowlevel/tutorial004.py
+++ b/docs_src/lowlevel/tutorial004.py
@@ -1,4 +1,5 @@
-from mcp_types import (
+from mcp.server import Server, ServerRequestContext
+from mcp.types import (
CallToolRequestParams,
CallToolResult,
ListToolsResult,
@@ -7,8 +8,6 @@
Tool,
)
-from mcp.server import Server, ServerRequestContext
-
SEARCH_BOOKS = Tool(
name="search_books",
description="Search the catalog by title or author.",
diff --git a/docs_src/lowlevel/tutorial005.py b/docs_src/lowlevel/tutorial005.py
index e33077ecec..69e58024b6 100644
--- a/docs_src/lowlevel/tutorial005.py
+++ b/docs_src/lowlevel/tutorial005.py
@@ -2,7 +2,8 @@
from contextlib import asynccontextmanager
from dataclasses import dataclass
-from mcp_types import (
+from mcp.server import Server, ServerRequestContext
+from mcp.types import (
CallToolRequestParams,
CallToolResult,
ListToolsResult,
@@ -11,8 +12,6 @@
Tool,
)
-from mcp.server import Server, ServerRequestContext
-
@dataclass
class Catalog:
diff --git a/docs_src/lowlevel/tutorial006.py b/docs_src/lowlevel/tutorial006.py
index 601fe5c576..158dca506d 100644
--- a/docs_src/lowlevel/tutorial006.py
+++ b/docs_src/lowlevel/tutorial006.py
@@ -1,4 +1,7 @@
-from mcp_types import (
+from pydantic import BaseModel
+
+from mcp.server import Server, ServerRequestContext
+from mcp.types import (
CallToolRequestParams,
CallToolResult,
ListToolsResult,
@@ -7,9 +10,6 @@
TextContent,
Tool,
)
-from pydantic import BaseModel
-
-from mcp.server import Server, ServerRequestContext
SEARCH_BOOKS = Tool(
name="search_books",
diff --git a/docs_src/media/tutorial004.py b/docs_src/media/tutorial004.py
index a06e6dfcd1..d0b717c866 100644
--- a/docs_src/media/tutorial004.py
+++ b/docs_src/media/tutorial004.py
@@ -1,6 +1,5 @@
-from mcp_types import Icon
-
from mcp.server import MCPServer
+from mcp.types import Icon
LOGO = Icon(src="https://example.com/brand-kit.png", mime_type="image/png", sizes=["48x48"])
PALETTE = Icon(src="https://example.com/palette.svg", mime_type="image/svg+xml", sizes=["any"])
diff --git a/docs_src/middleware/tutorial001.py b/docs_src/middleware/tutorial001.py
index 71be62db8f..0c26c48201 100644
--- a/docs_src/middleware/tutorial001.py
+++ b/docs_src/middleware/tutorial001.py
@@ -1,7 +1,9 @@
import logging
import time
-from mcp_types import (
+from mcp.server import Server, ServerRequestContext
+from mcp.server.context import CallNext, HandlerResult
+from mcp.types import (
CallToolRequestParams,
CallToolResult,
ListToolsResult,
@@ -10,9 +12,6 @@
Tool,
)
-from mcp.server import Server, ServerRequestContext
-from mcp.server.context import CallNext, HandlerResult
-
logger = logging.getLogger(__name__)
diff --git a/docs_src/mrtr/tutorial001.py b/docs_src/mrtr/tutorial001.py
index c0f4153cab..9175ca4e28 100644
--- a/docs_src/mrtr/tutorial001.py
+++ b/docs_src/mrtr/tutorial001.py
@@ -1,4 +1,5 @@
-from mcp_types import (
+from mcp.server import Server, ServerRequestContext
+from mcp.types import (
CallToolRequestParams,
CallToolResult,
ElicitRequest,
@@ -11,8 +12,6 @@
Tool,
)
-from mcp.server import Server, ServerRequestContext
-
ASK_REGION = ElicitRequest(
params=ElicitRequestFormParams(
message="Which region should the database live in?",
diff --git a/docs_src/mrtr/tutorial002.py b/docs_src/mrtr/tutorial002.py
index 0a14021833..23cc1b19f4 100644
--- a/docs_src/mrtr/tutorial002.py
+++ b/docs_src/mrtr/tutorial002.py
@@ -1,6 +1,5 @@
-from mcp_types import CallToolResult, ElicitRequest, ElicitResult, InputRequest, InputRequiredResult, InputResponse
-
from mcp import Client
+from mcp.types import CallToolResult, ElicitRequest, ElicitResult, InputRequest, InputRequiredResult, InputResponse
def fulfil(request: InputRequest) -> InputResponse:
diff --git a/docs_src/mrtr/tutorial003.py b/docs_src/mrtr/tutorial003.py
index 03eb6bf74f..6d7af85d9c 100644
--- a/docs_src/mrtr/tutorial003.py
+++ b/docs_src/mrtr/tutorial003.py
@@ -1,7 +1,6 @@
-from mcp_types import ElicitRequestParams, ElicitResult
-
from mcp import Client
from mcp.client import ClientRequestContext
+from mcp.types import ElicitRequestParams, ElicitResult
async def handle_elicitation(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
diff --git a/docs_src/mrtr/tutorial004.py b/docs_src/mrtr/tutorial004.py
index 05b945935f..8cf90bee5a 100644
--- a/docs_src/mrtr/tutorial004.py
+++ b/docs_src/mrtr/tutorial004.py
@@ -1,7 +1,6 @@
-from mcp_types import ElicitRequest, ElicitRequestFormParams, ElicitResult, InputRequiredResult
-
from mcp.server.mcpserver import Context, MCPServer
from mcp.server.mcpserver.prompts.base import UserMessage
+from mcp.types import ElicitRequest, ElicitRequestFormParams, ElicitResult, InputRequiredResult
mcp = MCPServer("Briefing")
diff --git a/docs_src/oauth_clients/tutorial002.py b/docs_src/oauth_clients/tutorial002.py
index 99865c6aea..dd4105f937 100644
--- a/docs_src/oauth_clients/tutorial002.py
+++ b/docs_src/oauth_clients/tutorial002.py
@@ -29,7 +29,7 @@ async def set_client_info(self, client_info: OAuthClientInformationFull) -> None
storage=InMemoryTokenStorage(),
client_id="reporting-agent",
client_secret="...",
- scopes="user",
+ scope="user",
)
diff --git a/docs_src/pagination/tutorial001.py b/docs_src/pagination/tutorial001.py
index 2ad4b9453f..3bc97540f9 100644
--- a/docs_src/pagination/tutorial001.py
+++ b/docs_src/pagination/tutorial001.py
@@ -1,8 +1,7 @@
from typing import Any
-from mcp_types import ListResourcesResult, PaginatedRequestParams, Resource
-
from mcp.server import Server, ServerRequestContext
+from mcp.types import ListResourcesResult, PaginatedRequestParams, Resource
BOOKS = [f"book-{n}" for n in range(1, 101)]
diff --git a/docs_src/pagination/tutorial002.py b/docs_src/pagination/tutorial002.py
index cacb796e8b..f72847772a 100644
--- a/docs_src/pagination/tutorial002.py
+++ b/docs_src/pagination/tutorial002.py
@@ -1,9 +1,8 @@
from typing import Any
-from mcp_types import ListResourcesResult, PaginatedRequestParams, Resource
-
from mcp import Client
from mcp.server import Server, ServerRequestContext
+from mcp.types import ListResourcesResult, PaginatedRequestParams, Resource
BOOKS = [f"book-{n}" for n in range(1, 101)]
diff --git a/docs_src/protocol_versions/tutorial004.py b/docs_src/protocol_versions/tutorial004.py
index c1b8fc6b5b..dd0443b972 100644
--- a/docs_src/protocol_versions/tutorial004.py
+++ b/docs_src/protocol_versions/tutorial004.py
@@ -16,4 +16,5 @@ async def main() -> None:
async with Client(mcp, mode="2026-07-28", prior_discover=saved) as client:
print(client.protocol_version)
- print(client.server_info.name)
+ if client.server_info is not None:
+ print(client.server_info.name)
diff --git a/docs_src/sampling_and_roots/tutorial001.py b/docs_src/sampling_and_roots/tutorial001.py
index c1e041c328..406d48d3ad 100644
--- a/docs_src/sampling_and_roots/tutorial001.py
+++ b/docs_src/sampling_and_roots/tutorial001.py
@@ -1,9 +1,8 @@
from typing import Annotated
-from mcp_types import CreateMessageResult, SamplingMessage, TextContent
-
from mcp.server import MCPServer
from mcp.server.mcpserver import Resolve, Sample
+from mcp.types import CreateMessageResult, SamplingMessage, TextContent
mcp = MCPServer("Bookshop")
diff --git a/docs_src/sampling_and_roots/tutorial002.py b/docs_src/sampling_and_roots/tutorial002.py
index 44a1d10578..1646d432b0 100644
--- a/docs_src/sampling_and_roots/tutorial002.py
+++ b/docs_src/sampling_and_roots/tutorial002.py
@@ -1,9 +1,8 @@
from typing import Annotated
-from mcp_types import ListRootsResult
-
from mcp.server import MCPServer
from mcp.server.mcpserver import ListRoots, Resolve
+from mcp.types import ListRootsResult
mcp = MCPServer("Bookshop")
diff --git a/docs_src/session_groups/tutorial004.py b/docs_src/session_groups/tutorial004.py
index 7d107669f7..88fcec9cd1 100644
--- a/docs_src/session_groups/tutorial004.py
+++ b/docs_src/session_groups/tutorial004.py
@@ -1,8 +1,7 @@
import asyncio
-from mcp_types import Implementation
-
from mcp import ClientSessionGroup, StdioServerParameters
+from mcp.types import Implementation
def by_server(name: str, server_info: Implementation) -> str:
diff --git a/docs_src/subscriptions/tutorial002.py b/docs_src/subscriptions/tutorial002.py
index 39e42dcc04..b5e99d1ed2 100644
--- a/docs_src/subscriptions/tutorial002.py
+++ b/docs_src/subscriptions/tutorial002.py
@@ -1,7 +1,6 @@
from typing import Any
-import mcp_types as types
-
+import mcp.types as types
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, ResourceUpdated
diff --git a/docs_src/subscriptions/tutorial003.py b/docs_src/subscriptions/tutorial003.py
index 811f6944bd..00abeacb8e 100644
--- a/docs_src/subscriptions/tutorial003.py
+++ b/docs_src/subscriptions/tutorial003.py
@@ -1,7 +1,6 @@
-from mcp_types import TextResourceContents
-
from mcp import Client
from mcp.client.subscriptions import ResourceUpdated, ToolsListChanged
+from mcp.types import TextResourceContents
BOARD = "board://sprint"
diff --git a/docs_src/subscriptions/tutorial006.py b/docs_src/subscriptions/tutorial006.py
new file mode 100644
index 0000000000..3e88f7ae74
--- /dev/null
+++ b/docs_src/subscriptions/tutorial006.py
@@ -0,0 +1,38 @@
+from mcp_types import INVALID_REQUEST, SubscriptionsListenRequestParams
+
+from mcp.server.auth.middleware.auth_context import get_access_token
+from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
+from mcp.server.mcpserver import MCPServer
+from mcp.shared.exceptions import MCPError
+
+# Who may see each file. Replace this table with a database or your RBAC system.
+ACCESS = {
+ "files://report.pdf": {"alice", "bob"},
+ "files://payroll.csv": {"carol"},
+}
+
+
+def can_access(user: str | None, uri: str) -> bool:
+ return user is not None and user in ACCESS.get(uri, set())
+
+
+async def gate_subscriptions(ctx: ServerRequestContext, call_next: CallNext) -> HandlerResult:
+ if ctx.method == "subscriptions/listen":
+ params = SubscriptionsListenRequestParams.model_validate(ctx.params or {}, by_name=False)
+ token = get_access_token()
+ user = token.subject if token else None
+ if not all(can_access(user, uri) for uri in params.notifications.resource_subscriptions or ()):
+ raise MCPError(INVALID_REQUEST, "not permitted to watch the requested resources")
+ return await call_next(ctx)
+
+
+mcp = MCPServer("Reports", middleware=[gate_subscriptions])
+
+
+@mcp.resource("files://{name}")
+def file(name: str) -> str:
+ uri = f"files://{name}"
+ token = get_access_token()
+ if not can_access(token.subject if token else None, uri):
+ raise MCPError(INVALID_REQUEST, f"Unknown resource: {uri}")
+ return f"contents of {name}"
diff --git a/docs_src/tools/tutorial005.py b/docs_src/tools/tutorial005.py
index f9fcbce966..9ba551293a 100644
--- a/docs_src/tools/tutorial005.py
+++ b/docs_src/tools/tutorial005.py
@@ -1,6 +1,5 @@
-from mcp_types import ToolAnnotations
-
from mcp.server import MCPServer
+from mcp.types import ToolAnnotations
mcp = MCPServer("Bookshop")
diff --git a/docs_src/uri_templates/tutorial004.py b/docs_src/uri_templates/tutorial004.py
index c1920b3cc5..5abeb94d85 100644
--- a/docs_src/uri_templates/tutorial004.py
+++ b/docs_src/uri_templates/tutorial004.py
@@ -1,4 +1,5 @@
-from mcp_types import (
+from mcp.server import Server, ServerRequestContext
+from mcp.types import (
ListResourcesResult,
PaginatedRequestParams,
ReadResourceRequestParams,
@@ -7,8 +8,6 @@
TextResourceContents,
)
-from mcp.server import Server, ServerRequestContext
-
RESOURCES = {
"config://shop": '{"currency": "USD", "tax_rate": 0.08}',
"status://health": "ok",
diff --git a/docs_src/uri_templates/tutorial005.py b/docs_src/uri_templates/tutorial005.py
index 716ff08dc1..94ac2facca 100644
--- a/docs_src/uri_templates/tutorial005.py
+++ b/docs_src/uri_templates/tutorial005.py
@@ -1,4 +1,7 @@
-from mcp_types import (
+from mcp.server import Server, ServerRequestContext
+from mcp.shared.path_security import contains_path_traversal, is_absolute_path
+from mcp.shared.uri_template import UriTemplate
+from mcp.types import (
ListResourceTemplatesResult,
PaginatedRequestParams,
ReadResourceRequestParams,
@@ -7,10 +10,6 @@
TextResourceContents,
)
-from mcp.server import Server, ServerRequestContext
-from mcp.shared.path_security import contains_path_traversal, is_absolute_path
-from mcp.shared.uri_template import UriTemplate
-
TEMPLATES = {
"manuals": UriTemplate.parse("manuals://{+path}"),
"books": UriTemplate.parse("books://{isbn}"),
diff --git a/docs_src/whats_new/tutorial001.py b/docs_src/whats_new/tutorial001.py
index 5e41ae1c04..0a2426cab8 100644
--- a/docs_src/whats_new/tutorial001.py
+++ b/docs_src/whats_new/tutorial001.py
@@ -1,4 +1,6 @@
-from mcp_types import (
+from mcp import MCPError
+from mcp.server import Server, ServerRequestContext
+from mcp.types import (
INVALID_PARAMS,
CallToolRequestParams,
CallToolResult,
@@ -8,9 +10,6 @@
Tool,
)
-from mcp import MCPError
-from mcp.server import Server, ServerRequestContext
-
SEARCH_BOOKS = Tool(
name="search_books",
description="Search the catalog by title or author.",
diff --git a/examples/mcpserver/direct_call_tool_result_return.py b/examples/mcpserver/direct_call_tool_result_return.py
index c73e6164f5..44a316bc6b 100644
--- a/examples/mcpserver/direct_call_tool_result_return.py
+++ b/examples/mcpserver/direct_call_tool_result_return.py
@@ -2,10 +2,10 @@
from typing import Annotated
-from mcp_types import CallToolResult, TextContent
from pydantic import BaseModel
from mcp.server.mcpserver import MCPServer
+from mcp.types import CallToolResult, TextContent
mcp = MCPServer("Echo Server")
diff --git a/examples/servers/everything-server/mcp_everything_server/server.py b/examples/servers/everything-server/mcp_everything_server/server.py
index 4b56a671c7..b22e76aeab 100644
--- a/examples/servers/everything-server/mcp_everything_server/server.py
+++ b/examples/servers/everything-server/mcp_everything_server/server.py
@@ -16,7 +16,8 @@
from mcp.server.mcpserver.prompts.base import Prompt, UserMessage
from mcp.server.streamable_http import EventCallback, EventMessage, EventStore
from mcp.shared.exceptions import MCPError
-from mcp_types import (
+from mcp.types import (
+ MISSING_REQUIRED_CLIENT_CAPABILITY,
AudioContent,
Completion,
CompletionArgument,
@@ -44,7 +45,6 @@
TextResourceContents,
UnsubscribeRequestParams,
)
-from mcp_types.jsonrpc import MISSING_REQUIRED_CLIENT_CAPABILITY
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
@@ -102,6 +102,7 @@ async def replay_events_after(self, last_event_id: EventId, send_callback: Event
mcp = MCPServer(
name="mcp-conformance-test-server",
+ version="0.1.0",
request_state_security=RequestStateSecurity(keys=[_REQUEST_STATE_KEY]),
)
@@ -357,13 +358,13 @@ async def test_missing_capability(ctx: Context) -> str:
``CallToolResult.isError``) so the conformance harness observes a protocol-level
error response with ``data.requiredCapabilities``.
"""
- client_params = ctx.session.client_params
- sampling_declared = client_params is not None and client_params.capabilities.sampling is not None
+ capabilities = ctx.session.client_capabilities
+ sampling_declared = capabilities is not None and capabilities.sampling is not None
if not sampling_declared:
raise MCPError(
code=MISSING_REQUIRED_CLIENT_CAPABILITY,
message="This tool requires the client 'sampling' capability",
- data={"requiredCapabilities": ["sampling"]},
+ data={"requiredCapabilities": {"sampling": {}}},
)
return "Client declared sampling capability; proceeding."
diff --git a/examples/servers/simple-pagination/mcp_simple_pagination/server.py b/examples/servers/simple-pagination/mcp_simple_pagination/server.py
index 9aca87f730..b2bf0cc611 100644
--- a/examples/servers/simple-pagination/mcp_simple_pagination/server.py
+++ b/examples/servers/simple-pagination/mcp_simple_pagination/server.py
@@ -8,7 +8,7 @@
import anyio
import click
-import mcp_types as types
+import mcp.types as types
from mcp.server import Server, ServerRequestContext
T = TypeVar("T")
diff --git a/examples/servers/simple-prompt/mcp_simple_prompt/server.py b/examples/servers/simple-prompt/mcp_simple_prompt/server.py
index 31e3eb7d76..6ddec6536c 100644
--- a/examples/servers/simple-prompt/mcp_simple_prompt/server.py
+++ b/examples/servers/simple-prompt/mcp_simple_prompt/server.py
@@ -1,6 +1,6 @@
import anyio
import click
-import mcp_types as types
+import mcp.types as types
from mcp.server import Server, ServerRequestContext
diff --git a/examples/servers/simple-resource/mcp_simple_resource/server.py b/examples/servers/simple-resource/mcp_simple_resource/server.py
index fe9dcfb709..24534cf35d 100644
--- a/examples/servers/simple-resource/mcp_simple_resource/server.py
+++ b/examples/servers/simple-resource/mcp_simple_resource/server.py
@@ -2,7 +2,7 @@
import anyio
import click
-import mcp_types as types
+import mcp.types as types
from mcp.server import Server, ServerRequestContext
SAMPLE_RESOURCES = {
diff --git a/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py b/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py
index 9df18cc6a2..575f8ab808 100644
--- a/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py
+++ b/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py
@@ -2,7 +2,7 @@
import anyio
import click
-import mcp_types as types
+import mcp.types as types
import uvicorn
from mcp.server import Server, ServerRequestContext
from starlette.middleware.cors import CORSMiddleware
diff --git a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/event_store.py b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/event_store.py
index c9369cfc2c..3501fa47ce 100644
--- a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/event_store.py
+++ b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/event_store.py
@@ -10,7 +10,7 @@
from uuid import uuid4
from mcp.server.streamable_http import EventCallback, EventId, EventMessage, EventStore, StreamId
-from mcp_types import JSONRPCMessage
+from mcp.types import JSONRPCMessage
logger = logging.getLogger(__name__)
diff --git a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py
index e650b35732..70ddaf10d3 100644
--- a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py
+++ b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py
@@ -2,7 +2,7 @@
import anyio
import click
-import mcp_types as types
+import mcp.types as types
import uvicorn
from mcp.server import Server, ServerRequestContext
from starlette.middleware.cors import CORSMiddleware
diff --git a/examples/servers/simple-tool/mcp_simple_tool/server.py b/examples/servers/simple-tool/mcp_simple_tool/server.py
index b16249e068..a43dd0f7b4 100644
--- a/examples/servers/simple-tool/mcp_simple_tool/server.py
+++ b/examples/servers/simple-tool/mcp_simple_tool/server.py
@@ -1,6 +1,6 @@
import anyio
import click
-import mcp_types as types
+import mcp.types as types
from mcp.server import Server, ServerRequestContext
from mcp.shared._httpx_utils import create_mcp_http_client
diff --git a/examples/servers/sse-polling-demo/mcp_sse_polling_demo/event_store.py b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/event_store.py
index e2cca4a2eb..c77bddef36 100644
--- a/examples/servers/sse-polling-demo/mcp_sse_polling_demo/event_store.py
+++ b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/event_store.py
@@ -10,7 +10,7 @@
from uuid import uuid4
from mcp.server.streamable_http import EventCallback, EventId, EventMessage, EventStore, StreamId
-from mcp_types import JSONRPCMessage
+from mcp.types import JSONRPCMessage
logger = logging.getLogger(__name__)
diff --git a/examples/servers/sse-polling-demo/mcp_sse_polling_demo/server.py b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/server.py
index 7d2c60fa32..452a3816da 100644
--- a/examples/servers/sse-polling-demo/mcp_sse_polling_demo/server.py
+++ b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/server.py
@@ -16,7 +16,7 @@
import anyio
import click
-import mcp_types as types
+import mcp.types as types
import uvicorn
from mcp.server import Server, ServerRequestContext
diff --git a/examples/servers/structured-output-lowlevel/mcp_structured_output_lowlevel/__main__.py b/examples/servers/structured-output-lowlevel/mcp_structured_output_lowlevel/__main__.py
index 393ff7a5a0..2fb62a947a 100644
--- a/examples/servers/structured-output-lowlevel/mcp_structured_output_lowlevel/__main__.py
+++ b/examples/servers/structured-output-lowlevel/mcp_structured_output_lowlevel/__main__.py
@@ -10,9 +10,8 @@
import random
from datetime import datetime
-import mcp_types as types
-
import mcp.server.stdio
+import mcp.types as types
from mcp.server import Server, ServerRequestContext
diff --git a/examples/snippets/clients/completion_client.py b/examples/snippets/clients/completion_client.py
index 52957d97d8..dc0c1b4f72 100644
--- a/examples/snippets/clients/completion_client.py
+++ b/examples/snippets/clients/completion_client.py
@@ -5,10 +5,9 @@
import asyncio
import os
-from mcp_types import PromptReference, ResourceTemplateReference
-
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
+from mcp.types import PromptReference, ResourceTemplateReference
# Create server parameters for stdio connection
server_params = StdioServerParameters(
diff --git a/examples/snippets/clients/pagination_client.py b/examples/snippets/clients/pagination_client.py
index 00663ef038..b9b8c23ae7 100644
--- a/examples/snippets/clients/pagination_client.py
+++ b/examples/snippets/clients/pagination_client.py
@@ -2,10 +2,9 @@
import asyncio
-from mcp_types import PaginatedRequestParams, Resource
-
from mcp.client.session import ClientSession
from mcp.client.stdio import StdioServerParameters, stdio_client
+from mcp.types import PaginatedRequestParams, Resource
async def list_all_resources() -> None:
diff --git a/examples/snippets/clients/parsing_tool_results.py b/examples/snippets/clients/parsing_tool_results.py
index f9aade41e3..6f2a985efb 100644
--- a/examples/snippets/clients/parsing_tool_results.py
+++ b/examples/snippets/clients/parsing_tool_results.py
@@ -2,8 +2,7 @@
import asyncio
-import mcp_types as types
-
+import mcp.types as types
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
diff --git a/examples/snippets/clients/stdio_client.py b/examples/snippets/clients/stdio_client.py
index 6fff083853..577189eb8d 100644
--- a/examples/snippets/clients/stdio_client.py
+++ b/examples/snippets/clients/stdio_client.py
@@ -5,8 +5,7 @@
import asyncio
import os
-import mcp_types as types
-
+import mcp.types as types
from mcp import ClientSession, StdioServerParameters
from mcp.client.context import ClientRequestContext
from mcp.client.stdio import stdio_client
diff --git a/examples/snippets/clients/url_elicitation_client.py b/examples/snippets/clients/url_elicitation_client.py
index de962eb718..14fc08d9f9 100644
--- a/examples/snippets/clients/url_elicitation_client.py
+++ b/examples/snippets/clients/url_elicitation_client.py
@@ -28,13 +28,12 @@
from typing import Any
from urllib.parse import urlparse
-import mcp_types as types
-from mcp_types import URL_ELICITATION_REQUIRED
-
+import mcp.types as types
from mcp import ClientSession
from mcp.client.context import ClientRequestContext
from mcp.client.sse import sse_client
from mcp.shared.exceptions import MCPError, UrlElicitationRequiredError
+from mcp.types import URL_ELICITATION_REQUIRED
async def handle_elicitation(
diff --git a/examples/snippets/servers/completion.py b/examples/snippets/servers/completion.py
index 7fc2f20454..47accffa3b 100644
--- a/examples/snippets/servers/completion.py
+++ b/examples/snippets/servers/completion.py
@@ -1,4 +1,5 @@
-from mcp_types import (
+from mcp.server.mcpserver import MCPServer
+from mcp.types import (
Completion,
CompletionArgument,
CompletionContext,
@@ -6,8 +7,6 @@
ResourceTemplateReference,
)
-from mcp.server.mcpserver import MCPServer
-
mcp = MCPServer(name="Example")
diff --git a/examples/snippets/servers/direct_call_tool_result.py b/examples/snippets/servers/direct_call_tool_result.py
index f3035338b3..4c98c358ee 100644
--- a/examples/snippets/servers/direct_call_tool_result.py
+++ b/examples/snippets/servers/direct_call_tool_result.py
@@ -2,10 +2,10 @@
from typing import Annotated
-from mcp_types import CallToolResult, TextContent
from pydantic import BaseModel
from mcp.server.mcpserver import MCPServer
+from mcp.types import CallToolResult, TextContent
mcp = MCPServer("CallToolResult Example")
diff --git a/examples/snippets/servers/elicitation.py b/examples/snippets/servers/elicitation.py
index 97e847b510..79453f543e 100644
--- a/examples/snippets/servers/elicitation.py
+++ b/examples/snippets/servers/elicitation.py
@@ -7,11 +7,11 @@
import uuid
-from mcp_types import ElicitRequestURLParams
from pydantic import BaseModel, Field
from mcp.server.mcpserver import Context, MCPServer
from mcp.shared.exceptions import UrlElicitationRequiredError
+from mcp.types import ElicitRequestURLParams
mcp = MCPServer(name="Elicitation Example")
diff --git a/examples/snippets/servers/lowlevel/basic.py b/examples/snippets/servers/lowlevel/basic.py
index ff9b0a2c49..6292a2d153 100644
--- a/examples/snippets/servers/lowlevel/basic.py
+++ b/examples/snippets/servers/lowlevel/basic.py
@@ -4,9 +4,8 @@
import asyncio
-import mcp_types as types
-
import mcp.server.stdio
+import mcp.types as types
from mcp.server import Server, ServerRequestContext
diff --git a/examples/snippets/servers/lowlevel/direct_call_tool_result.py b/examples/snippets/servers/lowlevel/direct_call_tool_result.py
index 4d6607d2ff..5545887f63 100644
--- a/examples/snippets/servers/lowlevel/direct_call_tool_result.py
+++ b/examples/snippets/servers/lowlevel/direct_call_tool_result.py
@@ -4,9 +4,8 @@
import asyncio
-import mcp_types as types
-
import mcp.server.stdio
+import mcp.types as types
from mcp.server import Server, ServerRequestContext
diff --git a/examples/snippets/servers/lowlevel/lifespan.py b/examples/snippets/servers/lowlevel/lifespan.py
index 46db9ecc07..747dfb3894 100644
--- a/examples/snippets/servers/lowlevel/lifespan.py
+++ b/examples/snippets/servers/lowlevel/lifespan.py
@@ -6,9 +6,8 @@
from contextlib import asynccontextmanager
from typing import TypedDict
-import mcp_types as types
-
import mcp.server.stdio
+import mcp.types as types
from mcp.server import Server, ServerRequestContext
diff --git a/examples/snippets/servers/lowlevel/structured_output.py b/examples/snippets/servers/lowlevel/structured_output.py
index 84e411ff55..70c6ebfb9d 100644
--- a/examples/snippets/servers/lowlevel/structured_output.py
+++ b/examples/snippets/servers/lowlevel/structured_output.py
@@ -5,9 +5,8 @@
import asyncio
import json
-import mcp_types as types
-
import mcp.server.stdio
+import mcp.types as types
from mcp.server import Server, ServerRequestContext
diff --git a/examples/snippets/servers/pagination_example.py b/examples/snippets/servers/pagination_example.py
index 4f7435acf6..6ee17e8102 100644
--- a/examples/snippets/servers/pagination_example.py
+++ b/examples/snippets/servers/pagination_example.py
@@ -1,7 +1,6 @@
"""Example of implementing pagination with the low-level MCP server."""
-import mcp_types as types
-
+import mcp.types as types
from mcp.server import Server, ServerRequestContext
# Sample data to paginate
diff --git a/examples/snippets/servers/sampling.py b/examples/snippets/servers/sampling.py
index 83ec5066dd..a3f6d5c7bd 100644
--- a/examples/snippets/servers/sampling.py
+++ b/examples/snippets/servers/sampling.py
@@ -1,6 +1,5 @@
-from mcp_types import SamplingMessage, TextContent
-
from mcp.server.mcpserver import Context, MCPServer
+from mcp.types import SamplingMessage, TextContent
mcp = MCPServer(name="Sampling Example")
diff --git a/examples/stories/_harness.py b/examples/stories/_harness.py
index 3ef52b3239..9501fc065a 100644
--- a/examples/stories/_harness.py
+++ b/examples/stories/_harness.py
@@ -19,13 +19,13 @@
import anyio
import httpx2
-from mcp_types.version import LATEST_MODERN_VERSION
from mcp import StdioServerParameters, stdio_client
from mcp.client import Transport
from mcp.client.streamable_http import streamable_http_client
from mcp.server import Server
from mcp.server.mcpserver import MCPServer
+from mcp.types.version import LATEST_MODERN_VERSION
if sys.version_info >= (3, 11):
import tomllib
@@ -162,7 +162,7 @@ def run_client(main: Callable[..., Awaitable[None]]) -> None:
if cfg["era"] == "dual-in-body":
# The story pins its connection modes inside ``main`` itself, so hand it "auto"
# (the ``Client`` default) and let those in-body pins decide. A hard version pin
- # here would skip the discover probe and leave ``server_info`` blank.
+ # here would skip the discover probe and leave `server_info` None.
era = "in-body"
mode = {"modern": LATEST_MODERN_VERSION, "legacy": "legacy", "in-body": "auto"}[era]
diff --git a/examples/stories/apps/client.py b/examples/stories/apps/client.py
index dd79071b1d..661cfbf457 100644
--- a/examples/stories/apps/client.py
+++ b/examples/stories/apps/client.py
@@ -1,9 +1,8 @@
"""Negotiate MCP Apps, discover a tool's `ui://` UI, fetch it, and call the tool."""
-from mcp_types import TextContent, TextResourceContents
-
from mcp.client import Client, advertise
from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID
+from mcp.types import TextContent, TextResourceContents
from stories._harness import Target, run_client
diff --git a/examples/stories/bearer_auth/server_lowlevel.py b/examples/stories/bearer_auth/server_lowlevel.py
index f5abfc08c4..e03cb26d03 100644
--- a/examples/stories/bearer_auth/server_lowlevel.py
+++ b/examples/stories/bearer_auth/server_lowlevel.py
@@ -2,10 +2,10 @@
from typing import Any
-import mcp_types as types
from pydantic import AnyHttpUrl
from starlette.applications import Starlette
+import mcp.types as types
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.settings import AuthSettings
from mcp.server.context import ServerRequestContext
diff --git a/examples/stories/custom_methods/client.py b/examples/stories/custom_methods/client.py
index 7bf27dd76c..5e64068fe5 100644
--- a/examples/stories/custom_methods/client.py
+++ b/examples/stories/custom_methods/client.py
@@ -2,8 +2,7 @@
from typing import Literal
-import mcp_types as types
-
+import mcp.types as types
from mcp.client import Client
from stories._harness import Target, run_client
diff --git a/examples/stories/custom_methods/server.py b/examples/stories/custom_methods/server.py
index 260aff787c..88013db2ae 100644
--- a/examples/stories/custom_methods/server.py
+++ b/examples/stories/custom_methods/server.py
@@ -6,8 +6,7 @@
from typing import Any
-import mcp_types as types
-
+import mcp.types as types
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from stories._hosting import run_server_from_args
diff --git a/examples/stories/dual_era/README.md b/examples/stories/dual_era/README.md
index f14f164027..6eca876934 100644
--- a/examples/stories/dual_era/README.md
+++ b/examples/stories/dual_era/README.md
@@ -30,7 +30,9 @@ leg fails there today — run over `--http`.
at construction; no date strings appear in the body.
- `client.py` — `client.protocol_version` / `client.server_info` /
`client.server_capabilities` are era-neutral: populated by `initialize` *or*
- `server/discover`, whichever ran.
+ `server/discover`, whichever ran. On the 2026 era `server_info` comes from
+ the optional `serverInfo` `_meta` stamp (`None` for a server that does not
+ identify itself); `initialize` always carries it.
- `server.py` — `ctx.request_context.protocol_version` is the era branch key
(lowlevel: `ctx.protocol_version` directly). Compare against
`MODERN_PROTOCOL_VERSIONS`, never a date literal.
diff --git a/examples/stories/dual_era/client.py b/examples/stories/dual_era/client.py
index ba9acf5d99..30eb262bac 100644
--- a/examples/stories/dual_era/client.py
+++ b/examples/stories/dual_era/client.py
@@ -1,9 +1,8 @@
"""Connect to the same server factory twice — once per era, so `main` takes `targets` — and assert both are served."""
-import mcp_types as types
-from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION
-
+import mcp.types as types
from mcp.client import Client
+from mcp.types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION
from stories._harness import TargetFactory, run_client
@@ -13,7 +12,11 @@ async def main(targets: TargetFactory, *, mode: str = "auto") -> None:
# The version/info/capabilities accessors are era-neutral.
async with Client(targets(), mode=mode) as modern:
assert modern.protocol_version == LATEST_MODERN_VERSION
- assert modern.server_info.name == "dual-era-example"
+ # On the 2026 era, server identity is an optional serverInfo stamp in the
+ # result _meta (None for an anonymous server); this server stamps it.
+ info = modern.server_info
+ assert info is not None, "the server stamps serverInfo into its results"
+ assert info.name == "dual-era-example"
assert modern.server_capabilities.tools is not None
listed = await modern.list_tools()
@@ -28,7 +31,9 @@ async def main(targets: TargetFactory, *, mode: str = "auto") -> None:
# The same accessors are populated identically — here by ``initialize``.
async with Client(targets(), mode="legacy") as legacy:
assert legacy.protocol_version == LATEST_HANDSHAKE_VERSION
- assert legacy.server_info.name == "dual-era-example"
+ info = legacy.server_info
+ assert info is not None, "initialize always carries serverInfo"
+ assert info.name == "dual-era-example"
assert legacy.server_capabilities.tools is not None
result = await legacy.call_tool("greet", {"name": "2025 client"})
diff --git a/examples/stories/dual_era/server.py b/examples/stories/dual_era/server.py
index 3f70ee63c9..59b6571ff8 100644
--- a/examples/stories/dual_era/server.py
+++ b/examples/stories/dual_era/server.py
@@ -1,8 +1,7 @@
"""One MCPServer factory that serves both the 2025 handshake era and the 2026 stateless era."""
-from mcp_types.version import MODERN_PROTOCOL_VERSIONS
-
from mcp.server.mcpserver import Context, MCPServer
+from mcp.types.version import MODERN_PROTOCOL_VERSIONS
from stories._hosting import run_server_from_args
diff --git a/examples/stories/dual_era/server_lowlevel.py b/examples/stories/dual_era/server_lowlevel.py
index b209135e6d..6402420172 100644
--- a/examples/stories/dual_era/server_lowlevel.py
+++ b/examples/stories/dual_era/server_lowlevel.py
@@ -2,11 +2,10 @@
from typing import Any
-import mcp_types as types
-from mcp_types.version import MODERN_PROTOCOL_VERSIONS
-
+import mcp.types as types
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
+from mcp.types.version import MODERN_PROTOCOL_VERSIONS
from stories._hosting import run_server_from_args
GREET_INPUT_SCHEMA: dict[str, Any] = {
diff --git a/examples/stories/error_handling/client.py b/examples/stories/error_handling/client.py
index 872ec7fe31..4a7cffb0c0 100644
--- a/examples/stories/error_handling/client.py
+++ b/examples/stories/error_handling/client.py
@@ -1,9 +1,8 @@
"""Prove the two error channels: is_error results return; MCPError raises."""
-from mcp_types import INVALID_PARAMS, TextContent
-
from mcp import MCPError
from mcp.client import Client
+from mcp.types import INVALID_PARAMS, TextContent
from stories._harness import Target, run_client
diff --git a/examples/stories/error_handling/server.py b/examples/stories/error_handling/server.py
index e4f3554433..96667a5d0c 100644
--- a/examples/stories/error_handling/server.py
+++ b/examples/stories/error_handling/server.py
@@ -1,10 +1,9 @@
"""Two error channels: ToolError -> is_error result; MCPError -> JSON-RPC protocol error."""
-from mcp_types import INVALID_PARAMS
-
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.shared.exceptions import MCPError
+from mcp.types import INVALID_PARAMS
from stories._hosting import run_server_from_args
diff --git a/examples/stories/error_handling/server_lowlevel.py b/examples/stories/error_handling/server_lowlevel.py
index 9bb9aef86a..81462abe3a 100644
--- a/examples/stories/error_handling/server_lowlevel.py
+++ b/examples/stories/error_handling/server_lowlevel.py
@@ -2,8 +2,7 @@
from typing import Any
-import mcp_types as types
-
+import mcp.types as types
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from mcp.shared.exceptions import MCPError
diff --git a/examples/stories/extensions/client.py b/examples/stories/extensions/client.py
index 0bb033d7a3..eceb4a1a58 100644
--- a/examples/stories/extensions/client.py
+++ b/examples/stories/extensions/client.py
@@ -2,10 +2,9 @@
from typing import Literal
-import mcp_types as types
-from mcp_types import TextContent
-
+import mcp.types as types
from mcp.client import Client, advertise
+from mcp.types import TextContent
from stories._harness import Target, run_client
EXTENSION_ID = "com.example/catalog"
diff --git a/examples/stories/extensions/server.py b/examples/stories/extensions/server.py
index 837c668dc5..7c34e8fa22 100644
--- a/examples/stories/extensions/server.py
+++ b/examples/stories/extensions/server.py
@@ -9,9 +9,9 @@
from collections.abc import Sequence
from typing import Any
-import mcp_types as types
from pydantic import Field
+import mcp.types as types
from mcp.server.context import ServerRequestContext
from mcp.server.extension import Extension, MethodBinding, ToolBinding
from mcp.server.mcpserver import MCPServer, require_client_extension
diff --git a/examples/stories/identity_assertion/idp.py b/examples/stories/identity_assertion/idp.py
index 5d77c665f1..9ce794249b 100644
--- a/examples/stories/identity_assertion/idp.py
+++ b/examples/stories/identity_assertion/idp.py
@@ -14,7 +14,7 @@
IDP_ISSUER = "https://idp.example.com"
# Demo only: a real IdP signs with its private key and the authorization server verifies the
# signature against the IdP's published JWKS. A shared HMAC secret keeps this story self-contained.
-IDP_SIGNING_KEY = "demo-idp-signing-key"
+IDP_SIGNING_KEY = "the-demo-idp-signing-key-for-this-story"
def issue_id_jag(*, subject: str, client_id: str, audience: str, resource: str, scope: str) -> str:
diff --git a/examples/stories/identity_assertion/server_lowlevel.py b/examples/stories/identity_assertion/server_lowlevel.py
index 1fcf8def79..8085276289 100644
--- a/examples/stories/identity_assertion/server_lowlevel.py
+++ b/examples/stories/identity_assertion/server_lowlevel.py
@@ -3,9 +3,9 @@
import json
from typing import Any
-import mcp_types as types
from starlette.applications import Starlette
+import mcp.types as types
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import ProviderTokenVerifier
from mcp.server.context import ServerRequestContext
diff --git a/examples/stories/json_response/client.py b/examples/stories/json_response/client.py
index 8cbfed3fce..c5a00a6760 100644
--- a/examples/stories/json_response/client.py
+++ b/examples/stories/json_response/client.py
@@ -6,15 +6,15 @@
"""
import httpx2
-from mcp_types import TextContent
-from mcp_types.version import LATEST_MODERN_VERSION
from mcp.client import Client
+from mcp.types import TextContent
+from mcp.types.version import LATEST_MODERN_VERSION
from stories._harness import Target, run_client
# The raw 2026-07-28 POST envelope: per-request `_meta` replaces the initialize handshake.
# The key/header strings are spelled out on purpose — this is the raw-wire story. In code
-# use the named constants instead: `mcp_types.PROTOCOL_VERSION_META_KEY` /
+# use the named constants instead: `mcp.types.PROTOCOL_VERSION_META_KEY` /
# `CLIENT_INFO_META_KEY` / `CLIENT_CAPABILITIES_META_KEY` and
# `mcp.shared.inbound.MCP_PROTOCOL_VERSION_HEADER` (`legacy_routing/` shows that form).
RAW_ENVELOPE_BODY: dict[str, object] = {
diff --git a/examples/stories/json_response/server_lowlevel.py b/examples/stories/json_response/server_lowlevel.py
index bcb14eb9ab..33c2ebdc3a 100644
--- a/examples/stories/json_response/server_lowlevel.py
+++ b/examples/stories/json_response/server_lowlevel.py
@@ -2,9 +2,9 @@
from typing import Any
-import mcp_types as types
from starlette.applications import Starlette
+import mcp.types as types
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from stories._hosting import NO_DNS_REBIND, run_app_from_args
diff --git a/examples/stories/legacy_elicitation/client.py b/examples/stories/legacy_elicitation/client.py
index 52bb95e516..96ce0ec4e0 100644
--- a/examples/stories/legacy_elicitation/client.py
+++ b/examples/stories/legacy_elicitation/client.py
@@ -1,7 +1,6 @@
"""Auto-answer form and URL elicitations and assert the tool result reflects them."""
-import mcp_types as types
-
+import mcp.types as types
from mcp.client import Client, ClientRequestContext
from stories._harness import Target, run_client
diff --git a/examples/stories/legacy_elicitation/server_lowlevel.py b/examples/stories/legacy_elicitation/server_lowlevel.py
index 08c7c3a766..6c93a9ed54 100644
--- a/examples/stories/legacy_elicitation/server_lowlevel.py
+++ b/examples/stories/legacy_elicitation/server_lowlevel.py
@@ -2,8 +2,7 @@
from typing import Any
-import mcp_types as types
-
+import mcp.types as types
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from stories._hosting import run_server_from_args
diff --git a/examples/stories/legacy_routing/README.md b/examples/stories/legacy_routing/README.md
index 84a4528c96..c36352aec9 100644
--- a/examples/stories/legacy_routing/README.md
+++ b/examples/stories/legacy_routing/README.md
@@ -94,8 +94,7 @@ eras need different auth, rate limits, or scaling.
- DNS-rebinding protection is on by default; the harness disables it
(`NO_DNS_REBIND`) because the in-process httpx2 client sends no `Origin`.
Drop the kwarg for a real deployment.
-- `mcp.shared.inbound` is a deep import path — a shorter re-export is planned
- before beta.
+- `mcp.shared.inbound` is a deep import path; there is no shorter re-export.
## Spec
diff --git a/examples/stories/legacy_routing/client.py b/examples/stories/legacy_routing/client.py
index b9b401a2d3..a32094727c 100644
--- a/examples/stories/legacy_routing/client.py
+++ b/examples/stories/legacy_routing/client.py
@@ -2,12 +2,11 @@
from typing import Any
-import mcp_types as types
-from mcp_types import CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY
-from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION
-
+import mcp.types as types
from mcp.client import Client
from mcp.shared.inbound import MCP_METHOD_HEADER, MCP_PROTOCOL_VERSION_HEADER, InboundLadderRejection
+from mcp.types import CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY
+from mcp.types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION
from stories._harness import TargetFactory, run_client
from .server import classify_era
diff --git a/examples/stories/legacy_routing/server.py b/examples/stories/legacy_routing/server.py
index 79cc2afa67..29712d09bb 100644
--- a/examples/stories/legacy_routing/server.py
+++ b/examples/stories/legacy_routing/server.py
@@ -3,13 +3,13 @@
from collections.abc import Mapping
from typing import Any, Literal
-from mcp_types import INVALID_PARAMS
-from mcp_types.version import MODERN_PROTOCOL_VERSIONS
from starlette.applications import Starlette
from starlette.middleware.cors import CORSMiddleware
from mcp.server.mcpserver import Context, MCPServer
from mcp.shared.inbound import InboundLadderRejection, InboundModernRoute, classify_inbound_request
+from mcp.types import INVALID_PARAMS
+from mcp.types.version import MODERN_PROTOCOL_VERSIONS
from stories._hosting import NO_DNS_REBIND, run_app_from_args
#: Response headers a browser-based MCP client must be able to read.
diff --git a/examples/stories/legacy_routing/server_lowlevel.py b/examples/stories/legacy_routing/server_lowlevel.py
index d2f763c8ec..034f9e1894 100644
--- a/examples/stories/legacy_routing/server_lowlevel.py
+++ b/examples/stories/legacy_routing/server_lowlevel.py
@@ -2,13 +2,13 @@
from typing import Any
-import mcp_types as types
-from mcp_types.version import MODERN_PROTOCOL_VERSIONS
from starlette.applications import Starlette
from starlette.middleware.cors import CORSMiddleware
+import mcp.types as types
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
+from mcp.types.version import MODERN_PROTOCOL_VERSIONS
from stories._hosting import NO_DNS_REBIND, run_app_from_args
from .server import MCP_ALLOWED_HEADERS, MCP_ALLOWED_METHODS, MCP_EXPOSED_HEADERS
diff --git a/examples/stories/lifespan/client.py b/examples/stories/lifespan/client.py
index 51633177fa..f84895cd9d 100644
--- a/examples/stories/lifespan/client.py
+++ b/examples/stories/lifespan/client.py
@@ -1,8 +1,7 @@
"""Prove the lifespan-yielded state is reachable from a tool call."""
-from mcp_types import TextContent
-
from mcp.client import Client
+from mcp.types import TextContent
from stories._harness import Target, run_client
diff --git a/examples/stories/lifespan/server_lowlevel.py b/examples/stories/lifespan/server_lowlevel.py
index 09945c12c3..c5301dc149 100644
--- a/examples/stories/lifespan/server_lowlevel.py
+++ b/examples/stories/lifespan/server_lowlevel.py
@@ -5,8 +5,7 @@
from dataclasses import dataclass
from typing import Any
-import mcp_types as types
-
+import mcp.types as types
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from stories._hosting import run_server_from_args
diff --git a/examples/stories/middleware/README.md b/examples/stories/middleware/README.md
index 599f890f80..cfce502f11 100644
--- a/examples/stories/middleware/README.md
+++ b/examples/stories/middleware/README.md
@@ -31,12 +31,13 @@ uv run python -m stories.middleware.client --http
## Caveats
-- **Lowlevel-only.** `Server.middleware` on `mcp.server.lowlevel.Server` is the
- one public hook; `MCPServer` has no public accessor for it yet (a
- `MCPServer.middleware` accessor is planned before beta).
+- **One list, two accessors.** `Server.middleware` on
+ `mcp.server.lowlevel.Server` is the hook this story uses; `MCPServer`
+ exposes the same list as `MCPServer.middleware` (or takes it at
+ construction as `MCPServer(name, middleware=[...])`).
- The middleware signature is **provisional** (see the TODO in
- `src/mcp/server/lowlevel/server.py`): it tightens to a covariant `Context[L]`
- and gains an outbound seam before v2 final.
+ `src/mcp/server/lowlevel/server.py`): it may change in a 2.x minor release,
+ tightening to a covariant `Context[L]` and gaining an outbound seam.
- `ServerMiddleware` / `CallNext` / `HandlerResult` are imported from
`mcp.server.context` (helper tier); not re-exported at `mcp.server.lowlevel`.
- Do **not** `await ctx.session.send_request(...)` while wrapping `initialize`
diff --git a/examples/stories/middleware/server.py b/examples/stories/middleware/server.py
index 076120dccd..42df3e8c6e 100644
--- a/examples/stories/middleware/server.py
+++ b/examples/stories/middleware/server.py
@@ -1,14 +1,13 @@
"""Dispatch-layer middleware: `Server.middleware` is the public hook.
-A lowlevel-only story: `MCPServer` has no public middleware accessor yet, so the
-one supported registration point is the `middleware` list on `lowlevel.Server`.
+This story registers on the lowlevel `Server`; `MCPServer` exposes the same
+list as `MCPServer.middleware`, so the recipe carries over unchanged.
"""
import json
from typing import Any
-import mcp_types as types
-
+import mcp.types as types
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
from mcp.server.lowlevel import Server
from stories._hosting import run_server_from_args
diff --git a/examples/stories/mrtr/client.py b/examples/stories/mrtr/client.py
index 7280fd0aed..eb770c712e 100644
--- a/examples/stories/mrtr/client.py
+++ b/examples/stories/mrtr/client.py
@@ -1,7 +1,6 @@
"""Drive the deploy tool both ways: the Client auto-loop, and a manual session-level loop."""
-import mcp_types as types
-
+import mcp.types as types
from mcp import MCPError
from mcp.client import Client, ClientRequestContext
from stories._harness import Target, run_client
diff --git a/examples/stories/mrtr/server.py b/examples/stories/mrtr/server.py
index 8155b90f4d..cb308b9bc4 100644
--- a/examples/stories/mrtr/server.py
+++ b/examples/stories/mrtr/server.py
@@ -1,8 +1,7 @@
"""Multi-round tool result (2026 era): a tool returns input_required and resumes from echoed state."""
-from mcp_types import ElicitRequest, ElicitRequestedSchema, ElicitRequestFormParams, ElicitResult, InputRequiredResult
-
from mcp.server.mcpserver import Context, MCPServer
+from mcp.types import ElicitRequest, ElicitRequestedSchema, ElicitRequestFormParams, ElicitResult, InputRequiredResult
from stories._hosting import run_server_from_args
CONFIRM_SCHEMA: ElicitRequestedSchema = {
diff --git a/examples/stories/mrtr/server_lowlevel.py b/examples/stories/mrtr/server_lowlevel.py
index 6f3f489d8b..4382acbf0a 100644
--- a/examples/stories/mrtr/server_lowlevel.py
+++ b/examples/stories/mrtr/server_lowlevel.py
@@ -2,8 +2,7 @@
from typing import Any
-import mcp_types as types
-
+import mcp.types as types
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from mcp.server.request_state import RequestStateBoundary, RequestStateSecurity
diff --git a/examples/stories/oauth/server_lowlevel.py b/examples/stories/oauth/server_lowlevel.py
index 0bc7799c1e..df2b0a4d29 100644
--- a/examples/stories/oauth/server_lowlevel.py
+++ b/examples/stories/oauth/server_lowlevel.py
@@ -2,9 +2,9 @@
from typing import Any
-import mcp_types as types
from starlette.applications import Starlette
+import mcp.types as types
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import ProviderTokenVerifier
from mcp.server.context import ServerRequestContext
diff --git a/examples/stories/oauth_client_credentials/client.py b/examples/stories/oauth_client_credentials/client.py
index 86d4057dc1..78dc7c7c3c 100644
--- a/examples/stories/oauth_client_credentials/client.py
+++ b/examples/stories/oauth_client_credentials/client.py
@@ -26,7 +26,7 @@ def build_auth(_http: httpx2.AsyncClient) -> httpx2.Auth:
storage=InMemoryTokenStorage(),
client_id=DEMO_CLIENT_ID,
client_secret=DEMO_CLIENT_SECRET,
- scopes=DEMO_SCOPE,
+ scope=DEMO_SCOPE,
)
diff --git a/examples/stories/oauth_client_credentials/server_lowlevel.py b/examples/stories/oauth_client_credentials/server_lowlevel.py
index ba2003dedf..cde947e9ed 100644
--- a/examples/stories/oauth_client_credentials/server_lowlevel.py
+++ b/examples/stories/oauth_client_credentials/server_lowlevel.py
@@ -5,13 +5,13 @@
import secrets
from typing import Any
-import mcp_types as types
from pydantic import AnyHttpUrl
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import Route
+import mcp.types as types
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import AccessToken
from mcp.server.context import ServerRequestContext
diff --git a/examples/stories/pagination/server_lowlevel.py b/examples/stories/pagination/server_lowlevel.py
index 55958a9624..cf024abf21 100644
--- a/examples/stories/pagination/server_lowlevel.py
+++ b/examples/stories/pagination/server_lowlevel.py
@@ -2,8 +2,7 @@
from typing import Any
-import mcp_types as types
-
+import mcp.types as types
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from mcp.shared.exceptions import MCPError
diff --git a/examples/stories/parallel_calls/client.py b/examples/stories/parallel_calls/client.py
index 945e5410a6..c940053dc8 100644
--- a/examples/stories/parallel_calls/client.py
+++ b/examples/stories/parallel_calls/client.py
@@ -1,9 +1,9 @@
"""Two concurrent `Client`s, so `main` takes `targets`; their rendezvous in one tool proves concurrent dispatch."""
import anyio
-from mcp_types import TextContent
from mcp.client import Client
+from mcp.types import TextContent
from stories._harness import TargetFactory, run_client
diff --git a/examples/stories/parallel_calls/server_lowlevel.py b/examples/stories/parallel_calls/server_lowlevel.py
index 32807e1706..2874e85dd1 100644
--- a/examples/stories/parallel_calls/server_lowlevel.py
+++ b/examples/stories/parallel_calls/server_lowlevel.py
@@ -4,8 +4,8 @@
from typing import Any
import anyio
-import mcp_types as types
+import mcp.types as types
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from stories._hosting import run_server_from_args
diff --git a/examples/stories/prompts/client.py b/examples/stories/prompts/client.py
index 22aae4af43..d683713204 100644
--- a/examples/stories/prompts/client.py
+++ b/examples/stories/prompts/client.py
@@ -1,8 +1,7 @@
"""List prompts, autocomplete an argument, then render both prompts."""
-from mcp_types import PromptReference, TextContent
-
from mcp.client import Client
+from mcp.types import PromptReference, TextContent
from stories._harness import Target, run_client
diff --git a/examples/stories/prompts/server.py b/examples/stories/prompts/server.py
index 2ef3fc3d83..9fe9788d22 100644
--- a/examples/stories/prompts/server.py
+++ b/examples/stories/prompts/server.py
@@ -1,9 +1,8 @@
"""Prompts primitive: register templates, list, render, complete an argument."""
-from mcp_types import Completion, CompletionArgument, CompletionContext, PromptReference, ResourceTemplateReference
-
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, UserMessage
+from mcp.types import Completion, CompletionArgument, CompletionContext, PromptReference, ResourceTemplateReference
from stories._hosting import run_server_from_args
LANGUAGES = ["python", "pytorch", "rust", "go", "typescript"]
diff --git a/examples/stories/prompts/server_lowlevel.py b/examples/stories/prompts/server_lowlevel.py
index 2fb41de8bc..2524f79dd9 100644
--- a/examples/stories/prompts/server_lowlevel.py
+++ b/examples/stories/prompts/server_lowlevel.py
@@ -2,8 +2,7 @@
from typing import Any
-import mcp_types as types
-
+import mcp.types as types
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from stories._hosting import run_server_from_args
diff --git a/examples/stories/reconnect/README.md b/examples/stories/reconnect/README.md
index 78d281e7a9..a5d3d8f595 100644
--- a/examples/stories/reconnect/README.md
+++ b/examples/stories/reconnect/README.md
@@ -35,7 +35,7 @@ uv run python -m stories.reconnect.client --http --server server_lowlevel
## Caveats
- `mode=` *without* `prior_discover=` synthesizes a placeholder
- whose `server_info` is `Implementation(name="", version="")`. Pass the cached
+ with no `serverInfo` stamp, so `server_info` reads `None`. Pass the cached
result to get real identity on reconnect. Whether `Client` should expose a
public synthesizer (or refuse the bare pin) is open.
- `client.session.discover_result` is a one-hop reach into the mechanics layer;
diff --git a/examples/stories/reconnect/client.py b/examples/stories/reconnect/client.py
index aab2312dc9..c9c8a548f8 100644
--- a/examples/stories/reconnect/client.py
+++ b/examples/stories/reconnect/client.py
@@ -1,9 +1,8 @@
"""Probe server/discover once, persist the result, reconnect with zero round-trips — a fresh `Client` via `targets`."""
-from mcp_types import DiscoverResult
-from mcp_types.version import LATEST_MODERN_VERSION
-
from mcp.client import Client
+from mcp.types import DiscoverResult
+from mcp.types.version import LATEST_MODERN_VERSION
from stories._harness import TargetFactory, run_client
@@ -15,7 +14,11 @@ async def main(targets: TargetFactory, *, mode: str = "auto") -> None:
discovered = client.session.discover_result
assert discovered is not None, "mode='auto' against a modern server populates discover_result"
assert client.protocol_version == LATEST_MODERN_VERSION
- assert client.server_info.name == "reconnect-example"
+ # On the 2026 era, server identity is an optional serverInfo stamp in the
+ # result _meta; an anonymous server reads as None. This one stamps it.
+ info = client.server_info
+ assert info is not None, "the server stamps serverInfo into its results"
+ assert info.name == "reconnect-example"
assert LATEST_MODERN_VERSION in discovered.supported_versions
result = await client.call_tool("add", {"a": 2, "b": 3})
@@ -28,11 +31,13 @@ async def main(targets: TargetFactory, *, mode: str = "auto") -> None:
# Reconnect: a version pin plus the cached DiscoverResult adopts the prior state with
# zero round-trips on entry. A Client cannot be re-entered after exit, so targets()
- # yields a fresh one. Without prior_discover= a bare pin would synthesize a blank
- # server_info — the cache is what makes the era-neutral accessors useful here.
+ # yields a fresh one. Without prior_discover= a bare pin would leave server_info
+ # None — the cache is what carries the server's identity stamp across reconnects.
async with Client(targets(), mode=LATEST_MODERN_VERSION, prior_discover=rehydrated) as second:
assert second.protocol_version == LATEST_MODERN_VERSION
- assert second.server_info.name == "reconnect-example"
+ info = second.server_info
+ assert info is not None, "the cached DiscoverResult carries the serverInfo stamp"
+ assert info.name == "reconnect-example"
assert second.server_capabilities.tools is not None
assert second.session.discover_result == rehydrated
diff --git a/examples/stories/reconnect/server_lowlevel.py b/examples/stories/reconnect/server_lowlevel.py
index 5c6a057d6e..ce6e2cb350 100644
--- a/examples/stories/reconnect/server_lowlevel.py
+++ b/examples/stories/reconnect/server_lowlevel.py
@@ -2,8 +2,7 @@
from typing import Any
-import mcp_types as types
-
+import mcp.types as types
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from stories._hosting import run_server_from_args
diff --git a/examples/stories/refund_desk/client.py b/examples/stories/refund_desk/client.py
index 0ff8d28fca..293947d5e5 100644
--- a/examples/stories/refund_desk/client.py
+++ b/examples/stories/refund_desk/client.py
@@ -1,7 +1,6 @@
"""Prove the refund amount is schema-hidden, resolvers memoize per call, and decline semantics differ per consumer."""
-import mcp_types as types
-
+import mcp.types as types
from mcp.client import Client, ClientRequestContext
from stories._harness import Target, run_client
diff --git a/examples/stories/resources/client.py b/examples/stories/resources/client.py
index 29f88d529a..9e12e51e7f 100644
--- a/examples/stories/resources/client.py
+++ b/examples/stories/resources/client.py
@@ -1,8 +1,7 @@
"""List resources and templates, then read both the static and templated URIs."""
-from mcp_types import TextResourceContents
-
from mcp.client import Client
+from mcp.types import TextResourceContents
from stories._harness import Target, run_client
diff --git a/examples/stories/resources/server_lowlevel.py b/examples/stories/resources/server_lowlevel.py
index 2161fecc9e..2431ffdba6 100644
--- a/examples/stories/resources/server_lowlevel.py
+++ b/examples/stories/resources/server_lowlevel.py
@@ -2,12 +2,11 @@
from typing import Any
-import mcp_types as types
-from mcp_types.jsonrpc import INVALID_PARAMS
-
+import mcp.types as types
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from mcp.shared.exceptions import MCPError
+from mcp.types import INVALID_PARAMS
from stories._hosting import run_server_from_args
diff --git a/examples/stories/roots/README.md b/examples/stories/roots/README.md
index d11bf8845e..c0aa34c421 100644
--- a/examples/stories/roots/README.md
+++ b/examples/stories/roots/README.md
@@ -3,7 +3,6 @@
> **Deprecated** in the 2026-07-28 protocol (SEP-2577); functional through the
> deprecation window. Migration: accept directory paths as ordinary tool
> parameters or resource URIs instead of relying on `roots/list`.
-> TODO(maxisbey): revisit before beta.
The client passes a `list_roots_callback` returning the filesystem locations it
is willing to expose; a server tool calls `ctx.session.list_roots()` mid-request
diff --git a/examples/stories/roots/client.py b/examples/stories/roots/client.py
index 9d8252991d..ce18cd10dc 100644
--- a/examples/stories/roots/client.py
+++ b/examples/stories/roots/client.py
@@ -1,9 +1,9 @@
"""Expose two filesystem roots and verify the server's tool can read them back."""
-from mcp_types import ListRootsResult, Root, TextContent
from pydantic import FileUrl
from mcp.client import Client, ClientRequestContext
+from mcp.types import ListRootsResult, Root, TextContent
from stories._harness import Target, run_client
diff --git a/examples/stories/roots/server_lowlevel.py b/examples/stories/roots/server_lowlevel.py
index 2696c946c5..af48d427a8 100644
--- a/examples/stories/roots/server_lowlevel.py
+++ b/examples/stories/roots/server_lowlevel.py
@@ -2,8 +2,7 @@
from typing import Any
-import mcp_types as types
-
+import mcp.types as types
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from stories._hosting import run_server_from_args
diff --git a/examples/stories/sampling/README.md b/examples/stories/sampling/README.md
index 1c4a9bf794..3c68be8511 100644
--- a/examples/stories/sampling/README.md
+++ b/examples/stories/sampling/README.md
@@ -3,7 +3,6 @@
> **Deprecated** in the 2026-07-28 protocol (SEP-2577); functional through the
> deprecation window. Migration: call your LLM provider directly from the
> server instead of requesting completions through the client.
-> TODO(maxisbey): revisit before beta.
A tool that asks the **client's** LLM for a completion mid-call — the inverted
MCP direction. The server holds no model API key; it awaits
diff --git a/examples/stories/sampling/client.py b/examples/stories/sampling/client.py
index 0ca88db996..93d3dddf1c 100644
--- a/examples/stories/sampling/client.py
+++ b/examples/stories/sampling/client.py
@@ -1,8 +1,7 @@
"""Supply a canned sampling_callback and assert its text round-trips through the tool."""
-from mcp_types import CreateMessageRequestParams, CreateMessageResult, TextContent
-
from mcp.client import Client, ClientRequestContext
+from mcp.types import CreateMessageRequestParams, CreateMessageResult, TextContent
from stories._harness import Target, run_client
diff --git a/examples/stories/sampling/server.py b/examples/stories/sampling/server.py
index c97d8ab24f..7481f2e36b 100644
--- a/examples/stories/sampling/server.py
+++ b/examples/stories/sampling/server.py
@@ -1,8 +1,7 @@
"""Sampling primitive: a tool asks the client's LLM for a completion mid-call."""
-from mcp_types import SamplingMessage, TextContent
-
from mcp.server.mcpserver import Context, MCPServer
+from mcp.types import SamplingMessage, TextContent
from stories._hosting import run_server_from_args
diff --git a/examples/stories/sampling/server_lowlevel.py b/examples/stories/sampling/server_lowlevel.py
index 5bc2a19436..82f87332af 100644
--- a/examples/stories/sampling/server_lowlevel.py
+++ b/examples/stories/sampling/server_lowlevel.py
@@ -2,8 +2,7 @@
from typing import Any
-import mcp_types as types
-
+import mcp.types as types
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from stories._hosting import run_server_from_args
diff --git a/examples/stories/schema_validators/client.py b/examples/stories/schema_validators/client.py
index 8f6794eddc..66e990bc61 100644
--- a/examples/stories/schema_validators/client.py
+++ b/examples/stories/schema_validators/client.py
@@ -1,8 +1,7 @@
"""Asserts each variant publishes a `who` object schema and the call round-trips."""
-from mcp_types import TextContent
-
from mcp.client import Client
+from mcp.types import TextContent
from stories._harness import Target, run_client
diff --git a/examples/stories/schema_validators/server_lowlevel.py b/examples/stories/schema_validators/server_lowlevel.py
index 02dca8d162..657c03202f 100644
--- a/examples/stories/schema_validators/server_lowlevel.py
+++ b/examples/stories/schema_validators/server_lowlevel.py
@@ -2,8 +2,7 @@
from typing import Any
-import mcp_types as types
-
+import mcp.types as types
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from stories._hosting import run_server_from_args
diff --git a/examples/stories/serve_one/README.md b/examples/stories/serve_one/README.md
index dd75486164..b71b07e566 100644
--- a/examples/stories/serve_one/README.md
+++ b/examples/stories/serve_one/README.md
@@ -34,15 +34,15 @@ uv run python -m stories.serve_one.client
## Caveats
- **Deep imports** — `serve_one`, `serve_connection`, and `Connection` are only
- reachable at `mcp.server.runner` / `mcp.server.connection` today; a shorter
- `mcp.server.*` re-export is tracked for beta.
+ reachable at `mcp.server.runner` / `mcp.server.connection`; there is no
+ shorter `mcp.server.*` re-export.
- **Lowlevel-only.** The drivers take a `lowlevel.Server` and `MCPServer` has
no public accessor for its underlying one (`_lowlevel_server` is private), so
there is no `MCPServer`-tier variant of this story. Build the lowlevel
`Server` directly until that accessor lands.
- **No public `DispatchContext`** — `SingleExchangeContext` is hand-rolled
- boilerplate; a public helper (or a `serve_one` overload that builds one) is
- tracked for beta.
+ boilerplate; there is no public helper (or `serve_one` overload) that builds
+ one.
- **Lifespan** — the transport entry enters `server.lifespan(server)` **once**
and threads `lifespan_state` to every `handle_one()` call; never enter it
per-request.
diff --git a/examples/stories/serve_one/client.py b/examples/stories/serve_one/client.py
index 73bd457e10..b75510e9a6 100644
--- a/examples/stories/serve_one/client.py
+++ b/examples/stories/serve_one/client.py
@@ -1,9 +1,8 @@
"""Drive `handle_one` directly to assert the raw result-dict shape, then over the wire."""
-import mcp_types as types
-from mcp_types.version import LATEST_MODERN_VERSION
-
+import mcp.types as types
from mcp.client import Client
+from mcp.types.version import LATEST_MODERN_VERSION
from stories._harness import Target, run_client
from stories.serve_one.server import build_server, handle_one
diff --git a/examples/stories/serve_one/server.py b/examples/stories/serve_one/server.py
index 447e4a82b8..774a08fc39 100644
--- a/examples/stories/serve_one/server.py
+++ b/examples/stories/serve_one/server.py
@@ -13,9 +13,8 @@
from typing import Any
import anyio
-import mcp_types as types
-from mcp_types.version import LATEST_MODERN_VERSION
+import mcp.types as types
from mcp.server.connection import Connection # deep-path import; shorter re-export planned
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
@@ -24,6 +23,7 @@
from mcp.shared.exceptions import NoBackChannelError
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
from mcp.shared.transport_context import TransportContext
+from mcp.types.version import LATEST_MODERN_VERSION
__all__ = ["SingleExchangeContext", "build_server", "handle_one"]
diff --git a/examples/stories/sse_polling/README.md b/examples/stories/sse_polling/README.md
index 026ba3ce07..0a54a9fe18 100644
--- a/examples/stories/sse_polling/README.md
+++ b/examples/stories/sse_polling/README.md
@@ -4,7 +4,7 @@
> the sessionful transport are removed in the 2026-07-28 protocol (SEP-2575)
> with no modern-era equivalent; the closest 2026-era pattern is client-side
> reconnection over a persisted `DiscoverResult` —
-> [`reconnect/`](../reconnect/). TODO(maxisbey): revisit before beta.
+> [`reconnect/`](../reconnect/).
SEP-1699 server-initiated SSE disconnection with `Last-Event-ID` replay. The
server's `EventStore` stamps every SSE event with an ID and opens each response
diff --git a/examples/stories/sse_polling/client.py b/examples/stories/sse_polling/client.py
index d2f3918952..39cec5dc93 100644
--- a/examples/stories/sse_polling/client.py
+++ b/examples/stories/sse_polling/client.py
@@ -1,9 +1,9 @@
"""Call a tool whose SSE stream the server closes mid-flight; the call still completes. HTTP-only — no SSE on stdio."""
import anyio
-from mcp_types import TextContent
from mcp.client import Client
+from mcp.types import TextContent
from stories._harness import Target, run_client
diff --git a/examples/stories/sse_polling/event_store.py b/examples/stories/sse_polling/event_store.py
index 95d2b8accf..1cd24827a7 100644
--- a/examples/stories/sse_polling/event_store.py
+++ b/examples/stories/sse_polling/event_store.py
@@ -4,9 +4,8 @@
this interface with persistent storage so replay survives a process restart.
"""
-from mcp_types import JSONRPCMessage
-
from mcp.server.streamable_http import EventCallback, EventId, EventMessage, EventStore, StreamId
+from mcp.types import JSONRPCMessage
class InMemoryEventStore(EventStore):
diff --git a/examples/stories/sse_polling/server_lowlevel.py b/examples/stories/sse_polling/server_lowlevel.py
index fcf3199861..72cb79d61f 100644
--- a/examples/stories/sse_polling/server_lowlevel.py
+++ b/examples/stories/sse_polling/server_lowlevel.py
@@ -2,9 +2,9 @@
from typing import Any
-import mcp_types as types
from starlette.applications import Starlette
+import mcp.types as types
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from stories._hosting import NO_DNS_REBIND, run_app_from_args
diff --git a/examples/stories/standalone_get/client.py b/examples/stories/standalone_get/client.py
index aaf870f0e7..7e9801db22 100644
--- a/examples/stories/standalone_get/client.py
+++ b/examples/stories/standalone_get/client.py
@@ -1,9 +1,9 @@
"""Receive `notifications/resources/list_changed` over the standalone GET stream, then re-list."""
import anyio
-import mcp_types as types
-from mcp.client import Client
+import mcp.types as types
+from mcp.client import Client, IncomingMessage
from stories._harness import Target, run_client
@@ -13,7 +13,7 @@ async def main(target: Target, *, mode: str = "auto") -> None:
received: list[types.ResourceListChangedNotification] = []
seen = anyio.Event()
- async def on_message(message: object) -> None:
+ async def on_message(message: IncomingMessage) -> None:
if isinstance(message, types.ResourceListChangedNotification):
received.append(message)
seen.set()
diff --git a/examples/stories/standalone_get/server_lowlevel.py b/examples/stories/standalone_get/server_lowlevel.py
index 21ee8c1f1b..d8c054f10a 100644
--- a/examples/stories/standalone_get/server_lowlevel.py
+++ b/examples/stories/standalone_get/server_lowlevel.py
@@ -3,8 +3,7 @@
import itertools
from typing import Any
-import mcp_types as types
-
+import mcp.types as types
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from stories._hosting import run_server_from_args
diff --git a/examples/stories/starlette_mount/README.md b/examples/stories/starlette_mount/README.md
index 97b3a84bbe..0aa5a8ef51 100644
--- a/examples/stories/starlette_mount/README.md
+++ b/examples/stories/starlette_mount/README.md
@@ -45,8 +45,7 @@ kill "$SERVER_PID"
no `Origin` header. Remove it (or configure allowed hosts) for a real
deployment.
- The parent-lifespan dance is a known SDK ergonomics gap (other SDKs mount
- with no extra ceremony); tracked for the beta reshape. The recipe shown here
- is what works today.
+ with no extra ceremony). The recipe shown here is what works today.
## Spec
diff --git a/examples/stories/starlette_mount/client.py b/examples/stories/starlette_mount/client.py
index dcfc3495b3..c286577354 100644
--- a/examples/stories/starlette_mount/client.py
+++ b/examples/stories/starlette_mount/client.py
@@ -1,8 +1,7 @@
"""Connect to the sub-mounted MCP endpoint at /api/, list tools and call greet. HTTP-only: the mount is the story."""
-from mcp_types import TextContent
-
from mcp.client import Client
+from mcp.types import TextContent
from stories._harness import Target, run_client
diff --git a/examples/stories/stateless_legacy/client.py b/examples/stories/stateless_legacy/client.py
index d21ff850cf..1f9ea47fde 100644
--- a/examples/stories/stateless_legacy/client.py
+++ b/examples/stories/stateless_legacy/client.py
@@ -1,9 +1,8 @@
"""Connect at each era — two connections, so `main` takes `targets`; the same stateless app answers both."""
-from mcp_types import TextContent
-from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION
-
from mcp.client import Client
+from mcp.types import TextContent
+from mcp.types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION
from stories._harness import TargetFactory, run_client
diff --git a/examples/stories/stateless_legacy/server_lowlevel.py b/examples/stories/stateless_legacy/server_lowlevel.py
index 44943abd3d..1bcf358106 100644
--- a/examples/stories/stateless_legacy/server_lowlevel.py
+++ b/examples/stories/stateless_legacy/server_lowlevel.py
@@ -2,9 +2,9 @@
from typing import Any
-import mcp_types as types
from starlette.applications import Starlette
+import mcp.types as types
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from stories._hosting import NO_DNS_REBIND, run_app_from_args
diff --git a/examples/stories/stickynotes/client.py b/examples/stories/stickynotes/client.py
index 56ca10f551..b6b89151b3 100644
--- a/examples/stories/stickynotes/client.py
+++ b/examples/stories/stickynotes/client.py
@@ -1,10 +1,10 @@
"""Drive the sticky-notes board end to end and prove `remove_all` clears only on a confirmed elicitation."""
import anyio
-import mcp_types as types
-from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS
-from mcp.client import Client, ClientRequestContext
+import mcp.types as types
+from mcp.client import Client, ClientRequestContext, IncomingMessage
+from mcp.types.version import HANDSHAKE_PROTOCOL_VERSIONS
from stories._harness import Target, run_client
@@ -18,7 +18,7 @@ async def on_elicit(context: ClientRequestContext, params: types.ElicitRequestPa
return types.ElicitResult(action="cancel")
return types.ElicitResult(action="accept", content={"confirm": answer == "confirm"})
- async def on_message(message: object) -> None:
+ async def on_message(message: IncomingMessage) -> None:
if isinstance(message, types.ResourceListChangedNotification):
list_changed.set()
diff --git a/examples/stories/stickynotes/server_lowlevel.py b/examples/stories/stickynotes/server_lowlevel.py
index 15a20a797d..92266f144a 100644
--- a/examples/stories/stickynotes/server_lowlevel.py
+++ b/examples/stories/stickynotes/server_lowlevel.py
@@ -5,8 +5,7 @@
from dataclasses import dataclass, field
from typing import Any
-import mcp_types as types
-
+import mcp.types as types
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from stories._hosting import run_server_from_args
diff --git a/examples/stories/streaming/README.md b/examples/stories/streaming/README.md
index e6bedb915a..a7a37cab97 100644
--- a/examples/stories/streaming/README.md
+++ b/examples/stories/streaming/README.md
@@ -55,11 +55,13 @@ uv run python -m stories.streaming.client --http --server server_lowlevel
through the deprecation window. Migration: write to stderr or emit
OpenTelemetry instead of `notifications/message`. It is shown here because
servers still need to support 2025-era clients during that window. Progress
- and cancellation are **not** deprecated. TODO(maxisbey): revisit before beta.
-- When a request is cancelled the server currently replies with
- `ErrorData(code=0, message="Request cancelled")`; the spec says it should not
- reply at all. The client never observes it (its awaiting task is already
- cancelled), so this story does not assert on the reply.
+ and cancellation are **not** deprecated.
+- A cancelled request is not answered: no response follows
+ `notifications/cancelled`. (The 2025-era streamable HTTP transport is the one
+ exception - its wire ends a request only with a response, so it terminates
+ with a `-32800` `REQUEST_CANCELLED` error.) The client never observes any of
+ this - its awaiting task is already cancelled - so this story does not assert
+ on it.
## Spec
@@ -69,5 +71,6 @@ uv run python -m stories.streaming.client --http --server server_lowlevel
## See also
-`parallel_calls/` (concurrent in-flight calls), `error_handling/` (the
-cancellation error path), `tools/` (the basics this builds on).
+`parallel_calls/` (concurrent in-flight calls), `error_handling/` (error
+surfaces: `is_error` results vs protocol errors), `tools/` (the basics this
+builds on).
diff --git a/examples/stories/streaming/client.py b/examples/stories/streaming/client.py
index e584b4c1ef..7192e68814 100644
--- a/examples/stories/streaming/client.py
+++ b/examples/stories/streaming/client.py
@@ -1,9 +1,9 @@
"""Asserts progress + log notifications arrive in order, then cancels a call mid-flight."""
import anyio
-from mcp_types import LoggingMessageNotificationParams
from mcp.client import Client
+from mcp.types import LoggingMessageNotificationParams
from stories._harness import Target, run_client
@@ -15,7 +15,9 @@ async def main(target: Target, *, mode: str = "auto") -> None:
async def on_log(params: LoggingMessageNotificationParams) -> None:
logs.append(params)
- async with Client(target, mode=mode, logging_callback=on_log) as client:
+ # `log_level` is the 2026-07-28 per-request opt-in: without it a modern server
+ # sends no log notifications at all (pre-2026 servers ignore it and send anyway).
+ async with Client(target, mode=mode, logging_callback=on_log, log_level="info") as client:
# ── progress + logging: a short countdown delivers exactly `steps` of each, in order ──
updates: list[tuple[float, float | None, str | None]] = []
diff --git a/examples/stories/streaming/server.py b/examples/stories/streaming/server.py
index ced59878d7..8a39f48fad 100644
--- a/examples/stories/streaming/server.py
+++ b/examples/stories/streaming/server.py
@@ -1,8 +1,8 @@
"""Progress, in-flight logging, and cancellation from a single long-running tool."""
import anyio
-import mcp_types as types
+import mcp.types as types
from mcp.server.mcpserver import Context, MCPServer
from stories._hosting import run_server_from_args
@@ -16,9 +16,10 @@ async def countdown(steps: int, ctx: Context) -> dict[str, int]:
try:
for i in range(1, steps + 1):
await ctx.report_progress(float(i), float(steps), f"step {i}/{steps}")
- # No non-deprecated logging helper on Context yet, so send the raw
- # notification. `related_request_id` keeps it on this request's response
- # stream (matters over streamable HTTP).
+ # Protocol logging is deprecated (SEP-2577), so the raw notification
+ # keeps this warning-free. On 2026-07-28+ the client only receives it
+ # because it opts in with `log_level=`; `related_request_id` keeps it on
+ # this request's response stream (matters over streamable HTTP).
await ctx.request_context.session.send_notification(
types.LoggingMessageNotification(
params=types.LoggingMessageNotificationParams(
diff --git a/examples/stories/streaming/server_lowlevel.py b/examples/stories/streaming/server_lowlevel.py
index 6d9add0b6c..13393d49b1 100644
--- a/examples/stories/streaming/server_lowlevel.py
+++ b/examples/stories/streaming/server_lowlevel.py
@@ -3,8 +3,8 @@
from typing import Any
import anyio
-import mcp_types as types
+import mcp.types as types
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from stories._hosting import run_server_from_args
diff --git a/examples/stories/subscriptions/client.py b/examples/stories/subscriptions/client.py
index d2053aaf7c..a034f0a83e 100644
--- a/examples/stories/subscriptions/client.py
+++ b/examples/stories/subscriptions/client.py
@@ -1,8 +1,8 @@
"""Open a `subscriptions/listen` stream, watch one URI and the tool list, then close it."""
import anyio
-import mcp_types as types
+import mcp.types as types
from mcp.client import Client
from mcp.client.subscriptions import ResourceUpdated, ToolsListChanged
from stories._harness import Target, run_client
diff --git a/examples/stories/subscriptions/server_lowlevel.py b/examples/stories/subscriptions/server_lowlevel.py
index 6d9da182d5..d982c1ff13 100644
--- a/examples/stories/subscriptions/server_lowlevel.py
+++ b/examples/stories/subscriptions/server_lowlevel.py
@@ -2,8 +2,7 @@
from typing import Any
-import mcp_types as types
-
+import mcp.types as types
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from mcp.server.subscriptions import (
diff --git a/examples/stories/tools/client.py b/examples/stories/tools/client.py
index 74e1ab4c0f..55c22e3b64 100644
--- a/examples/stories/tools/client.py
+++ b/examples/stories/tools/client.py
@@ -1,8 +1,7 @@
"""List tools, inspect schemas + annotations, call both tools, assert structured output."""
-from mcp_types import TextContent
-
from mcp.client import Client
+from mcp.types import TextContent
from stories._harness import Target, run_client
diff --git a/examples/stories/tools/server.py b/examples/stories/tools/server.py
index a1f035c26a..93e4398092 100644
--- a/examples/stories/tools/server.py
+++ b/examples/stories/tools/server.py
@@ -2,10 +2,10 @@
from typing import Literal
-from mcp_types import ToolAnnotations
from pydantic import BaseModel
from mcp.server.mcpserver import MCPServer
+from mcp.types import ToolAnnotations
from stories._hosting import run_server_from_args
diff --git a/examples/stories/tools/server_lowlevel.py b/examples/stories/tools/server_lowlevel.py
index e6c4c05ef7..15cc7db364 100644
--- a/examples/stories/tools/server_lowlevel.py
+++ b/examples/stories/tools/server_lowlevel.py
@@ -2,8 +2,7 @@
from typing import Any
-import mcp_types as types
-
+import mcp.types as types
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from stories._hosting import run_server_from_args
diff --git a/mkdocs.yml b/mkdocs.yml
index 4c0cd06ad7..06b293f876 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -4,7 +4,7 @@ site_description: The official Python SDK for the Model Context Protocol
repo_name: modelcontextprotocol/python-sdk
repo_url: https://github.com/modelcontextprotocol/python-sdk
edit_uri: edit/main/docs/
-site_url: https://py.sdk.modelcontextprotocol.io/v2/
+site_url: https://py.sdk.modelcontextprotocol.io/
# TODO(Marcelo): Add Anthropic copyright?
# copyright: © Model Context Protocol 2025 to present
diff --git a/pyproject.toml b/pyproject.toml
index cab256720b..3c814106d1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -58,6 +58,9 @@ dev = [
# We add mcp[cli] so `uv sync` considers the extras.
"mcp[cli]",
"mcp-example-stories",
+ # pydantic-settings is used only by examples (simple-auth, mcpserver/text_me);
+ # keep it reachable for pyright, which covers examples/servers.
+ "pydantic-settings>=2.5.2",
"tomli>=2.0; python_version < '3.11'",
"pyright>=1.1.400",
"pytest>=8.4.0",
@@ -128,7 +131,6 @@ dependencies = [
"starlette>=0.27; python_version < '3.14'",
"python-multipart>=0.0.9",
"sse-starlette>=3.0.0",
- "pydantic-settings>=2.5.2",
"uvicorn>=0.31.1; sys_platform != 'emscripten'",
"jsonschema>=4.20.0",
"pywin32>=311; sys_platform == 'win32'",
@@ -140,7 +142,7 @@ dependencies = [
[project.urls]
Homepage = "https://modelcontextprotocol.io"
-Documentation = "https://py.sdk.modelcontextprotocol.io/v2/"
+Documentation = "https://py.sdk.modelcontextprotocol.io/"
Repository = "https://github.com/modelcontextprotocol/python-sdk"
Issues = "https://github.com/modelcontextprotocol/python-sdk/issues"
@@ -215,8 +217,10 @@ max-complexity = 24 # Default is 10
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401"]
+# The mcp.types package is an alias that mirrors mcp_types namespaces by design.
+"src/mcp/types/*.py" = ["F403"]
# Generated by scripts/gen_surface_types.py: raw datamodel-codegen output (TID251 lifts the repo-wide RootModel ban for these generated validators).
-"src/mcp-types/mcp_types/v*/__init__.py" = ["D212", "E501", "I001", "TID251", "UP007", "UP037"]
+"src/mcp-types/mcp_types/_v*/__init__.py" = ["D212", "E501", "I001", "TID251", "UP007", "UP037"]
"tests/server/mcpserver/test_func_metadata.py" = ["E501"]
"tests/shared/test_progress_notifications.py" = ["PLW0603"]
diff --git a/schema/2026-07-28.json b/schema/2026-07-28.json
index 87116a420e..7b0d05712d 100644
--- a/schema/2026-07-28.json
+++ b/schema/2026-07-28.json
@@ -123,7 +123,7 @@
"description": "A result that supports a time-to-live (TTL) hint for client-side caching.",
"properties": {
"_meta": {
- "$ref": "#/$defs/MetaObject"
+ "$ref": "#/$defs/ResultMetaObject"
},
"cacheScope": {
"description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).",
@@ -208,7 +208,7 @@
"description": "The result returned by the server for a {@link CallToolRequesttools/call} request.",
"properties": {
"_meta": {
- "$ref": "#/$defs/MetaObject"
+ "$ref": "#/$defs/ResultMetaObject"
},
"content": {
"description": "A list of content objects that represent the unstructured result of the tool call.",
@@ -501,7 +501,7 @@
"description": "The result returned by the server for a {@link CompleteRequestcompletion/complete} request.",
"properties": {
"_meta": {
- "$ref": "#/$defs/MetaObject"
+ "$ref": "#/$defs/ResultMetaObject"
},
"completion": {
"properties": {
@@ -740,7 +740,7 @@
"description": "The result returned by the server for a {@link DiscoverRequestserver/discover} request.",
"properties": {
"_meta": {
- "$ref": "#/$defs/MetaObject"
+ "$ref": "#/$defs/ResultMetaObject"
},
"cacheScope": {
"description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).",
@@ -762,10 +762,6 @@
"description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.",
"type": "string"
},
- "serverInfo": {
- "$ref": "#/$defs/Implementation",
- "description": "Information about the server software implementation."
- },
"supportedVersions": {
"description": "MCP Protocol Versions this server supports. The client should choose a\nversion from this list for use in subsequent requests.",
"items": {
@@ -783,7 +779,6 @@
"cacheScope",
"capabilities",
"resultType",
- "serverInfo",
"supportedVersions",
"ttlMs"
],
@@ -1084,7 +1079,7 @@
"description": "The result returned by the server for a {@link GetPromptRequestprompts/get} request.",
"properties": {
"_meta": {
- "$ref": "#/$defs/MetaObject"
+ "$ref": "#/$defs/ResultMetaObject"
},
"description": {
"description": "An optional description for the prompt.",
@@ -1310,7 +1305,7 @@
"description": "An InputRequiredResult sent by the server to indicate that additional input is needed\nbefore the request can be completed.\n\nAt least one of `inputRequests` or `requestState` MUST be present.",
"properties": {
"_meta": {
- "$ref": "#/$defs/MetaObject"
+ "$ref": "#/$defs/ResultMetaObject"
},
"inputRequests": {
"$ref": "#/$defs/InputRequests"
@@ -1644,7 +1639,7 @@
"description": "The result returned by the server for a {@link ListPromptsRequestprompts/list} request.",
"properties": {
"_meta": {
- "$ref": "#/$defs/MetaObject"
+ "$ref": "#/$defs/ResultMetaObject"
},
"cacheScope": {
"description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).",
@@ -1733,7 +1728,7 @@
"description": "The result returned by the server for a {@link ListResourceTemplatesRequestresources/templates/list} request.",
"properties": {
"_meta": {
- "$ref": "#/$defs/MetaObject"
+ "$ref": "#/$defs/ResultMetaObject"
},
"cacheScope": {
"description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).",
@@ -1822,7 +1817,7 @@
"description": "The result returned by the server for a {@link ListResourcesRequestresources/list} request.",
"properties": {
"_meta": {
- "$ref": "#/$defs/MetaObject"
+ "$ref": "#/$defs/ResultMetaObject"
},
"cacheScope": {
"description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).",
@@ -1947,7 +1942,7 @@
"description": "The result returned by the server for a {@link ListToolsRequesttools/list} request.",
"properties": {
"_meta": {
- "$ref": "#/$defs/MetaObject"
+ "$ref": "#/$defs/ResultMetaObject"
},
"cacheScope": {
"description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).",
@@ -2299,7 +2294,7 @@
"PaginatedResult": {
"properties": {
"_meta": {
- "$ref": "#/$defs/MetaObject"
+ "$ref": "#/$defs/ResultMetaObject"
},
"nextCursor": {
"description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.",
@@ -2600,7 +2595,7 @@
"description": "The result returned by the server for a {@link ReadResourceRequestresources/read} request.",
"properties": {
"_meta": {
- "$ref": "#/$defs/MetaObject"
+ "$ref": "#/$defs/ResultMetaObject"
},
"cacheScope": {
"description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).",
@@ -2700,7 +2695,7 @@
},
"io.modelcontextprotocol/clientInfo": {
"$ref": "#/$defs/Implementation",
- "description": "Identifies the client software making the request. Required.\n\nThe {@link Implementation} schema requires `name` and `version`; other\nfields are optional."
+ "description": "Identifies the client software making the request. Clients SHOULD\ninclude this field on every request unless specifically configured not\nto do so.\n\nThe {@link Implementation} schema requires `name` and `version`; other\nfields are optional.\n\nThe value is self-reported by the client and is not verified by the\nprotocol. It is intended for display, logging, and debugging. Servers\nSHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for\nsecurity decisions."
},
"io.modelcontextprotocol/logLevel": {
"$ref": "#/$defs/LoggingLevel",
@@ -2717,7 +2712,6 @@
},
"required": [
"io.modelcontextprotocol/clientCapabilities",
- "io.modelcontextprotocol/clientInfo",
"io.modelcontextprotocol/protocolVersion"
],
"type": "object"
@@ -3005,7 +2999,7 @@
"description": "Common result fields.",
"properties": {
"_meta": {
- "$ref": "#/$defs/MetaObject"
+ "$ref": "#/$defs/ResultMetaObject"
},
"resultType": {
"description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.",
@@ -3017,6 +3011,16 @@
],
"type": "object"
},
+ "ResultMetaObject": {
+ "description": "Extends {@link MetaObject} with additional result-specific fields. All key naming rules from `MetaObject` apply.",
+ "properties": {
+ "io.modelcontextprotocol/serverInfo": {
+ "$ref": "#/$defs/Implementation",
+ "description": "Identifies the server software producing the response. Servers SHOULD\ninclude this field on every response unless specifically configured not\nto do so.\n\nThe {@link Implementation} schema requires `name` and `version`; other\nfields are optional.\n\nThe value is self-reported by the server and is not verified by the\nprotocol. It is intended for display, logging, and debugging. Clients\nSHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for\nsecurity decisions."
+ }
+ },
+ "type": "object"
+ },
"ResultType": {
"description": "Indicates the type of a {@link Result} object, allowing the client to\ndetermine how to parse the response.\n\ncomplete - the request completed successfully and the result contains the final content.\ninput_required - the request requires additional input and the result contains an {@link InputRequiredResult} object with instructions for the client to provide additional input before retrying the original request.",
"type": "string"
@@ -3312,7 +3316,7 @@
"type": "object"
},
"SubscriptionsAcknowledgedNotification": {
- "description": "Sent by the server as the first message on a\n{@link SubscriptionsListenRequestsubscriptions/listen} stream to acknowledge\nthat the subscription has been established and to report which notification\ntypes it agreed to honor.",
+ "description": "Sent by the server to acknowledge that a\n{@link SubscriptionsListenRequestsubscriptions/listen} subscription has been\nestablished and to report which notification types it agreed to honor.\n\nThis notification MUST be the first message the server sends carrying the\nsubscription's ID in `io.modelcontextprotocol/subscriptionId`. The server MUST\nNOT send any notification on the subscription before acknowledging it. On\nstdio, where every subscription shares one channel, this ordering is defined\nper subscription ID and not per channel: messages belonging to other\nsubscriptions MAY be interleaved before it.",
"properties": {
"jsonrpc": {
"const": "2.0",
@@ -3410,8 +3414,12 @@
"type": "object"
},
"SubscriptionsListenResultMeta": {
- "description": "Extends {@link MetaObject} with the subscription-stream identifier carried by a\n{@link SubscriptionsListenResult}. All key naming rules from `MetaObject` apply.",
+ "description": "Extends {@link ResultMetaObject} with the subscription-stream identifier carried by a\n{@link SubscriptionsListenResult}. All key naming rules from `MetaObject` apply.",
"properties": {
+ "io.modelcontextprotocol/serverInfo": {
+ "$ref": "#/$defs/Implementation",
+ "description": "Identifies the server software producing the response. Servers SHOULD\ninclude this field on every response unless specifically configured not\nto do so.\n\nThe {@link Implementation} schema requires `name` and `version`; other\nfields are optional.\n\nThe value is self-reported by the server and is not verified by the\nprotocol. It is intended for display, logging, and debugging. Clients\nSHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for\nsecurity decisions."
+ },
"io.modelcontextprotocol/subscriptionId": {
"$ref": "#/$defs/RequestId",
"description": "Identifies the subscription stream this response closes, so the client can\ncorrelate it with the originating subscription — mirroring the same key on\nthe stream's notifications. The value is the JSON-RPC ID of the\n`subscriptions/listen` request that opened the stream (and equals this\nresponse's `id`)."
diff --git a/schema/PINNED.json b/schema/PINNED.json
index 9b1739d29d..1c3e2c235a 100644
--- a/schema/PINNED.json
+++ b/schema/PINNED.json
@@ -8,7 +8,7 @@
{
"protocol_version": "2026-07-28",
"source_path_in_spec_repo": "schema/draft/schema.json",
- "spec_commit": "ead35b59b4fda8b32e276810025d8f92bdcec1b6",
- "sha256": "e00f675287e8cf078688c26c8a89d283ff2613da3b76d5cd15aff9d189df639c"
+ "spec_commit": "71e306956a4959c9655e5036be215d41986596e6",
+ "sha256": "6293cdfe015c14bd36eda4b1331ce37bda377609e58ed6d09d16f28e7d3c7ad4"
}
]
diff --git a/schema/README.md b/schema/README.md
index 7bb2145f7b..9f855f6f32 100644
--- a/schema/README.md
+++ b/schema/README.md
@@ -3,8 +3,8 @@
JSON Schema files for each protocol version the SDK has a wire-shape surface
package for, vendored from the [spec repository] at the commit recorded in
`PINNED.json`. `scripts/gen_surface_types.py` reads these to regenerate
-`src/mcp-types/mcp_types/v/__init__.py`; CI runs the generator with
-`--check`.
+`src/mcp-types/mcp_types/_v/__init__.py` (underscore-private: internal
+validators, not public API); CI runs the generator with `--check`.
To bump: drop the new `schema.json` here as `.json`, update
the matching entry in `PINNED.json` (commit + sha256), and run
diff --git a/scripts/build-docs.sh b/scripts/build-docs.sh
index 8286786091..f0991b759b 100755
--- a/scripts/build-docs.sh
+++ b/scripts/build-docs.sh
@@ -2,16 +2,20 @@
#
# Build combined v1 + v2 documentation for GitHub Pages.
#
-# v1 docs (from the v1.x branch) are placed at the site root.
-# v2 docs (from main) are placed under /v2/.
+# The current major (v2, from main) is placed at the site root and mirrored
+# under /v2/; the v1 maintenance line (from the v1.x branch) is placed under
+# /v1/. Per-major paths are permanent: /v2/ is a byte-identical copy of the
+# root so that /v2/... links keep resolving after a future major takes the
+# root, the way /v1/... does for v1 today.
#
# The two lines use different toolchains: v1.x still builds with MkDocs, while
# main builds with Zensical (which needs a pre-build step to materialise the API
# reference and a post-build step for llms.txt — see scripts/docs/). Each branch
-# is fetched fresh from origin and built with its own synced `docs` group, so
-# the output is identical regardless of which branch triggered the workflow.
-# This script is intended to run in CI; for a local v2 preview use
-# `scripts/serve-docs.sh`.
+# is fetched fresh from origin and built with its own synced `docs` group. Only
+# main deploys the combined site (the v1.x branch carries no deploy workflow), so
+# a v1.x docs change goes live on the next main deploy or a manual
+# `workflow_dispatch` of deploy-docs.yml. This script is intended to run in CI;
+# for a local v2 preview use `scripts/serve-docs.sh`.
#
# Usage:
# scripts/build-docs.sh [output-dir]
@@ -50,6 +54,9 @@ build_site() {
fi
}
+# Fetch a branch fresh from origin, build its docs, and copy the result to
+# `dest`. The built tree stays in `worktree/site` afterwards so a caller can
+# mirror it to a second destination.
build_branch() {
local branch="$1" worktree="$2" dest="$3"
@@ -70,7 +77,12 @@ build_branch() {
rm -rf "${OUTPUT_DIR:?}"/*
-build_branch v1.x "$V1_WORKTREE" "$OUTPUT_DIR"
-build_branch main "$V2_WORKTREE" "$OUTPUT_DIR/v2"
+# v2 (main) at the root, then mirrored to /v2/ from the same build, then v1
+# under /v1/. The mirror is copied from the worktree's build directory rather
+# than from the root so it never picks up the /v1/ tree.
+build_branch main "$V2_WORKTREE" "$OUTPUT_DIR"
+mkdir -p "$OUTPUT_DIR/v2"
+cp -a "$V2_WORKTREE/site/." "$OUTPUT_DIR/v2/"
+build_branch v1.x "$V1_WORKTREE" "$OUTPUT_DIR/v1"
echo "=== Combined docs built at $OUTPUT_DIR ==="
diff --git a/scripts/docs/gen_ref_pages.py b/scripts/docs/gen_ref_pages.py
index 2340e18466..26916e8c39 100644
--- a/scripts/docs/gen_ref_pages.py
+++ b/scripts/docs/gen_ref_pages.py
@@ -30,6 +30,12 @@
# it from `src/` would emit the unimportable `mcp-types.mcp_types.*`.
PACKAGES = (ROOT / "src" / "mcp", ROOT / "src" / "mcp-types" / "mcp_types")
+# Alias packages that mirror another package's namespaces (`mcp.types` mirrors
+# `mcp_types`, `mcp.types.version` mirrors `mcp_types.version`): the mirrored
+# package's pages are the canonical rendering, so an alias, and every module
+# under it, earns no page of its own.
+EXCLUDED = frozenset({"mcp.types"})
+
_KIND_SECTIONS = {
griffe.Kind.MODULE: "Modules",
griffe.Kind.CLASS: "Classes",
@@ -185,6 +191,8 @@ def generate() -> list[NavItem]:
continue
ident = ".".join(parts)
+ if any(ident == e or ident.startswith(f"{e}.") for e in EXCLUDED):
+ continue
documented.add(ident)
stubs[API_DIR / doc_path] = _stub(parts[-1], f"::: {ident}")
pages[ident] = API_DIR / doc_path
diff --git a/scripts/gen_surface_types.py b/scripts/gen_surface_types.py
index f338629095..ab8be15cf3 100644
--- a/scripts/gen_surface_types.py
+++ b/scripts/gen_surface_types.py
@@ -1,8 +1,9 @@
"""Regenerate the per-version wire-shape surface packages from vendored schemas.
Runs `datamodel-code-generator` over each `schema/PINNED.json` entry and
-writes the result to `src/mcp-types/mcp_types/v/__init__.py` with only the
-fixes the raw output needs: a small JSON pre-patch for the known
+writes the result to `src/mcp-types/mcp_types/_v/__init__.py` (the
+underscore marks these as internal validators, not public API) with only
+the fixes the raw output needs: a small JSON pre-patch for the known
`number`-as-`integer` schema.json defect, a header, full URLs for the spec's
site-absolute doc links, and per-version epilogue aliases. Run with
`uv run --frozen --group codegen python scripts/gen_surface_types.py [--check]`.
@@ -25,6 +26,10 @@
SCHEMA_DIR = REPO_ROOT / "schema"
TYPES_DIR = REPO_ROOT / "src" / "mcp-types" / "mcp_types"
+# The result-meta serverInfo stamp: every `$defs` entry carrying this property
+# gets its typed `$ref` stripped by `make_server_info_opaque` below.
+SERVER_INFO_META_PROPERTY = "io.modelcontextprotocol/serverInfo"
+
# schema.ts -> schema.json renders TypeScript `number` as JSON Schema
# `integer` at these sites; patch the JSON before codegen so floats validate.
# Patched to `["integer", "number"]` (not bare `"number"`) so codegen emits
@@ -89,6 +94,7 @@
"MetaObject",
"NotificationMetaObject",
"RequestMetaObject",
+ "ResultMetaObject",
"SubscriptionsListenResultMeta",
"InputSchema",
"OutputSchema",
@@ -128,9 +134,14 @@ def load_pinned() -> list[dict[str, str]]:
def patch_schema(schema: dict[str, Any], patches: list[tuple[str, Any, Any]]) -> None:
- """Apply `(path, old, new)` JSON-pointer-ish patches in place, asserting the old value."""
+ """Apply `(path, old, new)` JSON-pointer-ish patches in place, asserting the old value.
+
+ Path segments use JSON-pointer escaping (`~1` for `/`, `~0` for `~`) so keys
+ that themselves contain a slash (the reserved `io.modelcontextprotocol/*`
+ `_meta` keys) are addressable.
+ """
for path, old, new in patches:
- *parts, leaf = path.split("/")
+ *parts, leaf = (part.replace("~1", "/").replace("~0", "~") for part in path.split("/"))
node: Any = schema
for part in parts:
node = node[int(part) if part.isdigit() else part]
@@ -139,6 +150,23 @@ def patch_schema(schema: dict[str, Any], patches: list[tuple[str, Any, Any]]) ->
node[leaf] = new
+def make_server_info_opaque(schema: dict[str, Any]) -> None:
+ """Strip the typed `$ref` from every result-meta serverInfo property.
+
+ The stamp is display-only: the spec forbids acting on it, so a malformed
+ value must never fail a whole response (clients validate every inbound
+ result against this surface). Walking every `$defs` entry keeps future
+ result-meta definitions lenient by construction instead of relying on an
+ enumerated list; the typed, lenient parse happens at the read edge
+ (`ClientSession.server_info`). typescript-sdk does the same with a
+ schema-level catch-to-undefined.
+ """
+ for definition in schema.get("$defs", {}).values():
+ prop = definition.get("properties", {}).get(SERVER_INFO_META_PROPERTY)
+ if prop is not None and "$ref" in prop:
+ del prop["$ref"]
+
+
def run_codegen(schema_path: Path, output_path: Path) -> None:
"""Run datamodel-code-generator at the version pinned in the `codegen` dependency group."""
# fmt: off
@@ -196,6 +224,7 @@ def build(entry: dict[str, str]) -> str:
version = entry["protocol_version"]
schema = json.loads((SCHEMA_DIR / f"{version}.json").read_text())
patch_schema(schema, SCHEMA_PATCHES.get(version, []))
+ make_server_info_opaque(schema)
with tempfile.TemporaryDirectory() as tmp:
patched = Path(tmp) / "schema.json"
@@ -242,7 +271,7 @@ def main(argv: list[str] | None = None) -> int:
drift = False
for entry in load_pinned():
- target = TYPES_DIR / ("v" + entry["protocol_version"].replace("-", "_")) / "__init__.py"
+ target = TYPES_DIR / ("_v" + entry["protocol_version"].replace("-", "_")) / "__init__.py"
candidate = build(entry)
if not args.check:
target.parent.mkdir(parents=True, exist_ok=True)
diff --git a/src/mcp-types/mcp_types/__init__.py b/src/mcp-types/mcp_types/__init__.py
index 87c0c5d594..41cec2edf3 100644
--- a/src/mcp-types/mcp_types/__init__.py
+++ b/src/mcp-types/mcp_types/__init__.py
@@ -12,6 +12,7 @@
DEFAULT_NEGOTIATED_VERSION,
LOG_LEVEL_META_KEY,
PROTOCOL_VERSION_META_KEY,
+ SERVER_INFO_META_KEY,
Annotations,
AudioContent,
BaseMetadata,
@@ -231,6 +232,8 @@
"CLIENT_INFO_META_KEY",
"CLIENT_CAPABILITIES_META_KEY",
"LOG_LEVEL_META_KEY",
+ # Reserved result _meta keys
+ "SERVER_INFO_META_KEY",
# Type aliases and variables
"CORE_RESULT_TYPES",
"ContentBlock",
diff --git a/src/mcp-types/mcp_types/_types.py b/src/mcp-types/mcp_types/_types.py
index 9c0516836a..5852d9bba3 100644
--- a/src/mcp-types/mcp_types/_types.py
+++ b/src/mcp-types/mcp_types/_types.py
@@ -3,7 +3,7 @@
One model per protocol construct, carrying every field from every supported
protocol version, so application code sees a single set of types regardless of
the negotiated version. Per-field docstrings note version availability. The
-`mcp_types.v*` surface packages carry the schema-exact wire shapes.
+`mcp_types._v*` surface packages carry the schema-exact wire shapes.
"""
from __future__ import annotations
@@ -69,6 +69,13 @@ class MCPModel(BaseModel):
introduces it. If absent, the server must not send log notifications.
"""
+SERVER_INFO_META_KEY = "io.modelcontextprotocol/serverInfo"
+"""Reserved result `_meta` key: the server `Implementation` (2026-07-28). SDK-managed.
+
+Servers SHOULD stamp it on every result. The value is self-reported and
+unverified - display, logging, and debugging only; never behavior or security.
+"""
+
class RequestParamsMeta(TypedDict, extra_items=Any):
"""The `_meta` object on request params (schema name: `RequestMetaObject`).
@@ -591,8 +598,6 @@ class DiscoverResult(CacheableResult):
capabilities: ServerCapabilities
- server_info: Implementation
-
instructions: str | None = None
"""Natural-language guidance describing the server and its features, e.g. for
a system prompt. Should not duplicate information already in tool descriptions."""
diff --git a/src/mcp-types/mcp_types/v2025_11_25/__init__.py b/src/mcp-types/mcp_types/_v2025_11_25/__init__.py
similarity index 100%
rename from src/mcp-types/mcp_types/v2025_11_25/__init__.py
rename to src/mcp-types/mcp_types/_v2025_11_25/__init__.py
diff --git a/src/mcp-types/mcp_types/v2026_07_28/__init__.py b/src/mcp-types/mcp_types/_v2026_07_28/__init__.py
similarity index 96%
rename from src/mcp-types/mcp_types/v2026_07_28/__init__.py
rename to src/mcp-types/mcp_types/_v2026_07_28/__init__.py
index 2963c13232..fb168b3059 100644
--- a/src/mcp-types/mcp_types/v2026_07_28/__init__.py
+++ b/src/mcp-types/mcp_types/_v2026_07_28/__init__.py
@@ -1,7 +1,7 @@
"""Internal wire-shape models for protocol 2026-07-28. Generated; do not edit.
Regenerate with `scripts/gen_surface_types.py` from `schema/2026-07-28.json`
-(sha256 `e00f675287e8cf078688c26c8a89d283ff2613da3b76d5cd15aff9d189df639c`)."""
+(sha256 `6293cdfe015c14bd36eda4b1331ce37bda377609e58ed6d09d16f28e7d3c7ad4`)."""
# pyright: reportIncompatibleVariableOverride=false, reportGeneralTypeIssues=false
from __future__ import annotations
@@ -602,28 +602,6 @@ class NumberSchema(WireModel):
type: Literal["integer", "number"]
-class PaginatedResult(WireModel):
- model_config = ConfigDict(
- extra="ignore",
- )
- meta: Annotated[MetaObject | None, Field(alias="_meta")] = None
- next_cursor: Annotated[str | None, Field(alias="nextCursor")] = None
- """
- An opaque token representing the pagination position after the last returned result.
- If present, there may be more results available.
- """
- result_type: Annotated[str, Field(alias="resultType")]
- """
- Indicates the type of the result, which allows the client to determine
- how to parse the result object.
-
- Servers implementing this protocol version MUST include this field.
- For backward compatibility, when a client receives a result from a
- server implementing an earlier protocol version (which does not include
- `resultType`), the client MUST treat the absent field as `"complete"`.
- """
-
-
class ParseError(WireModel):
"""
A JSON-RPC error indicating that invalid JSON was received by the server. This error is returned when the server cannot parse the JSON text of a message.
@@ -757,24 +735,27 @@ class ResourceTemplateReference(WireModel):
"""
-class Result(WireModel):
+class ResultMetaObject(WireModel):
"""
- Common result fields.
+ Extends {@link MetaObject} with additional result-specific fields. All key naming rules from `MetaObject` apply.
"""
model_config = ConfigDict(
extra="allow",
)
- meta: Annotated[MetaObject | None, Field(alias="_meta")] = None
- result_type: Annotated[str, Field(alias="resultType")]
+ io_modelcontextprotocol_server_info: Annotated[Any | None, Field(alias="io.modelcontextprotocol/serverInfo")] = None
"""
- Indicates the type of the result, which allows the client to determine
- how to parse the result object.
+ Identifies the server software producing the response. Servers SHOULD
+ include this field on every response unless specifically configured not
+ to do so.
- Servers implementing this protocol version MUST include this field.
- For backward compatibility, when a client receives a result from a
- server implementing an earlier protocol version (which does not include
- `resultType`), the client MUST treat the absent field as `"complete"`.
+ The {@link Implementation} schema requires `name` and `version`; other
+ fields are optional.
+
+ The value is self-reported by the server and is not verified by the
+ protocol. It is intended for display, logging, and debugging. Clients
+ SHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for
+ security decisions.
"""
@@ -911,13 +892,27 @@ class SubscriptionFilter(WireModel):
class SubscriptionsListenResultMeta(WireModel):
"""
- Extends {@link MetaObject} with the subscription-stream identifier carried by a
+ Extends {@link ResultMetaObject} with the subscription-stream identifier carried by a
{@link SubscriptionsListenResult}. All key naming rules from `MetaObject` apply.
"""
model_config = ConfigDict(
extra="allow",
)
+ io_modelcontextprotocol_server_info: Annotated[Any | None, Field(alias="io.modelcontextprotocol/serverInfo")] = None
+ """
+ Identifies the server software producing the response. Servers SHOULD
+ include this field on every response unless specifically configured not
+ to do so.
+
+ The {@link Implementation} schema requires `name` and `version`; other
+ fields are optional.
+
+ The value is self-reported by the server and is not verified by the
+ protocol. It is intended for display, logging, and debugging. Clients
+ SHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for
+ security decisions.
+ """
io_modelcontextprotocol_subscription_id: Annotated[RequestId, Field(alias="io.modelcontextprotocol/subscriptionId")]
"""
Identifies the subscription stream this response closes, so the client can
@@ -1401,7 +1396,7 @@ class CacheableResult(WireModel):
model_config = ConfigDict(
extra="ignore",
)
- meta: Annotated[MetaObject | None, Field(alias="_meta")] = None
+ meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None
cache_scope: Annotated[Literal["private", "public"], Field(alias="cacheScope")]
"""
Indicates the intended scope of the cached response, analogous to HTTP
@@ -1438,13 +1433,6 @@ class CacheableResult(WireModel):
"""
-class ClientResult(RootModel[Result]):
- root: Result
- """
- Common result fields.
- """
-
-
class CompleteResult(WireModel):
"""
The result returned by the server for a {@link CompleteRequestcompletion/complete} request.
@@ -1453,7 +1441,7 @@ class CompleteResult(WireModel):
model_config = ConfigDict(
extra="ignore",
)
- meta: Annotated[MetaObject | None, Field(alias="_meta")] = None
+ meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None
completion: Completion
result_type: Annotated[str, Field(alias="resultType")]
"""
@@ -1507,13 +1495,6 @@ class EmbeddedResource(WireModel):
type: Literal["resource"]
-class EmptyResult(RootModel[Result]):
- root: Result
- """
- Common result fields.
- """
-
-
class EnumSchema(
RootModel[
UntitledSingleSelectEnumSchema
@@ -1599,19 +1580,6 @@ class JSONRPCRequest(WireModel):
params: dict[str, Any] | None = None
-class JSONRPCResultResponse(WireModel):
- """
- A successful (non-error) response to a request.
- """
-
- model_config = ConfigDict(
- extra="ignore",
- )
- id: RequestId
- jsonrpc: Literal["2.0"]
- result: Result
-
-
class Params(WireModel):
model_config = ConfigDict(
extra="ignore",
@@ -1690,6 +1658,28 @@ class NotificationParams(WireModel):
meta: Annotated[NotificationMetaObject | None, Field(alias="_meta")] = None
+class PaginatedResult(WireModel):
+ model_config = ConfigDict(
+ extra="ignore",
+ )
+ meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None
+ next_cursor: Annotated[str | None, Field(alias="nextCursor")] = None
+ """
+ An opaque token representing the pagination position after the last returned result.
+ If present, there may be more results available.
+ """
+ result_type: Annotated[str, Field(alias="resultType")]
+ """
+ Indicates the type of the result, which allows the client to determine
+ how to parse the result object.
+
+ Servers implementing this protocol version MUST include this field.
+ For backward compatibility, when a client receives a result from a
+ server implementing an earlier protocol version (which does not include
+ `resultType`), the client MUST treat the absent field as `"complete"`.
+ """
+
+
class PrimitiveSchemaDefinition(
RootModel[
StringSchema
@@ -1810,7 +1800,7 @@ class ReadResourceResult(WireModel):
model_config = ConfigDict(
extra="ignore",
)
- meta: Annotated[MetaObject | None, Field(alias="_meta")] = None
+ meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None
cache_scope: Annotated[Literal["private", "public"], Field(alias="cacheScope")]
"""
Indicates the intended scope of the cached response, analogous to HTTP
@@ -2053,6 +2043,27 @@ class ResourceUpdatedNotificationParams(WireModel):
"""
+class Result(WireModel):
+ """
+ Common result fields.
+ """
+
+ model_config = ConfigDict(
+ extra="allow",
+ )
+ meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None
+ result_type: Annotated[str, Field(alias="resultType")]
+ """
+ Indicates the type of the result, which allows the client to determine
+ how to parse the result object.
+
+ Servers implementing this protocol version MUST include this field.
+ For backward compatibility, when a client receives a result from a
+ server implementing an earlier protocol version (which does not include
+ `resultType`), the client MUST treat the absent field as `"complete"`.
+ """
+
+
class SingleSelectEnumSchema(RootModel[UntitledSingleSelectEnumSchema | TitledSingleSelectEnumSchema]):
root: UntitledSingleSelectEnumSchema | TitledSingleSelectEnumSchema
@@ -2245,6 +2256,13 @@ class ClientNotification(WireModel):
params: CancelledNotificationParams
+class ClientResult(RootModel[Result]):
+ root: Result
+ """
+ Common result fields.
+ """
+
+
class ContentBlock(RootModel[TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource]):
root: TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource
@@ -2261,19 +2279,25 @@ class ElicitRequest(WireModel):
params: ElicitRequestParams
-class JSONRPCMessage(RootModel[JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse]):
- root: JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse
+class EmptyResult(RootModel[Result]):
+ root: Result
"""
- Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent.
+ Common result fields.
"""
-class JSONRPCResponse(RootModel[JSONRPCResultResponse | JSONRPCErrorResponse]):
- root: JSONRPCResultResponse | JSONRPCErrorResponse
+class JSONRPCResultResponse(WireModel):
"""
- A response to a request, containing either the result or error.
+ A successful (non-error) response to a request.
"""
+ model_config = ConfigDict(
+ extra="ignore",
+ )
+ id: RequestId
+ jsonrpc: Literal["2.0"]
+ result: Result
+
class ListPromptsResult(WireModel):
"""
@@ -2283,7 +2307,7 @@ class ListPromptsResult(WireModel):
model_config = ConfigDict(
extra="ignore",
)
- meta: Annotated[MetaObject | None, Field(alias="_meta")] = None
+ meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None
cache_scope: Annotated[Literal["private", "public"], Field(alias="cacheScope")]
"""
Indicates the intended scope of the cached response, analogous to HTTP
@@ -2347,7 +2371,7 @@ class ListResourceTemplatesResult(WireModel):
model_config = ConfigDict(
extra="ignore",
)
- meta: Annotated[MetaObject | None, Field(alias="_meta")] = None
+ meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None
cache_scope: Annotated[Literal["private", "public"], Field(alias="cacheScope")]
"""
Indicates the intended scope of the cached response, analogous to HTTP
@@ -2411,7 +2435,7 @@ class ListResourcesResult(WireModel):
model_config = ConfigDict(
extra="ignore",
)
- meta: Annotated[MetaObject | None, Field(alias="_meta")] = None
+ meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None
cache_scope: Annotated[Literal["private", "public"], Field(alias="cacheScope")]
"""
Indicates the intended scope of the cached response, analogous to HTTP
@@ -2475,7 +2499,7 @@ class ListToolsResult(WireModel):
model_config = ConfigDict(
extra="ignore",
)
- meta: Annotated[MetaObject | None, Field(alias="_meta")] = None
+ meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None
cache_scope: Annotated[Literal["private", "public"], Field(alias="cacheScope")]
"""
Indicates the intended scope of the cached response, analogous to HTTP
@@ -2597,10 +2621,16 @@ class ResourceUpdatedNotification(WireModel):
class SubscriptionsAcknowledgedNotification(WireModel):
"""
- Sent by the server as the first message on a
- {@link SubscriptionsListenRequestsubscriptions/listen} stream to acknowledge
- that the subscription has been established and to report which notification
- types it agreed to honor.
+ Sent by the server to acknowledge that a
+ {@link SubscriptionsListenRequestsubscriptions/listen} subscription has been
+ established and to report which notification types it agreed to honor.
+
+ This notification MUST be the first message the server sends carrying the
+ subscription's ID in `io.modelcontextprotocol/subscriptionId`. The server MUST
+ NOT send any notification on the subscription before acknowledging it. On
+ stdio, where every subscription shares one channel, this ordering is defined
+ per subscription ID and not per channel: messages belonging to other
+ subscriptions MAY be interleaved before it.
"""
model_config = ConfigDict(
@@ -2662,7 +2692,7 @@ class CallToolResult(WireModel):
model_config = ConfigDict(
extra="ignore",
)
- meta: Annotated[MetaObject | None, Field(alias="_meta")] = None
+ meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None
content: list[ContentBlock]
"""
A list of content objects that represent the unstructured result of the tool call.
@@ -2728,7 +2758,7 @@ class GetPromptResult(WireModel):
model_config = ConfigDict(
extra="ignore",
)
- meta: Annotated[MetaObject | None, Field(alias="_meta")] = None
+ meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None
description: str | None = None
"""
An optional description for the prompt.
@@ -2746,6 +2776,20 @@ class GetPromptResult(WireModel):
"""
+class JSONRPCMessage(RootModel[JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse]):
+ root: JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse
+ """
+ Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent.
+ """
+
+
+class JSONRPCResponse(RootModel[JSONRPCResultResponse | JSONRPCErrorResponse]):
+ root: JSONRPCResultResponse | JSONRPCErrorResponse
+ """
+ A response to a request, containing either the result or error.
+ """
+
+
class LoggingMessageNotification(WireModel):
"""
JSONRPCNotification of a log message passed from server to client. The client opts in by setting `"io.modelcontextprotocol/logLevel"` in a request's `_meta`.
@@ -3100,7 +3144,7 @@ class DiscoverResult(WireModel):
model_config = ConfigDict(
extra="ignore",
)
- meta: Annotated[MetaObject | None, Field(alias="_meta")] = None
+ meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None
cache_scope: Annotated[Literal["private", "public"], Field(alias="cacheScope")]
"""
Indicates the intended scope of the cached response, analogous to HTTP
@@ -3137,10 +3181,6 @@ class DiscoverResult(WireModel):
server implementing an earlier protocol version (which does not include
`resultType`), the client MUST treat the absent field as `"complete"`.
"""
- server_info: Annotated[Implementation, Field(alias="serverInfo")]
- """
- Information about the server software implementation.
- """
supported_versions: Annotated[list[str], Field(alias="supportedVersions")]
"""
MCP Protocol Versions this server supports. The client should choose a
@@ -3231,7 +3271,7 @@ class InputRequiredResult(WireModel):
model_config = ConfigDict(
extra="ignore",
)
- meta: Annotated[MetaObject | None, Field(alias="_meta")] = None
+ meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None
input_requests: Annotated[InputRequests | None, Field(alias="inputRequests")] = None
request_state: Annotated[str | None, Field(alias="requestState")] = None
result_type: Annotated[str, Field(alias="resultType")]
@@ -3442,12 +3482,21 @@ class RequestMetaObject(WireModel):
an empty object means the client supports no optional capabilities.
Servers MUST NOT infer capabilities from prior requests.
"""
- io_modelcontextprotocol_client_info: Annotated[Implementation, Field(alias="io.modelcontextprotocol/clientInfo")]
+ io_modelcontextprotocol_client_info: Annotated[
+ Implementation | None, Field(alias="io.modelcontextprotocol/clientInfo")
+ ] = None
"""
- Identifies the client software making the request. Required.
+ Identifies the client software making the request. Clients SHOULD
+ include this field on every request unless specifically configured not
+ to do so.
The {@link Implementation} schema requires `name` and `version`; other
fields are optional.
+
+ The value is self-reported by the client and is not verified by the
+ protocol. It is intended for display, logging, and debugging. Servers
+ SHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for
+ security decisions.
"""
io_modelcontextprotocol_log_level: Annotated[
LoggingLevel | None, Field(alias="io.modelcontextprotocol/logLevel")
diff --git a/src/mcp-types/mcp_types/_wire_base.py b/src/mcp-types/mcp_types/_wire_base.py
index 8d7b09d7f3..69254a850b 100644
--- a/src/mcp-types/mcp_types/_wire_base.py
+++ b/src/mcp-types/mcp_types/_wire_base.py
@@ -1,4 +1,4 @@
-"""Shared pydantic base for the generated `mcp_types.v*` wire-shape packages."""
+"""Shared pydantic base for the generated `mcp_types._v*` wire-shape packages."""
from pydantic import BaseModel, ConfigDict
diff --git a/src/mcp-types/mcp_types/jsonrpc.py b/src/mcp-types/mcp_types/jsonrpc.py
index fcc3317d86..e9c6db96b4 100644
--- a/src/mcp-types/mcp_types/jsonrpc.py
+++ b/src/mcp-types/mcp_types/jsonrpc.py
@@ -6,6 +6,29 @@
from pydantic import BaseModel, Field, TypeAdapter
+__all__ = [
+ "CONNECTION_CLOSED",
+ "HEADER_MISMATCH",
+ "INTERNAL_ERROR",
+ "INVALID_PARAMS",
+ "INVALID_REQUEST",
+ "JSONRPC_VERSION",
+ "METHOD_NOT_FOUND",
+ "MISSING_REQUIRED_CLIENT_CAPABILITY",
+ "PARSE_ERROR",
+ "REQUEST_TIMEOUT",
+ "UNSUPPORTED_PROTOCOL_VERSION",
+ "URL_ELICITATION_REQUIRED",
+ "ErrorData",
+ "JSONRPCError",
+ "JSONRPCMessage",
+ "JSONRPCNotification",
+ "JSONRPCRequest",
+ "JSONRPCResponse",
+ "RequestId",
+ "jsonrpc_message_adapter",
+]
+
RequestId = Annotated[int, Field(strict=True)] | str
"""The ID of a JSON-RPC request."""
diff --git a/src/mcp-types/mcp_types/methods.py b/src/mcp-types/mcp_types/methods.py
index 37e1145386..41959c56d7 100644
--- a/src/mcp-types/mcp_types/methods.py
+++ b/src/mcp-types/mcp_types/methods.py
@@ -1,6 +1,6 @@
"""Per-version method maps and parse/serialize functions for MCP traffic.
-This module is supported public API; the `mcp_types.v*` packages it draws on
+This module is supported public API; the `mcp_types._v*` packages it draws on
are internal validators and not for direct import.
Surface maps key `(method, version)` to per-version wire types (key absence is
@@ -18,8 +18,8 @@
from pydantic import BaseModel, TypeAdapter
import mcp_types as types
-import mcp_types.v2025_11_25 as v2025
-import mcp_types.v2026_07_28 as v2026
+import mcp_types._v2025_11_25 as v2025
+import mcp_types._v2026_07_28 as v2026
from mcp_types.version import KNOWN_PROTOCOL_VERSIONS
__all__ = [
diff --git a/src/mcp-types/mcp_types/version.py b/src/mcp-types/mcp_types/version.py
index c5c2233274..e2f7e1ac86 100644
--- a/src/mcp-types/mcp_types/version.py
+++ b/src/mcp-types/mcp_types/version.py
@@ -9,6 +9,18 @@
from typing import Final
+__all__ = [
+ "KNOWN_PROTOCOL_VERSIONS",
+ "HANDSHAKE_PROTOCOL_VERSIONS",
+ "MODERN_PROTOCOL_VERSIONS",
+ "SUPPORTED_PROTOCOL_VERSIONS",
+ "LATEST_PROTOCOL_VERSION",
+ "LATEST_HANDSHAKE_VERSION",
+ "LATEST_MODERN_VERSION",
+ "OLDEST_SUPPORTED_VERSION",
+ "is_version_at_least",
+]
+
KNOWN_PROTOCOL_VERSIONS: Final[tuple[str, ...]] = (
"2024-11-05",
"2025-03-26",
diff --git a/src/mcp-types/pyproject.toml b/src/mcp-types/pyproject.toml
index 51cabf501b..4f3500c291 100644
--- a/src/mcp-types/pyproject.toml
+++ b/src/mcp-types/pyproject.toml
@@ -31,7 +31,7 @@ dependencies = [
[project.urls]
Homepage = "https://modelcontextprotocol.io"
-Documentation = "https://py.sdk.modelcontextprotocol.io/v2/"
+Documentation = "https://py.sdk.modelcontextprotocol.io/"
Repository = "https://github.com/modelcontextprotocol/python-sdk"
Issues = "https://github.com/modelcontextprotocol/python-sdk/issues"
diff --git a/src/mcp/__init__.py b/src/mcp/__init__.py
index 085e445d4a..28bc4703ed 100644
--- a/src/mcp/__init__.py
+++ b/src/mcp/__init__.py
@@ -58,6 +58,9 @@
)
from mcp_types import Role as SamplingRole
+# Bind the `mcp.types` submodule on the package, as v1's `from .types import
+# ...` did, so `import mcp` followed by `mcp.types.Tool` keeps working.
+from . import types as types
from .client._input_required import InputRequiredRoundsExceededError
from .client.client import Client
from .client.session import ClientSession
diff --git a/src/mcp/client/__init__.py b/src/mcp/client/__init__.py
index 21581749d0..d6b07045ce 100644
--- a/src/mcp/client/__init__.py
+++ b/src/mcp/client/__init__.py
@@ -20,7 +20,7 @@
UnexpectedClaimedResult,
advertise,
)
-from mcp.client.session import ClientSession
+from mcp.client.session import ClientSession, IncomingMessage
__all__ = [
"CacheConfig",
@@ -32,6 +32,7 @@
"ClientExtension",
"ClientRequestContext",
"ClientSession",
+ "IncomingMessage",
"InMemoryResponseCacheStore",
"InputRequiredRoundsExceededError",
"NotificationBinding",
diff --git a/src/mcp/client/__main__.py b/src/mcp/client/__main__.py
index 5fa3ce109b..60e3b02390 100644
--- a/src/mcp/client/__main__.py
+++ b/src/mcp/client/__main__.py
@@ -9,11 +9,10 @@
import mcp_types as types
from mcp.client._transport import ReadStream, WriteStream
-from mcp.client.session import ClientSession
+from mcp.client.session import ClientSession, IncomingMessage
from mcp.client.sse import sse_client
from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.shared.message import SessionMessage
-from mcp.shared.session import RequestResponder
if not sys.warnoptions:
warnings.simplefilter("ignore")
@@ -22,9 +21,7 @@
logger = logging.getLogger("client")
-async def message_handler(
- message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
-) -> None:
+async def message_handler(message: IncomingMessage) -> None:
if isinstance(message, Exception):
logger.error("Error: %s", message)
return
diff --git a/src/mcp/client/_probe.py b/src/mcp/client/_probe.py
index 69fea656f3..0e46ae57d9 100644
--- a/src/mcp/client/_probe.py
+++ b/src/mcp/client/_probe.py
@@ -9,8 +9,13 @@
``supported`` list. The streamable-HTTP transport already maps HTTP-layer
4xx rejections (no JSON-RPC body) into ``MCPError`` codes, so those reach
the same path. Any non-``MCPError`` exception (network/connection errors,
-anyio cancellation, the ``RuntimeError`` from ``adopt()`` on no-mutual)
-propagates to the caller; an outage or in-process bug is never an era verdict.
+anyio cancellation) propagates to the caller; an outage or in-process bug
+is never an era verdict.
+
+A successful ``DiscoverResult`` whose ``supportedVersions`` shares no modern
+version with this client is treated the same way: the server speaks discover
+but advertises only handshake-era versions, which is a legacy advertisement,
+not an incompatibility.
The fallback handshake itself can be answered with ``-32022`` — e.g. a probe
that timed out client-side but succeeded on a slow-starting server locked the
@@ -89,13 +94,21 @@ async def negotiate_auto(session: ClientSession) -> None:
version = mutual[-1]
continue
return
- # any other exception (httpx2.TransportError, ConnectionError, anyio errors,
- # RuntimeError from adopt) → propagate
+ # any other exception (httpx2.TransportError, ConnectionError,
+ # anyio errors) → propagate
try:
result = types.DiscoverResult.model_validate(raw)
except ValidationError:
await session.initialize() # unparseable result → not modern evidence
return
+ if not any(v in result.supported_versions for v in MODERN_PROTOCOL_VERSIONS):
+ # A discover-answering server that advertises no modern version
+ # (go-sdk's stateful streamable default does this) is an explicit
+ # legacy advertisement: fall back like the -32022 branch above
+ # instead of letting `adopt()` raise. The ts and go clients fall
+ # back here too.
+ await session.initialize()
+ return
session.adopt(result)
return
raise AssertionError("unreachable") # pragma: no cover — loop body always returns or raises
diff --git a/src/mcp/client/auth/extensions/client_credentials.py b/src/mcp/client/auth/extensions/client_credentials.py
index c05cc55b36..29197bb504 100644
--- a/src/mcp/client/auth/extensions/client_credentials.py
+++ b/src/mcp/client/auth/extensions/client_credentials.py
@@ -4,11 +4,9 @@
- ClientCredentialsOAuthProvider: For client_credentials with client_id + client_secret
- PrivateKeyJWTOAuthProvider: For client_credentials with private_key_jwt authentication
(typically using a pre-built JWT from workload identity federation)
-- RFC7523OAuthClientProvider: For jwt-bearer grant (RFC 7523 Section 2.1)
"""
import time
-import warnings
from collections.abc import Awaitable, Callable
from typing import Any, Literal
from uuid import uuid4
@@ -17,9 +15,8 @@
import jwt
from pydantic import BaseModel, Field
-from mcp.client.auth import OAuthClientProvider, OAuthFlowError, OAuthTokenError, TokenStorage
-from mcp.shared.auth import AuthorizationCodeResult, OAuthClientInformationFull, OAuthClientMetadata
-from mcp.shared.exceptions import MCPDeprecationWarning
+from mcp.client.auth import OAuthClientProvider, OAuthFlowError, TokenStorage
+from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata
class ClientCredentialsOAuthProvider(OAuthClientProvider):
@@ -46,7 +43,7 @@ def __init__(
client_id: str,
client_secret: str,
token_endpoint_auth_method: Literal["client_secret_basic", "client_secret_post"] = "client_secret_basic",
- scopes: str | None = None,
+ scope: str | None = None,
) -> None:
"""Initialize client_credentials OAuth provider.
@@ -57,16 +54,16 @@ def __init__(
client_secret: The OAuth client secret.
token_endpoint_auth_method: Authentication method for token endpoint.
Either "client_secret_basic" (default) or "client_secret_post".
- scopes: Optional space-separated list of scopes to request.
+ scope: Optional space-separated list of scopes to request.
"""
# Build minimal client_metadata for the base class
client_metadata = OAuthClientMetadata(
redirect_uris=None,
grant_types=["client_credentials"],
token_endpoint_auth_method=token_endpoint_auth_method,
- scope=scopes,
+ scope=scope,
)
- super().__init__(server_url, client_metadata, storage, None, None, 300.0)
+ super().__init__(server_url, client_metadata, storage, None, None)
# Store client_info to be set during _initialize - no dynamic registration needed
self._fixed_client_info = OAuthClientInformationFull(
redirect_uris=None,
@@ -74,7 +71,7 @@ def __init__(
client_secret=client_secret,
grant_types=["client_credentials"],
token_endpoint_auth_method=token_endpoint_auth_method,
- scope=scopes,
+ scope=scope,
)
async def _initialize(self) -> None:
@@ -258,7 +255,7 @@ def __init__(
storage: TokenStorage,
client_id: str,
assertion_provider: Callable[[str], Awaitable[str]],
- scopes: str | None = None,
+ scope: str | None = None,
) -> None:
"""Initialize private_key_jwt OAuth provider.
@@ -271,16 +268,16 @@ def __init__(
`SignedJWTParameters.create_assertion_provider()` for SDK-signed JWTs,
`static_assertion_provider()` for pre-built JWTs, or provide your own
callback for workload identity federation.
- scopes: Optional space-separated list of scopes to request.
+ scope: Optional space-separated list of scopes to request.
"""
# Build minimal client_metadata for the base class
client_metadata = OAuthClientMetadata(
redirect_uris=None,
grant_types=["client_credentials"],
token_endpoint_auth_method="private_key_jwt",
- scope=scopes,
+ scope=scope,
)
- super().__init__(server_url, client_metadata, storage, None, None, 300.0)
+ super().__init__(server_url, client_metadata, storage, None, None)
self._assertion_provider = assertion_provider
# Store client_info to be set during _initialize - no dynamic registration needed
self._fixed_client_info = OAuthClientInformationFull(
@@ -288,7 +285,7 @@ def __init__(
client_id=client_id,
grant_types=["client_credentials"],
token_endpoint_auth_method="private_key_jwt",
- scope=scopes,
+ scope=scope,
)
async def _initialize(self) -> None:
@@ -334,153 +331,3 @@ async def _exchange_token_client_credentials(self) -> httpx2.Request:
token_url = self._get_token_endpoint()
return httpx2.Request("POST", token_url, data=token_data, headers=headers)
-
-
-class JWTParameters(BaseModel):
- """JWT parameters."""
-
- assertion: str | None = Field(
- default=None,
- description="JWT assertion for JWT authentication. "
- "Will be used instead of generating a new assertion if provided.",
- )
-
- issuer: str | None = Field(default=None, description="Issuer for JWT assertions.")
- subject: str | None = Field(default=None, description="Subject identifier for JWT assertions.")
- audience: str | None = Field(default=None, description="Audience for JWT assertions.")
- claims: dict[str, Any] | None = Field(default=None, description="Additional claims for JWT assertions.")
- jwt_signing_algorithm: str | None = Field(default="RS256", description="Algorithm for signing JWT assertions.")
- jwt_signing_key: str | None = Field(default=None, description="Private key for JWT signing.")
- jwt_lifetime_seconds: int = Field(default=300, description="Lifetime of generated JWT in seconds.")
-
- def to_assertion(self, with_audience_fallback: str | None = None) -> str:
- if self.assertion is not None:
- # Prebuilt JWT (e.g. acquired out-of-band)
- assertion = self.assertion
- else:
- if not self.jwt_signing_key:
- raise OAuthFlowError("Missing signing key for JWT bearer grant") # pragma: no cover
- if not self.issuer:
- raise OAuthFlowError("Missing issuer for JWT bearer grant") # pragma: no cover
- if not self.subject:
- raise OAuthFlowError("Missing subject for JWT bearer grant") # pragma: no cover
-
- audience = self.audience if self.audience else with_audience_fallback
- if not audience:
- raise OAuthFlowError("Missing audience for JWT bearer grant") # pragma: no cover
-
- now = int(time.time())
- claims: dict[str, Any] = {
- "iss": self.issuer,
- "sub": self.subject,
- "aud": audience,
- "exp": now + self.jwt_lifetime_seconds,
- "iat": now,
- "jti": str(uuid4()),
- }
- claims.update(self.claims or {})
-
- assertion = jwt.encode(
- claims,
- self.jwt_signing_key,
- algorithm=self.jwt_signing_algorithm or "RS256",
- )
- return assertion
-
-
-class RFC7523OAuthClientProvider(OAuthClientProvider):
- """OAuth client provider for RFC 7523 jwt-bearer grant.
-
- .. deprecated::
- Use :class:`ClientCredentialsOAuthProvider` for client_credentials with
- client_id + client_secret, or :class:`PrivateKeyJWTOAuthProvider` for
- client_credentials with private_key_jwt authentication instead.
-
- This provider supports the jwt-bearer authorization grant (RFC 7523 Section 2.1)
- where the JWT itself is the authorization grant.
- """
-
- def __init__(
- self,
- server_url: str,
- client_metadata: OAuthClientMetadata,
- storage: TokenStorage,
- redirect_handler: Callable[[str], Awaitable[None]] | None = None,
- callback_handler: Callable[[], Awaitable[AuthorizationCodeResult]] | None = None,
- timeout: float = 300.0,
- jwt_parameters: JWTParameters | None = None,
- ) -> None:
- warnings.warn(
- "RFC7523OAuthClientProvider is deprecated. Use ClientCredentialsOAuthProvider "
- "or PrivateKeyJWTOAuthProvider instead.",
- MCPDeprecationWarning,
- stacklevel=2,
- )
- super().__init__(server_url, client_metadata, storage, redirect_handler, callback_handler, timeout)
- self.jwt_parameters = jwt_parameters
-
- async def _exchange_token_authorization_code(
- self, auth_code: str, code_verifier: str, *, token_data: dict[str, Any] | None = None
- ) -> httpx2.Request: # pragma: no cover
- """Build token exchange request for authorization_code flow."""
- token_data = token_data or {}
- if self.context.client_metadata.token_endpoint_auth_method == "private_key_jwt":
- self._add_client_authentication_jwt(token_data=token_data)
- return await super()._exchange_token_authorization_code(auth_code, code_verifier, token_data=token_data)
-
- async def _perform_authorization(self) -> httpx2.Request: # pragma: no cover
- """Perform the authorization flow."""
- if "urn:ietf:params:oauth:grant-type:jwt-bearer" in self.context.client_metadata.grant_types:
- token_request = await self._exchange_token_jwt_bearer()
- return token_request
- else:
- return await super()._perform_authorization()
-
- def _add_client_authentication_jwt(self, *, token_data: dict[str, Any]): # pragma: no cover
- """Add JWT assertion for client authentication to token endpoint parameters."""
- if not self.jwt_parameters:
- raise OAuthTokenError("Missing JWT parameters for private_key_jwt flow")
- if not self.context.oauth_metadata:
- raise OAuthTokenError("Missing OAuth metadata for private_key_jwt flow")
-
- # We need to set the audience to the issuer identifier of the authorization server
- # https://datatracker.ietf.org/doc/html/draft-ietf-oauth-rfc7523bis-01#name-updates-to-rfc-7523
- issuer = str(self.context.oauth_metadata.issuer)
- assertion = self.jwt_parameters.to_assertion(with_audience_fallback=issuer)
-
- # When using private_key_jwt, in a client_credentials flow, we use RFC 7523 Section 2.2
- token_data["client_assertion"] = assertion
- token_data["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
- # We need to set the audience to the resource server, the audience is different from the one in claims
- # it represents the resource server that will validate the token
- token_data["audience"] = self.context.get_resource_url()
-
- async def _exchange_token_jwt_bearer(self) -> httpx2.Request:
- """Build token exchange request for JWT bearer grant."""
- if not self.context.client_info:
- raise OAuthFlowError("Missing client info") # pragma: no cover
- if not self.jwt_parameters:
- raise OAuthFlowError("Missing JWT parameters") # pragma: no cover
- if not self.context.oauth_metadata:
- raise OAuthTokenError("Missing OAuth metadata") # pragma: no cover
-
- # We need to set the audience to the issuer identifier of the authorization server
- # https://datatracker.ietf.org/doc/html/draft-ietf-oauth-rfc7523bis-01#name-updates-to-rfc-7523
- issuer = str(self.context.oauth_metadata.issuer)
- assertion = self.jwt_parameters.to_assertion(with_audience_fallback=issuer)
-
- token_data = {
- "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
- "assertion": assertion,
- }
-
- if self.context.should_include_resource_param(self.context.protocol_version): # pragma: no branch
- token_data["resource"] = self.context.get_resource_url()
-
- if self.context.client_metadata.scope: # pragma: no branch
- token_data["scope"] = self.context.client_metadata.scope
-
- token_url = self._get_token_endpoint()
- return httpx2.Request(
- "POST", token_url, data=token_data, headers={"Content-Type": "application/x-www-form-urlencoded"}
- )
diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py
index 073e6a8039..7dc62b52b9 100644
--- a/src/mcp/client/auth/oauth2.py
+++ b/src/mcp/client/auth/oauth2.py
@@ -11,7 +11,7 @@
import time
from collections.abc import AsyncGenerator, Awaitable, Callable
from dataclasses import dataclass, field
-from typing import Any, Protocol
+from typing import Any, Protocol, get_args
from urllib.parse import quote, urlencode, urljoin, urlparse
import anyio
@@ -19,7 +19,7 @@
from mcp_types.version import is_version_at_least
from pydantic import BaseModel, Field, ValidationError
-from mcp.client.auth.exceptions import OAuthFlowError, OAuthTokenError
+from mcp.client.auth.exceptions import OAuthFlowError, OAuthRegistrationError, OAuthTokenError
from mcp.client.auth.utils import (
build_oauth_authorization_server_metadata_discovery_urls,
build_protected_resource_metadata_discovery_urls,
@@ -48,6 +48,7 @@
OAuthMetadata,
OAuthToken,
ProtectedResourceMetadata,
+ TokenEndpointAuthMethod,
)
from mcp.shared.auth_utils import (
calculate_token_expiry,
@@ -58,6 +59,55 @@
logger = logging.getLogger(__name__)
+# Methods a registered client's record may carry without a token request being an error,
+# derived from the set the SDK is willing to request so the two cannot drift. `None`/"none"
+# send no client secret. `private_key_jwt` sends none from here either: only
+# `PrivateKeyJWTOAuthProvider` signs the assertion, and only in its client-credentials
+# exchange, so its inherited refresh path must pass through here without raising - a refresh
+# the server then rejects falls back to a fresh client-credentials exchange, which signs.
+# Anything else is a method no client here can apply.
+_KNOWN_TOKEN_ENDPOINT_AUTH_METHODS: tuple[str | None, ...] = (None, *get_args(TokenEndpointAuthMethod))
+
+# Methods that authenticate the token request with the minted `client_secret`; a
+# registration assigning one is only usable if the server issued that secret.
+_SECRET_TOKEN_ENDPOINT_AUTH_METHODS = ("client_secret_post", "client_secret_basic")
+
+# Methods a registration completed by the authorization-code flow can act on. That flow
+# authenticates the token request with the minted client secret (or nothing); it holds no key
+# to sign a `private_key_jwt` assertion, so a server assigning that method has registered a
+# client this flow cannot use. `PrivateKeyJWTOAuthProvider` never registers dynamically.
+_REGISTRATION_USABLE_TOKEN_ENDPOINT_AUTH_METHODS: tuple[str | None, ...] = tuple(
+ method for method in _KNOWN_TOKEN_ENDPOINT_AUTH_METHODS if method != "private_key_jwt"
+)
+
+
+def check_registration_usable(client_info: OAuthClientInformationFull) -> None:
+ """Confirm a registration this flow completed is one it can act on.
+
+ RFC 7591 §3.2.1 lets the authorization server replace requested metadata and leaves it to
+ the client to "check the values in the response to determine if the registration is
+ sufficient for use". Two substitutions make the minted credentials unusable, and both are
+ judged here - before the record is persisted or any interactive authorization begins -
+ rather than surfacing later as an opaque failure at the token endpoint: a token-endpoint
+ auth method the authorization-code flow cannot apply (one it does not implement, or
+ `private_key_jwt`, whose assertion this flow has no key to sign), and a secret-based
+ method the flow could apply but for which the server issued no `client_secret`.
+
+ Raises:
+ OAuthRegistrationError: The server registered the client with a
+ `token_endpoint_auth_method` this flow cannot apply, or with a secret-based
+ method but no `client_secret`.
+ """
+ method = client_info.token_endpoint_auth_method
+ if method not in _REGISTRATION_USABLE_TOKEN_ENDPOINT_AUTH_METHODS:
+ raise OAuthRegistrationError(
+ f"Authorization server registered the client with unsupported token_endpoint_auth_method {method!r}"
+ )
+ if method in _SECRET_TOKEN_ENDPOINT_AUTH_METHODS and client_info.client_secret is None:
+ raise OAuthRegistrationError(
+ f"Authorization server registered the client for {method!r} but issued no client_secret"
+ )
+
class PKCEParameters(BaseModel):
"""PKCE (Proof Key for Code Exchange) parameters."""
@@ -103,7 +153,6 @@ class OAuthContext:
storage: TokenStorage
redirect_handler: Callable[[str], Awaitable[None]] | None
callback_handler: Callable[[], Awaitable[AuthorizationCodeResult]] | None
- timeout: float = 300.0
client_metadata_url: str | None = None
# Discovered metadata
@@ -191,6 +240,12 @@ def prepare_token_auth(
Returns:
Tuple of (updated_data, updated_headers)
+
+ Raises:
+ OAuthTokenError: The client record carries a `token_endpoint_auth_method` this
+ client does not know. A dynamic registration assigning an unusable method is
+ rejected earlier, by `check_registration_usable`; this fires for a stored or
+ pre-registered record that reaches a token request with such a method.
"""
if headers is None:
headers = {} # pragma: no cover
@@ -200,7 +255,7 @@ def prepare_token_auth(
auth_method = self.client_info.token_endpoint_auth_method
- if auth_method == "client_secret_basic" and self.client_info.client_id and self.client_info.client_secret:
+ if auth_method == "client_secret_basic" and self.client_info.client_secret:
# URL-encode client ID and secret per RFC 6749 Section 2.3.1
encoded_id = quote(self.client_info.client_id, safe="")
encoded_secret = quote(self.client_info.client_secret, safe="")
@@ -209,11 +264,14 @@ def prepare_token_auth(
headers["Authorization"] = f"Basic {encoded_credentials}"
# Don't include client_secret in body for basic auth
data = {k: v for k, v in data.items() if k != "client_secret"}
- elif auth_method == "client_secret_post" and self.client_info.client_id and self.client_info.client_secret:
+ elif auth_method == "client_secret_post" and self.client_info.client_secret:
# Include client_id and client_secret in request body (RFC 6749 §2.3.1)
data["client_id"] = self.client_info.client_id
data["client_secret"] = self.client_info.client_secret
- # For auth_method == "none", don't add any client_secret
+ elif auth_method not in _KNOWN_TOKEN_ENDPOINT_AUTH_METHODS:
+ raise OAuthTokenError(f"Registered client uses unsupported token_endpoint_auth_method {auth_method!r}")
+ # For "none" (or absent), don't add any client_secret; "private_key_jwt" adds its
+ # assertion in the provider that implements it, not here.
return data, headers
@@ -233,7 +291,6 @@ def __init__(
storage: TokenStorage,
redirect_handler: Callable[[str], Awaitable[None]] | None = None,
callback_handler: Callable[[], Awaitable[AuthorizationCodeResult]] | None = None,
- timeout: float = 300.0,
client_metadata_url: str | None = None,
validate_resource_url: Callable[[str, str | None], Awaitable[None]] | None = None,
):
@@ -245,7 +302,6 @@ def __init__(
storage: Token storage implementation.
redirect_handler: Handler for authorization redirects.
callback_handler: Handler for authorization callbacks.
- timeout: Timeout for the OAuth flow.
client_metadata_url: URL-based client ID. When provided and the server
advertises client_id_metadata_document_supported=True, this URL will be
used as the client_id instead of performing dynamic client registration.
@@ -271,7 +327,6 @@ def __init__(
storage=storage,
redirect_handler=redirect_handler,
callback_handler=callback_handler,
- timeout=timeout,
client_metadata_url=client_metadata_url,
)
self._validate_resource_url_callback = validate_resource_url
@@ -383,9 +438,7 @@ def _get_token_endpoint(self) -> str:
token_url = urljoin(auth_base_url, "/token")
return token_url
- async def _exchange_token_authorization_code(
- self, auth_code: str, code_verifier: str, *, token_data: dict[str, Any] | None = {}
- ) -> httpx2.Request:
+ async def _exchange_token_authorization_code(self, auth_code: str, code_verifier: str) -> httpx2.Request:
"""Build token exchange request for authorization_code flow."""
if self.context.client_metadata.redirect_uris is None:
raise OAuthFlowError("No redirect URIs provided for authorization code grant") # pragma: no cover
@@ -393,16 +446,13 @@ async def _exchange_token_authorization_code(
raise OAuthFlowError("Missing client info") # pragma: no cover
token_url = self._get_token_endpoint()
- token_data = token_data or {}
- token_data.update(
- {
- "grant_type": "authorization_code",
- "code": auth_code,
- "redirect_uri": str(self.context.client_metadata.redirect_uris[0]),
- "client_id": self.context.client_info.client_id,
- "code_verifier": code_verifier,
- }
- )
+ token_data: dict[str, Any] = {
+ "grant_type": "authorization_code",
+ "code": auth_code,
+ "redirect_uri": str(self.context.client_metadata.redirect_uris[0]),
+ "client_id": self.context.client_info.client_id,
+ "code_verifier": code_verifier,
+ }
# Only include resource param if conditions are met
if self.context.should_include_resource_param(self.context.protocol_version):
@@ -673,6 +723,7 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
)
registration_response = yield registration_request
client_information = await handle_registration_response(registration_response)
+ check_registration_usable(client_information)
# Only record the issuer when the registration above actually targeted
# the discovered AS — either via its published registration_endpoint,
# or because the resource-origin /register fallback is on the issuer's
diff --git a/src/mcp/client/auth/utils.py b/src/mcp/client/auth/utils.py
index fc87c9e469..31e2e5cade 100644
--- a/src/mcp/client/auth/utils.py
+++ b/src/mcp/client/auth/utils.py
@@ -1,9 +1,11 @@
import re
+from typing import Any, cast
from urllib.parse import urljoin, urlparse
from httpx2 import Request, Response
from mcp_types import LATEST_PROTOCOL_VERSION
from pydantic import AnyUrl, ValidationError
+from pydantic_core import from_json
from mcp.client.auth import OAuthFlowError, OAuthRegistrationError, OAuthTokenError
from mcp.shared.auth import (
@@ -299,10 +301,17 @@ async def handle_registration_response(response: Response) -> OAuthClientInforma
try:
content = await response.aread()
- client_info = OAuthClientInformationFull.model_validate_json(content)
- return client_info
- except ValidationError as e: # pragma: no cover
- raise OAuthRegistrationError(f"Invalid registration response: {e}")
+ body = from_json(content)
+ # `issuer` is the SDK's own binding of these credentials to the server they were
+ # registered with (SEP-2352), stamped by the auth flow - never sourced from the
+ # wire, so it is dropped before the body is parsed rather than trusted or cleared.
+ if isinstance(body, dict):
+ cast(dict[str, Any], body).pop("issuer", None)
+ return OAuthClientInformationFull.model_validate(body)
+ except ValueError as e:
+ # `from_json` reports malformed bytes/JSON as ValueError, and pydantic's
+ # ValidationError is itself a ValueError, so both parse layers surface here.
+ raise OAuthRegistrationError(f"Invalid registration response: {e}") from e
def is_valid_client_metadata_url(url: str | None) -> bool:
@@ -381,8 +390,8 @@ def create_client_info_from_metadata_url(
Args:
client_metadata_url: The URL to use as the client_id
- redirect_uris: The redirect URIs from the client metadata (passed through for
- compatibility with OAuthClientInformationFull which inherits from OAuthClientMetadata)
+ redirect_uris: The redirect URIs from the client metadata, recorded on the client
+ information alongside the client_id
Returns:
OAuthClientInformationFull with the URL as client_id
diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py
index d519106a63..ed7c40f123 100644
--- a/src/mcp/client/client.py
+++ b/src/mcp/client/client.py
@@ -52,6 +52,7 @@
ClientRequestContext,
ClientSession,
ElicitationFnT,
+ IncomingMessage,
ListRootsFnT,
LoggingFnT,
MessageHandlerFnT,
@@ -68,7 +69,6 @@
from mcp.shared.exceptions import MCPDeprecationWarning, MCPError
from mcp.shared.extension import validate_extension_identifier
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
-from mcp.shared.session import RequestResponder
from mcp.shared.subscriptions import event_to_notification
logger = logging.getLogger(__name__)
@@ -155,9 +155,7 @@ def _strip_userinfo(url: str) -> str:
def _evicting_message_handler(cache: ClientResponseCache, user_handler: MessageHandlerFnT | None) -> MessageHandlerFnT:
"""Wrap the session message handler with cache eviction on server notifications."""
- async def handler(
- message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
- ) -> None:
+ async def handler(message: IncomingMessage) -> None:
if isinstance(message, types.ServerNotification):
try:
await cache.evict_for_notification(message)
@@ -176,7 +174,6 @@ def _synthesize_discover(protocol_version: str) -> types.DiscoverResult:
return types.DiscoverResult(
supported_versions=[protocol_version],
capabilities=types.ServerCapabilities(),
- server_info=types.Implementation(name="", version=""),
result_type="complete",
ttl_ms=0,
cache_scope="public",
@@ -315,6 +312,16 @@ async def main():
logging_callback: LoggingFnT | None = None
"""Callback for handling logging notifications."""
+ log_level: LoggingLevel | None = None
+ """The log level to opt in to on 2026-07-28+ connections (deprecated logging feature, SEP-2577).
+
+ Modern (2026-07-28+) servers send `notifications/message` only for requests that opt in by
+ carrying `io.modelcontextprotocol/logLevel` in `_meta`, and only at or above that level. Setting
+ this stamps that opt-in on every request; `None` (the default) means no opt-in, so no log
+ messages arrive - a `logging_callback` alone is not an opt-in. No effect on handshake-era
+ connections, where the deprecated `logging/setLevel` request governs delivery instead. A
+ per-request `_meta` entry with the same key overrides this default."""
+
# TODO(Marcelo): Why do we have both "callback" and "handler"?
message_handler: MessageHandlerFnT | None = None
"""Callback for handling raw messages."""
@@ -350,14 +357,15 @@ async def main():
transparently by `call_tool`), and its notification bindings. For an
ad-only entry use `mcp.client.advertise(identifier, settings)`."""
- cache: CacheConfig | Literal[False] | None = None
+ cache: CacheConfig | None = field(default_factory=CacheConfig)
"""Client-side response caching for the SEP-2549 cacheable methods (2026-07-28).
- `None` (the default) honors server `ttlMs`/`cacheScope` hints with a per-client
- in-memory store; pass a `CacheConfig` to customize, or `False` to disable. The
- cacheable verbs take a per-call `cache_mode` (see `CacheMode`); calls carrying
- `meta` always reach the server. A `CacheConfig` with a custom `store` requires
- `target_id` when the server is not a URL (no identity can be derived)."""
+ The default `CacheConfig()` honors server `ttlMs`/`cacheScope` hints with a
+ per-client in-memory store; pass a customized `CacheConfig`, or `None` to
+ disable. The cacheable verbs take a per-call `cache_mode` (see `CacheMode`);
+ calls carrying `meta` always reach the server. A `CacheConfig` with a custom
+ `store` requires `target_id` when the server is not a URL (no identity can be
+ derived)."""
_entered: bool = field(init=False, default=False)
_session: ClientSession | None = field(init=False, default=None)
@@ -389,8 +397,8 @@ def __post_init__(self) -> None:
else:
self._connect = _connect_transport(srv)
- if self.cache is not False:
- config = self.cache if self.cache is not None else CacheConfig()
+ if self.cache is not None:
+ config = self.cache
# Only the hash below leaves this scope - the raw identity may carry credentials; never log or store it.
target_id = config.target_id
if target_id is None and isinstance(self.server, str):
@@ -427,6 +435,7 @@ async def _build_session(self, exit_stack: AsyncExitStack) -> ClientSession:
sampling_capabilities=self.sampling_capabilities,
list_roots_callback=self.list_roots_callback,
logging_callback=self.logging_callback,
+ log_level=self.log_level,
message_handler=message_handler,
client_info=self.client_info,
elicitation_callback=self.elicitation_callback,
@@ -453,7 +462,8 @@ async def __aenter__(self) -> Client:
session.adopt(self.prior_discover or _synthesize_discover(self.mode))
# Only publish the session after the handshake succeeds, so `_session is not None`
- # implies the protocol_version/server_info/server_capabilities are populated. If the
+ # implies the protocol_version/server_capabilities are populated (server_info
+ # stays optional: 2026-era servers may not identify themselves). If the
# handshake raised above, the local exit_stack unwinds the transport for us.
self._session = session
self._exit_stack = exit_stack.pop_all()
@@ -479,18 +489,24 @@ def session(self) -> ClientSession:
return self._session
# TODO(maxisbey): the by-construction shape is for __aenter__ to return a connected-view
- # type whose protocol_version/server_info/server_capabilities are non-Optional fields,
+ # type whose protocol_version/server_capabilities are non-Optional fields,
# eliminating these guards (and the one in .session). Same family as resolving the
# transport/connector at __post_init__ so the Optional internal fields disappear.
+ # (server_info stays Optional even connected: the 2026-era stamp is optional.)
@property
def protocol_version(self) -> str:
"""Negotiated protocol version (set by initialize/discover/adopt during ``__aenter__``)."""
return _connected(self.session.protocol_version)
@property
- def server_info(self) -> Implementation:
- """Server name/version (set by initialize/discover/adopt during ``__aenter__``)."""
- return _connected(self.session.server_info)
+ def server_info(self) -> Implementation | None:
+ """Server name/version, or `None` when the server did not identify itself.
+
+ Legacy connections always carry it (`InitializeResult.serverInfo` is
+ required); on 2026-era connections the `_meta` `serverInfo` stamp is
+ optional, so an anonymous server reads as `None`.
+ """
+ return self.session.server_info
@property
def server_capabilities(self) -> ServerCapabilities:
diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py
index 097ade1c91..895339ca18 100644
--- a/src/mcp/client/session.py
+++ b/src/mcp/client/session.py
@@ -1,12 +1,13 @@
from __future__ import annotations
+import json
import logging
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from functools import reduce
from operator import or_
from types import TracebackType
-from typing import Annotated, Any, Final, Literal, Protocol, cast, overload
+from typing import TYPE_CHECKING, Annotated, Any, Final, Literal, Protocol, TypeAlias, cast, overload
import anyio
import anyio.abc
@@ -18,8 +19,10 @@
CLIENT_INFO_META_KEY,
CONNECTION_CLOSED,
INTERNAL_ERROR,
+ LOG_LEVEL_META_KEY,
METHOD_NOT_FOUND,
PROTOCOL_VERSION_META_KEY,
+ SERVER_INFO_META_KEY,
UNSUPPORTED_PROTOCOL_VERSION,
RequestId,
RequestParamsMeta,
@@ -52,10 +55,14 @@
)
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher, cancelled_request_id_from_params
from mcp.shared.message import ClientMessageMetadata, SessionMessage
-from mcp.shared.session import RequestResponder
from mcp.shared.subscriptions import SUBSCRIPTION_ID_META_KEY, event_from_wire
from mcp.shared.transport_context import TransportContext
+if TYPE_CHECKING:
+ # `jsonschema` is imported lazily inside `validate_tool_result`: pulling it (and its
+ # `attrs`/`referencing` tree) in at module scope costs every client that never validates.
+ from jsonschema.protocols import Validator
+
DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0")
DISCOVER_TIMEOUT_SECONDS = 10.0
_NOTIFICATION_QUEUE_SIZE: Final = 256
@@ -70,6 +77,17 @@ def _clamp_inbound_ttl(raw: dict[str, Any]) -> None:
raw["ttlMs"] = 0
+def _same_schema(a: dict[str, Any] | None, b: dict[str, Any] | None) -> bool:
+ """JSON equality for two output schemas.
+
+ Python `==` is not JSON equality: it conflates `True`/`1` and `False`/`0`, which JSON
+ Schema keeps distinct (`const: true` vs `const: 1`). Canonical serialization compares as
+ JSON does; where it is stricter (`1` vs `1.0`), erring toward "changed" only costs a
+ recompile, never a stale validator.
+ """
+ return json.dumps(a, sort_keys=True) == json.dumps(b, sort_keys=True)
+
+
def _preconnect_stamp(data: dict[str, Any], opts: CallOptions) -> None:
# initialize/discover forbid cancellation; other pre-handshake requests (lowlevel
# ClientSession callers may skip the handshake entirely) keep the courtesy cancel.
@@ -77,6 +95,21 @@ def _preconnect_stamp(data: dict[str, Any], opts: CallOptions) -> None:
opts["cancel_on_abandon"] = False
+def _parse_server_info_stamp(result: types.DiscoverResult) -> types.Implementation | None:
+ """The typed identity from a discover result's `_meta` serverInfo stamp.
+
+ The stamp is display-only per the spec, so absent and malformed both read
+ as `None` rather than failing the connection.
+ """
+ raw = (result.meta or {}).get(SERVER_INFO_META_KEY)
+ if raw is None:
+ return None
+ try:
+ return types.Implementation.model_validate(raw)
+ except ValidationError:
+ return None
+
+
def _make_handshake_stamp(protocol_version: str) -> Callable[[dict[str, Any], CallOptions], None]:
def stamp(data: dict[str, Any], opts: CallOptions) -> None:
opts.setdefault("headers", {})[MCP_PROTOCOL_VERSION_HEADER] = protocol_version
@@ -89,6 +122,8 @@ def _make_modern_stamp(
client_info: dict[str, Any],
capabilities: dict[str, Any],
resolve_param_headers: Callable[[str, Mapping[str, Any]], dict[str, str]],
+ *,
+ log_level: types.LoggingLevel | None = None,
) -> Callable[[dict[str, Any], CallOptions], None]:
def stamp(data: dict[str, Any], opts: CallOptions) -> None:
params = data.setdefault("params", {})
@@ -96,6 +131,11 @@ def stamp(data: dict[str, Any], opts: CallOptions) -> None:
meta[PROTOCOL_VERSION_META_KEY] = protocol_version
meta[CLIENT_INFO_META_KEY] = client_info
meta[CLIENT_CAPABILITIES_META_KEY] = capabilities
+ # The per-request log-delivery opt-in (2026 logging is opt-in per
+ # request). A default the caller can override on any single call by
+ # supplying the key in that request's `_meta`, hence setdefault.
+ if log_level is not None:
+ meta.setdefault(LOG_LEVEL_META_KEY, log_level)
# `cancel_on_abandon` stays at the dispatcher default (True): the
# courtesy `notifications/cancelled` is the abandon signal. On the
# stream transports it is the 2026 wire's cancellation spelling; the
@@ -156,16 +196,20 @@ class LoggingFnT(Protocol):
async def __call__(self, params: types.LoggingMessageNotificationParams) -> None: ... # pragma: no branch
+IncomingMessage: TypeAlias = types.ServerNotification | Exception
+"""What `message_handler` receives: the server notifications the session surfaces, plus transport-level exceptions.
+
+`notifications/cancelled` is applied by the dispatcher and never surfaced, and a
+`notifications/subscriptions/acknowledged` for a live `listen()` stream is consumed by that
+stream, so neither reaches the handler.
+"""
+
+
class MessageHandlerFnT(Protocol):
- async def __call__(
- self,
- message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
- ) -> None: ... # pragma: no branch
+ async def __call__(self, message: IncomingMessage) -> None: ... # pragma: no branch
-async def _default_message_handler(
- message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
-) -> None:
+async def _default_message_handler(message: IncomingMessage) -> None:
await anyio.lowlevel.checkpoint()
@@ -315,8 +359,10 @@ class ClientSession:
`dispatcher=`), enter as an async context manager, then call
`initialize()`. The dispatcher owns the receive loop and request
correlation; this class owns the typed MCP layer and the constructor
- callbacks. Transport `Exception` items reach `message_handler` only when
- the session builds its own dispatcher from a stream pair.
+ callbacks. Transport `Exception` items reach `message_handler` on any
+ stream-backed dispatcher (`JSONRPCDispatcher`), whether built here from a
+ stream pair or supplied without a stream-exception hook of its own; an
+ in-process `DirectDispatcher` carries none.
Extension `result_claims` fold into tools/call parsing at `adopt()`;
`notification_bindings` observe vendor notifications via bounded FIFOs.
@@ -334,6 +380,7 @@ def __init__(
message_handler: MessageHandlerFnT | None = None,
client_info: types.Implementation | None = None,
*,
+ log_level: types.LoggingLevel | None = None,
sampling_capabilities: types.SamplingCapability | None = None,
extensions: dict[str, dict[str, Any]] | None = None,
result_claims: Mapping[str, Sequence[ResultClaim[Any]]] | None = None,
@@ -355,11 +402,16 @@ def __init__(
self._elicitation_callback = elicitation_callback or _default_elicitation_callback
self._list_roots_callback = list_roots_callback or _default_list_roots_callback
self._logging_callback = logging_callback or _default_logging_callback
+ self._log_level: types.LoggingLevel | None = log_level
self._message_handler = message_handler or _default_message_handler
self._tool_output_schemas: dict[str, dict[str, Any] | None] = {}
+ # Compiled output-schema validators, derived from `_tool_output_schemas` and owned by
+ # `_absorb_tool_listing`, which evicts a tool's entry whenever its schema changes.
+ self._tool_output_validators: dict[str, Validator] = {}
self._x_mcp_header_maps: dict[str, dict[tuple[str, ...], str]] = {}
self._initialize_result: types.InitializeResult | None = None
self._discover_result: types.DiscoverResult | None = None
+ self._discover_server_info: types.Implementation | None = None
self._negotiated_version: str | None = None
self._stamp: Callable[[dict[str, Any], CallOptions], None] = _preconnect_stamp
self._task_group: anyio.abc.TaskGroup | None = None
@@ -603,14 +655,18 @@ def adopt(self, result: types.InitializeResult | types.DiscoverResult) -> None:
version = mutual[-1]
client_info = self._client_info.model_dump(by_alias=True, mode="json", exclude_none=True)
capabilities = self._build_capabilities(version).model_dump(by_alias=True, mode="json", exclude_none=True)
- self._stamp = _make_modern_stamp(version, client_info, capabilities, self._resolve_param_headers)
+ self._stamp = _make_modern_stamp(
+ version, client_info, capabilities, self._resolve_param_headers, log_level=self._log_level
+ )
self._discover_result = result
+ self._discover_server_info = _parse_server_info_stamp(result)
self._initialize_result = None
else:
version = result.protocol_version
self._stamp = _make_handshake_stamp(version)
self._initialize_result = result
self._discover_result = None
+ self._discover_server_info = None
self._negotiated_version = version
# Both arms reach here, so re-adoption resets cleanly; legacy versions activate no claims.
# Core-vocabulary tags are unconstructible (ResultClaim.__post_init__), so no exclusion needed.
@@ -719,9 +775,15 @@ def protocol_version(self) -> str | None:
@property
def server_info(self) -> types.Implementation | None:
- """Server name/version. None until `initialize()`, `discover()`, or `adopt()`."""
+ """Server name/version. None until `initialize()`, `discover()`, or `adopt()`.
+
+ On 2026-era connections this is the discover result's optional `_meta`
+ `serverInfo` stamp, parsed once at adopt time; `None` when the server
+ did not identify itself. The stamp is display-only per the spec, so a
+ malformed value reads as absent rather than failing the connection.
+ """
if self._discover_result is not None:
- return self._discover_result.server_info
+ return self._discover_server_info
if self._initialize_result is not None:
return self._initialize_result.server_info
return None
@@ -1032,16 +1094,49 @@ async def validate_tool_result(self, name: str, result: types.CallToolResult) ->
logger.warning(f"Tool {name} not listed by server, cannot validate any structured content")
if output_schema is not None:
- from jsonschema import SchemaError, ValidationError, validate
+ from jsonschema import exceptions as jsonschema_exceptions
if result.structured_content is None:
raise RuntimeError(f"Tool {name} has an output schema but did not return structured content")
- try:
- validate(result.structured_content, output_schema)
- except ValidationError as e:
- raise RuntimeError(f"Invalid structured content returned by tool {name}: {e}")
- except SchemaError as e: # pragma: no cover
- raise RuntimeError(f"Invalid schema for tool {name}: {e}") # pragma: no cover
+ validator = self._output_schema_validator(name, output_schema)
+ # `best_match` picks the same error the previous `jsonschema.validate()` call raised,
+ # so the message a caller sees is unchanged. It is untyped upstream.
+ errors = validator.iter_errors(result.structured_content)
+ error = cast(
+ "Exception | None",
+ jsonschema_exceptions.best_match(errors), # pyright: ignore[reportUnknownMemberType]
+ )
+ if error is not None:
+ raise RuntimeError(f"Invalid structured content returned by tool {name}: {error}") from error
+
+ def _output_schema_validator(self, name: str, output_schema: dict[str, Any]) -> Validator:
+ """Compiled validator for the tool's cached output schema, built once per schema value.
+
+ Compiling is ~60x the cost of validating, so a one-shot `jsonschema.validate()` per
+ result dominates `call_tool`; the compiled validator is cached instead. It stays valid
+ because `_absorb_tool_listing` evicts a tool's validator whenever it absorbs a different
+ schema for that tool, so a cached entry always matches `output_schema`.
+
+ Raises:
+ RuntimeError: The schema is not a valid JSON Schema. Raised on every call, since a
+ failed compile is never cached.
+ """
+ from jsonschema import SchemaError
+ from jsonschema.validators import validator_for
+
+ if (validator := self._tool_output_validators.get(name)) is not None:
+ return validator
+
+ validator_cls = validator_for(output_schema)
+ try:
+ validator_cls.check_schema(output_schema)
+ except SchemaError as e:
+ raise RuntimeError(f"Invalid schema for tool {name}: {e}")
+ # jsonschema ships no `py.typed`, so pyright reads typeshed's stub, which declares
+ # `registry` as required (concrete validators default it); cast to a schema-only ctor.
+ validator = cast("Callable[[dict[str, Any]], Validator]", validator_cls)(output_schema)
+ self._tool_output_validators[name] = validator
+ return validator
async def list_prompts(self, *, params: types.PaginatedRequestParams | None = None) -> types.ListPromptsResult:
"""Send a prompts/list request.
@@ -1170,8 +1265,14 @@ def _absorb_tool_listing(self, result: types.ListToolsResult, *, complete: bool)
kept.append(tool)
result.tools = kept
- # Cache tool output schemas for future validation; cursor pages only ever add.
+ # Cache tool output schemas for future validation; cursor pages only ever add. A
+ # changed schema evicts its compiled validator; an unchanged one (a re-listing, or the
+ # response cache re-absorbing a served hit) keeps it. Only validated tools pay the check.
for tool in result.tools:
+ if tool.name in self._tool_output_validators and not _same_schema(
+ self._tool_output_schemas.get(tool.name), tool.output_schema
+ ):
+ del self._tool_output_validators[tool.name]
self._tool_output_schemas[tool.name] = tool.output_schema
if complete:
@@ -1180,6 +1281,7 @@ def _absorb_tool_listing(self, result: types.ListToolsResult, *, complete: bool)
names = {tool.name for tool in result.tools}
self._x_mcp_header_maps = {k: v for k, v in self._x_mcp_header_maps.items() if k in names}
self._tool_output_schemas = {k: v for k, v in self._tool_output_schemas.items() if k in names}
+ self._tool_output_validators = {k: v for k, v in self._tool_output_validators.items() if k in names}
return result
diff --git a/src/mcp/client/session_group.py b/src/mcp/client/session_group.py
index 5f26a43365..a544cecbe8 100644
--- a/src/mcp/client/session_group.py
+++ b/src/mcp/client/session_group.py
@@ -25,8 +25,8 @@
from mcp.client.stdio import StdioServerParameters
from mcp.client.streamable_http import streamable_http_client
from mcp.shared._httpx_utils import create_mcp_http_client
+from mcp.shared.dispatcher import ProgressFnT
from mcp.shared.exceptions import MCPError
-from mcp.shared.session import ProgressFnT
class SseServerParameters(BaseModel):
diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py
index c95cfcf50b..226b0fecf9 100644
--- a/src/mcp/client/streamable_http.py
+++ b/src/mcp/client/streamable_http.py
@@ -635,15 +635,7 @@ async def terminate_session(self, client: httpx2.AsyncClient) -> None:
except Exception as exc: # pragma: no cover
logger.warning(f"Session termination failed: {exc}")
- # TODO(Marcelo): Check the TODO below, and cover this with tests if necessary.
- def get_session_id(self) -> str | None:
- """Get the current session ID."""
- return self.session_id # pragma: no cover
-
-# TODO(Marcelo): I've dropped the `get_session_id` callback because it breaks the Transport protocol. Is that needed?
-# It's a completely wrong abstraction, so removal is a good idea. But if we need the client to find the session ID,
-# we should think about a better way to do it. I believe we can achieve it with other means.
@asynccontextmanager
async def streamable_http_client(
url: str,
diff --git a/src/mcp/os/win32/utilities.py b/src/mcp/os/win32/utilities.py
index 1cc867d4fa..321fda8a66 100644
--- a/src/mcp/os/win32/utilities.py
+++ b/src/mcp/os/win32/utilities.py
@@ -1,4 +1,4 @@
-"""Windows-specific functionality for stdio client operations."""
+"""Windows-specific functionality for stdio transport operations."""
import logging
import shutil
@@ -17,6 +17,8 @@
# Windows-specific imports for Job Objects
if sys.platform == "win32":
+ import msvcrt
+
import pywintypes
import win32api
import win32con
@@ -25,9 +27,30 @@
# Type stubs for non-Windows platforms
win32api = None
win32con = None
+ msvcrt = None
win32job = None
pywintypes = None
+
+def rebind_std_handle_to_fd(fd: int) -> None:
+ """Points the Win32 standard-handle slot for fd 0, 1, or 2 at fd's current OS handle.
+
+ os.dup2 updates only the CRT descriptor table; subprocess handle inheritance
+ reads the Win32 slot, so it must be repointed too.
+
+ Raises:
+ OSError: The slot could not be set.
+ """
+ if sys.platform != "win32" or not win32api or not msvcrt or not pywintypes:
+ return
+ std_ids = {0: win32api.STD_INPUT_HANDLE, 1: win32api.STD_OUTPUT_HANDLE, 2: win32api.STD_ERROR_HANDLE}
+ try:
+ win32api.SetStdHandle(std_ids[fd], msvcrt.get_osfhandle(fd))
+ except pywintypes.error as exc:
+ # Normalized so callers' OSError-based best-effort handling covers it.
+ raise OSError(f"SetStdHandle failed for fd {fd}") from exc
+
+
# How often FallbackProcess polls the underlying Popen for exit.
_EXIT_POLL_INTERVAL = 0.01
diff --git a/src/mcp/server/_streamable_http_modern.py b/src/mcp/server/_streamable_http_modern.py
index f612511568..1db6b35f8a 100644
--- a/src/mcp/server/_streamable_http_modern.py
+++ b/src/mcp/server/_streamable_http_modern.py
@@ -218,9 +218,11 @@ async def _tool_input_schema(
"""
meta = {
PROTOCOL_VERSION_META_KEY: verdict.protocol_version,
- CLIENT_INFO_META_KEY: verdict.client_info,
CLIENT_CAPABILITIES_META_KEY: verdict.client_capabilities,
}
+ if verdict.client_info is not None:
+ # Optional key: a conforming pair-only caller omits it rather than sending null.
+ meta[CLIENT_INFO_META_KEY] = verdict.client_info
list_params: dict[str, Any] = {"_meta": meta}
try:
_methods.validate_client_request("tools/list", verdict.protocol_version, list_params)
diff --git a/src/mcp/server/apps.py b/src/mcp/server/apps.py
index d5b9d9ed85..583e203ac0 100644
--- a/src/mcp/server/apps.py
+++ b/src/mcp/server/apps.py
@@ -233,8 +233,7 @@ def client_supports_apps(ctx: Context[Any] | ServerRequestContext[Any, Any]) ->
def _client_capabilities(ctx: Context[Any] | ServerRequestContext[Any, Any]) -> Any:
if isinstance(ctx, Context):
return ctx.client_capabilities
- client_params = ctx.session.client_params
- return client_params.capabilities if client_params else None
+ return ctx.session.client_capabilities
def _require_ui_scheme(uri: str) -> None:
diff --git a/src/mcp/server/auth/handlers/register.py b/src/mcp/server/auth/handlers/register.py
index e565b27383..7fb14b2c43 100644
--- a/src/mcp/server/auth/handlers/register.py
+++ b/src/mcp/server/auth/handlers/register.py
@@ -50,6 +50,18 @@ async def handle(self, request: Request) -> Response:
# If auth method is None, default to client_secret_post
if client_metadata.token_endpoint_auth_method is None:
client_metadata.token_endpoint_auth_method = "client_secret_post"
+ # This server authenticates token requests with the client secret it mints; it holds
+ # no client key to verify a private_key_jwt assertion, so confirming that method would
+ # register a client whose every token request is then rejected. Refuse it instead
+ # (RFC 7591 §3.2.2), before minting credentials the client could never use.
+ if client_metadata.token_endpoint_auth_method == "private_key_jwt":
+ return PydanticJSONResponse(
+ content=RegistrationErrorResponse(
+ error="invalid_client_metadata",
+ error_description="token_endpoint_auth_method 'private_key_jwt' is not supported",
+ ),
+ status_code=400,
+ )
client_secret = None
if client_metadata.token_endpoint_auth_method != "none": # pragma: no branch
@@ -106,33 +118,27 @@ async def handle(self, request: Request) -> Response:
)
client_id_issued_at = int(time.time())
- client_secret_expires_at = (
- client_id_issued_at + self.options.client_secret_expiry_seconds
- if self.options.client_secret_expiry_seconds is not None
- else None
- )
+ # RFC 7591 §3.2.1: client_secret_expires_at is REQUIRED whenever a client_secret is
+ # issued, with 0 (not omission) meaning it never expires; a public client gets none.
+ client_secret_expires_at = None
+ if client_secret is not None:
+ client_secret_expires_at = (
+ client_id_issued_at + self.options.client_secret_expiry_seconds
+ if self.options.client_secret_expiry_seconds is not None
+ else 0
+ )
- client_info = OAuthClientInformationFull(
- client_id=client_id,
- client_id_issued_at=client_id_issued_at,
- client_secret=client_secret,
- client_secret_expires_at=client_secret_expires_at,
- # passthrough information from the client request
- redirect_uris=client_metadata.redirect_uris,
- token_endpoint_auth_method=client_metadata.token_endpoint_auth_method,
- grant_types=client_metadata.grant_types,
- response_types=client_metadata.response_types,
- client_name=client_metadata.client_name,
- client_uri=client_metadata.client_uri,
- logo_uri=client_metadata.logo_uri,
- scope=client_metadata.scope,
- contacts=client_metadata.contacts,
- tos_uri=client_metadata.tos_uri,
- policy_uri=client_metadata.policy_uri,
- jwks_uri=client_metadata.jwks_uri,
- jwks=client_metadata.jwks,
- software_id=client_metadata.software_id,
- software_version=client_metadata.software_version,
+ # RFC 7591 §3.2.1: the response returns all registered metadata about the client, so
+ # the record is the whole validated request plus the credentials minted here - built
+ # from the request's dump so no metadata field can be silently omitted from the echo.
+ client_info = OAuthClientInformationFull.model_validate(
+ {
+ **client_metadata.model_dump(),
+ "client_id": client_id,
+ "client_id_issued_at": client_id_issued_at,
+ "client_secret": client_secret,
+ "client_secret_expires_at": client_secret_expires_at,
+ }
)
try:
# Register client
diff --git a/src/mcp/server/connection.py b/src/mcp/server/connection.py
index 8cb7dc4213..99ba2da481 100644
--- a/src/mcp/server/connection.py
+++ b/src/mcp/server/connection.py
@@ -22,10 +22,11 @@
import logging
from collections.abc import Mapping
from contextlib import AsyncExitStack
-from typing import Any, TypeVar, overload
+from typing import Any, Final, TypeVar, get_args, overload
import anyio
from mcp_types import (
+ LOG_LEVEL_META_KEY,
ClientCapabilities,
CreateMessageRequest,
CreateMessageResult,
@@ -41,17 +42,52 @@
Request,
)
from mcp_types import methods as _methods
-from mcp_types.version import LATEST_HANDSHAKE_VERSION
+from mcp_types.version import LATEST_HANDSHAKE_VERSION, MODERN_PROTOCOL_VERSIONS
from pydantic import BaseModel, ValidationError
from typing_extensions import deprecated
from mcp.shared.dispatcher import CallOptions, Outbound
from mcp.shared.exceptions import MCPDeprecationWarning, NoBackChannelError
from mcp.shared.peer import Meta, dump_params
+from mcp.shared.subscriptions import LISTEN_STREAM_METHODS
__all__ = ["Connection"]
logger = logging.getLogger(__name__)
+# `Connection.log`'s `logger` parameter (public API, the spec's logger-name
+# field) shadows the module logger inside that method; this alias keeps the
+# module logger reachable there.
+_logger = logger
+
+_LOG_LEVELS: Final[tuple[LoggingLevel, ...]] = get_args(LoggingLevel)
+"""Severity-ascending, from the `LoggingLevel` literal's declaration order (the
+RFC 5424 scale) - the literal is the single source of the ordering."""
+
+_ALL_LOG_LEVELS: Final[frozenset[LoggingLevel]] = frozenset(_LOG_LEVELS)
+
+
+def allowed_log_levels(protocol_version: str, meta: Mapping[str, Any] | None) -> frozenset[LoggingLevel]:
+ """The `notifications/message` levels deliverable for one inbound request.
+
+ 2026-07-28+ makes log delivery a per-request opt-in (server/utilities/
+ logging): the client sets the reserved `io.modelcontextprotocol/logLevel`
+ `_meta` key, absent means no levels - the server MUST NOT send - and
+ present means that level and above. An unrecognized value reads as absent;
+ spec methods already reject a malformed value at surface validation
+ before any handler runs, so that arm only serves custom methods, where
+ dropping is the safe direction. Connection-scoped emitters pass
+ `meta=None`: `logging/setLevel` is gone at 2026 and log delivery is
+ request-scoped only, so they deliver nothing. Handshake versions keep
+ their `logging/setLevel`-era semantics: every level may be sent, filtering
+ is the application's `logging/setLevel` handler's job as before.
+ """
+ if protocol_version not in MODERN_PROTOCOL_VERSIONS:
+ return _ALL_LOG_LEVELS
+ requested = (meta or {}).get(LOG_LEVEL_META_KEY)
+ if requested not in _LOG_LEVELS:
+ return frozenset()
+ return frozenset(_LOG_LEVELS[_LOG_LEVELS.index(requested) :])
+
ResultT = TypeVar("ResultT", bound=BaseModel)
@@ -124,12 +160,25 @@ class NotifyOnlyOutbound(_NoChannelOutbound):
over duplex stream transports: the pipe is real, so server notifications
ride it, but the modern protocol forbids server-initiated JSON-RPC
requests, so `send_raw_request` (inherited) refuses by construction.
+
+ Change notifications (`notifications/*/list_changed`,
+ `notifications/resources/updated`) are dropped with a debug log: at this
+ era they reach a client only through a `subscriptions/listen` stream it
+ opened, so a bare copy on the shared channel would be an unrequested
+ notification. Publish them on the server's `SubscriptionBus` instead.
"""
def __init__(self, outbound: Outbound) -> None:
self._outbound = outbound
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
+ # At the 2026-07-28 era these are `subscriptions/listen` stream goods
+ # only: the spec forbids sending a change notification a subscription
+ # did not request, and listen streams deliver them (stamped, filtered)
+ # via the request-scoped outbound, never this connection-scoped channel.
+ if method in LISTEN_STREAM_METHODS:
+ logger.debug("dropped %s: delivered via subscriptions/listen at this era", method)
+ return
await self._outbound.notify(method, params, opts)
@@ -147,9 +196,13 @@ class Connection:
session_id: str | None
- client_params: InitializeRequestParams | None
- """The full `initialize` request params, or the equivalent built from the
- 2026-era envelope. `None` when no client info was supplied."""
+ client_capabilities: ClientCapabilities | None
+ """The capabilities the peer declared: the handshake's on the loop path,
+ the request envelope's on the modern path. `None` when none were declared.
+ Kept in lockstep with `client_params` by its setter, and settable on its
+ own for the modern envelope, where capabilities are required but client
+ info is optional (spec PR #3002) - capability checks must not depend on the
+ peer having identified itself."""
protocol_version: str
"""The protocol version this connection speaks. Populated at construction
@@ -180,11 +233,29 @@ def __init__(
self.outbound = outbound
self.protocol_version = protocol_version
self.session_id = session_id
+ self.client_capabilities = None
self.client_params = client_params
self.initialized = anyio.Event()
self.state = {}
self.exit_stack = AsyncExitStack()
+ @property
+ def client_params(self) -> InitializeRequestParams | None:
+ """The full `initialize` request params, or the equivalent built from the
+ 2026-era envelope. `None` when no client info was supplied."""
+ return self._client_params
+
+ @client_params.setter
+ def client_params(self, value: InitializeRequestParams | None) -> None:
+ # Assignment is the sync point: recording full client params (the
+ # handshake commit, or a modern envelope carrying client info) also
+ # records the capabilities fact, so the two can never drift. Clearing
+ # to `None` leaves `client_capabilities` alone - the modern envelope
+ # declares capabilities without client info.
+ self._client_params = value
+ if value is not None:
+ self.client_capabilities = value.capabilities
+
@classmethod
def from_envelope(
cls,
@@ -201,13 +272,15 @@ def from_envelope(
values. `client_info` and `client_capabilities` are the raw envelope
values: this constructor owns turning them into connection identity,
identically on every modern entry, so a mis-shaped value degrades to
- not-supplied rather than failing the request. `initialized`
- is set and the info/capabilities (when both supplied and well-formed)
- are recorded as `client_params` so capability checks work. `outbound`
- defaults to the no-channel sentinel for the single-exchange HTTP path;
- duplex modern transports (e.g. stdio) pass a notify-only wrapper
- around the dispatcher so server notifications ride the pipe while
- server-initiated requests stay refused.
+ not-supplied rather than failing the request. `initialized` is set,
+ well-formed capabilities are recorded as `client_capabilities` (client
+ info is optional per spec PR #3002, so capability checks never depend on
+ it), and the full `client_params` is additionally synthesized when
+ client info was supplied too. `outbound` defaults to the no-channel
+ sentinel for the single-exchange HTTP path; duplex modern transports
+ (e.g. stdio) pass a notify-only wrapper around the dispatcher so
+ server notifications ride the pipe while server-initiated requests
+ stay refused.
"""
info = _typed(Implementation, client_info)
capabilities = _typed(ClientCapabilities, client_capabilities)
@@ -219,6 +292,7 @@ def from_envelope(
client_info=info,
)
connection = cls(outbound, protocol_version=protocol_version, client_params=client_params)
+ connection.client_capabilities = capabilities
connection.initialized.set()
return connection
@@ -348,7 +422,17 @@ async def ping(self, *, meta: Meta | None = None, opts: CallOptions | None = Non
@deprecated("The logging capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)
async def log(self, level: LoggingLevel, data: Any, logger: str | None = None, *, meta: Meta | None = None) -> None:
- """Send a `notifications/message` log entry on the standalone stream. Best-effort."""
+ """Send a `notifications/message` log entry on the standalone stream. Best-effort.
+
+ On 2026-07-28+ connections this never sends: log delivery is a
+ per-request opt-in that rides the requesting stream (`ctx.log`,
+ `ctx.session.send_log_message`), and the standalone stream is
+ forbidden from carrying `notifications/message`, so the entry is
+ debug-logged and dropped.
+ """
+ if level not in allowed_log_levels(self.protocol_version, None):
+ _logger.debug("dropped notifications/message: no connection-wide log delivery at %s", self.protocol_version)
+ return
params: dict[str, Any] = {"level": level, "data": data}
if logger is not None:
params["logger"] = logger
@@ -369,13 +453,13 @@ async def send_resource_updated(self, uri: str, *, meta: Meta | None = None) ->
def check_capability(self, capability: ClientCapabilities) -> bool:
"""Return whether the connected client declared the given capability.
- Returns `False` when no client info has been recorded.
+ Returns `False` when no capabilities have been recorded.
"""
# TODO(L53): redesign - mirrors v1 ServerSession.check_client_capability
# verbatim for parity.
- if self.client_params is None:
+ if self.client_capabilities is None:
return False
- have = self.client_params.capabilities
+ have = self.client_capabilities
if capability.roots is not None:
if have.roots is None:
return False
diff --git a/src/mcp/server/context.py b/src/mcp/server/context.py
index b5c356075a..bfcb9c9ca4 100644
--- a/src/mcp/server/context.py
+++ b/src/mcp/server/context.py
@@ -1,3 +1,4 @@
+import logging
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass
from typing import Any, Generic, Protocol
@@ -6,7 +7,7 @@
from pydantic import BaseModel
from typing_extensions import TypeVar, deprecated
-from mcp.server.connection import Connection
+from mcp.server.connection import Connection, allowed_log_levels
from mcp.server.session import ServerSession
from mcp.shared.context import BaseContext
from mcp.shared.dispatcher import DispatchContext
@@ -15,6 +16,12 @@
from mcp.shared.peer import Meta
from mcp.shared.transport_context import TransportContext
+logger = logging.getLogger(__name__)
+# `Context.log`'s `logger` parameter (public API, the spec's logger-name
+# field) shadows the module logger inside that method; this alias keeps it
+# reachable there.
+_logger = logger
+
# Invariant: parametrizes a mutable dataclass field; dict default matches the default lifespan.
LifespanContextT = TypeVar("LifespanContextT", default=dict[str, Any])
RequestT = TypeVar("RequestT", default=Any)
@@ -67,6 +74,9 @@ def __init__(
super().__init__(dctx, meta=meta)
self._lifespan = lifespan
self._connection = connection
+ # Same per-request log gate as `ServerSession`: fixed at construction
+ # from this request's `_meta` log-level opt-in and the connection's era.
+ self._allowed_log_levels = allowed_log_levels(connection.protocol_version, meta)
@property
def lifespan(self) -> LifespanT_co:
@@ -102,7 +112,15 @@ async def log(self, level: LoggingLevel, data: Any, logger: str | None = None, *
Uses this request's back-channel (so the entry rides the request's SSE
stream in streamable HTTP), not the standalone stream - use
`ctx.connection.log(...)` for that.
+
+ On 2026-07-28+ delivery is a per-request opt-in: nothing is sent
+ unless this request's `_meta` carried the reserved log-level key, and
+ entries below the requested level are dropped (debug-logged).
+ Handshake versions send unconditionally, as before.
"""
+ if level not in self._allowed_log_levels:
+ _logger.debug("dropped notifications/message at %r: not opted in at that level on this request", level)
+ return
params: dict[str, Any] = {"level": level, "data": data}
if logger is not None:
params["logger"] = logger
@@ -116,8 +134,11 @@ async def log(self, level: LoggingLevel, data: Any, logger: str | None = None, *
all three to a result dict."""
CallNext = Callable[["ServerRequestContext[Any, Any]"], Awaitable[HandlerResult]]
-"""Invokes the rest of the chain. Pass the `ctx` through; rewrite `method` or
-`params` with `dataclasses.replace(ctx, ...)` to alter what the handler sees."""
+"""Invokes the rest of the chain with the given context. What a context
+rewrite (`dataclasses.replace(ctx, ...)`) can alter depends on the tier:
+`ServerMiddleware` runs before params validation, so its rewrites change what
+the handler is invoked with; an `Extension` interceptor runs after, so its
+rewrites change only what the handler observes on `ctx`."""
_MwLifespanT = TypeVar("_MwLifespanT")
diff --git a/src/mcp/server/elicitation.py b/src/mcp/server/elicitation.py
index 5a4acdd6c3..26425c1338 100644
--- a/src/mcp/server/elicitation.py
+++ b/src/mcp/server/elicitation.py
@@ -7,7 +7,7 @@
from mcp_types import RequestId
# Internal surface package; imported as the gate's source of truth for spec-valid property schemas.
-from mcp_types.v2025_11_25 import PrimitiveSchemaDefinition
+from mcp_types._v2025_11_25 import PrimitiveSchemaDefinition
from pydantic import BaseModel, ValidationError
from pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue
from pydantic_core import core_schema
diff --git a/src/mcp/server/extension.py b/src/mcp/server/extension.py
index c87d93b006..6205a7033d 100644
--- a/src/mcp/server/extension.py
+++ b/src/mcp/server/extension.py
@@ -27,7 +27,7 @@
from mcp_types.methods import SPEC_CLIENT_METHODS
from pydantic import BaseModel
-from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext
+from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
# Re-exported from `mcp.shared.extension` (shared with the client surface) for existing importers.
from mcp.shared.extension import validate_extension_identifier as validate_extension_identifier
@@ -144,30 +144,38 @@ async def intercept_tool_call(
Override to short-circuit (return a result without calling `call_next`)
or to observe the call. `params` is the validated `tools/call` params;
- `call_next(ctx)` runs the rest of the chain and the real handler.
+ `call_next(ctx)` runs the rest of the chain and the real handler, and
+ returns the handler's domain result. Interceptors run at the handler
+ layer: whatever they return is serialized like any handler result,
+ including the 2026-era `serverInfo` `_meta` stamp. The `params` this
+ interceptor received is what the wrapped handler is invoked with -
+ passing a rewritten context through `call_next` adjusts what the
+ handler observes on `ctx`, not the tool invocation. Wire-level request
+ rewriting belongs to `Server.middleware`, above params validation.
"""
return await call_next(ctx)
-def compose_tool_call_interceptor(extensions: Sequence[Extension]) -> ServerMiddleware[Any]:
- """Fold every extension's `intercept_tool_call` into one `ServerMiddleware`.
+def compose_tool_call_handler(extensions: Sequence[Extension], handler: RequestHandler) -> RequestHandler:
+ """Fold every extension's `intercept_tool_call` around the `tools/call` handler.
- The returned middleware nests the interceptors (first extension outermost)
- and is a no-op for any method other than `tools/call`. It validates the
- `tools/call` params once and threads them to each interceptor.
+ The returned handler nests the interceptors (first extension outermost) and
+ replaces the plain `tools/call` registration. Interception happens at the
+ handler layer, below the runner's outbound envelope pass, so a
+ short-circuiting interceptor's result is sieved and stamped exactly like
+ the wrapped handler's would be.
"""
- async def middleware(ctx: ServerRequestContext[Any, Any], call_next: CallNext) -> HandlerResult:
- if ctx.method != "tools/call":
- return await call_next(ctx)
- params = CallToolRequestParams.model_validate({} if ctx.params is None else ctx.params, by_name=False)
+ async def wrapped(ctx: ServerRequestContext[Any, Any], params: CallToolRequestParams) -> HandlerResult:
+ async def innermost(inner_ctx: ServerRequestContext[Any, Any]) -> HandlerResult:
+ return await handler(inner_ctx, params)
- chain = call_next
+ chain: CallNext = innermost
for extension in reversed(extensions):
chain = _bind_interceptor(extension, params, chain)
return await chain(ctx)
- return middleware
+ return wrapped
def _bind_interceptor(extension: Extension, params: CallToolRequestParams, call_next: CallNext) -> CallNext:
diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py
index abf98c6fdb..efdf4b216e 100644
--- a/src/mcp/server/lowlevel/server.py
+++ b/src/mcp/server/lowlevel/server.py
@@ -36,12 +36,13 @@ async def main():
from __future__ import annotations
+import copy
import logging
import warnings
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from dataclasses import dataclass
-from importlib.metadata import version as importlib_version
+from functools import cached_property
from typing import Any, Generic, overload
import mcp_types as types
@@ -124,22 +125,13 @@ async def _ping_handler(ctx: ServerRequestContext[Any], params: types.RequestPar
return types.EmptyResult()
-def _package_version(package: str) -> str:
- try:
- return importlib_version(package)
- except Exception: # pragma: no cover
- pass
-
- return "unknown" # pragma: no cover
-
-
class Server(Generic[LifespanResultT]):
@overload
def __init__(
self,
name: str,
*,
- version: str | None = None,
+ version: str = "",
title: str | None = None,
description: str | None = None,
instructions: str | None = None,
@@ -222,7 +214,7 @@ def __init__(
self,
name: str,
*,
- version: str | None = None,
+ version: str = "",
title: str | None = None,
description: str | None = None,
instructions: str | None = None,
@@ -314,7 +306,7 @@ def __init__(
self,
name: str,
*,
- version: str | None = None,
+ version: str = "",
title: str | None = None,
description: str | None = None,
instructions: str | None = None,
@@ -441,9 +433,9 @@ def __init__(
# `OpenTelemetryMiddleware` ships on by default so every server emits a
# SERVER span per message; it is a no-op until an OTel exporter is
# installed. Drop it from this list to opt out.
- # TODO(L54): provisional - signature and semantics change with the
- # Context/middleware rework (covariant `Context[L]`, outbound seam) before
- # v2 final.
+ # TODO(L54): provisional - signature and semantics may change in a 2.x
+ # minor release with the Context/middleware rework (covariant
+ # `Context[L]`, outbound seam).
self.middleware: list[ServerMiddleware[LifespanResultT]] = [OpenTelemetryMiddleware()]
# SEP-2133 extension settings advertised under `ServerCapabilities.extensions`
# (identifier -> settings). Higher layers (e.g. `MCPServer(extensions=...)`)
@@ -547,7 +539,7 @@ def create_initialization_options(
"""
return InitializationOptions(
server_name=self.name,
- server_version=self.version if self.version else _package_version("mcp"),
+ server_version=self.version,
title=self.title,
description=self.description,
capabilities=self.get_capabilities(
@@ -636,18 +628,35 @@ def get_capabilities(
def server_info(self) -> types.Implementation:
"""The `serverInfo` block describing this implementation.
- Derived from the constructor's identity fields. `version` falls back to
- the installed `mcp` package version when not supplied explicitly.
+ Derived from the constructor's identity fields. An unversioned server
+ reports an empty `version`; the SDK never substitutes its own.
"""
return types.Implementation(
name=self.name,
- version=self.version if self.version else _package_version("mcp"),
+ version=self.version,
title=self.title,
description=self.description,
website_url=self.website_url,
icons=self.icons,
)
+ @cached_property
+ def _server_info_stamp_source(self) -> dict[str, Any]:
+ # Identity is fixed at construction, so the dump is computed once per
+ # server instead of per request. Never handed out directly: nested
+ # values (`icons`) would alias the cache into stamped responses.
+ return self.server_info.model_dump(by_alias=True, mode="json", exclude_none=True)
+
+ @property
+ def server_info_stamp(self) -> dict[str, Any]:
+ """A fresh wire dump of `server_info`; callers own the returned dict.
+
+ Each access materializes a deep copy of the once-per-server dump, so
+ a caller mutating a stamped response can never corrupt the identity
+ stamped into later responses.
+ """
+ return copy.deepcopy(self._server_info_stamp_source)
+
async def _handle_discover(
self, ctx: ServerRequestContext[LifespanResultT], params: types.RequestParams | None
) -> types.DiscoverResult:
@@ -662,7 +671,6 @@ async def _handle_discover(
return types.DiscoverResult(
supported_versions=list(MODERN_PROTOCOL_VERSIONS),
capabilities=self.get_capabilities(protocol_version=ctx.protocol_version),
- server_info=self.server_info,
instructions=self.instructions,
)
@@ -695,9 +703,9 @@ async def run(
Thin wrapper over `serve_dual_era_loop`: enters the server lifespan,
then drives the loop, serving the legacy handshake era and the modern
- per-request-envelope era (the first era-distinctive message to succeed
- locks the connection). Transports with their own lifespan owner (the
- streamable-HTTP manager) call `serve_loop` directly instead.
+ per-request-envelope era (the client's first request decides which).
+ Transports with their own lifespan owner (the streamable-HTTP manager)
+ call `serve_loop` directly instead.
"""
async with self.lifespan(self) as lifespan_context:
await serve_dual_era_loop(
diff --git a/src/mcp/server/mcpserver/context.py b/src/mcp/server/mcpserver/context.py
index 2b7fdf35ee..bf4c26a248 100644
--- a/src/mcp/server/mcpserver/context.py
+++ b/src/mcp/server/mcpserver/context.py
@@ -54,7 +54,6 @@ async def my_tool(x: int, ctx: Context) -> str:
# Get request info
request_id = ctx.request_id
- client_id = ctx.client_id
return str(x)
```
@@ -275,16 +274,6 @@ async def log(
related_request_id=self.request_id,
)
- # TODO(maxisbey): see if this is needed otherwise remove
- @property
- def client_id(self) -> str | None:
- """Get the client ID if available.
-
- Note: this reads from the MCP request's `_meta` params, not the OAuth
- bearer token. For that, use `get_access_token().client_id`.
- """
- return self.request_context.meta.get("client_id") if self.request_context.meta else None # pragma: no cover
-
@property
def headers(self) -> Mapping[str, str] | None:
"""Request headers carried by this message, when the transport has them.
@@ -326,11 +315,11 @@ def request_state(self) -> str | None:
def client_capabilities(self) -> ClientCapabilities | None:
"""The client's declared capabilities for this connection.
- `None` when the client supplied no client info (e.g. an anonymous
- stateless request without the reserved `_meta` keys).
+ `None` when the client declared none (e.g. an anonymous stateless
+ request without the reserved `_meta` keys). Client info is not
+ required for capabilities to be recorded.
"""
- client_params = self.request_context.session.client_params
- return client_params.capabilities if client_params else None
+ return self.request_context.session.client_capabilities
@property
def session(self):
diff --git a/src/mcp/server/mcpserver/exceptions.py b/src/mcp/server/mcpserver/exceptions.py
index 8095c451d5..239785e9a9 100644
--- a/src/mcp/server/mcpserver/exceptions.py
+++ b/src/mcp/server/mcpserver/exceptions.py
@@ -5,10 +5,6 @@ class MCPServerError(Exception):
"""Base error for MCPServer."""
-class ValidationError(MCPServerError):
- """Error in validating parameters or return values."""
-
-
class ResourceError(MCPServerError):
"""Error in resource operations."""
diff --git a/src/mcp/server/mcpserver/resources/base.py b/src/mcp/server/mcpserver/resources/base.py
index f7bedf6cbe..a1d9cebf3f 100644
--- a/src/mcp/server/mcpserver/resources/base.py
+++ b/src/mcp/server/mcpserver/resources/base.py
@@ -16,7 +16,7 @@
class Resource(BaseModel, abc.ABC):
"""Base class for all resources."""
- model_config = ConfigDict(validate_default=True)
+ model_config = ConfigDict(validate_default=True, extra="forbid")
uri: str = Field(default=..., description="URI of the resource")
name: str | None = Field(description="Name of the resource", default=None)
diff --git a/src/mcp/server/mcpserver/resources/types.py b/src/mcp/server/mcpserver/resources/types.py
index e6f00dec58..2edf342337 100644
--- a/src/mcp/server/mcpserver/resources/types.py
+++ b/src/mcp/server/mcpserver/resources/types.py
@@ -4,6 +4,7 @@
import json
from collections.abc import Callable
+from functools import partial
from pathlib import Path
from typing import Any
@@ -13,12 +14,34 @@
import pydantic
import pydantic_core
from mcp_types import Annotations, Icon, InputRequiredResult
-from pydantic import Field, ValidationInfo, validate_call
+from pydantic import Field, validate_call
from mcp.server.mcpserver.resources.base import Resource
from mcp.shared._callable_inspection import is_async_callable
from mcp.shared.exceptions import MCPError
+# `application/*` types that are textual but predate the `+json`/`+xml`
+# structured-syntax suffixes, so the suffix rule below can't catch them.
+_TEXTUAL_APPLICATION_TYPES = frozenset({"application/json", "application/xml"})
+
+
+def _default_file_encoding(mime_type: str) -> str | None:
+ """The encoding a file of this mime type is decoded with by default.
+
+ A declared `charset=` parameter wins. Otherwise textual types (`text/*`, JSON,
+ XML) are `utf-8-sig` — UTF-8 that also tolerates a byte-order mark — and
+ everything else is bytes (None).
+ """
+ essence, *params = (part.strip() for part in mime_type.split(";"))
+ for param in params:
+ name, _, value = param.partition("=")
+ if name.strip().lower() == "charset" and value:
+ return value.strip().strip('"')
+ essence = essence.lower()
+ if essence.startswith("text/") or essence.endswith(("+json", "+xml")) or essence in _TEXTUAL_APPLICATION_TYPES:
+ return "utf-8-sig"
+ return None
+
class TextResource(Resource):
"""A resource that reads from a string."""
@@ -122,17 +145,17 @@ def from_function(
class FileResource(Resource):
"""A resource that reads from a file.
- Set is_binary=True to read the file as binary data instead of text.
+ The file is decoded with `encoding` and served as text, or read as bytes and
+ served as a base64 blob when `encoding` is None. When `encoding` is omitted it
+ defaults to the `charset` declared in `mime_type`, else `"utf-8-sig"` for
+ textual mime types (`text/*`, JSON, XML) and None for everything else; pass
+ it explicitly to override either way.
"""
path: Path = Field(description="Path to the file")
- is_binary: bool = Field(
- default=False,
- description="Whether to read the file as binary data",
- )
- mime_type: str = Field(
- default="text/plain",
- description="MIME type of the resource content",
+ encoding: str | None = Field(
+ default_factory=lambda data: _default_file_encoding(data["mime_type"]),
+ description="Text encoding used to decode the file, or None to serve its bytes as a blob",
)
@pydantic.field_validator("path")
@@ -143,21 +166,27 @@ def validate_absolute_path(cls, path: Path) -> Path:
raise ValueError("Path must be absolute")
return path
- @pydantic.field_validator("is_binary")
+ @pydantic.field_validator("encoding")
@classmethod
- def set_binary_from_mime_type(cls, is_binary: bool, info: ValidationInfo) -> bool:
- """Set is_binary based on mime_type if not explicitly set."""
- if is_binary:
- return True
- mime_type = info.data.get("mime_type", "text/plain")
- return not mime_type.startswith("text/")
+ def validate_text_encoding(cls, encoding: str | None) -> str | None:
+ """Ensure the encoding names a usable text codec, so a mistake fails at construction not at read."""
+ if encoding is not None:
+ # Decoding a probe byte rejects both unknown names and codecs that
+ # aren't text encodings (base64_codec, rot13, ...) via LookupError.
+ try:
+ b"x".decode(encoding)
+ except LookupError as e:
+ raise ValueError(str(e)) from e
+ except UnicodeError:
+ pass # a real text encoding; the probe byte just doesn't decode in it
+ return encoding
async def read(self) -> str | bytes:
"""Read the file content."""
try:
- if self.is_binary:
+ if self.encoding is None:
return await anyio.to_thread.run_sync(self.path.read_bytes)
- return await anyio.to_thread.run_sync(self.path.read_text)
+ return await anyio.to_thread.run_sync(partial(self.path.read_text, encoding=self.encoding))
except Exception as e:
raise ValueError(f"Error reading file {self.path}: {e}")
diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py
index 7fc1cd1948..bc79c44a36 100644
--- a/src/mcp/server/mcpserver/server.py
+++ b/src/mcp/server/mcpserver/server.py
@@ -44,8 +44,8 @@
from mcp_types import Resource as MCPResource
from mcp_types import ResourceTemplate as MCPResourceTemplate
from mcp_types import Tool as MCPTool
+from pydantic import BaseModel
from pydantic.networks import AnyUrl
-from pydantic_settings import BaseSettings, SettingsConfigDict
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.authentication import AuthenticationMiddleware
@@ -59,12 +59,12 @@
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, ProviderTokenVerifier, TokenVerifier
from mcp.server.auth.settings import AuthSettings
from mcp.server.caching import CacheableMethod, CacheHint
-from mcp.server.context import HandlerResult, ServerRequestContext
+from mcp.server.context import HandlerResult, ServerMiddleware, ServerRequestContext
from mcp.server.extension import (
Extension,
MethodBinding,
RequestHandler,
- compose_tool_call_interceptor,
+ compose_tool_call_handler,
validate_extension_identifier,
)
from mcp.server.lowlevel.helper_types import ReadResourceContents
@@ -98,20 +98,8 @@
_CallableT = TypeVar("_CallableT", bound=Callable[..., Any])
-class Settings(BaseSettings, Generic[LifespanResultT]):
- """MCPServer settings.
-
- All settings can be configured via environment variables with the prefix MCP_.
- For example, MCP_DEBUG=true will set debug=True.
- """
-
- model_config = SettingsConfigDict(
- env_prefix="MCP_",
- env_file=".env",
- env_nested_delimiter="__",
- nested_model_default_partial_update=True,
- extra="ignore",
- )
+class Settings(BaseModel, Generic[LifespanResultT]):
+ """MCPServer settings, as passed to the `MCPServer` constructor."""
# Server settings
debug: bool
@@ -165,7 +153,7 @@ def __init__(
instructions: str | None = None,
website_url: str | None = None,
icons: list[Icon] | None = None,
- version: str | None = None,
+ version: str = "",
auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any] | None = None,
token_verifier: TokenVerifier | None = None,
*,
@@ -184,6 +172,7 @@ def __init__(
request_state_security: RequestStateSecurity | None = None,
cache_hints: Mapping[CacheableMethod, CacheHint] | None = None,
subscriptions: SubscriptionBus | None = None,
+ middleware: Sequence[ServerMiddleware[Any]] | None = None,
):
self._resource_security = resource_security
self.settings = Settings(
@@ -228,8 +217,9 @@ def __init__(
# We need to create a Lifespan type that is a generic on the server type, like Starlette does.
lifespan=(lifespan_wrapper(self, self.settings.lifespan) if self.settings.lifespan else default_lifespan), # type: ignore
)
- # Ordering: inside OpenTelemetry (spans record the sealed wire form),
- # outside extension interceptors (extensions see plaintext).
+ # Ordering: inside OpenTelemetry (spans record the sealed wire form).
+ # Extension interceptors run at the handler layer, inside this
+ # boundary, so they see plaintext.
if request_state_security is None:
security = RequestStateSecurity.ephemeral()
else:
@@ -239,6 +229,9 @@ def __init__(
raise ValueError(_MISSING_AUDIENCE)
security = request_state_security
self._lowlevel_server.middleware.append(RequestStateBoundary(security, default_audience=self.name))
+ # User middleware runs inside the SDK's built-ins (OpenTelemetry, then the
+ # request-state boundary), outermost-first in the order given.
+ self._lowlevel_server.middleware.extend(middleware or ())
# Validate auth configuration
if self.settings.auth is not None:
if auth_server_provider and token_verifier: # pragma: no cover
@@ -268,6 +261,17 @@ def __init__(
def name(self) -> str:
return self._lowlevel_server.name
+ @property
+ def middleware(self) -> list[ServerMiddleware[Any]]:
+ """The middleware chain wrapping every inbound message, outermost-first.
+
+ The same list as the low-level `Server.middleware`: append an
+ `async (ctx, call_next)` callable to observe, refuse, or rewrite
+ messages before they reach a handler. Provisional - the signature may
+ change in a 2.x minor release; see the middleware guide.
+ """
+ return self._lowlevel_server.middleware
+
@property
def title(self) -> str | None:
return self._lowlevel_server.title
@@ -289,7 +293,7 @@ def icons(self) -> list[Icon] | None:
return self._lowlevel_server.icons
@property
- def version(self) -> str | None:
+ def version(self) -> str:
return self._lowlevel_server.version
@property
@@ -334,13 +338,20 @@ def _apply_extension(self, extension: Extension) -> None:
self._lowlevel_server.extensions[extension.identifier] = extension.settings()
def _install_extension_interceptor(self) -> None:
- """Compose every extension's `tools/call` interceptor into one middleware.
+ """Wrap the `tools/call` handler with every extension's interceptor.
Installed only when at least one extension overrides `intercept_tool_call`,
- so a server with purely additive extensions adds no middleware.
+ so a server with purely additive extensions keeps the bare handler. The
+ chain wraps the handler itself, below the runner's outbound envelope
+ pass, so a short-circuiting interceptor's result is sieved and stamped
+ exactly like a handler result.
"""
if any(type(e).intercept_tool_call is not Extension.intercept_tool_call for e in self._extensions):
- self._lowlevel_server.middleware.append(compose_tool_call_interceptor(self._extensions))
+ self._lowlevel_server.add_request_handler(
+ "tools/call",
+ CallToolRequestParams,
+ compose_tool_call_handler(self._extensions, self._handle_call_tool),
+ )
@overload
def run(self, transport: Literal["stdio"] = ...) -> None: ...
@@ -1320,8 +1331,8 @@ def require_client_extension(ctx: ServerRequestContext[Any, Any], identifier: st
MCPError: With code `MISSING_REQUIRED_CLIENT_CAPABILITY` if the client
did not advertise `identifier`.
"""
- client_params = ctx.session.client_params
- declared = client_params.capabilities.extensions if client_params else None
+ capabilities = ctx.session.client_capabilities
+ declared = capabilities.extensions if capabilities else None
if not declared or identifier not in declared:
data = MissingRequiredClientCapabilityErrorData(
required_capabilities=ClientCapabilities(extensions={identifier: {}})
diff --git a/src/mcp/server/runner.py b/src/mcp/server/runner.py
index 3b53335ae4..26e8efbe57 100644
--- a/src/mcp/server/runner.py
+++ b/src/mcp/server/runner.py
@@ -13,28 +13,33 @@
from __future__ import annotations
+import contextvars
import logging
-from collections.abc import Awaitable, Mapping
+from collections.abc import AsyncIterator, Awaitable, Mapping
+from contextlib import asynccontextmanager
from dataclasses import KW_ONLY, dataclass, replace
from functools import cached_property, partial
-from typing import TYPE_CHECKING, Any, Generic, Literal, cast
+from typing import TYPE_CHECKING, Any, Generic, cast
import anyio
import anyio.abc
from mcp_types import (
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
+ CORE_RESULT_TYPES,
INTERNAL_ERROR,
INVALID_PARAMS,
INVALID_REQUEST,
METHOD_NOT_FOUND,
PROTOCOL_VERSION_META_KEY,
+ SERVER_INFO_META_KEY,
UNSUPPORTED_PROTOCOL_VERSION,
CacheableResult,
ErrorData,
Implementation,
InitializeRequestParams,
InitializeResult,
+ JSONRPCRequest,
RequestId,
RequestParams,
RequestParamsMeta,
@@ -55,6 +60,7 @@
from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext
from mcp.server.models import InitializationOptions
from mcp.server.session import ServerSession
+from mcp.shared._context_streams import ContextReceiveStream
from mcp.shared._stream_protocols import ReadStream, WriteStream
from mcp.shared.dispatcher import CallOptions, DispatchContext, Dispatcher, OnNotify, OnRequest
from mcp.shared.exceptions import MCPError, NoBackChannelError
@@ -111,7 +117,10 @@ def _dump_result(result: Any) -> dict[str, Any]:
if isinstance(result, BaseModel):
return result.model_dump(by_alias=True, mode="json", exclude_none=True)
if isinstance(result, dict):
- return cast(dict[str, Any], result)
+ # Copied so callers own the returned dict: handlers and middleware may
+ # retain the object they returned, and the outbound pipeline shapes the
+ # wire form without reaching into anything the handler still holds.
+ return dict(cast(dict[str, Any], result))
raise TypeError(f"handler returned {type(result).__name__}; expected BaseModel, dict, or None")
@@ -209,22 +218,15 @@ async def _inner(ctx: ServerRequestContext[LifespanT, Any]) -> HandlerResult:
if isinstance(result, ErrorData):
# Raise inside the chain so middleware observes the failure.
raise MCPError.from_error_data(result)
- # Fill cache hints on the handler result, before the serialize sieve
- # decides whether the negotiated version carries the fields at all.
- # MRTR carve-out: `input_required` interim results, typed or mapping, never get hints.
- if (hint := self.server.cache_hints.get(method)) is not None:
- if isinstance(result, CacheableResult):
- result = apply_cache_hint(result, hint)
- elif isinstance(result, Mapping) and not _methods.is_input_required(result):
- # Hint keys first so wire keys the handler set win, matching `apply_cache_hint` precedence.
- result = {"ttlMs": hint.ttl_ms, "cacheScope": hint.scope, **result}
- # Dump and serialize inside the chain so the OpenTelemetry span (the
+ # Shape for the wire inside the chain so the OpenTelemetry span (the
# outermost middleware) records a failing handler return shape too.
return self._serialize(method, version, result)
call = self._compose_server_middleware(_inner)
# `_inner` already produced the wire dict; a middleware that short-circuited
- # without `call_next` is trusted to return its own well-formed result.
+ # without `call_next` is trusted to return its own well-formed result -
+ # including its response envelope. The pipeline never patches it up after
+ # the fact.
result = _dump_result(await call(ctx))
if method == "initialize":
# Commit only on chain success, so a middleware veto leaves no state.
@@ -319,7 +321,10 @@ def _make_context(
# Per-request session: `dctx` is the request-scoped channel (auto-threads
# its own request_id on streamable HTTP); the standalone channel is read
# off `connection.outbound`. `related_request_id` on the public API selects.
- session = ServerSession(dctx, self.connection)
+ # `meta` carries a request's log-level opt-in for the session's log gate. A
+ # notification has no request to opt in (and no response stream to carry
+ # the log entry), so its `_meta` never opens the gate.
+ session = ServerSession(dctx, self.connection, request_meta=meta if dctx.request_id is not None else None)
return ServerRequestContext(
session=session,
lifespan_context=self.lifespan_state,
@@ -333,27 +338,84 @@ def _make_context(
close_standalone_sse_stream=close_standalone_sse_stream,
)
- @staticmethod
- def _serialize(method: str, version: str, result: HandlerResult) -> dict[str, Any]:
- """Dump a handler result to the wire dict, serializing spec methods.
-
- Runs inside the middleware chain so the OpenTelemetry span observes a
- failing return shape (unsupported type, malformed spec result) as an
- error rather than closing on a request that the client sees fail.
+ def _serialize(self, method: str, version: str, result: HandlerResult) -> dict[str, Any]:
+ """Shape a handler result into its wire form: the outbound counterpart
+ of the inbound classification ladder.
+
+ One pass owns the whole response envelope, in order: cache hints fill
+ `ttlMs`/`cacheScope` the handler left unset, core-vocabulary spec-method
+ results are validated and sieved by the per-version surface (a claimed
+ extension `resultType` shape is the extension's to own), and 2026-era
+ results get the `serverInfo` `_meta` stamp (spec #3002). Runs inside the
+ middleware chain so the OpenTelemetry span observes a failing return
+ shape (unsupported type, malformed spec result) as an error rather
+ than closing on a request that the client sees fail - and so a
+ middleware that short-circuits without `call_next` owns its result,
+ envelope included.
"""
+ # MRTR carve-out: `input_required` interim results, typed or mapping, never get hints.
+ if (hint := self.server.cache_hints.get(method)) is not None:
+ if isinstance(result, CacheableResult):
+ result = apply_cache_hint(result, hint)
+ elif isinstance(result, Mapping) and not _methods.is_input_required(result):
+ # Hint keys first so wire keys the handler set win, matching `apply_cache_hint` precedence.
+ result = {"ttlMs": hint.ttl_ms, "cacheScope": hint.scope, **result}
dumped = _dump_result(result)
- # TODO(L56): reject resultType values outside {"complete", "input_required"} unless the
- # corresponding extension is in this request's _meta clientCapabilities.extensions; the
+ # A modern-era extension `resultType` (outside the core vocabulary) marks
+ # a claimed shape owned by the extension that defined it: the per-version
+ # surface doesn't describe it, so the sieve applies to core results only.
+ # Legacy connections sieve everything - claimed shapes are 2026-era
+ # vocabulary and cannot be delivered on a legacy wire (mirrors the
+ # client-side ResultClaim rule).
+ # TODO(L56): reject extension resultType values unless the corresponding
+ # extension is in this request's _meta clientCapabilities.extensions; the
# explicit MUST-reject is client-side (basic/index.mdx ResultType), this enforces it proactively.
- if method not in _methods.SPEC_CLIENT_METHODS:
- return dumped
- try:
- return _methods.serialize_server_result(method, version, dumped)
- except ValidationError:
- # Server bug, not client fault. Detail stays in the server log:
- # pydantic messages echo the result body.
- logger.exception("handler for %r returned an invalid result", method)
- raise MCPError(code=INTERNAL_ERROR, message="Handler returned an invalid result") from None
+ result_type = dumped.get("resultType")
+ core_shape = (
+ version not in MODERN_PROTOCOL_VERSIONS
+ or not isinstance(result_type, str)
+ or result_type in CORE_RESULT_TYPES
+ )
+ if method in _methods.SPEC_CLIENT_METHODS and core_shape:
+ try:
+ dumped = _methods.serialize_server_result(method, version, dumped)
+ except ValidationError:
+ # Server bug, not client fault. Detail stays in the server log:
+ # pydantic messages echo the result body.
+ logger.exception("handler for %r returned an invalid result", method)
+ raise MCPError(code=INTERNAL_ERROR, message="Handler returned an invalid result") from None
+ if version in MODERN_PROTOCOL_VERSIONS and dumped.get("resultType") is None:
+ # Spec 2026-07-28: `Result.resultType` is required - servers MUST
+ # include it (the absent-means-complete bridge is for clients of
+ # older servers only). The sieve guarantees it for core methods;
+ # this covers everything else: custom methods, extension methods,
+ # and empty results.
+ dumped["resultType"] = "complete"
+ return self._stamp_server_info(version, dumped)
+
+ def _stamp_server_info(self, version: str, result: dict[str, Any]) -> dict[str, Any]:
+ """Fill the `serverInfo` `_meta` stamp on a 2026-era result (spec #3002).
+
+ A handler-authored value wins; an explicit `null` reads as absent and
+ is stamped over, mirroring the request-side `clientInfo` posture (a
+ `null` is not a valid `Implementation`, so presence means a value). A
+ non-mapping `_meta` is the handler's to own, and handshake-era results
+ are never stamped. `result` is
+ pipeline-owned (`_dump_result` copies dicts; the spec-method sieve
+ re-dumps), but `_meta` may still be the handler's object, so the stamp
+ replaces it rather than writing into it. `server_info_stamp` is a
+ fresh dict per access, so the response never aliases server state.
+ """
+ if version not in MODERN_PROTOCOL_VERSIONS:
+ return result
+ raw_meta = result.get("_meta")
+ if raw_meta is None:
+ result["_meta"] = {SERVER_INFO_META_KEY: self.server.server_info_stamp}
+ elif isinstance(raw_meta, dict):
+ meta = cast("dict[str, Any]", raw_meta)
+ if meta.get(SERVER_INFO_META_KEY) is None:
+ result["_meta"] = {**meta, SERVER_INFO_META_KEY: self.server.server_info_stamp}
+ return result
@staticmethod
def _negotiate_initialize(params: Mapping[str, Any] | None) -> tuple[InitializeRequestParams, str]:
@@ -439,19 +501,20 @@ async def serve_loop(
)
-_MODERN_ENVELOPE_KEYS = (PROTOCOL_VERSION_META_KEY, CLIENT_INFO_META_KEY, CLIENT_CAPABILITIES_META_KEY)
-
-
def _has_modern_envelope(params: Mapping[str, Any] | None) -> bool:
- """Whether `params._meta` carries every reserved modern-envelope key.
-
- Era evidence is the FULL key triple - bare `_meta` is not (legacy traffic
- carries `progressToken` there).
+ """Whether `params._meta` carries the reserved protocol-version key.
+
+ The `io.modelcontextprotocol/protocolVersion` key exists only in
+ 2026-07-28+ envelopes and its prefix is spec-reserved, so legacy traffic
+ never mints it (a bare `_meta` is not evidence - legacy requests carry
+ `progressToken` there). The version key alone is the signal, not the full
+ required pair, so a half-built envelope still routes modern and gets the
+ classifier's INVALID_PARAMS naming the missing key.
"""
if not params:
return False
meta = params.get("_meta")
- return isinstance(meta, Mapping) and all(key in meta for key in _MODERN_ENVELOPE_KEYS)
+ return isinstance(meta, Mapping) and PROTOCOL_VERSION_META_KEY in meta
def _initialize_after_modern_data(params: Mapping[str, Any] | None) -> dict[str, Any]:
@@ -546,91 +609,180 @@ async def serve_dual_era_loop(
init_options: InitializationOptions | None = None,
raise_exceptions: bool = False,
) -> None:
- """Drive `server` over a duplex stream pair, serving both protocol eras.
-
- The stream-pair counterpart of the modern HTTP entry's era router. Era is
- a property of the connection, decided by how the client opens it, and
- mid-stream switching is undefined - so the first era-distinctive message
- to SUCCEED locks the connection (matching the typescript-sdk):
-
- - A successful `initialize` locks legacy: the connection behaves exactly
- like `serve_loop` for its lifetime, and modern envelope traffic is then
- rejected with INVALID_REQUEST. `initialize` never routes modern - the
- method is legacy-distinctive by definition - even when a confused
- client stamps the envelope triple on it.
- - A request carrying the modern `_meta` envelope triple - or
- `server/discover`, a modern-only method - is classified
- (`classify_inbound_request`) and served single-exchange via `serve_one`
- with a born-ready per-request `Connection`, the same dispatch model as
- the modern HTTP entry. The first such request to succeed locks the
- connection modern; a later `initialize` is then rejected with
- UNSUPPORTED_PROTOCOL_VERSION naming the modern versions.
-
- Modern connections push notifications over the duplex pipe but refuse
- server-initiated requests on both channels (the modern protocol forbids
- them). A request that fails - rejected classification, malformed envelope
- content, unknown method - never locks either era, so a failed probe
- leaves the legacy handshake available: released auto-negotiating clients
- fall back on any error code except -32022, and that code is only emitted
- for genuine version negotiation or for `initialize` on an
- already-modern connection.
-
- The era lock rides the request's own dispatch. For the inline methods
- (`initialize`, `server/discover`) that completes before the next frame is
- read, so the canonical probe-then-go flow is race-free; a pinned-modern
- client that pipelines frames ahead of its first response should expect
- envelope-less notifications sent in that window to be dropped. The lock
- settles exactly once: a request from the other era that was already in
- flight when the lock committed may still complete and its response
- stands, but the era does not move; and a success the peer cancelled away
- (it sees "Request cancelled", not the result) does not lock either.
+ """Drive `server` over a duplex stream pair, in the era the client opens with.
+
+ The client's first request decides the connection's protocol era, once:
+ a request carrying the 2026-07-28 per-request `_meta` envelope opens a
+ modern connection, and anything else - the `initialize` handshake, which
+ does not exist at 2026 versions even when a client stamps the envelope on
+ it - opens a legacy one. The deciding frame is replayed into the chosen
+ serving loop along with everything the client sent before it. A later
+ claim from the other era is refused: `initialize` on a modern connection
+ gets UNSUPPORTED_PROTOCOL_VERSION naming the served versions, and an
+ enveloped request on a legacy connection gets INVALID_REQUEST.
+ """
+ # This loop owns both streams from the moment it is called, so the write
+ # stream is closed even if the client leaves before sending any request.
+ try:
+ async with _replay_from_opening_request(read_stream) as (opening, replayed):
+ opens_modern = (
+ opening is not None and opening.method != "initialize" and _has_modern_envelope(opening.params)
+ )
+ if opens_modern:
+ await _serve_modern_stream(
+ server, replayed, write_stream, lifespan_state=lifespan_state, raise_exceptions=raise_exceptions
+ )
+ else:
+ await _serve_legacy_stream(
+ server,
+ replayed,
+ write_stream,
+ lifespan_state=lifespan_state,
+ session_id=session_id,
+ init_options=init_options,
+ raise_exceptions=raise_exceptions,
+ )
+ finally:
+ await write_stream.aclose()
+
+
+_PRE_REQUEST_REPLAY_LIMIT: int = 8
+"""How many frames arriving ahead of the client's first request are kept
+for the chosen era's loop (a bare `notifications/initialized` is the one that
+matters); further ones are dropped and never decide the era."""
+
+
+def _sender_context(stream: ReadStream[Any]) -> contextvars.Context:
+ """The per-message sender context a context-aware stream carries, else the current one."""
+ ctx = getattr(stream, "last_context", None)
+ return ctx if ctx is not None else contextvars.copy_context()
+
+
+@asynccontextmanager
+async def _replay_from_opening_request(
+ read_stream: ReadStream[SessionMessage | Exception],
+) -> AsyncIterator[tuple[JSONRPCRequest | None, ReadStream[SessionMessage | Exception]]]:
+ """Peek at the client's first request without consuming it.
+
+ Yields that request together with a stream that replays it - preceded by
+ up to `_PRE_REQUEST_REPLAY_LIMIT` earlier frames - and relays the rest of
+ `read_stream` behind it, sender contexts included. The request is `None`
+ if the channel closes before one arrives.
"""
+ lead: list[tuple[contextvars.Context, SessionMessage | Exception]] = []
+ opening_request: JSONRPCRequest | None = None
+ replay_send, replay_receive = anyio.create_memory_object_stream[
+ tuple[contextvars.Context, SessionMessage | Exception]
+ ]()
+ replayed = ContextReceiveStream(replay_receive)
+
+ async def replay_then_relay() -> None:
+ async with replay_send:
+ for envelope in lead:
+ await replay_send.send(envelope)
+ try:
+ async for item in read_stream:
+ await replay_send.send((_sender_context(read_stream), item))
+ except anyio.ClosedResourceError:
+ # Receive end closed under us (stateless SHTTP teardown); same as EOF.
+ logger.debug("read stream closed by transport; treating as EOF")
+
+ # This helper takes ownership of `read_stream` from the serving loop, so
+ # every exit - including cancellation while awaiting the first request -
+ # closes it and the replay channel.
+ try:
+ try:
+ async for item in read_stream:
+ if isinstance(item, SessionMessage) and isinstance(item.message, JSONRPCRequest):
+ opening_request = item.message
+ elif len(lead) >= _PRE_REQUEST_REPLAY_LIMIT:
+ logger.debug("dropped a frame received before the first request: %r", item)
+ continue
+ lead.append((_sender_context(read_stream), item))
+ if opening_request is not None:
+ break
+ except anyio.ClosedResourceError:
+ # Receive end closed under us (stateless SHTTP teardown); same as EOF.
+ logger.debug("read stream closed by transport; treating as EOF")
+ async with anyio.create_task_group() as tg:
+ tg.start_soon(replay_then_relay)
+ yield opening_request, replayed
+ tg.cancel_scope.cancel()
+ finally:
+ await read_stream.aclose()
+ replay_send.close()
+ replay_receive.close()
+
+
+async def _serve_legacy_stream(
+ server: Server[LifespanT],
+ read_stream: ReadStream[SessionMessage | Exception],
+ write_stream: WriteStream[SessionMessage],
+ *,
+ lifespan_state: LifespanT,
+ session_id: str | None,
+ init_options: InitializationOptions | None,
+ raise_exceptions: bool,
+) -> None:
+ """Serve a 2025 handshake connection; enveloped requests are refused."""
dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(
read_stream,
write_stream,
raise_handler_exceptions=raise_exceptions,
- # `initialize` inline for the same pipelining reason as `serve_loop`;
- # `server/discover` inline so the modern era lock commits before the
- # next pipelined message is read.
- inline_methods=frozenset({"initialize", "server/discover"}),
+ # `initialize` inline for the same pipelining reason as `serve_loop`.
+ inline_methods=frozenset({"initialize"}),
)
- loop_connection = Connection.for_loop(dispatcher, session_id=session_id)
- loop_runner = ServerRunner(server, loop_connection, lifespan_state, init_options=init_options)
- standalone_outbound = NotifyOnlyOutbound(dispatcher)
- era: Literal["unlocked", "legacy", "modern"] = "unlocked"
- modern_version = LATEST_MODERN_VERSION
-
- def era_settles(dctx: DispatchContext[TransportContext]) -> bool:
- # The one definition of "this request may lock the era": it settled as
- # a client-visible success on a still-unlocked connection. The lock is
- # monotone - the first success wins, so a straggling request from the
- # other era can never overwrite a committed lock. A pending peer
- # cancel means the dispatcher is about to replace this response with
- # "Request cancelled": the client never sees the success, no lock.
- return era == "unlocked" and not dctx.cancel_requested.is_set()
-
- async def serve_modern(
+ connection = Connection.for_loop(dispatcher, session_id=session_id)
+ runner = ServerRunner(server, connection, lifespan_state, init_options=init_options)
+
+ async def on_request(
dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
) -> dict[str, Any]:
- nonlocal era, modern_version
+ if method != "initialize" and _has_modern_envelope(params):
+ raise MCPError(
+ code=INVALID_REQUEST,
+ message="this connection serves the handshake protocol era; "
+ "requests carrying the 2026-07-28 envelope are not accepted on it",
+ )
+ return await runner.on_request(dctx, method, params)
+
+ try:
+ await dispatcher.run(on_request, runner.on_notify)
+ finally:
+ await aclose_shielded(connection)
+
+
+async def _serve_modern_stream(
+ server: Server[LifespanT],
+ read_stream: ReadStream[SessionMessage | Exception],
+ write_stream: WriteStream[SessionMessage],
+ *,
+ lifespan_state: LifespanT,
+ raise_exceptions: bool,
+) -> None:
+ """Serve a 2026-07-28 connection: every request carries its own envelope."""
+ dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(
+ read_stream, write_stream, raise_handler_exceptions=raise_exceptions
+ )
+ outbound = NotifyOnlyOutbound(dispatcher)
+
+ async def on_request(
+ dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
+ ) -> dict[str, Any]:
+ if method == "initialize":
+ raise MCPError(
+ code=UNSUPPORTED_PROTOCOL_VERSION,
+ message="connection is serving the 2026-07-28 protocol; the initialize handshake is not accepted",
+ data=_initialize_after_modern_data(params),
+ )
route = classify_inbound_request({"method": method, "params": params})
if isinstance(route, InboundLadderRejection):
raise MCPError(code=route.code, message=route.message, data=route.data)
- if method == "subscriptions/listen":
- # The registered listen handler assumes the HTTP entry's stream
- # semantics; served over a stream pair it would wedge. Reject until
- # this transport grows its own listen design.
- raise MCPError(
- code=METHOD_NOT_FOUND, message="subscriptions/listen is not served over this transport", data=method
- )
connection = Connection.from_envelope(
- route.protocol_version,
- route.client_info,
- route.client_capabilities,
- outbound=standalone_outbound,
+ route.protocol_version, route.client_info, route.client_capabilities, outbound=outbound
)
try:
- result = await serve_one(
+ return await serve_one(
server,
_NoServerRequestsDispatchContext(dctx),
method,
@@ -639,68 +791,25 @@ async def serve_modern(
lifespan_state=lifespan_state,
)
except (MCPError, ValidationError):
- # The dispatcher's shared ladder maps these to the same wire error
- # the modern HTTP entry produces.
+ # The dispatcher's shared ladder maps these to the wire error.
raise
except Exception as exc:
if raise_exceptions:
raise
error = modern_error_data(exc)
raise MCPError(code=error.code, message=error.message, data=error.data) from exc
- if era_settles(dctx):
- era, modern_version = "modern", route.protocol_version
- return result
-
- async def on_request(
- dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
- ) -> dict[str, Any]:
- nonlocal era
- if era == "legacy":
- if _has_modern_envelope(params):
- raise MCPError(
- code=INVALID_REQUEST,
- message="connection is locked to the legacy handshake era; "
- "modern envelope requests are not accepted",
- )
- # Bare modern-only methods (e.g. `server/discover`) fall through to
- # the loop runner's per-version surface validation - the same
- # METHOD_NOT_FOUND a handshake-only server produced, byte for byte.
- return await loop_runner.on_request(dctx, method, params)
- if era == "modern":
- if method == "initialize":
- raise MCPError(
- code=UNSUPPORTED_PROTOCOL_VERSION,
- message="connection already negotiated a modern protocol version",
- data=_initialize_after_modern_data(params),
- )
- return await serve_modern(dctx, method, params)
- # Unlocked. `initialize` is legacy-distinctive by definition (the
- # method does not exist at modern versions), so it takes the handshake
- # path even when the envelope triple is stamped on it.
- if method != "initialize" and (method == "server/discover" or _has_modern_envelope(params)):
- return await serve_modern(dctx, method, params)
- result = await loop_runner.on_request(dctx, method, params)
- if method == "initialize" and era_settles(dctx):
- # Lock only on success: a failed handshake leaves both eras open.
- era = "legacy"
- return result
async def on_notify(dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None) -> None:
- if era != "modern":
- return await loop_runner.on_notify(dctx, method, params)
- # The envelope is request-only, so notifications inherit the
- # connection's locked version.
- connection = Connection.from_envelope(modern_version, None, None, outbound=standalone_outbound)
+ # The envelope is request-only, so a notification runs at the latest
+ # served version; the modern protocol has nothing version-specific here.
+ connection = Connection.from_envelope(LATEST_MODERN_VERSION, None, None, outbound=outbound)
notify_runner = ServerRunner(server, connection, lifespan_state)
try:
await notify_runner.on_notify(_NoServerRequestsDispatchContext(dctx), method, params)
finally:
await aclose_shielded(connection)
- try:
- await dispatcher.run(on_request, on_notify)
- finally:
- await aclose_shielded(loop_connection)
+ await dispatcher.run(on_request, on_notify)
async def serve_one(
diff --git a/src/mcp/server/session.py b/src/mcp/server/session.py
index 0a61689eb5..bb446415e8 100644
--- a/src/mcp/server/session.py
+++ b/src/mcp/server/session.py
@@ -6,14 +6,16 @@
`send_log_message`, ...) to call back to the client.
"""
+import logging
from typing import Any, TypeVar, overload
import mcp_types as types
from mcp_types import methods as _methods
+from mcp_types.version import MODERN_PROTOCOL_VERSIONS
from pydantic import AnyUrl, BaseModel
from typing_extensions import deprecated
-from mcp.server.connection import Connection
+from mcp.server.connection import Connection, allowed_log_levels
from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages, wants_sampling_tools
from mcp.shared.dispatcher import CallOptions, DispatchContext, ProgressFnT
from mcp.shared.exceptions import MCPDeprecationWarning
@@ -21,6 +23,12 @@
__all__ = ["ServerSession"]
+logger = logging.getLogger(__name__)
+# `send_log_message`'s `logger` parameter (public API, the spec's logger-name
+# field) shadows the module logger inside that method; this alias keeps it
+# reachable there.
+_logger = logger
+
ResultT = TypeVar("ResultT", bound=BaseModel)
@@ -36,15 +44,38 @@ class ServerSession:
never crosses the `Outbound` Protocol.
"""
- def __init__(self, request_outbound: DispatchContext[Any], connection: Connection) -> None:
+ def __init__(
+ self,
+ request_outbound: DispatchContext[Any],
+ connection: Connection,
+ *,
+ request_meta: types.RequestParamsMeta | None = None,
+ ) -> None:
self._request_outbound = request_outbound
self._connection = connection
+ # The per-request log-delivery contract, fixed at construction: on
+ # 2026-07-28+ the inbound request's `_meta` log-level opt-in decides
+ # which `notifications/message` levels may be sent for this request
+ # (and they ride this request's stream only); on handshake versions
+ # every level may be sent (`logging/setLevel`-era semantics).
+ self._log_is_request_scoped = connection.protocol_version in MODERN_PROTOCOL_VERSIONS
+ self._allowed_log_levels = allowed_log_levels(connection.protocol_version, request_meta)
@property
def client_params(self) -> types.InitializeRequestParams | None:
"""The client's `initialize` request params; `None` when no client info was supplied."""
return self._connection.client_params
+ @property
+ def client_capabilities(self) -> types.ClientCapabilities | None:
+ """The capabilities the client declared; `None` when none were declared.
+
+ Prefer this over `client_params.capabilities`: on 2026-07-28+ the
+ request envelope declares capabilities while client info stays
+ optional, so capabilities can be present without `client_params`.
+ """
+ return self._connection.client_capabilities
+
@property
def can_send_request(self) -> bool:
"""Whether this request's channel can currently deliver a server-initiated request."""
@@ -96,7 +127,10 @@ async def send_notification(
related_request_id: types.RequestId | None = None,
) -> None:
"""Send a typed server-to-client notification."""
- channel = self._request_outbound if related_request_id is not None else self._connection.outbound
+ await self._notify(notification, request_scoped=related_request_id is not None)
+
+ async def _notify(self, notification: types.ServerNotification, *, request_scoped: bool) -> None:
+ channel = self._request_outbound if request_scoped else self._connection.outbound
data = notification.model_dump(by_alias=True, mode="json", exclude_none=True)
await channel.notify(data["method"], data.get("params"))
@@ -112,8 +146,20 @@ async def send_log_message(
logger: str | None = None,
related_request_id: types.RequestId | None = None,
) -> None:
- """Send a log message notification."""
- await self.send_notification(
+ """Send a log message notification.
+
+ On 2026-07-28+ delivery is a per-request opt-in: nothing is sent
+ unless this request's `_meta` carried the reserved log-level key, and
+ entries below the requested level are dropped (debug-logged). What is
+ sent rides this request's stream regardless of `related_request_id` -
+ the spec forbids `notifications/message` on any stream but the one
+ carrying the response. Handshake versions send unconditionally on the
+ channel `related_request_id` selects, as before.
+ """
+ if level not in self._allowed_log_levels:
+ _logger.debug("dropped notifications/message at %r: not opted in at that level on this request", level)
+ return
+ await self._notify(
types.LoggingMessageNotification(
params=types.LoggingMessageNotificationParams(
level=level,
@@ -121,7 +167,7 @@ async def send_log_message(
logger=logger,
),
),
- related_request_id,
+ request_scoped=self._log_is_request_scoped or related_request_id is not None,
)
async def send_resource_updated(self, uri: str | AnyUrl) -> None:
@@ -236,8 +282,7 @@ async def create_message(
NoBackChannelError: The connection has no back-channel for
server-initiated requests.
"""
- client_caps = self.client_params.capabilities if self.client_params else None
- validate_sampling_tools(client_caps, tools, tool_choice)
+ validate_sampling_tools(self.client_capabilities, tools, tool_choice)
validate_tool_use_result_messages(messages)
request = types.CreateMessageRequest(
diff --git a/src/mcp/server/stdio.py b/src/mcp/server/stdio.py
index 876d256ddb..de8bbae5f1 100644
--- a/src/mcp/server/stdio.py
+++ b/src/mcp/server/stdio.py
@@ -1,15 +1,9 @@
-"""Stdio Server Transport Module
-
-This module provides functionality for creating an stdio-based transport layer
-that can be used to communicate with an MCP client through standard input/output
-streams.
+"""Stdio server transport for MCP.
Example:
```python
async def run_server():
async with stdio_server() as (read_stream, write_stream):
- # read_stream contains incoming JSONRPCMessages from stdin
- # write_stream allows sending JSONRPCMessages to stdout
server = await create_my_server()
await server.run(read_stream, write_stream, init_options)
@@ -17,61 +11,207 @@ async def run_server():
```
"""
+import os
import sys
-from contextlib import asynccontextmanager
+import threading
+from collections.abc import Callable
+from contextlib import asynccontextmanager, suppress
+from dataclasses import dataclass
from io import TextIOWrapper
+from typing import BinaryIO, Literal, TextIO
import anyio
import anyio.lowlevel
import mcp_types as types
+from mcp.os.win32.utilities import rebind_std_handle_to_fd
from mcp.shared._context_streams import create_context_streams
from mcp.shared.message import SessionMessage
+if sys.platform != "win32": # pragma: no branch
+ import fcntl # pragma: lax no cover - POSIX-only line, uncovered on Windows runners
+
+# Stream-claim contract (design and attack log in PR #3117):
+# - _claims is the single authority for who owns fd 0/1; mutated only under the
+# lock, only by acquire's insert and release's deregister.
+# - private_fd is recorded the instant the wire duplicate exists, before fd is
+# ever moved, and is never closed while the claim is registered.
+# - Release deregisters only after dup2(private_fd, fd) restores the wire; a
+# failed release keeps the claim, so successors are refused, never fed a
+# diverted descriptor. Every failure lands on that safe side.
+_claims: dict[int, "_StreamClaim"] = {}
+_claims_lock = threading.Lock()
+
+
+@dataclass
+class _StreamClaim:
+ fd: int
+ private_fd: int | None = None
+
+
+class _UnownedTextWrapper(TextIOWrapper):
+ """Text layer whose close never closes the underlying buffer.
+
+ The buffer is not the transport's to close: in the in-place paths it is the
+ sys stream's own buffer, and closing it at garbage collection destroyed
+ sys.stdout for the rest of the process (issue #1933).
+ """
+
+ def close(self) -> None:
+ with suppress(ValueError):
+ self.detach()
+
+
+def _is_backed_by_fd(stream: TextIO, fd: int) -> bool:
+ try:
+ return stream.buffer.fileno() == fd
+ except (AttributeError, OSError, ValueError):
+ return False
+
+
+def _dup_above_std(fd: int) -> int:
+ """Duplicate fd onto a descriptor that cannot land in the standard range."""
+ if sys.platform == "win32": # pragma: no cover
+ duplicate = os.dup(fd)
+ if duplicate <= 2:
+ os.close(duplicate)
+ raise OSError(f"duplicate of fd {fd} landed in the standard range")
+ return duplicate
+ return fcntl.fcntl(fd, fcntl.F_DUPFD_CLOEXEC, 3) # pragma: lax no cover - POSIX-only
+
+
+def _open_stdin_diversion() -> int:
+ return os.open(os.devnull, os.O_RDONLY)
+
+
+def _open_stdout_diversion() -> int:
+ try:
+ return os.dup(2)
+ except OSError:
+ return os.open(os.devnull, os.O_WRONLY)
+
+
+def _restore_fd(fd: int, private_fd: int) -> bool:
+ """Point fd back at the wire; the Windows handle rebind never affects the outcome."""
+ try:
+ os.dup2(private_fd, fd)
+ except OSError:
+ return False
+ if sys.platform == "win32": # pragma: no cover
+ with suppress(OSError):
+ rebind_std_handle_to_fd(fd)
+ return True
+
+
+def _claim_fd(
+ fd: int, stream: TextIO, mode: Literal["rb", "wb"], open_diversion: Callable[[], int]
+) -> tuple[BinaryIO, Callable[[], None] | None]:
+ """Claim a standard stream: divert fd and serve the wire from a private duplicate.
+
+ Best-effort: when descriptors cannot be duplicated or diverted, serves the
+ sys stream's buffer in place, exactly as v1 did, with the claim held.
+
+ Raises:
+ RuntimeError: fd is already claimed by another transport in this process.
+ """
+ if not _is_backed_by_fd(stream, fd):
+ return stream.buffer, None
+ claim = _StreamClaim(fd)
+ with _claims_lock:
+ if fd in _claims:
+ raise RuntimeError(f"another stdio_server() in this process has already claimed fd {fd}")
+ _claims[fd] = claim
+
+ def release() -> None:
+ if claim.private_fd is None or _restore_fd(fd, claim.private_fd):
+ with _claims_lock:
+ del _claims[fd]
+
+ try:
+ private_fd = _dup_above_std(fd)
+ except OSError:
+ return stream.buffer, release
+ claim.private_fd = private_fd
+
+ try:
+ diversion_fd = open_diversion()
+ except OSError:
+ return stream.buffer, release
+ try:
+ os.dup2(diversion_fd, fd)
+ except OSError:
+ # The divert did not land; ensure fd carries the wire (a Windows dup2 can
+ # close its target before failing), then serve it in place through the
+ # shared buffer, since two writers on one pipe would tear frames.
+ with suppress(OSError):
+ os.close(diversion_fd)
+ _restore_fd(fd, private_fd)
+ return stream.buffer, release
+ with suppress(OSError):
+ os.close(diversion_fd)
+ if sys.platform == "win32": # pragma: no cover
+ with suppress(OSError):
+ rebind_std_handle_to_fd(fd)
+
+ # closefd=False: a worker thread can still block on this descriptor after
+ # the transport exits, so it must never be closed and recycled under it.
+ return os.fdopen(private_fd, mode, closefd=False), release
+
@asynccontextmanager
async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio.AsyncFile[str] | None = None):
- """Server transport for stdio: this communicates with an MCP client by reading
- from the current process' stdin and writing to stdout.
+ """Serve MCP over the process's stdin and stdout.
+
+ While serving, fd 0 points at the null device and fd 1 at stderr, so handlers
+ and children read EOF and their stray output misses the wire; both descriptors
+ are restored on exit. Explicit streams skip the claim, and a second concurrent
+ stdio_server() raises RuntimeError.
"""
- # Purposely not using context managers for these, as we don't want to close
- # standard process handles. Encoding of stdin/stdout as text streams on
- # python is platform-dependent (Windows is particularly problematic), so we
- # re-wrap the underlying binary stream to ensure UTF-8.
- if not stdin:
- stdin = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace"))
- if not stdout:
- stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8"))
-
- read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0)
- write_stream, write_stream_reader = create_context_streams[SessionMessage](0)
-
- async def stdin_reader():
- try:
- async with read_stream_writer:
- async for line in stdin:
- try:
- message = types.jsonrpc_message_adapter.validate_json(line, by_name=False)
- except Exception as exc:
- await read_stream_writer.send(exc)
- continue
-
- session_message = SessionMessage(message)
- await read_stream_writer.send(session_message)
- except anyio.ClosedResourceError: # pragma: no cover
- await anyio.lowlevel.checkpoint()
-
- async def stdout_writer():
- try:
- async with write_stream_reader:
- async for session_message in write_stream_reader:
- json = session_message.message.model_dump_json(by_alias=True, exclude_unset=True)
- await stdout.write(json + "\n")
- await stdout.flush()
- except anyio.ClosedResourceError: # pragma: no cover
- await anyio.lowlevel.checkpoint()
-
- async with anyio.create_task_group() as tg:
- tg.start_soon(stdin_reader)
- tg.start_soon(stdout_writer)
- yield read_stream, write_stream
+ # Re-wrap the binary buffers as UTF-8 text; the std handles' platform encodings are unreliable.
+ restore_stdin: Callable[[], None] | None = None
+ restore_stdout: Callable[[], None] | None = None
+ try:
+ if not stdin:
+ stdin_buffer, restore_stdin = _claim_fd(0, sys.stdin, "rb", _open_stdin_diversion)
+ stdin = anyio.wrap_file(_UnownedTextWrapper(stdin_buffer, encoding="utf-8", errors="replace"))
+ if not stdout:
+ stdout_buffer, restore_stdout = _claim_fd(1, sys.stdout, "wb", _open_stdout_diversion)
+ stdout = anyio.wrap_file(_UnownedTextWrapper(stdout_buffer, encoding="utf-8"))
+
+ read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0)
+ write_stream, write_stream_reader = create_context_streams[SessionMessage](0)
+
+ async def stdin_reader():
+ try:
+ async with read_stream_writer:
+ async for line in stdin:
+ try:
+ message = types.jsonrpc_message_adapter.validate_json(line, by_name=False)
+ except Exception as exc:
+ await read_stream_writer.send(exc)
+ continue
+
+ session_message = SessionMessage(message)
+ await read_stream_writer.send(session_message)
+ except anyio.ClosedResourceError: # pragma: no cover
+ await anyio.lowlevel.checkpoint()
+
+ async def stdout_writer():
+ try:
+ async with write_stream_reader:
+ async for session_message in write_stream_reader:
+ json = session_message.message.model_dump_json(by_alias=True, exclude_unset=True)
+ await stdout.write(json + "\n")
+ await stdout.flush()
+ except anyio.ClosedResourceError: # pragma: no cover
+ await anyio.lowlevel.checkpoint()
+
+ async with anyio.create_task_group() as tg:
+ tg.start_soon(stdin_reader)
+ tg.start_soon(stdout_writer)
+ yield read_stream, write_stream
+ finally:
+ if restore_stdout is not None:
+ restore_stdout()
+ if restore_stdin is not None:
+ restore_stdin()
diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py
index d316345c7e..1a4e9939a4 100644
--- a/src/mcp/server/streamable_http.py
+++ b/src/mcp/server/streamable_http.py
@@ -44,7 +44,7 @@
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams
from mcp.shared._stream_protocols import ReadStream, WriteStream
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
-from mcp.shared.message import ServerMessageMetadata, SessionMessage
+from mcp.shared.message import CloseSSEStreamCallback, ServerMessageMetadata, SessionMessage
logger = logging.getLogger(__name__)
@@ -65,6 +65,13 @@
# whole session on a lazily-started `sse_writer`. See #1764.
REQUEST_STREAM_BUFFER_SIZE: Final = 16
+# Error code answering a request that settled without a response (e.g. it was
+# cancelled) on this 2025-era wire, which ends a request's stream only with a
+# response. Mirrors LSP's RequestCancelled; not sent by the 2026 transports, where
+# the spec forbids answering a cancelled request. See
+# `StreamableHTTPServerTransport._terminate_unanswered_request`.
+REQUEST_CANCELLED: Final = -32800
+
# Session ID validation pattern (visible ASCII characters ranging from 0x21 to 0x7E)
# Pattern ensures entire string contains only valid characters by using ^ and $ anchors
SESSION_ID_PATTERN = re.compile(r"^[\x21-\x7E]+$")
@@ -166,8 +173,12 @@ def __init__(
Args:
mcp_session_id: Optional session identifier for this connection.
Must contain only visible ASCII characters (0x21-0x7E).
- is_json_response_enabled: If True, return JSON responses for requests
- instead of SSE streams. Default is False.
+ is_json_response_enabled: If True, answer each request POST with a single
+ JSON body instead of an SSE stream, which removes
+ the request-scoped back-channel: a server-initiated
+ request tied to the call raises `NoBackChannelError`
+ and its notifications are dropped (see
+ `TransportContext.can_send_request`). Default is False.
event_store: Event store for resumability support. If provided,
resumability will be enabled, allowing clients to
reconnect and resume messages.
@@ -205,6 +216,29 @@ def is_terminated(self) -> bool:
"""Check if this transport has been explicitly terminated."""
return self._terminated
+ def _message_metadata(
+ self,
+ request: Request,
+ *,
+ close_sse_stream: CloseSSEStreamCallback | None = None,
+ close_standalone_sse_stream: CloseSSEStreamCallback | None = None,
+ on_request_unanswered: Callable[[], Awaitable[None]] | None = None,
+ ) -> ServerMessageMetadata:
+ """The metadata this transport frames every inbound message with.
+
+ The one place `can_send_request` is stamped, so no construction site can
+ forget it: a JSON body carries only the response, so in JSON-response mode
+ the request-scoped channel cannot carry a server-initiated request (see
+ `TransportContext.can_send_request`).
+ """
+ return ServerMessageMetadata(
+ request_context=request,
+ close_sse_stream=close_sse_stream,
+ close_standalone_sse_stream=close_standalone_sse_stream,
+ on_request_unanswered=on_request_unanswered,
+ can_send_request=not self.is_json_response_enabled,
+ )
+
def close_sse_stream(self, request_id: RequestId) -> None:
"""Close SSE connection for a specific request without terminating the stream.
@@ -252,7 +286,7 @@ def close_standalone_sse_stream(self) -> None:
def _create_session_message(
self,
- message: JSONRPCMessage,
+ message: JSONRPCRequest,
request: Request,
request_id: RequestId,
protocol_version: str,
@@ -262,7 +296,10 @@ def _create_session_message(
The close_sse_stream callbacks are only provided when the client supports
resumability (protocol version >= 2025-11-25). Old clients can't resume if
the stream is closed early because they didn't receive a priming event.
+ Every request carries `on_request_unanswered`, so a request that settles
+ without a response is still terminated on this era's wire.
"""
+ end_stream = partial(self._terminate_unanswered_request, message.id)
# Only provide close callbacks when client supports resumability
if self._event_store and is_version_at_least(protocol_version, "2025-11-25"):
@@ -272,13 +309,14 @@ async def close_stream_callback() -> None:
async def close_standalone_stream_callback() -> None:
self.close_standalone_sse_stream()
- metadata = ServerMessageMetadata(
- request_context=request,
+ metadata = self._message_metadata(
+ request,
close_sse_stream=close_stream_callback,
close_standalone_sse_stream=close_standalone_stream_callback,
+ on_request_unanswered=end_stream,
)
else:
- metadata = ServerMessageMetadata(request_context=request)
+ metadata = self._message_metadata(request, on_request_unanswered=end_stream)
return SessionMessage(message, metadata=metadata)
@@ -390,6 +428,20 @@ def _create_event_data(self, event_message: EventMessage) -> SSEEvent:
return event_data
+ async def _terminate_unanswered_request(self, request_id: RequestId) -> None:
+ """Terminate a request that settled without a response (e.g. cancelled).
+
+ The 2025-era wire ends a request's stream only with a response for its
+ id - and stores that response so a resuming client's replay terminates
+ too - so this era answers a cancelled request with `REQUEST_CANCELLED`
+ where the dispatcher itself stays silent (the 2026 transports MUST NOT
+ answer). It is written through the same ordered channel as the request's
+ other messages, so it cannot overtake anything already queued for it.
+ """
+ assert self._write_stream is not None # a dispatched request implies connect() ran
+ error = ErrorData(code=REQUEST_CANCELLED, message="Request cancelled")
+ await self._write_stream.send(SessionMessage(JSONRPCError(jsonrpc="2.0", id=request_id, error=error)))
+
async def _clean_up_memory_streams(self, request_id: RequestId) -> None:
"""Clean up memory streams for a given request ID."""
if request_id in self._request_streams: # pragma: no branch
@@ -532,8 +584,7 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re
await response(scope, receive, send)
# Process the message after sending the response
- metadata = ServerMessageMetadata(request_context=request)
- session_message = SessionMessage(message, metadata=metadata)
+ session_message = SessionMessage(message, metadata=self._message_metadata(request))
await writer.send(session_message)
return
@@ -555,47 +606,30 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re
)
request_stream_reader = self._request_streams[request_id][1]
# Process the message
- metadata = ServerMessageMetadata(request_context=request)
+ metadata = self._message_metadata(
+ request, on_request_unanswered=partial(self._terminate_unanswered_request, message.id)
+ )
session_message = SessionMessage(message, metadata=metadata)
await writer.send(session_message)
try:
- # Process messages from the request-specific stream
- # We need to collect all messages until we get a response
- response_message = None
-
- # Use similar approach to SSE writer for consistency
- async for event_message in request_stream_reader: # pragma: no branch
- # If it's a response, this is what we're waiting for
- if isinstance(event_message.message, JSONRPCResponse | JSONRPCError):
- response_message = event_message.message
- break
- # For notifications and requests, keep waiting
- else: # pragma: no cover
- logger.debug(f"received: {event_message.message.method}")
-
- # At this point we should have a response
- if response_message:
- # Create JSON response
- response = self._create_json_response(response_message)
- await response(scope, receive, send)
- else: # pragma: no cover
- # This shouldn't happen in normal operation
- logger.error("No response message received before stream closed")
- response = self._create_error_response(
- "Error processing request: No response received",
- HTTPStatus.INTERNAL_SERVER_ERROR,
- )
- await response(scope, receive, send)
- except Exception: # pragma: no cover
- logger.exception("Error processing JSON response")
+ # `message_router` deposits only this request's own response
+ # here: anything else scoped to the request has no wire in
+ # JSON-response mode.
+ event_message = await request_stream_reader.receive()
+ except (anyio.EndOfStream, anyio.ClosedResourceError):
+ # The stream closed with no response: the session was
+ # terminated while this request was in flight.
+ logger.debug(f"Session terminated with request {request_id} in flight; no response to send")
response = self._create_error_response(
- "Error processing request",
+ "Session terminated before the request completed",
HTTPStatus.INTERNAL_SERVER_ERROR,
INTERNAL_ERROR,
)
- await response(scope, receive, send)
+ else:
+ response = self._create_json_response(event_message.message)
finally:
await self._clean_up_memory_streams(request_id)
+ await response(scope, receive, send)
else:
# Mint the priming event before any per-request state exists:
# `EventStore.store_event` is user code and may raise, in which
@@ -996,7 +1030,14 @@ async def message_router():
)
and session_message.metadata.related_request_id is not None
):
- target_request_id = str(session_message.metadata.related_request_id)
+ related_request_id = session_message.metadata.related_request_id
+ if self.is_json_response_enabled:
+ # A JSON body carries only the response: this message
+ # has no wire form (nor a replay), so drop it before
+ # storing or queueing rather than park it (#1764).
+ logger.debug(f"Dropped message related to request {related_request_id} in JSON mode")
+ continue
+ target_request_id = str(related_request_id)
request_stream_id = target_request_id if target_request_id is not None else GET_STREAM_KEY
diff --git a/src/mcp/server/subscriptions.py b/src/mcp/server/subscriptions.py
index 6b0b3d49b5..f5d3b5b0a4 100644
--- a/src/mcp/server/subscriptions.py
+++ b/src/mcp/server/subscriptions.py
@@ -164,8 +164,8 @@ class ListenHandler:
cancels the handler; the stream just ends, per the spec's abrupt-close
contract) or `close` ends all streams gracefully.
- Requires a transport that can stream a request's response (streamable
- HTTP's SSE mode).
+ Served on any transport that can carry the request's response stream:
+ streamable HTTP's SSE mode, or a duplex stream pair such as stdio.
`max_subscriptions` bounds concurrent streams (further listen requests are
rejected with `INTERNAL_ERROR`, before the ack). `max_buffered_events`
diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py
index 2bbf7a715a..881379d381 100644
--- a/src/mcp/shared/auth.py
+++ b/src/mcp/shared/auth.py
@@ -1,10 +1,27 @@
-from typing import Any, Literal
+from typing import Any, Literal, cast
-from pydantic import AnyHttpUrl, AnyUrl, BaseModel, ConfigDict, Field, field_validator
+from pydantic import AnyHttpUrl, AnyUrl, BaseModel, ConfigDict, Field, field_validator, model_validator
# RFC 7523 JWT bearer grant; SEP-990 leg 2 uses this to present the ID-JAG.
JWT_BEARER_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"
+# Token-endpoint client authentication methods this SDK's clients request, and the set
+# `OAuthContext.prepare_token_auth` recognizes on a registered client (`private_key_jwt` is
+# applied by `PrivateKeyJWTOAuthProvider`; the rest send a client secret or nothing).
+TokenEndpointAuthMethod = Literal["none", "client_secret_post", "client_secret_basic", "private_key_jwt"]
+
+# grant_types a client requests when it does not specify its own (RFC 7591 §2).
+DEFAULT_GRANT_TYPES = ["authorization_code", "refresh_token"]
+
+
+def _empty_str_to_none(v: object) -> object:
+ # RFC 7591 §2 marks these URL fields OPTIONAL; a "" placeholder means absent, so it
+ # must not fail AnyHttpUrl validation. (The registered-client record applies the same
+ # rule to every member; this coercion serves the request model.)
+ if v == "":
+ return None
+ return v
+
class OAuthToken(BaseModel):
"""See https://datatracker.ietf.org/doc/html/rfc6749#section-5.1"""
@@ -47,32 +64,21 @@ def __init__(self, message: str):
self.message = message
-class OAuthClientMetadata(BaseModel):
- """RFC 7591 OAuth 2.0 Dynamic Client Registration Metadata.
+class OAuthClientMetadataBase(BaseModel):
+ """RFC 7591 OAuth 2.0 Dynamic Client Registration metadata shared verbatim by the
+ registration request (`OAuthClientMetadata`) and the authorization server's record of a
+ registered client (`OAuthClientInformationFull`). Fields whose acceptable values differ
+ between the two - what this SDK sends versus what a third-party server may echo - are
+ declared on each model rather than here.
See https://datatracker.ietf.org/doc/html/rfc7591#section-2
"""
model_config = ConfigDict(url_preserve_empty_path=True)
- redirect_uris: list[AnyUrl] | None = Field(..., min_length=1)
- # supported auth methods for the token endpoint
- token_endpoint_auth_method: (
- Literal["none", "client_secret_post", "client_secret_basic", "private_key_jwt"] | None
- ) = None
- # supported grant_types of this implementation
- grant_types: list[
- Literal["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:jwt-bearer"] | str
- ] = [
- "authorization_code",
- "refresh_token",
- ]
# The MCP spec requires the "code" response type, but OAuth
# servers may also return additional types they support
response_types: list[str] = ["code"]
scope: str | None = None
- # SEP-837: OIDC application_type. Defaults to "native" since MCP clients typically use
- # loopback redirect URIs; set "web" for remote browser-based clients on a non-local host.
- application_type: Literal["web", "native"] = "native"
# these fields are currently unused, but we support & store them for potential
# future use
@@ -97,13 +103,76 @@ class OAuthClientMetadata(BaseModel):
)
@classmethod
def _empty_string_optional_url_to_none(cls, v: object) -> object:
- # RFC 7591 §2 marks these URL fields OPTIONAL. Some authorization servers
- # echo omitted metadata back as "" instead of dropping the keys, which
- # AnyHttpUrl would otherwise reject — throwing away an otherwise valid
- # registration response. Treat "" as absent.
- if v == "":
- return None
- return v
+ # These URL fields are OPTIONAL; an echoed "" would otherwise fail AnyHttpUrl
+ # and throw away an otherwise valid registration response.
+ return _empty_str_to_none(v)
+
+
+class OAuthClientMetadata(OAuthClientMetadataBase):
+ """RFC 7591 OAuth 2.0 Dynamic Client Registration request metadata: what an MCP
+ client sends when it registers. Field values are narrowed to what this SDK will put
+ on the wire; parsing the authorization server's response is `OAuthClientInformationFull`'s
+ job. See https://datatracker.ietf.org/doc/html/rfc7591#section-2
+ """
+
+ redirect_uris: list[AnyUrl] | None = Field(..., min_length=1)
+ # supported auth methods for the token endpoint
+ token_endpoint_auth_method: TokenEndpointAuthMethod | None = None
+ # supported grant_types of this implementation
+ grant_types: list[
+ Literal["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:jwt-bearer"] | str
+ ] = list(DEFAULT_GRANT_TYPES)
+ # SEP-837: OIDC application_type. Defaults to "native" since MCP clients typically use
+ # loopback redirect URIs; set "web" for remote browser-based clients on a non-local host.
+ application_type: Literal["web", "native"] = "native"
+
+
+class OAuthClientInformationFull(OAuthClientMetadataBase):
+ """RFC 7591 OAuth 2.0 Dynamic Client Registration client information response
+ (client information plus metadata) - the authorization server's record of a
+ registered client. See https://datatracker.ietf.org/doc/html/rfc7591#section-3.2.1
+
+ A third-party authorization server "MAY reject or replace any of the client's
+ requested metadata values submitted during the registration and substitute them with
+ suitable values", so `application_type`, `token_endpoint_auth_method`, and `grant_types`
+ are typed to accept any string the server echoes, and `redirect_uris` may be absent or
+ empty. A member the server serializes as a placeholder - an explicit `null`, or `""` -
+ is read as an omitted key, so the field's default applies rather than the parse failing.
+ Whether a substituted value is usable is decided where the value is used, not at parse.
+ `redirect_uris` elements are still parsed as URLs, as the authorization server compares
+ them against a client's requested `redirect_uri`.
+ """
+
+ redirect_uris: list[AnyUrl] | None = None
+ # RFC 7591 §3.2.1: the server may assign an auth method other than the one requested,
+ # including methods this SDK does not implement, or omit it.
+ token_endpoint_auth_method: str | None = None
+ grant_types: list[str] = list(DEFAULT_GRANT_TYPES)
+ # SEP-837: OIDC application_type. OIDC Registration §2 defines "web" and "native", but
+ # servers echo other strings or an explicit null; the value is informational here.
+ application_type: str | None = None
+
+ # RFC 7591 §3.2.1: client_id is REQUIRED in a client information response - a body
+ # without one is not a registration, whatever else it echoes.
+ client_id: str
+ client_secret: str | None = None
+ client_id_issued_at: int | None = None
+ client_secret_expires_at: int | None = None
+ # SEP-2352: the issuer these credentials were registered with, recorded by the SDK (not an
+ # RFC 7591 field) to detect authorization-server migration and avoid cross-AS credential reuse.
+ issuer: str | None = None
+
+ @model_validator(mode="before")
+ @classmethod
+ def _placeholder_members_read_as_omitted(cls, data: object) -> object:
+ # Servers dump unset members of their client record as null, or echo them as "",
+ # instead of omitting the keys. Either placeholder would otherwise fail the parse of a
+ # list field (or read "" as an unrecognized method) and discard an already-provisioned
+ # registration; a placeholder and an absent key mean the same thing.
+ if isinstance(data, dict):
+ members = cast(dict[str, Any], data)
+ return {key: value for key, value in members.items() if value is not None and value != ""}
+ return data
def validate_scope(self, requested_scope: str | None) -> list[str] | None:
if requested_scope is None:
@@ -118,27 +187,15 @@ def validate_scope(self, requested_scope: str | None) -> list[str] | None:
def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl:
if redirect_uri is not None:
# Validate redirect_uri against client's registered redirect URIs
- if self.redirect_uris is None or redirect_uri not in self.redirect_uris:
+ if not self.redirect_uris or redirect_uri not in self.redirect_uris:
raise InvalidRedirectUriError(f"Redirect URI '{redirect_uri}' not registered for client")
return redirect_uri
- elif self.redirect_uris is not None and len(self.redirect_uris) == 1:
+ elif self.redirect_uris and len(self.redirect_uris) == 1:
return self.redirect_uris[0]
else:
- raise InvalidRedirectUriError("redirect_uri must be specified when client has multiple registered URIs")
-
-
-class OAuthClientInformationFull(OAuthClientMetadata):
- """RFC 7591 OAuth 2.0 Dynamic Client Registration full response
- (client information plus metadata).
- """
-
- client_id: str | None = None
- client_secret: str | None = None
- client_id_issued_at: int | None = None
- client_secret_expires_at: int | None = None
- # SEP-2352: the issuer these credentials were registered with, recorded by the SDK (not an
- # RFC 7591 field) to detect authorization-server migration and avoid cross-AS credential reuse.
- issuer: str | None = None
+ raise InvalidRedirectUriError(
+ "redirect_uri must be specified unless the client has exactly one registered URI"
+ )
class OAuthMetadata(BaseModel):
diff --git a/src/mcp/shared/dispatcher.py b/src/mcp/shared/dispatcher.py
index f109638f2a..f2ff96e7d5 100644
--- a/src/mcp/shared/dispatcher.py
+++ b/src/mcp/shared/dispatcher.py
@@ -250,7 +250,8 @@ class Dispatcher(Outbound, Protocol[TransportT_co]):
Implementations own correlation of outbound requests to inbound results, the
receive loop, per-request concurrency, and cancellation/progress wiring.
- The lifecycle surface is provisional; `run()` may change before v2 stable.
+ The lifecycle surface is provisional; `run()` may change in a 2.x minor
+ release.
"""
async def run(
diff --git a/src/mcp/shared/exceptions.py b/src/mcp/shared/exceptions.py
index 2f8a539dab..c2a7fd44e7 100644
--- a/src/mcp/shared/exceptions.py
+++ b/src/mcp/shared/exceptions.py
@@ -53,12 +53,12 @@ def __str__(self) -> str:
class NoBackChannelError(MCPError):
- """Raised when sending a server-initiated request over a transport that cannot deliver it.
+ """Raised when a server-initiated request has no channel that can deliver it.
- Stateless HTTP and JSON-response-mode HTTP have no channel for the server to
- push requests (sampling, elicitation, roots/list) to the client. This is
- raised by `DispatchContext.send_raw_request` when `can_send_request` is
- `False`, and serializes to an `INVALID_REQUEST` error response.
+ Raised by `DispatchContext.send_raw_request` when its request-scoped channel
+ reports `TransportContext.can_send_request` as `False` (the cases are
+ documented on that field), and by a connection's standalone channel when it
+ has none; serializes to an `INVALID_REQUEST` error response.
"""
def __init__(self, method: str):
diff --git a/src/mcp/shared/inbound.py b/src/mcp/shared/inbound.py
index c3e0ea338f..c28aa7fb71 100644
--- a/src/mcp/shared/inbound.py
+++ b/src/mcp/shared/inbound.py
@@ -327,9 +327,10 @@ def _value_at_path(arguments: Mapping[str, Any], path: tuple[str, ...]) -> Any:
class InboundModernRoute:
"""A modern-protocol request whose envelope passed every ladder rung.
- `client_info` and `client_capabilities` are the raw envelope values;
- the classifier checks presence only, not shape. Method existence is not a
- ladder rung — kernel dispatch is the single source of truth for that.
+ `client_info` and `client_capabilities` are the raw envelope values; the
+ classifier checks presence only, not shape, and `client_info` is `None`
+ when the (optional, SHOULD-include) key is absent. Method existence is not
+ a ladder rung — kernel dispatch is the single source of truth for that.
"""
protocol_version: str
@@ -376,9 +377,11 @@ def classify_inbound_request(
Rungs, in order — first failure wins:
- 1. `params._meta` is a mapping carrying every reserved envelope key
- (protocol version, client info, client capabilities) → else
- :data:`~mcp_types.jsonrpc.INVALID_PARAMS`.
+ 1. `params._meta` is a mapping carrying the required envelope pair
+ (protocol version, client capabilities) → else
+ :data:`~mcp_types.jsonrpc.INVALID_PARAMS` naming the missing key(s)
+ (basic/index.mdx "Per-request protocol fields"). Client info is
+ optional (SHOULD-include, spec PR #3002); absent reads as `None`.
2. When `headers` is given, `MCP-Protocol-Version` equals the envelope's
protocol version, `Mcp-Method` equals `body.method`, and — for the
methods in :data:`NAME_BEARING_METHODS` — `Mcp-Name` equals the named
@@ -404,16 +407,24 @@ def classify_inbound_request(
accepts on the per-request-envelope path.
"""
try:
- meta = body["params"]["_meta"]
- protocol_version = meta[PROTOCOL_VERSION_META_KEY]
- client_info = meta[CLIENT_INFO_META_KEY]
- client_capabilities = meta[CLIENT_CAPABILITIES_META_KEY]
+ meta_value = body["params"]["_meta"]
except (KeyError, TypeError):
+ meta_value = None
+ if not isinstance(meta_value, Mapping):
return InboundLadderRejection(
code=INVALID_PARAMS,
- message="params._meta must carry the reserved protocol-version, client-info and "
- "client-capabilities envelope keys",
+ message="params._meta must be an object carrying the required "
+ f"{PROTOCOL_VERSION_META_KEY!r} and {CLIENT_CAPABILITIES_META_KEY!r} envelope keys",
)
+ meta = cast("Mapping[str, Any]", meta_value)
+ if missing := [key for key in (PROTOCOL_VERSION_META_KEY, CLIENT_CAPABILITIES_META_KEY) if key not in meta]:
+ return InboundLadderRejection(
+ code=INVALID_PARAMS,
+ message=f"params._meta is missing the required envelope key(s): {', '.join(missing)}",
+ )
+ protocol_version: Any = meta[PROTOCOL_VERSION_META_KEY]
+ client_info: Any = meta.get(CLIENT_INFO_META_KEY)
+ client_capabilities: Any = meta[CLIENT_CAPABILITIES_META_KEY]
if headers is not None:
version_header = headers.get(MCP_PROTOCOL_VERSION_HEADER)
# Presence is checked explicitly: a null body version would otherwise
@@ -431,8 +442,8 @@ def classify_inbound_request(
)
name_key = NAME_BEARING_METHODS.get(method)
if name_key is not None:
- # Rung 1 already proved body["params"] is a mapping.
- body_value = body["params"].get(name_key)
+ # Rung 1 already proved body["params"] is a mapping (its `_meta` is one).
+ body_value = cast("Mapping[str, Any]", body["params"]).get(name_key)
if body_value is not None and decode_header_value(headers.get(MCP_NAME_HEADER)) != body_value:
return InboundLadderRejection(
code=HEADER_MISMATCH,
diff --git a/src/mcp/shared/jsonrpc_dispatcher.py b/src/mcp/shared/jsonrpc_dispatcher.py
index 42798fdc54..87bdf31ceb 100644
--- a/src/mcp/shared/jsonrpc_dispatcher.py
+++ b/src/mcp/shared/jsonrpc_dispatcher.py
@@ -80,7 +80,9 @@
PeerCancelMode = Literal["interrupt", "signal"]
"""How `notifications/cancelled` is applied: `"interrupt"` (default) cancels
-the handler's scope; `"signal"` only sets `ctx.cancel_requested`."""
+the handler's scope; `"signal"` only sets `ctx.cancel_requested` and lets the
+handler run to completion. Either way the cancelled request is never
+answered - the handler's eventual result or error is dropped, not written."""
def handler_exception_to_error_data(exc: BaseException) -> ErrorData | None:
@@ -182,8 +184,17 @@ def close(self) -> None:
self._closed = True
-def _default_transport_builder(_meta: MessageMetadata) -> TransportContext:
- return TransportContext(kind="jsonrpc", can_send_request=True)
+def _default_transport_builder(metadata: MessageMetadata) -> TransportContext:
+ """The `TransportContext` for a message, honoring the transport's own verdict when it stamps one.
+
+ A message reads as riding a full duplex pipe (`can_send_request=True`)
+ unless the transport that framed it says otherwise on the metadata it
+ attached, so a transport whose response has no room for a server request
+ (streamable HTTP in JSON-response mode) needs no wiring from whoever drives
+ its streams.
+ """
+ can_send_request = metadata.can_send_request if isinstance(metadata, ServerMessageMetadata) else True
+ return TransportContext(kind="jsonrpc", can_send_request=can_send_request)
def _shielded_progress(fn: ProgressFnT) -> ProgressFnT:
@@ -696,9 +707,13 @@ async def _handle_request(
) -> None:
"""Run `on_request` for one inbound request and write its response.
- The single exception-to-wire boundary: handler exceptions become `JSONRPCError` here.
+ The single exception-to-wire boundary: handler exceptions become
+ `JSONRPCError` here. A request the peer cancelled is never answered
+ (spec: MUST NOT send further messages for it) - it settles unanswered
+ instead, and `_settle_unanswered` tells the transport.
"""
answer_write_started = False
+ handler_failure: BaseException | None = None # re-raised once the request settles
try:
with scope:
try:
@@ -711,27 +726,21 @@ async def _handle_request(
key = coerce_request_id(req.id)
if (entry := self._in_flight.get(key)) is not None and entry.dctx is dctx:
del self._in_flight[key]
- # A write interrupted by cancellation may still have delivered
- # (a memory-stream send can hand its item to the receiver and
- # still raise), so a started answer write counts as sent below:
- # peers drop late responses, while a second answer for one id
- # would break JSON-RPC.
- answer_write_started = True
- await self._write_result(req.id, result)
- if scope.cancelled_caught:
- # anyio absorbs the scope's own cancel at __exit__, and
- # `cancelled_caught` (unlike `cancel_called`) guarantees the
- # result write above did not happen - no double response.
- # TODO(L38): spec says SHOULD NOT respond after cancel;
- # the existing server always has, so match that for now.
- answer_write_started = True
- await self._write_error(req.id, ErrorData(code=0, message="Request cancelled"))
+ if not dctx.cancel_requested.is_set():
+ # A write interrupted by cancellation may still have delivered
+ # (a memory-stream send can hand its item to the receiver and
+ # still raise), so a started answer write counts as sent below:
+ # peers drop late responses, while a second answer for one id
+ # would break JSON-RPC.
+ answer_write_started = True
+ await self._write_result(req.id, result)
except anyio.get_cancelled_exc_class():
# Shutdown: answer the request so the peer isn't left waiting - unless
# an answer write already started (it may have reached the transport;
- # prefer possibly-zero answers over possibly-two). The shielded helper
- # is needed because bare awaits re-raise here.
- if not answer_write_started:
+ # prefer possibly-zero answers over possibly-two), or the peer already
+ # cancelled it and stopped waiting. The shielded helper is needed
+ # because bare awaits re-raise here.
+ if not answer_write_started and not dctx.cancel_requested.is_set():
await self._final_write(
partial(self._write_error, req.id, ErrorData(code=CONNECTION_CLOSED, message="Connection closed")),
shield=True,
@@ -741,15 +750,24 @@ async def _handle_request(
raise
except Exception as e:
error = handler_exception_to_error_data(e)
- if error is not None:
- await self._write_error(req.id, error)
- else:
+ if error is None:
logger.exception("handler for %r raised", req.method)
# TODO(L58): code=0 pins existing-server compat; JSON-RPC says
# INTERNAL_ERROR. Revisit per the suite's divergence entry.
- await self._write_error(req.id, ErrorData(code=0, message=str(e)))
+ error = ErrorData(code=0, message=str(e))
if self._raise_handler_exceptions:
- raise
+ handler_failure = e
+ # A cancel silences only the wire; the failure stays as visible as before.
+ if not dctx.cancel_requested.is_set():
+ answer_write_started = True
+ await self._write_error(req.id, error)
+ # The one place a cancelled request settles: the handler is done (any
+ # mode) with nothing written. A peer-interrupt cancel is absorbed at
+ # scope __exit__ and lands here too.
+ if not answer_write_started:
+ await self._settle_unanswered(dctx)
+ if handler_failure is not None:
+ raise handler_failure
# No `_in_flight` pop here: the inner finally covers every path, and a late pop could evict a reused id.
def _allocate_id(self) -> int:
@@ -771,6 +789,23 @@ async def _write_error(self, request_id: RequestId, error: ErrorData) -> None:
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
logger.debug("dropped error for %r: write stream closed", request_id)
+ async def _settle_unanswered(self, dctx: _JSONRPCDispatchContext[TransportT]) -> None:
+ """Run the transport's `on_request_unanswered` hook: this request settled with no response.
+
+ The dispatcher writes nothing for it; a transport whose wire must still
+ end the request (2025-era streamable HTTP) does so from this hook. A
+ raising hook is contained here, like the other callback boundaries.
+ """
+ metadata = dctx.message_metadata
+ if not isinstance(metadata, ServerMessageMetadata) or metadata.on_request_unanswered is None:
+ return
+ try:
+ await metadata.on_request_unanswered()
+ except (anyio.BrokenResourceError, anyio.ClosedResourceError):
+ logger.debug("on_request_unanswered dropped: connection closing")
+ except Exception:
+ logger.exception("on_request_unanswered hook raised")
+
async def _final_write(
self,
write: Callable[[], Awaitable[None]],
diff --git a/src/mcp/shared/message.py b/src/mcp/shared/message.py
index 236569fac2..31e51e7128 100644
--- a/src/mcp/shared/message.py
+++ b/src/mcp/shared/message.py
@@ -41,6 +41,15 @@ class ServerMessageMetadata:
close_sse_stream: CloseSSEStreamCallback | None = None
# Callback to close the standalone GET SSE stream (for unsolicited notifications)
close_standalone_sse_stream: CloseSSEStreamCallback | None = None
+ # Callback the dispatcher runs when this request settles without a response
+ # (e.g. it was cancelled), for a transport whose wire must still end the
+ # request even though no response is written.
+ on_request_unanswered: Callable[[], Awaitable[None]] | None = None
+ # The transport's verdict on whether this message's request-scoped channel
+ # can deliver a server-initiated request (see
+ # `TransportContext.can_send_request`); a transport that says nothing leaves
+ # it True.
+ can_send_request: bool = True
MessageMetadata = ClientMessageMetadata | ServerMessageMetadata | None
diff --git a/src/mcp/shared/session.py b/src/mcp/shared/session.py
deleted file mode 100644
index f8f0a6d416..0000000000
--- a/src/mcp/shared/session.py
+++ /dev/null
@@ -1,22 +0,0 @@
-"""Compatibility names that outlived the removed v1 session layer (`BaseSession`)."""
-
-from typing import Generic, TypeVar
-
-from mcp_types import RequestParamsMeta
-
-from mcp.shared.dispatcher import ProgressFnT as ProgressFnT
-from mcp.shared.message import MessageMetadata
-
-RequestId = str | int
-
-ReceiveRequestT = TypeVar("ReceiveRequestT")
-SendResultT = TypeVar("SendResultT")
-
-
-class RequestResponder(Generic[ReceiveRequestT, SendResultT]):
- """Typing stub for the v1 responder; the SDK never instantiates it."""
-
- request_id: RequestId
- request_meta: RequestParamsMeta | None
- request: ReceiveRequestT
- message_metadata: MessageMetadata
diff --git a/src/mcp/shared/subscriptions.py b/src/mcp/shared/subscriptions.py
index ba50917fa4..30449a82ff 100644
--- a/src/mcp/shared/subscriptions.py
+++ b/src/mcp/shared/subscriptions.py
@@ -21,6 +21,7 @@
)
__all__ = [
+ "LISTEN_STREAM_METHODS",
"SUBSCRIPTION_ID_META_KEY",
"PromptsListChanged",
"ResourceUpdated",
@@ -79,6 +80,10 @@ def event_to_notification(event: ServerEvent, meta: dict[str, Any]) -> ServerNot
"notifications/resources/list_changed": ResourcesListChanged(),
}
+LISTEN_STREAM_METHODS: frozenset[str] = frozenset({*_LIST_CHANGED_EVENTS, "notifications/resources/updated"})
+"""The notification methods that ride `subscriptions/listen` streams at 2026-07-28
+(and, at that era, nowhere else): the change-notification vocabulary."""
+
def event_from_wire(method: str, params: Mapping[str, Any] | None) -> ServerEvent | None:
"""The event a raw listen-stream frame announces, or None if it carries none.
diff --git a/src/mcp/shared/transport_context.py b/src/mcp/shared/transport_context.py
index 55e5f6bc5f..8d15a2eaa2 100644
--- a/src/mcp/shared/transport_context.py
+++ b/src/mcp/shared/transport_context.py
@@ -23,11 +23,18 @@ class TransportContext:
"""Short identifier for the transport (e.g. `"stdio"`, `"streamable-http"`)."""
can_send_request: bool
- """Whether the transport can deliver server-initiated requests to the peer.
-
- `False` for stateless HTTP and HTTP with JSON response mode; `True` for
- stdio, SSE, and stateful streamable HTTP. When `False`,
- `DispatchContext.send_raw_request` raises `NoBackChannelError`.
+ """Whether this message's request-scoped channel can deliver a server-initiated request.
+
+ `False` for any of three reasons: the response has no room (streamable
+ HTTP in JSON-response mode and the 2026-07-28 single-exchange entry answer
+ with one JSON-RPC reply), the client's reply has nowhere to land (stateless
+ HTTP, no session), or the protocol forbids server-initiated requests (any
+ 2026-07-28 connection, whose dispatch masks the flag off). `True` for a
+ plain duplex pipe (stdio, SSE) and stateful streamable HTTP with SSE
+ responses, all pre-2026-07-28. When `False`,
+ `DispatchContext.send_raw_request` raises `NoBackChannelError` instead of
+ parking a waiter no reply can reach. Says nothing about the connection's
+ standalone channel, which refuses separately.
"""
headers: Mapping[str, str] | None = None
diff --git a/src/mcp/types/__init__.py b/src/mcp/types/__init__.py
new file mode 100644
index 0000000000..9bdfa06a56
--- /dev/null
+++ b/src/mcp/types/__init__.py
@@ -0,0 +1,31 @@
+"""The MCP protocol wire types, as the `mcp.types` namespace.
+
+This module mirrors the standalone `mcp_types` package exactly (every name is the
+same object), so SDK users can keep the familiar v1 spelling:
+
+ import mcp.types as types
+
+ types.TextContent(type="text", text="hi")
+
+The `mcp.types.jsonrpc`, `mcp.types.methods`, and `mcp.types.version`
+submodules mirror `mcp_types.jsonrpc`, `mcp_types.methods`, and
+`mcp_types.version` the same way, so every supported `mcp_types` import has
+an `mcp.types` spelling.
+
+Depend on and import `mcp_types` directly instead when you only need to
+(de)serialize MCP traffic and don't want the SDK's transport stack: its only
+runtime dependencies are `pydantic` and `typing-extensions`.
+"""
+
+# A wildcard mirror of the mcp_types namespace is the whole point of this module.
+# pyright: reportWildcardImportFromLibrary=false
+
+from mcp_types import *
+from mcp_types import __all__ as __all__
+
+# Bind the mirror submodules on the package, so `mcp.types.version.X` is as
+# reachable by attribute access as `mcp_types.version.X` (whose `__init__`
+# binds `.version` by importing from it), not only via `from ... import`.
+from . import jsonrpc as jsonrpc
+from . import methods as methods
+from . import version as version
diff --git a/src/mcp/types/jsonrpc.py b/src/mcp/types/jsonrpc.py
new file mode 100644
index 0000000000..a76b74ee26
--- /dev/null
+++ b/src/mcp/types/jsonrpc.py
@@ -0,0 +1,13 @@
+"""The JSON-RPC 2.0 message and error types, as the `mcp.types.jsonrpc` namespace.
+
+A mirror of `mcp_types.jsonrpc` (every name is the same object), so code that
+depends on `mcp` can import from `mcp.types.jsonrpc` without importing the
+`mcp_types` distribution directly. Depend on and import `mcp_types.jsonrpc`
+instead when you use `mcp-types` without the SDK.
+"""
+
+# A wildcard mirror of the mcp_types.jsonrpc namespace is the whole point of this module.
+# pyright: reportWildcardImportFromLibrary=false
+
+from mcp_types.jsonrpc import *
+from mcp_types.jsonrpc import __all__ as __all__
diff --git a/src/mcp/types/methods.py b/src/mcp/types/methods.py
new file mode 100644
index 0000000000..4b01fcc6cf
--- /dev/null
+++ b/src/mcp/types/methods.py
@@ -0,0 +1,13 @@
+"""The MCP method registry, as the `mcp.types.methods` namespace.
+
+A mirror of `mcp_types.methods` (every name is the same object), so code that
+depends on `mcp` can import from `mcp.types.methods` without importing the
+`mcp_types` distribution directly. Depend on and import `mcp_types.methods`
+instead when you use `mcp-types` without the SDK.
+"""
+
+# A wildcard mirror of the mcp_types.methods namespace is the whole point of this module.
+# pyright: reportWildcardImportFromLibrary=false
+
+from mcp_types.methods import *
+from mcp_types.methods import __all__ as __all__
diff --git a/src/mcp/types/version.py b/src/mcp/types/version.py
new file mode 100644
index 0000000000..857c53f0f8
--- /dev/null
+++ b/src/mcp/types/version.py
@@ -0,0 +1,13 @@
+"""The protocol-version registry, as the `mcp.types.version` namespace.
+
+A mirror of `mcp_types.version` (every name is the same object), so code that
+depends on `mcp` can write `from mcp.types.version import LATEST_MODERN_VERSION`
+without importing the `mcp_types` distribution directly. Depend on and import
+`mcp_types.version` instead when you use `mcp-types` without the SDK.
+"""
+
+# A wildcard mirror of the mcp_types.version namespace is the whole point of this module.
+# pyright: reportWildcardImportFromLibrary=false
+
+from mcp_types.version import *
+from mcp_types.version import __all__ as __all__
diff --git a/tests/_stamp.py b/tests/_stamp.py
new file mode 100644
index 0000000000..2dc380a36c
--- /dev/null
+++ b/tests/_stamp.py
@@ -0,0 +1,42 @@
+"""Shared helper: strip the 2026-era serverInfo `_meta` stamp from a result.
+
+Servers stamp `io.modelcontextprotocol/serverInfo` into every 2026-era
+result's `_meta` and never into handshake-era ones. Suites whose expected
+payloads should stay identity-free strip the stamp before exact comparison -
+and the strip is strict, so a modern result that lost its stamp fails the
+test instead of passing silently.
+
+The interaction matrix does not use this function directly: its `unstamped`
+fixture (tests/interaction/conftest.py) resolves per cell to this strict
+strip on modern cells and to a must-not-be-stamped assertion on
+handshake-era cells, so one comparison line enforces both eras.
+"""
+
+from typing import Any, Protocol, TypeVar
+
+from mcp_types import SERVER_INFO_META_KEY, Result
+
+R = TypeVar("R", bound=Result)
+
+
+class Unstamp(Protocol):
+ """An era-appropriate stamp normalizer: strips or forbids the stamp."""
+
+ def __call__(self, result: R) -> R: ...
+
+
+def unstamped(result: R) -> R:
+ """Assert the result carries a well-formed serverInfo stamp, then remove it.
+
+ Returns the result for inline use in comparisons. Use only where a stamp
+ is required (a 2026-era result); the interaction matrix's `unstamped`
+ fixture handles the era split.
+ """
+ meta = result.meta
+ assert meta is not None and SERVER_INFO_META_KEY in meta, "expected a serverInfo stamp on this result"
+ stamp: Any = meta.pop(SERVER_INFO_META_KEY)
+ assert isinstance(stamp, dict)
+ assert "name" in stamp and "version" in stamp
+ if not meta:
+ result.meta = None
+ return result
diff --git a/tests/client/auth/extensions/test_client_credentials.py b/tests/client/auth/extensions/test_client_credentials.py
index 3ad649d1f2..16336f8002 100644
--- a/tests/client/auth/extensions/test_client_credentials.py
+++ b/tests/client/auth/extensions/test_client_credentials.py
@@ -1,26 +1,20 @@
import urllib.parse
-import warnings
import jwt
import pytest
-from pydantic import AnyHttpUrl, AnyUrl
+from pydantic import AnyHttpUrl
from mcp.client.auth.extensions.client_credentials import (
ClientCredentialsOAuthProvider,
- JWTParameters,
PrivateKeyJWTOAuthProvider,
- RFC7523OAuthClientProvider,
SignedJWTParameters,
static_assertion_provider,
)
from mcp.shared.auth import (
- AuthorizationCodeResult,
OAuthClientInformationFull,
- OAuthClientMetadata,
OAuthMetadata,
OAuthToken,
)
-from mcp.shared.exceptions import MCPDeprecationWarning
class MockTokenStorage:
@@ -48,138 +42,6 @@ def mock_storage():
return MockTokenStorage()
-@pytest.fixture
-def client_metadata():
- return OAuthClientMetadata(
- client_name="Test Client",
- client_uri=AnyHttpUrl("https://example.com"),
- redirect_uris=[AnyUrl("http://localhost:3030/callback")],
- scope="read write",
- )
-
-
-@pytest.fixture
-def rfc7523_oauth_provider(client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage):
- async def redirect_handler(url: str) -> None: # pragma: no cover
- """Mock redirect handler."""
- pass
-
- async def callback_handler() -> AuthorizationCodeResult: # pragma: no cover
- """Mock callback handler."""
- return AuthorizationCodeResult(code="test_auth_code", state="test_state")
-
- with warnings.catch_warnings():
- warnings.simplefilter("ignore", MCPDeprecationWarning)
- return RFC7523OAuthClientProvider(
- server_url="https://api.example.com/v1/mcp",
- client_metadata=client_metadata,
- storage=mock_storage,
- redirect_handler=redirect_handler,
- callback_handler=callback_handler,
- )
-
-
-class TestOAuthFlowClientCredentials:
- """Test OAuth flow behavior for client credentials flows."""
-
- @pytest.mark.anyio
- async def test_token_exchange_request_jwt_predefined(self, rfc7523_oauth_provider: RFC7523OAuthClientProvider):
- """Test token exchange request building with a predefined JWT assertion."""
- # Set up required context
- rfc7523_oauth_provider.context.client_info = OAuthClientInformationFull(
- grant_types=["urn:ietf:params:oauth:grant-type:jwt-bearer"],
- token_endpoint_auth_method="private_key_jwt",
- redirect_uris=None,
- scope="read write",
- )
- rfc7523_oauth_provider.context.oauth_metadata = OAuthMetadata(
- issuer=AnyHttpUrl("https://api.example.com"),
- authorization_endpoint=AnyHttpUrl("https://api.example.com/authorize"),
- token_endpoint=AnyHttpUrl("https://api.example.com/token"),
- registration_endpoint=AnyHttpUrl("https://api.example.com/register"),
- )
- rfc7523_oauth_provider.context.client_metadata = rfc7523_oauth_provider.context.client_info
- rfc7523_oauth_provider.context.protocol_version = "2025-06-18"
- rfc7523_oauth_provider.jwt_parameters = JWTParameters(
- # https://www.jwt.io
- assertion="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30"
- )
-
- request = await rfc7523_oauth_provider._exchange_token_jwt_bearer()
-
- assert request.method == "POST"
- assert str(request.url) == "https://api.example.com/token"
- assert request.headers["Content-Type"] == "application/x-www-form-urlencoded"
-
- # Check form data
- content = urllib.parse.unquote_plus(request.content.decode())
- assert "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" in content
- assert "scope=read write" in content
- assert "resource=https://api.example.com/v1/mcp" in content
- assert (
- "assertion=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30"
- in content
- )
-
- @pytest.mark.anyio
- async def test_token_exchange_request_jwt(self, rfc7523_oauth_provider: RFC7523OAuthClientProvider):
- """Test token exchange request building wiith a generated JWT assertion."""
- # Set up required context
- rfc7523_oauth_provider.context.client_info = OAuthClientInformationFull(
- grant_types=["urn:ietf:params:oauth:grant-type:jwt-bearer"],
- token_endpoint_auth_method="private_key_jwt",
- redirect_uris=None,
- scope="read write",
- )
- rfc7523_oauth_provider.context.oauth_metadata = OAuthMetadata(
- issuer=AnyHttpUrl("https://api.example.com"),
- authorization_endpoint=AnyHttpUrl("https://api.example.com/authorize"),
- token_endpoint=AnyHttpUrl("https://api.example.com/token"),
- registration_endpoint=AnyHttpUrl("https://api.example.com/register"),
- )
- rfc7523_oauth_provider.context.client_metadata = rfc7523_oauth_provider.context.client_info
- rfc7523_oauth_provider.context.protocol_version = "2025-06-18"
- rfc7523_oauth_provider.jwt_parameters = JWTParameters(
- issuer="foo",
- subject="1234567890",
- claims={
- "name": "John Doe",
- "admin": True,
- "iat": 1516239022,
- },
- jwt_signing_algorithm="HS256",
- jwt_signing_key="a-string-secret-at-least-256-bits-long",
- jwt_lifetime_seconds=300,
- )
-
- request = await rfc7523_oauth_provider._exchange_token_jwt_bearer()
-
- assert request.method == "POST"
- assert str(request.url) == "https://api.example.com/token"
- assert request.headers["Content-Type"] == "application/x-www-form-urlencoded"
-
- # Check form data
- content = urllib.parse.unquote_plus(request.content.decode()).split("&")
- assert "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" in content
- assert "scope=read write" in content
- assert "resource=https://api.example.com/v1/mcp" in content
-
- # Check assertion
- assertion = next(param for param in content if param.startswith("assertion="))[len("assertion=") :]
- claims = jwt.decode(
- assertion,
- key="a-string-secret-at-least-256-bits-long",
- algorithms=["HS256"],
- audience="https://api.example.com/",
- subject="1234567890",
- issuer="foo",
- verify=True,
- )
- assert claims["name"] == "John Doe"
- assert claims["admin"]
- assert claims["iat"] == 1516239022
-
-
class TestClientCredentialsOAuthProvider:
"""Test ClientCredentialsOAuthProvider."""
@@ -210,7 +72,7 @@ async def test_init_with_scopes(self, mock_storage: MockTokenStorage):
storage=mock_storage,
client_id="test-client-id",
client_secret="test-client-secret",
- scopes="read write",
+ scope="read write",
)
await provider._initialize()
@@ -240,7 +102,7 @@ async def test_exchange_token_client_credentials(self, mock_storage: MockTokenSt
storage=mock_storage,
client_id="test-client-id",
client_secret="test-client-secret",
- scopes="read write",
+ scope="read write",
)
provider.context.oauth_metadata = OAuthMetadata(
issuer=AnyHttpUrl("https://api.example.com"),
@@ -268,7 +130,7 @@ async def test_exchange_token_client_secret_post_includes_client_id(self, mock_s
client_id="test-client-id",
client_secret="test-client-secret",
token_endpoint_auth_method="client_secret_post",
- scopes="read write",
+ scope="read write",
)
await provider._initialize()
provider.context.oauth_metadata = OAuthMetadata(
@@ -287,44 +149,6 @@ async def test_exchange_token_client_secret_post_includes_client_id(self, mock_s
# Should NOT have Basic auth header
assert "Authorization" not in request.headers
- @pytest.mark.anyio
- async def test_exchange_token_client_secret_post_without_client_id(self, mock_storage: MockTokenStorage):
- """Test client_secret_post skips body credentials when client_id is None."""
- provider = ClientCredentialsOAuthProvider(
- server_url="https://api.example.com/v1/mcp",
- storage=mock_storage,
- client_id="placeholder",
- client_secret="test-client-secret",
- token_endpoint_auth_method="client_secret_post",
- scopes="read write",
- )
- await provider._initialize()
- provider.context.oauth_metadata = OAuthMetadata(
- issuer=AnyHttpUrl("https://api.example.com"),
- authorization_endpoint=AnyHttpUrl("https://api.example.com/authorize"),
- token_endpoint=AnyHttpUrl("https://api.example.com/token"),
- )
- provider.context.protocol_version = "2025-06-18"
- # Override client_info to have client_id=None (edge case)
- provider.context.client_info = OAuthClientInformationFull(
- redirect_uris=None,
- client_id=None,
- client_secret="test-client-secret",
- grant_types=["client_credentials"],
- token_endpoint_auth_method="client_secret_post",
- scope="read write",
- )
-
- request = await provider._perform_authorization()
-
- content = urllib.parse.unquote_plus(request.content.decode())
- assert "grant_type=client_credentials" in content
- # Neither client_id nor client_secret should be in body since client_id is None
- # (RFC 6749 §2.3.1 requires both for client_secret_post)
- assert "client_id=" not in content
- assert "client_secret=" not in content
- assert "Authorization" not in request.headers
-
@pytest.mark.anyio
async def test_exchange_token_without_scopes(self, mock_storage: MockTokenStorage):
"""Test token exchange without scopes."""
@@ -386,7 +210,7 @@ async def mock_assertion_provider(audience: str) -> str:
storage=mock_storage,
client_id="test-client-id",
assertion_provider=mock_assertion_provider,
- scopes="read write",
+ scope="read write",
)
provider.context.oauth_metadata = OAuthMetadata(
issuer=AnyHttpUrl("https://auth.example.com"),
diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py
index 9e9599f86c..be96cc8eec 100644
--- a/tests/client/test_auth.py
+++ b/tests/client/test_auth.py
@@ -12,7 +12,7 @@
from pydantic import AnyHttpUrl, AnyUrl
from mcp.client.auth import OAuthClientProvider, PKCEParameters
-from mcp.client.auth.exceptions import OAuthFlowError, OAuthTokenError
+from mcp.client.auth.exceptions import OAuthFlowError, OAuthRegistrationError, OAuthTokenError
from mcp.client.auth.utils import (
build_oauth_authorization_server_metadata_discovery_urls,
build_protected_resource_metadata_discovery_urls,
@@ -192,7 +192,6 @@ async def test_oauth_provider_initialization(
assert oauth_provider.context.server_url == "https://api.example.com/v1/mcp"
assert oauth_provider.context.client_metadata == client_metadata
assert oauth_provider.context.storage == mock_storage
- assert oauth_provider.context.timeout == 300.0
assert oauth_provider.context is not None
def test_context_url_parsing(self, oauth_provider: OAuthClientProvider):
@@ -1009,6 +1008,91 @@ def text(self):
assert "Registration failed: 400" in str(exc_info.value)
+@pytest.mark.anyio
+async def test_registration_response_with_substituted_metadata_yields_the_credentials():
+ """A 201 whose echoed metadata differs from the request still registers the client.
+
+ The authorization server returned an application_type outside OIDC Registration's set,
+ a null redirect_uris, and an auth method the SDK does not implement. RFC 7591 §3.2.1
+ permits the server to substitute values; the client keeps the credentials it minted.
+ """
+ body = (
+ b'{"client_id": "issued-id", "client_secret": "issued-secret", '
+ b'"application_type": "confidential", "redirect_uris": null, '
+ b'"token_endpoint_auth_method": "client_secret_jwt"}'
+ )
+ response = httpx2.Response(201, content=body)
+
+ client_info = await handle_registration_response(response)
+
+ assert client_info.client_id == "issued-id"
+ assert client_info.client_secret == "issued-secret"
+ assert client_info.application_type == "confidential"
+
+
+@pytest.mark.anyio
+@pytest.mark.parametrize("echoed_issuer", ["https://not-the-flow.example", 12345], ids=["string", "not-a-string"])
+async def test_registration_response_does_not_seed_the_issuer_binding_from_the_body(echoed_issuer: object):
+ """The issuer binding (SEP-2352) is the SDK's record of which server it registered with,
+ stamped by the auth flow; an "issuer" member in the untrusted response body is dropped
+ before parsing - never populating the binding, and never failing the parse either, so a
+ mismatched or malformed value cannot discard the credentials on every 401."""
+ body = json.dumps({"client_id": "issued-id", "issuer": echoed_issuer}).encode()
+
+ client_info = await handle_registration_response(httpx2.Response(201, content=body))
+
+ assert client_info.client_id == "issued-id"
+ assert client_info.issuer is None
+
+
+@pytest.mark.anyio
+@pytest.mark.parametrize(
+ "content",
+ [b"not json", b'["json", "but", "not", "an", "object"]', '{"client_id": "café"}'.encode("latin-1")],
+ ids=["not-json", "not-an-object", "not-utf8"],
+)
+async def test_a_2xx_body_that_is_not_client_information_is_an_oauth_registration_error(content: bytes):
+ """A success status whose body is not client information - unparseable, not an object, or
+ not valid UTF-8 - surfaces as OAuthRegistrationError rather than a raw parse failure, so a
+ single OAuthFlowError handler still covers registration."""
+ response = httpx2.Response(201, content=content)
+
+ with pytest.raises(OAuthRegistrationError):
+ await handle_registration_response(response)
+
+
+@pytest.mark.anyio
+async def test_token_exchange_reports_an_unimplemented_registered_auth_method(oauth_provider: OAuthClientProvider):
+ """A server-assigned auth method the SDK cannot apply (RFC 7591 §3.2.1 lets the server
+ substitute one) is reported at the token exchange rather than sending the request
+ unauthenticated for the server to reject as invalid_client."""
+ oauth_provider.context.client_info = OAuthClientInformationFull(
+ client_id="registered-id",
+ client_secret="registered-secret",
+ token_endpoint_auth_method="client_secret_jwt",
+ )
+
+ with pytest.raises(OAuthTokenError):
+ await oauth_provider._exchange_token_authorization_code("test_auth_code", "test_verifier")
+
+
+def test_prepare_token_auth_leaves_a_private_key_jwt_client_to_its_provider(oauth_provider: OAuthClientProvider):
+ """private_key_jwt is recognized, so the base leaves the request untouched rather than
+ raising - PrivateKeyJWTOAuthProvider's inherited refresh path passes through here, and a
+ refresh the server then rejects (no assertion is signed on it) falls back to a fresh,
+ signed client-credentials exchange instead of aborting the flow."""
+ oauth_provider.context.client_info = OAuthClientInformationFull(
+ client_id="registered-id",
+ client_secret="registered-secret",
+ token_endpoint_auth_method="private_key_jwt",
+ )
+
+ data, headers = oauth_provider.context.prepare_token_auth({"grant_type": "refresh_token"}, {})
+
+ assert data == {"grant_type": "refresh_token"}
+ assert headers == {}
+
+
class TestCreateClientRegistrationRequest:
"""Test client registration request creation."""
diff --git a/tests/client/test_client.py b/tests/client/test_client.py
index 6c78503b97..697383e8ae 100644
--- a/tests/client/test_client.py
+++ b/tests/client/test_client.py
@@ -119,6 +119,7 @@ async def test_client_is_initialized(app: MCPServer):
tools=ToolsCapability(list_changed=False),
)
)
+ assert client.server_info is not None
assert client.server_info.name == "test"
@@ -134,7 +135,8 @@ async def test_client_with_simple_server(simple_server: Server):
resources = await client.list_resources()
assert resources == snapshot(
ListResourcesResult(
- resources=[Resource(name="Test Resource", uri="memory://test", description="A test resource")]
+ _meta={"io.modelcontextprotocol/serverInfo": {"name": "test_server", "version": ""}},
+ resources=[Resource(name="Test Resource", uri="memory://test", description="A test resource")],
)
)
@@ -150,6 +152,7 @@ async def test_client_list_tools(app: MCPServer):
result = await client.list_tools()
assert result == snapshot(
ListToolsResult(
+ _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
tools=[
Tool(
name="greet",
@@ -167,7 +170,7 @@ async def test_client_list_tools(app: MCPServer):
"type": "object",
},
)
- ]
+ ],
)
)
@@ -177,6 +180,7 @@ async def test_client_call_tool(app: MCPServer):
result = await client.call_tool("greet", {"name": "World"})
assert result == snapshot(
CallToolResult(
+ _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
content=[TextContent(text="Hello, World!")],
structured_content={"result": "Hello, World!"},
)
@@ -189,7 +193,8 @@ async def test_read_resource(app: MCPServer):
result = await client.read_resource("test://resource")
assert result == snapshot(
ReadResourceResult(
- contents=[TextResourceContents(uri="test://resource", mime_type="text/plain", text="Test content")]
+ _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
+ contents=[TextResourceContents(uri="test://resource", mime_type="text/plain", text="Test content")],
)
)
@@ -269,6 +274,7 @@ async def test_get_prompt(app: MCPServer):
result = await client.get_prompt("greeting_prompt", {"name": "Alice"})
assert result == snapshot(
GetPromptResult(
+ _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
description="A greeting prompt.",
messages=[PromptMessage(role="user", content=TextContent(text="Please greet Alice warmly."))],
)
@@ -335,6 +341,7 @@ async def test_client_list_resources_with_params(app: MCPServer):
result = await client.list_resources()
assert result == snapshot(
ListResourcesResult(
+ _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
resources=[
Resource(
name="test_resource",
@@ -342,7 +349,7 @@ async def test_client_list_resources_with_params(app: MCPServer):
description="A test resource.",
mime_type="text/plain",
)
- ]
+ ],
)
)
@@ -351,7 +358,11 @@ async def test_client_list_resource_templates(app: MCPServer):
"""Test listing resource templates with params parameter."""
async with Client(app) as client:
result = await client.list_resource_templates()
- assert result == snapshot(ListResourceTemplatesResult(resource_templates=[]))
+ assert result == snapshot(
+ ListResourceTemplatesResult(
+ _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, resource_templates=[]
+ )
+ )
async def test_list_prompts(app: MCPServer):
@@ -360,13 +371,14 @@ async def test_list_prompts(app: MCPServer):
result = await client.list_prompts()
assert result == snapshot(
ListPromptsResult(
+ _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
prompts=[
Prompt(
name="greeting_prompt",
description="A greeting prompt.",
arguments=[PromptArgument(name="name", required=True)],
)
- ]
+ ],
)
)
@@ -376,7 +388,12 @@ async def test_complete_with_prompt_reference(simple_server: Server):
async with Client(simple_server) as client:
ref = types.PromptReference(type="ref/prompt", name="test_prompt")
result = await client.complete(ref=ref, argument={"name": "arg", "value": "test"})
- assert result == snapshot(types.CompleteResult(completion=types.Completion(values=[])))
+ assert result == snapshot(
+ types.CompleteResult(
+ _meta={"io.modelcontextprotocol/serverInfo": {"name": "test_server", "version": ""}},
+ completion=types.Completion(values=[]),
+ )
+ )
def test_client_with_url_initializes_streamable_http_transport():
@@ -573,6 +590,7 @@ async def scripted_transport() -> AsyncIterator[TransportStreams]:
with anyio.fail_after(5):
async with Client(scripted_transport(), mode="auto") as client:
assert client.protocol_version == LATEST_HANDSHAKE_VERSION
+ assert client.server_info is not None
assert client.server_info.name == "legacy-only"
assert methods_seen == ["server/discover", "initialize", "notifications/initialized"]
@@ -639,12 +657,16 @@ async def test_a_complete_listing_prunes_per_tool_state_for_tools_it_no_longer_c
with anyio.fail_after(5):
async with Client(server) as client:
await client.session.list_tools()
+ # Compile the retired tool's output-schema validator so its eviction is observable.
+ await client.session.validate_tool_result("retired", CallToolResult(content=[], structured_content={}))
assert set(client.session._x_mcp_header_maps) == {"retired", "survivor"}
assert set(client.session._tool_output_schemas) == {"retired", "survivor"}
+ assert set(client.session._tool_output_validators) == {"retired"}
await client.session.list_tools()
assert set(client.session._x_mcp_header_maps) == {"survivor"}
assert set(client.session._tool_output_schemas) == {"survivor"}
+ assert client.session._tool_output_validators == {}
async def test_a_complete_listing_prunes_output_schemas_on_a_legacy_session_too() -> None:
@@ -754,7 +776,11 @@ async def elicitation_callback(
result = await client.call_tool("greet")
assert result == snapshot(
- CallToolResult(content=[TextContent(text="Hello, Ada!")], structured_content={"result": "Hello, Ada!"})
+ CallToolResult(
+ _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
+ content=[TextContent(text="Hello, Ada!")],
+ structured_content={"result": "Hello, Ada!"},
+ )
)
assert len(callback_params) == 1
assert isinstance(callback_params[0], types.ElicitRequestFormParams)
@@ -800,7 +826,9 @@ async def sampling_callback(
assert result == snapshot(
CallToolResult(
- content=[TextContent(text="Model said: Paris")], structured_content={"result": "Model said: Paris"}
+ _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
+ content=[TextContent(text="Model said: Paris")],
+ structured_content={"result": "Model said: Paris"},
)
)
assert len(callback_params) == 1
@@ -833,6 +861,7 @@ async def list_roots_callback(context: ClientRequestContext) -> types.ListRootsR
assert result == snapshot(
CallToolResult(
+ _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
content=[TextContent(text="Client exposed 1 root(s).")],
structured_content={"result": "Client exposed 1 root(s)."},
)
@@ -917,7 +946,12 @@ async def elicitation_callback(
with anyio.fail_after(5):
async with Client(server, mode="2026-07-28", elicitation_callback=elicitation_callback) as client:
result = await client.get_prompt("summary")
- assert result == snapshot(GetPromptResult(messages=[PromptMessage(role="user", content=TextContent(text="ok"))]))
+ assert result == snapshot(
+ GetPromptResult(
+ _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
+ messages=[PromptMessage(role="user", content=TextContent(text="ok"))],
+ )
+ )
async def test_read_resource_auto_loop_resolves_input_required_via_callbacks() -> None:
@@ -944,5 +978,8 @@ async def elicitation_callback(
async with Client(server, mode="2026-07-28", elicitation_callback=elicitation_callback) as client:
result = await client.read_resource("memory://gated")
assert result == snapshot(
- ReadResourceResult(contents=[TextResourceContents(uri="memory://gated", text="unlocked")])
+ ReadResourceResult(
+ _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
+ contents=[TextResourceContents(uri="memory://gated", text="unlocked")],
+ )
)
diff --git a/tests/client/test_client_caching.py b/tests/client/test_client_caching.py
index 953ea005ad..30f752eb6c 100644
--- a/tests/client/test_client_caching.py
+++ b/tests/client/test_client_caching.py
@@ -24,7 +24,6 @@
ElicitRequest,
ElicitRequestFormParams,
ElicitResult,
- Implementation,
InputRequiredResult,
ListPromptsResult,
ListResourcesResult,
@@ -43,7 +42,7 @@
)
from mcp_types.version import LATEST_MODERN_VERSION
-from mcp.client import Client
+from mcp.client import Client, IncomingMessage
from mcp.client._transport import TransportStreams
from mcp.client.caching import (
CacheConfig,
@@ -58,13 +57,10 @@
from mcp.shared.exceptions import MCPError
from mcp.shared.memory import MessageStream, create_client_server_memory_streams
from mcp.shared.message import SessionMessage
-from mcp.shared.session import RequestResponder
from tests.interaction._connect import BASE_URL, mounted_app
pytestmark = pytest.mark.anyio
-IncomingMessage = RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception
-
def _coordinator(client: Client) -> ClientResponseCache:
cache = client._response_cache
@@ -213,11 +209,11 @@ def test_a_custom_store_with_an_explicit_target_id_constructs_for_any_server() -
assert _coordinator(client)._store is store
-async def test_cache_false_disables_the_cache_and_the_handler_wrap() -> None:
+async def test_cache_none_disables_the_cache_and_the_handler_wrap() -> None:
async def handler(message: IncomingMessage) -> None:
raise NotImplementedError
- client = Client(_list_changed_server(), cache=False, message_handler=handler)
+ client = Client(_list_changed_server(), cache=None, message_handler=handler)
assert client._response_cache is None
async with client:
@@ -225,7 +221,7 @@ async def handler(message: IncomingMessage) -> None:
def test_the_default_cache_uses_a_per_client_in_memory_store() -> None:
- """`cache=None` (the default) is cache-on."""
+ """The default `CacheConfig()` is cache-on."""
server = Server("plain")
first = Client(server)
second = Client(server)
@@ -638,7 +634,7 @@ def text(result: ReadResourceResult) -> str:
async def test_cache_mode_is_inert_when_caching_is_disabled() -> None:
server, fetches = _varying_tools_server()
- async with Client(server, cache=False) as client:
+ async with Client(server, cache=None) as client:
await client.list_tools()
await client.list_tools(cache_mode="use")
await client.list_tools(cache_mode="refresh")
@@ -845,7 +841,6 @@ async def on_request(request: httpx2.Request) -> None:
discover = DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
- server_info=Implementation(name="srv", version="0"),
)
with anyio.fail_after(5):
diff --git a/tests/client/test_logging_callback.py b/tests/client/test_logging_callback.py
index d62b7e19b3..7ccdae3530 100644
--- a/tests/client/test_logging_callback.py
+++ b/tests/client/test_logging_callback.py
@@ -1,6 +1,5 @@
from typing import Literal
-import mcp_types as types
import pytest
from mcp_types import (
LoggingMessageNotificationParams,
@@ -8,8 +7,8 @@
)
from mcp import Client
+from mcp.client import IncomingMessage
from mcp.server.mcpserver import Context, MCPServer
-from mcp.shared.session import RequestResponder
class LoggingCollector:
@@ -55,9 +54,7 @@ async def test_tool_with_log_dict(
return True
# Create a message handler to catch exceptions
- async def message_handler(
- message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
- ) -> None:
+ async def message_handler(message: IncomingMessage) -> None:
if isinstance(message, Exception): # pragma: no cover
raise message
diff --git a/tests/client/test_notification_response.py b/tests/client/test_notification_response.py
index 6724dfaf1b..b21e734fa3 100644
--- a/tests/client/test_notification_response.py
+++ b/tests/client/test_notification_response.py
@@ -16,8 +16,8 @@
from starlette.routing import Route
from mcp import ClientSession, MCPError
+from mcp.client import IncomingMessage
from mcp.client.streamable_http import streamable_http_client
-from mcp.shared.session import RequestResponder
pytestmark = pytest.mark.anyio
@@ -82,9 +82,7 @@ async def test_non_compliant_notification_response() -> None:
"""
returned_exception = None
- async def message_handler( # pragma: no cover
- message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
- ) -> None:
+ async def message_handler(message: IncomingMessage) -> None: # pragma: no cover
nonlocal returned_exception
if isinstance(message, Exception):
returned_exception = message
diff --git a/tests/client/test_probe.py b/tests/client/test_probe.py
index e7fe49dc43..354f8fd0c1 100644
--- a/tests/client/test_probe.py
+++ b/tests/client/test_probe.py
@@ -25,8 +25,8 @@
METHOD_NOT_FOUND,
PARSE_ERROR,
REQUEST_TIMEOUT,
+ SERVER_INFO_META_KEY,
UNSUPPORTED_PROTOCOL_VERSION,
- Implementation,
ServerCapabilities,
)
from mcp_types.version import (
@@ -85,7 +85,7 @@ def _discover_dict(versions: list[str] | None = None) -> dict[str, Any]:
return types.DiscoverResult(
supported_versions=versions or list(MODERN_PROTOCOL_VERSIONS),
capabilities=ServerCapabilities(),
- server_info=Implementation(name="stub", version="0"),
+ _meta={SERVER_INFO_META_KEY: {"name": "stub", "version": "0"}},
).model_dump(by_alias=True, mode="json", exclude_none=True)
@@ -101,11 +101,13 @@ def _err_32022(supported: Any) -> MCPError:
async def test_a_valid_discover_result_is_adopted_without_initializing() -> None:
- """A parseable `DiscoverResult` from the probe is adopted; `initialize()` is never called."""
+ """A parseable `DiscoverResult` from the probe is adopted intact — including the
+ `_meta` serverInfo stamp — and `initialize()` is never called."""
session = _StubSession(_discover_dict())
await _negotiate(session)
assert session.adopted is not None
- assert session.adopted.server_info.name == "stub"
+ assert session.adopted.meta is not None
+ assert session.adopted.meta[SERVER_INFO_META_KEY] == {"name": "stub", "version": "0"}
assert not session.initialized
assert session.probed_at == [LATEST_MODERN_VERSION]
@@ -119,6 +121,16 @@ async def test_an_unparseable_discover_result_falls_back_to_initialize() -> None
assert session.adopted is None
+async def test_a_discover_result_advertising_only_legacy_versions_falls_back_to_initialize() -> None:
+ """A server that answers the probe but lists no modern version has made an
+ explicit legacy advertisement (go-sdk's default stateful streamable server
+ does this), so the policy runs the handshake instead of raising on adopt."""
+ session = _StubSession(_discover_dict(list(HANDSHAKE_PROTOCOL_VERSIONS)))
+ await _negotiate(session)
+ assert session.initialized
+ assert session.adopted is None
+
+
# --- the denylist: every JSON-RPC error code falls back ---
diff --git a/tests/client/test_send_request_mcp_name.py b/tests/client/test_send_request_mcp_name.py
index e22ec4015b..27c41e6614 100644
--- a/tests/client/test_send_request_mcp_name.py
+++ b/tests/client/test_send_request_mcp_name.py
@@ -101,7 +101,6 @@ def _adopt_modern(session: ClientSession) -> None:
types.DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
- server_info=Implementation(name="stub", version="0"),
)
)
diff --git a/tests/client/test_session.py b/tests/client/test_session.py
index 507c8f69e3..6e5e0a14d8 100644
--- a/tests/client/test_session.py
+++ b/tests/client/test_session.py
@@ -13,9 +13,11 @@
CONNECTION_CLOSED,
INTERNAL_ERROR,
INVALID_PARAMS,
+ LOG_LEVEL_META_KEY,
METHOD_NOT_FOUND,
PROTOCOL_VERSION_META_KEY,
REQUEST_TIMEOUT,
+ SERVER_INFO_META_KEY,
UNSUPPORTED_PROTOCOL_VERSION,
CallToolResult,
Implementation,
@@ -36,7 +38,7 @@
from pydantic import FileUrl, ValidationError
from mcp import MCPError
-from mcp.client import ClientRequestContext
+from mcp.client import ClientRequestContext, IncomingMessage
from mcp.client.client import Client
from mcp.client.session import DEFAULT_CLIENT_INFO, ClientSession
from mcp.client.subscriptions import ToolsListChanged, listen
@@ -44,7 +46,6 @@
from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair
from mcp.shared.dispatcher import CallOptions, DispatchContext, OnNotify, OnNotifyIntercept, OnRequest
from mcp.shared.message import SessionMessage
-from mcp.shared.session import RequestResponder
from mcp.shared.subscriptions import SUBSCRIPTION_ID_META_KEY
from mcp.shared.transport_context import TransportContext
@@ -122,9 +123,7 @@ async def mock_server():
)
# Create a message handler to catch exceptions
- async def message_handler( # pragma: no cover
- message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
- ) -> None:
+ async def message_handler(message: IncomingMessage) -> None: # pragma: no cover
if isinstance(message, Exception):
raise message
@@ -1227,10 +1226,8 @@ async def test_raising_notification_callbacks_over_direct_dispatch_cost_only_tha
async def logging_callback(params: types.LoggingMessageNotificationParams) -> None:
raise ValueError("logging callback boom")
- async def message_handler(
- message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
- ) -> None:
- assert not isinstance(message, RequestResponder | Exception)
+ async def message_handler(message: IncomingMessage) -> None:
+ assert not isinstance(message, Exception)
teed.append(message)
raise ValueError("message handler boom")
@@ -1323,7 +1320,6 @@ def test_adopt_raises_when_no_mutual_modern_version_is_supported() -> None:
types.DiscoverResult(
supported_versions=["1999-01-01"],
capabilities=types.ServerCapabilities(),
- server_info=types.Implementation(name="s", version="0"),
result_type="complete",
ttl_ms=0,
cache_scope="public",
@@ -1514,7 +1510,6 @@ def _discover_result_dict() -> dict[str, Any]:
return types.DiscoverResult(
supported_versions=["2026-07-28"],
capabilities=ServerCapabilities(),
- server_info=Implementation(name="stub", version="0"),
).model_dump(by_alias=True, mode="json", exclude_none=True)
@@ -1554,6 +1549,21 @@ async def test_discover_adopts_the_returned_result_and_installs_the_modern_stamp
assert ping_params["_meta"][PROTOCOL_VERSION_META_KEY] == "2026-07-28"
+@pytest.mark.anyio
+async def test_log_level_opt_in_is_stamped_on_modern_requests_and_overridable_per_call() -> None:
+ """SDK-defined: `log_level` stamps the reserved log-level `_meta` key on every modern
+ request, and a request supplying that key in its own `_meta` overrides the default."""
+ dispatcher = _ScriptedDispatcher(_discover_result_dict(), {}, {})
+ with anyio.fail_after(5):
+ async with ClientSession(dispatcher=dispatcher, log_level="warning") as session:
+ await session.discover()
+ await session.send_ping()
+ await session.send_ping(meta={LOG_LEVEL_META_KEY: "debug"})
+ default_meta, override_meta = (params["_meta"] for _, params in dispatcher.calls[-2:] if params is not None)
+ assert default_meta[LOG_LEVEL_META_KEY] == "warning"
+ assert override_meta[LOG_LEVEL_META_KEY] == "debug"
+
+
@pytest.mark.anyio
async def test_discover_retries_once_on_unsupported_version_then_adopts() -> None:
"""Spec SHOULD: a -32022 reply that names a mutually-supported version
@@ -1654,12 +1664,13 @@ def test_era_neutral_properties_are_none_before_any_handshake() -> None:
@pytest.mark.anyio
async def test_era_neutral_properties_after_discover() -> None:
"""SDK-defined: after `discover()` the era-neutral accessors read from the
- DiscoverResult; `initialize_result` stays None."""
+ DiscoverResult; `server_info` comes from the `_meta` serverInfo stamp and
+ `initialize_result` stays None."""
raw = types.DiscoverResult(
supported_versions=["2026-07-28"],
capabilities=ServerCapabilities(tools=types.ToolsCapability(list_changed=True)),
- server_info=Implementation(name="discovered", version="2.0"),
instructions="hello",
+ _meta={SERVER_INFO_META_KEY: {"name": "discovered", "version": "2.0"}},
).model_dump(by_alias=True, mode="json", exclude_none=True)
dispatcher = _ScriptedDispatcher(raw)
with anyio.fail_after(5):
@@ -1673,6 +1684,40 @@ async def test_era_neutral_properties_after_discover() -> None:
assert isinstance(session.discover_result, types.DiscoverResult)
+@pytest.mark.anyio
+async def test_server_info_is_none_when_the_discover_result_carries_no_stamp() -> None:
+ """Spec-mandated (2026-07-28, #3002): the serverInfo result-`_meta` stamp is
+ optional, so a server that does not identify itself reads as `None` rather
+ than failing the connection."""
+ raw = types.DiscoverResult(
+ supported_versions=["2026-07-28"],
+ capabilities=ServerCapabilities(),
+ ).model_dump(by_alias=True, mode="json", exclude_none=True)
+ dispatcher = _ScriptedDispatcher(raw)
+ with anyio.fail_after(5):
+ async with ClientSession(dispatcher=dispatcher) as session:
+ await session.discover()
+ assert session.protocol_version == "2026-07-28"
+ assert session.server_info is None
+
+
+@pytest.mark.anyio
+async def test_a_malformed_server_info_stamp_reads_as_absent() -> None:
+ """Spec-mandated (2026-07-28, #3002): the stamp is self-reported and
+ display-only, so a value that is not an `Implementation` must not fail the
+ call; it reads as if the server sent none."""
+ raw = types.DiscoverResult(
+ supported_versions=["2026-07-28"],
+ capabilities=ServerCapabilities(),
+ _meta={SERVER_INFO_META_KEY: {"version": "no name makes this invalid"}},
+ ).model_dump(by_alias=True, mode="json", exclude_none=True)
+ dispatcher = _ScriptedDispatcher(raw)
+ with anyio.fail_after(5):
+ async with ClientSession(dispatcher=dispatcher) as session:
+ await session.discover()
+ assert session.server_info is None
+
+
@pytest.mark.anyio
async def test_discover_reraises_unsupported_version_with_malformed_error_data() -> None:
"""SDK-defined: a -32022 reply whose `data` is not a valid
diff --git a/tests/client/test_session_claims.py b/tests/client/test_session_claims.py
index 94ebd7946e..f61fc3710e 100644
--- a/tests/client/test_session_claims.py
+++ b/tests/client/test_session_claims.py
@@ -107,7 +107,6 @@ def _adopt_modern(session: ClientSession) -> None:
types.DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
- server_info=Implementation(name="stub", version="0"),
)
)
diff --git a/tests/client/test_session_concurrency.py b/tests/client/test_session_concurrency.py
index 0a0ae62dde..beb1d6d4d5 100644
--- a/tests/client/test_session_concurrency.py
+++ b/tests/client/test_session_concurrency.py
@@ -68,9 +68,21 @@ async def call_and_record(tag: str) -> None:
assert completion_order == ["c", "b", "a"]
assert results == snapshot(
{
- "c": CallToolResult(content=[TextContent(text="result:c")], structured_content={"result": "result:c"}),
- "b": CallToolResult(content=[TextContent(text="result:b")], structured_content={"result": "result:b"}),
- "a": CallToolResult(content=[TextContent(text="result:a")], structured_content={"result": "result:a"}),
+ "c": CallToolResult(
+ _meta={"io.modelcontextprotocol/serverInfo": {"name": "parking", "version": ""}},
+ content=[TextContent(text="result:c")],
+ structured_content={"result": "result:c"},
+ ),
+ "b": CallToolResult(
+ _meta={"io.modelcontextprotocol/serverInfo": {"name": "parking", "version": ""}},
+ content=[TextContent(text="result:b")],
+ structured_content={"result": "result:b"},
+ ),
+ "a": CallToolResult(
+ _meta={"io.modelcontextprotocol/serverInfo": {"name": "parking", "version": ""}},
+ content=[TextContent(text="result:a")],
+ structured_content={"result": "result:a"},
+ ),
}
)
diff --git a/tests/client/test_session_notification_bindings.py b/tests/client/test_session_notification_bindings.py
index 2bed2bd64c..45e09998b8 100644
--- a/tests/client/test_session_notification_bindings.py
+++ b/tests/client/test_session_notification_bindings.py
@@ -7,7 +7,7 @@
import anyio
import mcp_types as types
import pytest
-from mcp_types import EmptyResult, Implementation, ServerCapabilities
+from mcp_types import EmptyResult, ServerCapabilities
from mcp_types.version import LATEST_MODERN_VERSION
from pydantic import BaseModel
@@ -42,7 +42,6 @@ def _adopt_modern(session: ClientSession) -> None:
types.DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
- server_info=Implementation(name="stub", version="0"),
)
)
diff --git a/tests/client/test_session_promotions.py b/tests/client/test_session_promotions.py
index e4a62732b9..6d6b6bc8dc 100644
--- a/tests/client/test_session_promotions.py
+++ b/tests/client/test_session_promotions.py
@@ -64,3 +64,64 @@ async def test_validate_tool_result_raises_on_schema_mismatch() -> None:
# Stable SDK prefix only: the message tail is jsonschema text that shifts with the dependency.
with pytest.raises(RuntimeError, match="Invalid structured content returned by tool t"):
await client.session.validate_tool_result("t", CallToolResult(content=[], structured_content={"x": "no"}))
+
+
+@pytest.mark.anyio
+async def test_validate_tool_result_raises_on_an_unusable_output_schema() -> None:
+ """A schema that isn't valid JSON Schema is reported as such, on every call."""
+ server = _make_server({"type": "not-a-json-schema-type"})
+ async with Client(server) as client:
+ result = CallToolResult(content=[], structured_content={"x": 1})
+ for _ in range(2):
+ # Compiling is never cached on failure, so the second call raises like the first.
+ # Stable SDK prefix only: the message tail is jsonschema text that shifts with the dependency.
+ with pytest.raises(RuntimeError, match="Invalid schema for tool t"):
+ await client.session.validate_tool_result("t", result)
+
+
+@pytest.mark.anyio
+async def test_validate_tool_result_compiles_the_output_schema_once_per_tool() -> None:
+ """Regression guard: compiling dominates validating, so the validator must outlive one call."""
+ server = _make_server({"type": "object", "properties": {"x": {"type": "integer"}}, "required": ["x"]})
+ async with Client(server) as client:
+ result = CallToolResult(content=[], structured_content={"x": 1})
+ await client.session.validate_tool_result("t", result)
+ compiled = client.session._tool_output_validators["t"]
+ await client.session.validate_tool_result("t", result)
+ assert client.session._tool_output_validators["t"] is compiled
+
+
+@pytest.mark.anyio
+async def test_validate_tool_result_keeps_the_validator_across_a_relisting_of_the_same_schema() -> None:
+ """SDK-defined: an unchanged schema on a re-listing (or a re-absorbed cache hit) keeps its
+ compiled validator, so relisting between calls doesn't reintroduce the per-call compile."""
+ server = _make_server({"type": "object", "properties": {"x": {"type": "integer"}}, "required": ["x"]})
+ async with Client(server) as client:
+ result = CallToolResult(content=[], structured_content={"x": 1})
+ await client.session.validate_tool_result("t", result)
+ compiled = client.session._tool_output_validators["t"]
+
+ await client.session.list_tools() # a second listing carrying an equal schema
+ await client.session.validate_tool_result("t", result)
+ assert client.session._tool_output_validators["t"] is compiled
+
+
+@pytest.mark.anyio
+async def test_validate_tool_result_recompiles_when_the_server_changes_the_schema() -> None:
+ """A relisted tool must not be validated against the schema it used to declare."""
+ schemas = [
+ {"type": "object", "properties": {"x": {"type": "integer"}}, "required": ["x"]},
+ {"type": "object", "properties": {"x": {"type": "string"}}, "required": ["x"]},
+ ]
+
+ async def on_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
+ return ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"}, output_schema=schemas.pop(0))])
+
+ server = Server("test-server", on_list_tools=on_list_tools)
+ async with Client(server) as client:
+ integer_result = CallToolResult(content=[], structured_content={"x": 1})
+ await client.session.validate_tool_result("t", integer_result)
+
+ await client.session.list_tools()
+ with pytest.raises(RuntimeError, match="Invalid structured content returned by tool t"):
+ await client.session.validate_tool_result("t", integer_result)
diff --git a/tests/client/test_subscriptions.py b/tests/client/test_subscriptions.py
index 0cc4f133e4..c9877c4ab0 100644
--- a/tests/client/test_subscriptions.py
+++ b/tests/client/test_subscriptions.py
@@ -408,7 +408,6 @@ async def test_listen_on_a_never_entered_session_raises_runtime_error():
types.DiscoverResult(
supported_versions=["2026-07-28"],
capabilities=types.ServerCapabilities(),
- server_info=types.Implementation(name="stub", version="0"),
)
)
with pytest.raises(RuntimeError, match="entered session"):
@@ -596,7 +595,7 @@ async def test_client_listen_installs_the_cache_eviction_barrier_exactly_when_a_
with anyio.fail_after(5):
async with cached_client.listen(tools_list_changed=True) as sub: # pragma: no branch
assert sub._on_event == cached_client._evict_for_listen_event # pyright: ignore[reportPrivateUsage]
- async with Client(_bus_server(bus), cache=False) as uncached_client:
+ async with Client(_bus_server(bus), cache=None) as uncached_client:
with anyio.fail_after(5):
async with uncached_client.listen(tools_list_changed=True) as sub: # pragma: no branch
assert sub._on_event is None # pyright: ignore[reportPrivateUsage]
diff --git a/tests/docs_src/_helpers.py b/tests/docs_src/_helpers.py
new file mode 100644
index 0000000000..0905c6f00a
--- /dev/null
+++ b/tests/docs_src/_helpers.py
@@ -0,0 +1,23 @@
+"""Shared helpers for the docs_src tests."""
+
+from typing import TypeVar
+
+from mcp_types import SERVER_INFO_META_KEY, Result
+
+from mcp.server import Server
+from mcp.server.mcpserver import MCPServer
+
+R = TypeVar("R", bound=Result)
+
+
+def strip_server_info(result: R, server: Server | MCPServer) -> R:
+ """Assert the 2026-era serverInfo stamp, then drop it so snapshots stay focused.
+
+ The doc snippets set no explicit version, so the stamp's version is empty;
+ the fenced outputs in the docs pages leave the stamp out, and the tests
+ mirror the fences.
+ """
+ assert result.meta is not None
+ assert result.meta[SERVER_INFO_META_KEY] == {"name": server.name, "version": ""}
+ result.meta = None
+ return result
diff --git a/tests/docs_src/test_caching.py b/tests/docs_src/test_caching.py
index 2fafde0a1c..751ca86121 100644
--- a/tests/docs_src/test_caching.py
+++ b/tests/docs_src/test_caching.py
@@ -102,9 +102,9 @@ async def test_a_hintless_result_is_not_cached_by_default() -> None:
assert fetches == [None, None]
-async def test_cache_false_makes_every_call_a_round_trip() -> None:
+async def test_cache_none_makes_every_call_a_round_trip() -> None:
server, fetches = _counting_tools_server()
- async with Client(server, cache=False) as client:
+ async with Client(server, cache=None) as client:
await client.list_tools()
await client.list_tools()
assert fetches == [None, None]
diff --git a/tests/docs_src/test_client.py b/tests/docs_src/test_client.py
index 3d70371f53..c8292d989b 100644
--- a/tests/docs_src/test_client.py
+++ b/tests/docs_src/test_client.py
@@ -27,6 +27,7 @@ async def test_every_client_program_on_the_page_runs(capsys: pytest.CaptureFixtu
async def test_connected_properties_are_populated_inside_the_block() -> None:
"""tutorial001: server_info, server_capabilities, protocol_version and instructions are just there."""
async with Client(tutorial001.mcp) as client:
+ assert client.server_info is not None
assert client.server_info.name == "Bookshop"
assert client.protocol_version == "2026-07-28"
assert client.instructions == "Search the catalog before recommending a book."
@@ -38,6 +39,7 @@ async def test_a_client_is_not_reusable_after_the_block_ends() -> None:
"""tutorial001: `async with` is the whole lifecycle. Construct a new Client per connection."""
client = Client(tutorial001.mcp)
async with client:
+ assert client.server_info is not None
assert client.server_info.name == "Bookshop"
with pytest.raises(RuntimeError, match="cannot reenter"):
await client.__aenter__()
diff --git a/tests/docs_src/test_client_transports.py b/tests/docs_src/test_client_transports.py
index 848eddd52e..4da0da1f42 100644
--- a/tests/docs_src/test_client_transports.py
+++ b/tests/docs_src/test_client_transports.py
@@ -22,6 +22,7 @@ async def test_the_in_memory_program_on_the_page_runs(capsys: pytest.CaptureFixt
async def test_in_memory_client_talks_to_the_server_object() -> None:
"""tutorial001: passing the server object connects in-process. No subprocess, no port."""
async with Client(tutorial001.mcp) as client:
+ assert client.server_info is not None
assert client.server_info.name == "Bookshop"
assert client.protocol_version == "2026-07-28"
result = await client.call_tool("search_books", {"query": "dune"})
diff --git a/tests/docs_src/test_index.py b/tests/docs_src/test_index.py
index 3012ae1a08..332184971c 100644
--- a/tests/docs_src/test_index.py
+++ b/tests/docs_src/test_index.py
@@ -6,6 +6,7 @@
from docs_src.index.tutorial001 import mcp
from mcp import Client
+from tests.docs_src._helpers import strip_server_info
# `pyproject.toml` globally downgrades `mcp.MCPDeprecationWarning` to *ignore* because the
# SDK still calls those methods internally. A documentation example must never lean on
@@ -18,6 +19,7 @@
async def test_add_tool() -> None:
async with Client(mcp) as client:
result = await client.call_tool("add", {"a": 1, "b": 2})
+ result = strip_server_info(result, mcp)
assert result == snapshot(
CallToolResult(content=[TextContent(type="text", text="3")], structured_content={"result": 3})
)
diff --git a/tests/docs_src/test_logging.py b/tests/docs_src/test_logging.py
index bed5c234b6..4571983329 100644
--- a/tests/docs_src/test_logging.py
+++ b/tests/docs_src/test_logging.py
@@ -9,6 +9,7 @@
from docs_src.logging import tutorial001
from mcp import Client
from mcp.server import MCPServer
+from tests.docs_src._helpers import strip_server_info
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
@@ -28,6 +29,7 @@ async def test_the_log_line_never_reaches_the_client() -> None:
"""tutorial001: the result is only the return value. Log output is invisible to the model."""
async with Client(tutorial001.mcp) as client:
result = await client.call_tool("search_books", {"query": "dune"})
+ result = strip_server_info(result, tutorial001.mcp)
assert result == snapshot(
CallToolResult(
content=[TextContent(type="text", text="Found 3 books matching 'dune'.")],
diff --git a/tests/docs_src/test_lowlevel.py b/tests/docs_src/test_lowlevel.py
index 34746dd0b3..7d58e941d2 100644
--- a/tests/docs_src/test_lowlevel.py
+++ b/tests/docs_src/test_lowlevel.py
@@ -2,7 +2,15 @@
import pytest
from inline_snapshot import snapshot
-from mcp_types import INTERNAL_ERROR, CallToolRequestParams, CallToolResult, ErrorData, RequestParams, TextContent
+from mcp_types import (
+ INTERNAL_ERROR,
+ SERVER_INFO_META_KEY,
+ CallToolRequestParams,
+ CallToolResult,
+ ErrorData,
+ RequestParams,
+ TextContent,
+)
from docs_src.lowlevel import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005, tutorial006
from mcp import Client, MCPError
@@ -79,8 +87,17 @@ async def test_output_schema_and_structured_content_are_both_yours_to_build() ->
}
)
result = await client.call_tool("search_books", {"query": "dune", "limit": 5})
- assert result.content == [TextContent(type="text", text="Found 3 books matching 'dune'.")]
- assert result.structured_content == {"matches": 3, "query": "dune"}
+ # The page shows this exact payload; tutorial003 pins `version="2.0.0"` so
+ # the identity stamp is deterministic and the fence is proved verbatim.
+ assert result.model_dump(by_alias=True, exclude_none=True) == snapshot(
+ {
+ "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "Bookshop", "version": "2.0.0"}},
+ "content": [{"type": "text", "text": "Found 3 books matching 'dune'."}],
+ "structuredContent": {"matches": 3, "query": "dune"},
+ "isError": False,
+ "resultType": "complete",
+ }
+ )
async def test_the_client_checks_the_schema_you_promised() -> None:
@@ -99,6 +116,10 @@ async def test_meta_reaches_the_client_application() -> None:
"""tutorial004: `_meta=` on the result comes back as `result.meta` and serialises under `_meta`."""
async with Client(tutorial004.server) as client:
result = await client.call_tool("search_books", {"query": "dune", "limit": 5})
+ assert result.meta is not None
+ # The server identity stamp shares `_meta` with the handler's keys without clobbering
+ # them. Remove it before the exact compares: the page's fence leaves the stamp out.
+ del result.meta[SERVER_INFO_META_KEY]
assert result.meta == {"bookshop/record_ids": ["bk_17", "bk_42", "bk_99"]}
assert result.model_dump(by_alias=True, exclude_none=True) == snapshot(
{
diff --git a/tests/docs_src/test_media.py b/tests/docs_src/test_media.py
index 7ea89eb790..7ccb4c8a24 100644
--- a/tests/docs_src/test_media.py
+++ b/tests/docs_src/test_media.py
@@ -93,6 +93,7 @@ def test_raw_data_without_a_format_falls_back_to_a_default_mime_type() -> None:
async def test_icons_are_visible_where_they_were_declared() -> None:
"""tutorial004: server icons land on `server_info`, tool icons on the `Tool`, resource icons on the `Resource`."""
async with Client(tutorial004.mcp) as client:
+ assert client.server_info is not None
assert client.server_info.icons == [
Icon(src="https://example.com/brand-kit.png", mime_type="image/png", sizes=["48x48"])
]
diff --git a/tests/docs_src/test_mrtr.py b/tests/docs_src/test_mrtr.py
index 50a9e53d9d..ddebd80090 100644
--- a/tests/docs_src/test_mrtr.py
+++ b/tests/docs_src/test_mrtr.py
@@ -22,6 +22,7 @@
from mcp import Client, MCPError
from mcp.client import ClientRequestContext
from mcp.server.mcpserver import InvalidRequestState
+from tests.docs_src._helpers import strip_server_info
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
@@ -31,6 +32,7 @@ async def test_first_call_returns_an_input_required_result() -> None:
"""tutorial001: a tool that is missing input returns `InputRequiredResult` instead of calling back."""
async with Client(tutorial001.server) as client:
result = await client.session.call_tool("provision", {"name": "orders"}, allow_input_required=True)
+ result = strip_server_info(result, tutorial001.server)
assert result == snapshot(
InputRequiredResult(
result_type="input_required",
@@ -57,6 +59,7 @@ async def test_the_auto_loop_drives_the_call_to_completion() -> None:
"""tutorial003: register `elicitation_callback`, call the tool, get a plain `CallToolResult` back."""
async with Client(tutorial001.server, elicitation_callback=tutorial003.handle_elicitation) as client:
result = await client.call_tool("provision", {"name": "orders"})
+ result = strip_server_info(result, tutorial001.server)
assert result == snapshot(
CallToolResult(content=[TextContent(type="text", text="Provisioned 'orders' in eu-west-1.")])
)
@@ -80,6 +83,7 @@ async def test_retry_with_input_responses_and_request_state_completes_the_call()
input_responses={"region": ElicitResult(action="accept", content={"region": "eu-west-1"})},
request_state="provision-v1",
)
+ result = strip_server_info(result, tutorial001.server)
assert result == snapshot(
CallToolResult(content=[TextContent(type="text", text="Provisioned 'orders' in eu-west-1.")])
)
@@ -89,6 +93,7 @@ async def test_the_manual_loop_drives_the_call_to_completion() -> None:
"""tutorial002: `client.session.call_tool(..., allow_input_required=True)` for callers who own the loop."""
async with Client(tutorial001.server) as client:
result = await tutorial002.provision(client, "billing")
+ result = strip_server_info(result, tutorial001.server)
assert result == snapshot(
CallToolResult(content=[TextContent(type="text", text="Provisioned 'billing' in eu-west-1.")])
)
@@ -121,6 +126,7 @@ async def test_a_prompt_returns_an_input_required_result_on_the_first_round() ->
returns the `InputRequiredResult` itself."""
async with Client(tutorial004.mcp) as client:
result = await client.session.get_prompt("briefing", allow_input_required=True)
+ result = strip_server_info(result, tutorial004.mcp)
assert result == snapshot(
InputRequiredResult(
result_type="input_required",
@@ -151,6 +157,7 @@ async def test_the_prompt_auto_loop_returns_the_final_messages() -> None:
caller sees only the complete `GetPromptResult`."""
async with Client(tutorial004.mcp, elicitation_callback=_answer_audience) as client:
result = await client.get_prompt("briefing")
+ result = strip_server_info(result, tutorial004.mcp)
assert result == snapshot(
GetPromptResult(
description="Draft a briefing tuned to its audience.",
diff --git a/tests/docs_src/test_oauth_clients.py b/tests/docs_src/test_oauth_clients.py
index 1d9a5998ab..801fbb2ca1 100644
--- a/tests/docs_src/test_oauth_clients.py
+++ b/tests/docs_src/test_oauth_clients.py
@@ -10,11 +10,9 @@
from mcp.client.auth import OAuthClientProvider, OAuthFlowError, OAuthRegistrationError, OAuthTokenError, TokenStorage
from mcp.client.auth.extensions.client_credentials import (
PrivateKeyJWTOAuthProvider,
- RFC7523OAuthClientProvider,
static_assertion_provider,
)
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
-from mcp.shared.exceptions import MCPDeprecationWarning
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
@@ -82,11 +80,11 @@ async def test_client_credentials_provider_builds_its_own_metadata() -> None:
assert metadata.scope == "user"
-async def test_the_three_remaining_keyword_arguments_have_defaults() -> None:
- """The page names `timeout`, `client_metadata_url` and `validate_resource_url` as the remainder."""
+async def test_the_two_remaining_keyword_arguments_have_defaults() -> None:
+ """The page names `client_metadata_url` and `validate_resource_url` as the remainder."""
parameters = inspect.signature(OAuthClientProvider.__init__).parameters
supplied = ["server_url", "client_metadata", "storage", "redirect_handler", "callback_handler"]
- remainder = ["timeout", "client_metadata_url", "validate_resource_url"]
+ remainder = ["client_metadata_url", "validate_resource_url"]
assert list(parameters) == ["self", *supplied, *remainder]
assert all(parameters[name].default is not inspect.Parameter.empty for name in remainder)
@@ -104,16 +102,6 @@ async def test_the_one_more_provider_is_private_key_jwt() -> None:
assert provider.context.client_metadata.token_endpoint_auth_method == "private_key_jwt"
-async def test_the_page_does_not_count_the_deprecated_provider() -> None:
- """Why the `!!! info` says *one* more provider: `RFC7523OAuthClientProvider` warns on construction."""
- with pytest.warns(MCPDeprecationWarning, match="RFC7523OAuthClientProvider is deprecated"):
- RFC7523OAuthClientProvider(
- server_url="http://localhost:8001/mcp",
- client_metadata=tutorial001.oauth.context.client_metadata,
- storage=tutorial001.InMemoryTokenStorage(),
- )
-
-
async def test_every_oauth_error_is_an_oauth_flow_error() -> None:
"""Catch `OAuthFlowError` and you have caught registration and token failures too."""
assert issubclass(OAuthRegistrationError, OAuthFlowError)
diff --git a/tests/docs_src/test_prompts.py b/tests/docs_src/test_prompts.py
index 3b0ad571a0..c375ab6149 100644
--- a/tests/docs_src/test_prompts.py
+++ b/tests/docs_src/test_prompts.py
@@ -8,6 +8,7 @@
from docs_src.prompts import tutorial001, tutorial002, tutorial003
from mcp import Client, MCPError
+from tests.docs_src._helpers import strip_server_info
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
@@ -30,6 +31,7 @@ async def test_returned_string_becomes_one_user_message() -> None:
"""tutorial001: a `str` return value is rendered as a single `user` message."""
async with Client(tutorial001.mcp) as client:
result = await client.get_prompt("review_code", {"code": "def add(a, b): return a + b"})
+ result = strip_server_info(result, tutorial001.mcp)
assert result.model_dump(mode="json", by_alias=True, exclude_none=True) == snapshot(
{
"description": "Review a piece of code.",
diff --git a/tests/docs_src/test_protocol_versions.py b/tests/docs_src/test_protocol_versions.py
index 73366a9840..06b8a3a0c0 100644
--- a/tests/docs_src/test_protocol_versions.py
+++ b/tests/docs_src/test_protocol_versions.py
@@ -3,7 +3,7 @@
import re
import pytest
-from mcp_types import DiscoverResult, Implementation, ServerCapabilities
+from mcp_types import SERVER_INFO_META_KEY, DiscoverResult, Implementation, ServerCapabilities
from docs_src.protocol_versions import tutorial001, tutorial002, tutorial003, tutorial004
from mcp import Client
@@ -16,6 +16,7 @@ async def test_auto_lands_on_the_modern_version() -> None:
"""tutorial001: the default `mode="auto"` probes `server/discover` and adopts the result."""
async with Client(tutorial001.mcp) as client:
assert client.protocol_version == "2026-07-28"
+ assert client.server_info is not None
assert client.server_info.name == "Bookshop"
assert client.session.discover_result is not None
assert client.session.initialize_result is None
@@ -25,18 +26,18 @@ async def test_legacy_forces_the_initialize_handshake() -> None:
"""tutorial002: `mode="legacy"` runs `initialize` against the very same server."""
async with Client(tutorial002.mcp, mode="legacy") as client:
assert client.protocol_version == "2025-11-25"
+ assert client.server_info is not None
assert client.server_info.name == "Bookshop"
assert client.session.initialize_result is not None
assert client.session.discover_result is None
async def test_version_pin_sends_nothing_and_knows_nothing() -> None:
- """tutorial003: a pin adopts the version locally; `server_info` and capabilities are blank."""
+ """tutorial003: a pin adopts the version locally; `server_info` is None and capabilities are blank."""
async with Client(tutorial003.mcp, mode="2026-07-28") as client:
assert client.protocol_version == "2026-07-28"
- assert client.server_info == Implementation(name="", version="")
- # The `!!! check` fence is the literal `print(client.server_info)` output.
- assert str(client.server_info) == "name='' title=None version='' description=None website_url=None icons=None"
+ # The `!!! check` fence is the literal `print(client.server_info)` output: None.
+ assert client.server_info is None
assert client.server_capabilities == ServerCapabilities()
result = await client.call_tool("search_books", {"query": "dune"})
assert result.structured_content == {"result": "Found 3 books matching 'dune'."}
@@ -63,6 +64,7 @@ async def test_prior_discover_round_trips() -> None:
async with Client(tutorial004.mcp, mode="2026-07-28", prior_discover=saved) as client:
assert client.protocol_version == "2026-07-28"
+ assert client.server_info is not None
assert client.server_info.name == "Bookshop"
assert client.server_capabilities.tools is not None
@@ -77,6 +79,7 @@ async def test_discover_result_survives_json() -> None:
assert restored == saved
async with Client(tutorial004.mcp, mode="2026-07-28", prior_discover=restored) as client:
+ assert client.server_info is not None
assert client.server_info.name == "Bookshop"
@@ -85,9 +88,14 @@ async def test_prior_discover_is_ignored_unless_mode_is_a_pin() -> None:
stale = DiscoverResult(
supported_versions=["2026-07-28"],
capabilities=ServerCapabilities(),
- server_info=Implementation(name="Stale", version="0.0.0"),
+ _meta={
+ SERVER_INFO_META_KEY: Implementation(name="Stale", version="0.0.0").model_dump(
+ by_alias=True, mode="json", exclude_none=True
+ )
+ },
)
async with Client(tutorial004.mcp, prior_discover=stale) as client:
+ assert client.server_info is not None
assert client.server_info.name == "Bookshop"
async with Client(tutorial004.mcp, mode="legacy", prior_discover=stale) as client:
assert client.session.discover_result is None
diff --git a/tests/docs_src/test_run.py b/tests/docs_src/test_run.py
index 4b9a8926ad..fb9ab908ed 100644
--- a/tests/docs_src/test_run.py
+++ b/tests/docs_src/test_run.py
@@ -9,6 +9,7 @@
from docs_src.run import tutorial001, tutorial002, tutorial003
from mcp import Client
from mcp.server import MCPServer
+from tests.docs_src._helpers import strip_server_info
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
@@ -18,6 +19,7 @@ async def test_the_run_call_is_guarded_so_importing_does_not_start_a_server() ->
"""tutorial001: `run()` sits under `__main__`, so the module imports cleanly and serves in-memory."""
async with Client(tutorial001.mcp) as client:
result = await client.call_tool("search_books", {"query": "dune"})
+ result = strip_server_info(result, tutorial001.mcp)
assert result == snapshot(
CallToolResult(
content=[TextContent(type="text", text="Found 3 books matching 'dune'.")],
diff --git a/tests/docs_src/test_session_groups.py b/tests/docs_src/test_session_groups.py
index 79721c6138..b8d54db176 100644
--- a/tests/docs_src/test_session_groups.py
+++ b/tests/docs_src/test_session_groups.py
@@ -16,6 +16,12 @@
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
+def _server_info(client: Client) -> Implementation:
+ """Narrow `client.server_info` for `connect_with_session`: these servers all identify themselves."""
+ assert client.server_info is not None
+ return client.server_info
+
+
async def test_both_servers_call_their_tool_search() -> None:
"""tutorial001 + tutorial002: two unrelated servers, one colliding tool name."""
async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web:
@@ -29,7 +35,7 @@ async def test_a_connected_server_is_aggregated_into_the_group() -> None:
"""tutorial003: the group exposes every component of every connected server as a dict."""
async with Client(tutorial001.mcp) as library:
group = ClientSessionGroup()
- await group.connect_with_session(library.server_info, library.session)
+ await group.connect_with_session(_server_info(library), library.session)
assert sorted(group.tools) == ["search"]
assert sorted(group.resources) == ["hours"]
assert group.prompts == {}
@@ -40,9 +46,9 @@ async def test_colliding_names_are_rejected() -> None:
"""tutorial003: without a hook the second `search` raises, and nothing from `Web` is kept."""
async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web:
group = ClientSessionGroup()
- await group.connect_with_session(library.server_info, library.session)
+ await group.connect_with_session(_server_info(library), library.session)
with pytest.raises(MCPError) as exc_info:
- await group.connect_with_session(web.server_info, web.session)
+ await group.connect_with_session(_server_info(web), web.session)
assert str(exc_info.value) == "{'search'} already exist in group tools."
assert exc_info.value.error.code == INVALID_PARAMS
assert sorted(group.tools) == ["search"]
@@ -56,8 +62,8 @@ async def test_component_name_hook_prefixes_every_name() -> None:
"""tutorial004: the hook rewrites every registered name, so both servers coexist."""
async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web:
group = ClientSessionGroup(component_name_hook=tutorial004.by_server)
- await group.connect_with_session(library.server_info, library.session)
- await group.connect_with_session(web.server_info, web.session)
+ await group.connect_with_session(_server_info(library), library.session)
+ await group.connect_with_session(_server_info(web), web.session)
assert sorted(group.tools) == ["Library.search", "Web.search"]
assert sorted(group.resources) == ["Library.hours"]
@@ -71,7 +77,7 @@ async def test_the_key_is_prefixed_but_the_wire_name_is_not() -> None:
"""tutorial004: the dict key is yours; the `Tool` inside keeps the name the server declared."""
async with Client(tutorial002.mcp) as web:
group = ClientSessionGroup(component_name_hook=tutorial004.by_server)
- await group.connect_with_session(web.server_info, web.session)
+ await group.connect_with_session(_server_info(web), web.session)
assert group.tools["Web.search"].name == "search"
@@ -79,8 +85,8 @@ async def test_call_tool_routes_to_the_owning_server() -> None:
"""tutorial004: `group.call_tool` resolves the prefixed name to the session that owns it."""
async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web:
group = ClientSessionGroup(component_name_hook=tutorial004.by_server)
- await group.connect_with_session(library.server_info, library.session)
- await group.connect_with_session(web.server_info, web.session)
+ await group.connect_with_session(_server_info(library), library.session)
+ await group.connect_with_session(_server_info(web), web.session)
web_result = await group.call_tool("Web.search", {"query": "model context protocol"})
assert web_result.structured_content == {"result": "12 pages match 'model context protocol'."}
library_result = await group.call_tool("Library.search", {"query": "dune"})
@@ -91,8 +97,8 @@ async def test_disconnect_removes_every_component_of_that_server() -> None:
"""tutorial004: `disconnect_from_server` takes the session back out of all three dicts."""
async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web:
group = ClientSessionGroup(component_name_hook=tutorial004.by_server)
- await group.connect_with_session(library.server_info, library.session)
- web_session = await group.connect_with_session(web.server_info, web.session)
+ await group.connect_with_session(_server_info(library), library.session)
+ web_session = await group.connect_with_session(_server_info(web), web.session)
await group.disconnect_from_server(web_session)
assert sorted(group.tools) == ["Library.search"]
assert sorted(group.resources) == ["Library.hours"]
diff --git a/tests/docs_src/test_subscriptions.py b/tests/docs_src/test_subscriptions.py
index 7a7b75157b..e2d9bf2c77 100644
--- a/tests/docs_src/test_subscriptions.py
+++ b/tests/docs_src/test_subscriptions.py
@@ -16,11 +16,16 @@
tutorial004_asyncio,
tutorial004_trio,
tutorial005,
+ tutorial006,
)
from mcp import Client
+from mcp.server.auth.middleware.auth_context import auth_context_var
+from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
+from mcp.server.auth.provider import AccessToken
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from mcp.server.subscriptions import SUBSCRIPTION_ID_META_KEY, ListenHandler, ToolsListChanged
+from mcp.shared.exceptions import MCPError
_ReadResource = Callable[
[ServerRequestContext[Any], types.ReadResourceRequestParams], Awaitable[types.ReadResourceResult]
@@ -301,3 +306,30 @@ async def test_the_follower_re_listens_after_the_stream_ends(capsys: pytest.Capt
printed = capsys.readouterr().out
assert "[x] design\n[ ] build" in printed # first stream, after design
assert "[x] design\n[x] build" in printed # second stream, after build
+
+
+def _signed_in_as(subject: str) -> Any:
+ """Stand in for the auth middleware: put this user's token in the auth context."""
+ token = AccessToken(token="demo", client_id="docs-client", scopes=[], subject=subject)
+ return auth_context_var.set(AuthenticatedUser(token))
+
+
+async def test_the_middleware_refuses_a_listen_the_caller_could_not_read() -> None:
+ """tutorial006: one `can_access` gates both `resources/read` and `subscriptions/listen`.
+
+ Alice may read (and so watch) the report, and is refused the payroll file on both
+ paths - the listen refusal is in-band, before any acknowledgment.
+ """
+ reset = _signed_in_as("alice")
+ try:
+ async with Client(tutorial006.mcp, mode="2026-07-28") as client:
+ async with client.listen(resource_subscriptions=["files://report.pdf"]) as sub:
+ assert sub.honored.resource_subscriptions == ["files://report.pdf"]
+ with pytest.raises(MCPError) as listen_error:
+ async with client.listen(resource_subscriptions=["files://report.pdf", "files://payroll.csv"]):
+ pass # pragma: no cover - the refusal precedes the stream
+ assert listen_error.value.error.message == "not permitted to watch the requested resources"
+ with pytest.raises(MCPError):
+ await client.read_resource("files://payroll.csv")
+ finally:
+ auth_context_var.reset(reset)
diff --git a/tests/docs_src/test_testing.py b/tests/docs_src/test_testing.py
index 5ab73e2e94..a6104840fc 100644
--- a/tests/docs_src/test_testing.py
+++ b/tests/docs_src/test_testing.py
@@ -10,6 +10,7 @@
from docs_src.testing.tutorial001 import mcp
from mcp import Client
+from tests.docs_src._helpers import strip_server_info
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
@@ -18,6 +19,7 @@
async def test_call_add_tool() -> None:
async with Client(mcp, raise_exceptions=True) as client:
result = await client.call_tool("add", {"a": 1, "b": 2})
+ result = strip_server_info(result, mcp)
assert result == snapshot(
CallToolResult(content=[TextContent(type="text", text="3")], structured_content={"result": 3})
)
diff --git a/tests/interaction/README.md b/tests/interaction/README.md
index 89be3d3abf..3060a240c1 100644
--- a/tests/interaction/README.md
+++ b/tests/interaction/README.md
@@ -40,7 +40,7 @@ flows — with a single subprocess test for stdio.
```text
tests/interaction/
_requirements.py the requirements manifest (see below)
- _helpers.py shared type aliases + the wire-recording transport
+ _helpers.py the wire-recording transport
_connect.py the transport-parametrized connection factories
conftest.py the connect fixture (the transport matrix)
test_coverage.py enforces the manifest ↔ test contract
diff --git a/tests/interaction/_connect.py b/tests/interaction/_connect.py
index 651dd6a8f9..6a8094e2d6 100644
--- a/tests/interaction/_connect.py
+++ b/tests/interaction/_connect.py
@@ -21,6 +21,7 @@
JSONRPCMessage,
JSONRPCRequest,
JSONRPCResponse,
+ LoggingLevel,
jsonrpc_message_adapter,
)
from mcp_types.version import LATEST_HANDSHAKE_VERSION, MODERN_PROTOCOL_VERSIONS
@@ -68,6 +69,7 @@ def __call__(
sampling_callback: SamplingFnT | None = None,
list_roots_callback: ListRootsFnT | None = None,
logging_callback: LoggingFnT | None = None,
+ log_level: LoggingLevel | None = None,
message_handler: MessageHandlerFnT | None = None,
client_info: Implementation | None = None,
elicitation_callback: ElicitationFnT | None = None,
@@ -84,6 +86,7 @@ async def connect_in_memory(
sampling_callback: SamplingFnT | None = None,
list_roots_callback: ListRootsFnT | None = None,
logging_callback: LoggingFnT | None = None,
+ log_level: LoggingLevel | None = None,
message_handler: MessageHandlerFnT | None = None,
client_info: Implementation | None = None,
elicitation_callback: ElicitationFnT | None = None,
@@ -103,6 +106,7 @@ async def connect_in_memory(
sampling_callback=sampling_callback,
list_roots_callback=list_roots_callback,
logging_callback=logging_callback,
+ log_level=log_level,
message_handler=message_handler,
client_info=client_info,
elicitation_callback=elicitation_callback,
@@ -123,6 +127,7 @@ async def connect_over_streamable_http(
sampling_callback: SamplingFnT | None = None,
list_roots_callback: ListRootsFnT | None = None,
logging_callback: LoggingFnT | None = None,
+ log_level: LoggingLevel | None = None,
message_handler: MessageHandlerFnT | None = None,
client_info: Implementation | None = None,
elicitation_callback: ElicitationFnT | None = None,
@@ -158,6 +163,7 @@ async def connect_over_streamable_http(
sampling_callback=sampling_callback,
list_roots_callback=list_roots_callback,
logging_callback=logging_callback,
+ log_level=log_level,
message_handler=message_handler,
client_info=client_info,
elicitation_callback=elicitation_callback,
@@ -232,6 +238,7 @@ async def client_via_http(
http_client: httpx2.AsyncClient,
*,
logging_callback: LoggingFnT | None = None,
+ log_level: LoggingLevel | None = None,
message_handler: MessageHandlerFnT | None = None,
elicitation_callback: ElicitationFnT | None = None,
) -> AsyncIterator[Client]:
@@ -248,6 +255,7 @@ async def client_via_http(
# closing DELETE); the modern flow is sessionless and would silently change the subject.
mode="legacy",
logging_callback=logging_callback,
+ log_level=log_level,
message_handler=message_handler,
elicitation_callback=elicitation_callback,
) as client:
@@ -360,6 +368,7 @@ async def connect_over_sse(
sampling_callback: SamplingFnT | None = None,
list_roots_callback: ListRootsFnT | None = None,
logging_callback: LoggingFnT | None = None,
+ log_level: LoggingLevel | None = None,
message_handler: MessageHandlerFnT | None = None,
client_info: Implementation | None = None,
elicitation_callback: ElicitationFnT | None = None,
@@ -394,6 +403,7 @@ def httpx_client_factory(
sampling_callback=sampling_callback,
list_roots_callback=list_roots_callback,
logging_callback=logging_callback,
+ log_level=log_level,
message_handler=message_handler,
client_info=client_info,
elicitation_callback=elicitation_callback,
diff --git a/tests/interaction/_helpers.py b/tests/interaction/_helpers.py
index 0641aeab97..b335def7d0 100644
--- a/tests/interaction/_helpers.py
+++ b/tests/interaction/_helpers.py
@@ -1,28 +1,16 @@
"""Shared helpers for the interaction suite.
-Keep this module small: it exists only for (a) types that every test would otherwise have to
-assemble from the SDK's internals to annotate a client callback, and (b) the recording transport
-used by the wire-level tests. Server fixtures and assertion helpers belong in the test that uses
-them.
+Keep this module small: it exists only for the recording transport used by the wire-level
+tests. Server fixtures and assertion helpers belong in the test that uses them.
"""
from types import TracebackType
import anyio
-from mcp_types import ClientResult, ServerNotification, ServerRequest
from typing_extensions import Self
from mcp.client._transport import ReadStream, Transport, TransportStreams, WriteStream
from mcp.shared.message import SessionMessage
-from mcp.shared.session import RequestResponder
-
-# TODO: this union is the parameter type of every client message handler (MessageHandlerFnT),
-# but the SDK does not export a name for it -- writing a correctly-typed handler requires
-# importing RequestResponder from mcp.shared.session and assembling the union by hand. It
-# should be a named, exported alias next to MessageHandlerFnT (like ClientRequestContext is
-# for the request callbacks), at which point this alias can be deleted.
-IncomingMessage = RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception
-"""Everything a client message handler can receive."""
class _RecordingReadStream:
diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py
index 752c17aa4f..964a1829d2 100644
--- a/tests/interaction/_requirements.py
+++ b/tests/interaction/_requirements.py
@@ -354,8 +354,8 @@ def __post_init__(self) -> None:
"lifecycle:stateless:request-envelope": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/lifecycle#stateless-operation",
behavior=(
- "At protocol_version 2026-07-28, every request carries io.modelcontextprotocol/protocolVersion, "
- "/clientInfo, and /clientCapabilities in params._meta; no initialize handshake occurs."
+ "At protocol_version 2026-07-28, every request carries io.modelcontextprotocol/protocolVersion "
+ "and /clientCapabilities in params._meta (/clientInfo is optional); no initialize handshake occurs."
),
added_in="2026-07-28",
),
@@ -408,7 +408,8 @@ def __post_init__(self) -> None:
source=f"{SPEC_2026_BASE_URL}/basic/lifecycle#discover",
behavior=(
"Calling discover() sends server/discover with no params and returns a typed DiscoverResult "
- "carrying protocolVersion, capabilities, serverInfo and the cache hint fields."
+ "carrying supportedVersions, capabilities and the cache hint fields; the server's identity "
+ "travels as the io.modelcontextprotocol/serverInfo stamp in the result _meta."
),
added_in="2026-07-28",
),
@@ -536,14 +537,16 @@ def __post_init__(self) -> None:
source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#behavior-requirements",
behavior=(
"A cancellation notification for an in-flight request stops the server-side handler, and the "
- "receiver does not send a response for the cancelled request."
+ "receiver does not send a response for the cancelled request - no result and no error."
),
divergence=Divergence(
note=(
- "The spec says receivers of a cancellation SHOULD NOT send a response for the cancelled "
- "request; both seats send an error response (code 0, 'Request cancelled') instead — the "
- "server for cancelled client requests, and the client for cancelled server-initiated "
- "requests — which is what unblocks the sender's pending call."
+ "The 2025-era streamable HTTP transport still answers a cancelled request, with a "
+ "REQUEST_CANCELLED (-32800) error - deliberate and era-scoped: that wire ends a request's "
+ "stream only with a response for its id, so silence would leave the POST (and any "
+ "resuming client's replay) open. Every other transport sends nothing, and the "
+ "2026-07-28 MUST NOT applies only there. Retires with the legacy transport; see "
+ "transport:streamable-http:cancelled-request-terminated."
),
),
arm_exclusions=(
@@ -657,7 +660,7 @@ def __post_init__(self) -> None:
"The dispatcher drops null-id error responses with a debug log; in v1, JSONRPCError.id was "
"non-nullable, so a null-id error response failed transport validation and the resulting "
"ValidationError was surfaced to message_handler as an exception. A typed fault channel "
- "restoring visibility is planned before v2 stable."
+ "restoring visibility is planned."
),
),
deferred=(
@@ -1519,6 +1522,7 @@ def __post_init__(self) -> None:
),
),
removed_in="2026-07-28",
+ superseded_by="logging:per-request:threshold",
note=(
"removed in 2026-07-28 (SEP-2575); logging/setLevel removed, replaced by per-request "
"io.modelcontextprotocol/logLevel in _meta."
@@ -1528,6 +1532,7 @@ def __post_init__(self) -> None:
source=f"{SPEC_BASE_URL}/server/utilities/logging#setting-log-level",
behavior="logging/setLevel delivers the requested level to the server's handler and returns an empty result.",
removed_in="2026-07-28",
+ superseded_by="logging:per-request:opt-in",
note=(
"removed in 2026-07-28 (SEP-2575); logging/setLevel removed, replaced by per-request "
"io.modelcontextprotocol/logLevel in _meta."
@@ -1537,11 +1542,40 @@ def __post_init__(self) -> None:
source=f"{SPEC_BASE_URL}/server/utilities/logging#error-handling",
behavior="logging/setLevel with an invalid level value returns JSON-RPC error -32602 (Invalid params).",
removed_in="2026-07-28",
+ superseded_by="logging:per-request:invalid-level",
note=(
"removed in 2026-07-28 (SEP-2575); logging/setLevel removed, replaced by per-request "
"io.modelcontextprotocol/logLevel in _meta."
),
),
+ "logging:per-request:opt-in": Requirement(
+ source=f"{SPEC_2026_BASE_URL}/server/utilities/logging#per-request-log-level",
+ behavior=(
+ "The server does not send log message notifications for a request unless the request opts in by "
+ "carrying io.modelcontextprotocol/logLevel in _meta; a handler's log calls on an un-opted "
+ "request are dropped, not delivered on another stream."
+ ),
+ added_in="2026-07-28",
+ supersedes=("logging:set-level",),
+ ),
+ "logging:per-request:threshold": Requirement(
+ source=f"{SPEC_2026_BASE_URL}/server/utilities/logging#per-request-log-level",
+ behavior=(
+ "A request that opts in receives log message notifications only at or above the level named in "
+ "its io.modelcontextprotocol/logLevel; entries below the level are dropped."
+ ),
+ added_in="2026-07-28",
+ supersedes=("logging:message:filtered",),
+ ),
+ "logging:per-request:invalid-level": Requirement(
+ source=f"{SPEC_2026_BASE_URL}/server/utilities/logging#error-handling",
+ behavior=(
+ "A request whose io.modelcontextprotocol/logLevel is not a recognized log level is rejected with "
+ "JSON-RPC error -32602 (Invalid params)."
+ ),
+ added_in="2026-07-28",
+ supersedes=("logging:set-level:invalid-level",),
+ ),
# ═══════════════════════════════════════════════════════════════════════════
# Sampling (server → client)
# ═══════════════════════════════════════════════════════════════════════════
@@ -2558,6 +2592,32 @@ def __post_init__(self) -> None:
transports=("streamable-http",),
note="Only observable over streamable HTTP: JSON-response mode is an HTTP framing option.",
),
+ "transport:streamable-http:cancelled-request-terminated": Requirement(
+ source="sdk",
+ behavior=(
+ "A request cancelled through notifications/cancelled is terminated with a REQUEST_CANCELLED "
+ "(-32800) error response, completing its POST - the JSON body in JSON-response mode, the "
+ "final event of its stream in SSE mode."
+ ),
+ transports=("streamable-http",),
+ note=(
+ "An SDK choice, not spec-mandated (the spec-side gap is the Divergence on "
+ "protocol:cancel:in-flight): this era's wire ends a request's stream only with a response "
+ "for its id, and stores it so a resuming client's replay terminates too. The terminator is "
+ "written through the same ordered channel as the request's other messages, so it cannot "
+ "overtake anything already queued for the request."
+ ),
+ ),
+ "transport:streamable-http:json-response-restrictions": Requirement(
+ source="sdk",
+ behavior=(
+ "In JSON-response mode a handler's request-scoped server-initiated request fails fast with an "
+ "INVALID_REQUEST protocol error and request-scoped notifications are not delivered, because the "
+ "single JSON body carries only the response; the connection's standalone stream is unaffected."
+ ),
+ transports=("streamable-http",),
+ note="Only observable over streamable HTTP: JSON-response mode is an HTTP framing option.",
+ ),
"transport:streamable-http:stateless": Requirement(
source=f"{SPEC_BASE_URL}/basic/transports#streamable-http",
behavior=(
@@ -2939,6 +2999,17 @@ def __post_init__(self) -> None:
transports=("streamable-http",),
note="Auth is enforced at the HTTP layer; Cache-Control is an HTTP header.",
),
+ "hosting:auth:as:register-echo": Requirement(
+ source="sdk",
+ behavior=(
+ "The bundled registration endpoint returns all registered metadata about the client "
+ "in its 201 response (RFC 7591 §3.2.1) - the client's `application_type` rather than a "
+ "substituted default, and `client_secret_expires_at` (0 when the secret never expires) "
+ "whenever a `client_secret` is issued."
+ ),
+ transports=("streamable-http",),
+ note="Auth is enforced at the HTTP layer; the bundled AS is an ASGI app.",
+ ),
"hosting:auth:as:register-error-response": Requirement(
source="sdk",
behavior=(
@@ -3261,8 +3332,10 @@ def __post_init__(self) -> None:
"hosting:http:modern:discover-response-shape": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/index",
behavior=(
- "A 2026-07-28 server/discover response carries supportedVersions, capabilities, and "
- "serverInfo, with supportedVersions naming the modern protocol revisions the server accepts."
+ "A 2026-07-28 server/discover response carries supportedVersions and capabilities in the "
+ "result body, with supportedVersions naming the modern protocol revisions the server "
+ "accepts; serverInfo is not a body field and travels as the io.modelcontextprotocol/serverInfo "
+ "result _meta stamp."
),
added_in="2026-07-28",
transports=("streamable-http",),
@@ -3657,6 +3730,19 @@ def __post_init__(self) -> None:
transports=("streamable-http",),
note="OAuth is HTTP-only.",
),
+ "client-auth:dcr:substituted-metadata": Requirement(
+ source="sdk",
+ behavior=(
+ "A 201 registration response whose echoed metadata the server substituted (RFC 7591 §3.2.1) - "
+ "an unregistered application_type, null redirect_uris, extra grant types - completes the flow; "
+ "substituted credentials the authorization-code flow cannot apply (an unimplemented "
+ "token_endpoint_auth_method; private_key_jwt, whose assertion it has no key to sign; or a "
+ "secret-based method with no client_secret issued) are instead reported as an "
+ "OAuthRegistrationError before the record is persisted or authorization begins."
+ ),
+ transports=("streamable-http",),
+ note="OAuth is HTTP-only.",
+ ),
"client-auth:dcr": Requirement(
source=f"{SPEC_BASE_URL}/basic/authorization#dynamic-client-registration",
behavior=(
@@ -3916,9 +4002,12 @@ def __post_init__(self) -> None:
note="Only observable over stdio: stdin/stdout purity is stdio-specific.",
divergence=Divergence(
note=(
- "stdio_server's own writes satisfy this, but it does not redirect or guard sys.stdout: "
- "handler code that calls print() writes directly to the protocol stream and corrupts the "
- "framing. The spec MUST is satisfied only as long as application code behaves."
+ "While serving, stdio_server moves the wire to private descriptors and diverts fd 0/1, so "
+ "handler code and its child processes can neither read protocol bytes nor write into the "
+ "stream (pinned by tests/server/test_stdio.py). Remaining gaps: output flushed to stdout "
+ "before the transport enters can still precede the first frame, and the claim is "
+ "best-effort - skipped for explicitly injected streams and for processes without "
+ "normal standard descriptors."
),
),
),
diff --git a/tests/interaction/auth/test_as_handlers.py b/tests/interaction/auth/test_as_handlers.py
index f59478b49a..1876cd7181 100644
--- a/tests/interaction/auth/test_as_handlers.py
+++ b/tests/interaction/auth/test_as_handlers.py
@@ -239,10 +239,42 @@ async def test_registration_with_invalid_metadata_is_rejected_with_400(
bad_scope = await http.post("/register", json=body | {"scope": "forbidden"})
assert bad_scope.status_code == 400
- body = bad_scope.json()
- assert body["error"] == "invalid_client_metadata"
+ bad_scope_body = bad_scope.json()
+ assert bad_scope_body["error"] == "invalid_client_metadata"
# The description embeds a set difference whose ordering is not stable, so assert the prefix.
- assert body["error_description"].startswith("Requested scopes are not valid: ")
+ assert bad_scope_body["error_description"].startswith("Requested scopes are not valid: ")
+
+ # The server holds no client key to verify a private_key_jwt assertion, so it refuses to
+ # confirm a registration whose every token request it would then reject (RFC 7591 §3.2.2).
+ unsignable = await http.post("/register", json=body | {"token_endpoint_auth_method": "private_key_jwt"})
+ assert unsignable.status_code == 400
+ assert unsignable.json() == snapshot(
+ {
+ "error": "invalid_client_metadata",
+ "error_description": "token_endpoint_auth_method 'private_key_jwt' is not supported",
+ }
+ )
+
+
+@requirement("hosting:auth:as:register-echo")
+@pytest.mark.parametrize("application_type", ["web", "native"])
+async def test_registration_response_echoes_the_registered_application_type(
+ as_app: tuple[httpx2.AsyncClient, InMemoryAuthorizationServerProvider],
+ application_type: str,
+) -> None:
+ """The 201 body reflects the application_type the client registered (RFC 7591 §3.2.1)."""
+ http, _ = as_app
+ body = oauth_client_metadata().model_dump(mode="json", exclude_none=True)
+
+ response = await http.post("/register", json=body | {"application_type": application_type})
+
+ assert response.status_code == 201
+ echoed = response.json()
+ assert echoed["application_type"] == application_type
+ # A secret was issued and no expiry is configured, so RFC 7591 §3.2.1 requires the
+ # response to carry client_secret_expires_at, with 0 (present, not omitted) for "never".
+ assert echoed["client_secret"]
+ assert echoed["client_secret_expires_at"] == 0
@requirement("hosting:auth:as:redirect-uri-binding")
diff --git a/tests/interaction/auth/test_discovery.py b/tests/interaction/auth/test_discovery.py
index dc9f3794af..28685bf8cd 100644
--- a/tests/interaction/auth/test_discovery.py
+++ b/tests/interaction/auth/test_discovery.py
@@ -17,14 +17,16 @@
import pytest
from inline_snapshot import snapshot
from mcp_types import ListToolsResult, Tool
-from pydantic import AnyHttpUrl
+from pydantic import AnyHttpUrl, AnyUrl
from mcp.client.auth import OAuthFlowError, OAuthRegistrationError
from mcp.server import Server, ServerRequestContext
-from mcp.shared.auth import OAuthMetadata, ProtectedResourceMetadata
+from mcp.shared.auth import OAuthClientInformationFull, OAuthMetadata, ProtectedResourceMetadata
from tests.interaction._connect import BASE_URL, mounted_app
from tests.interaction._requirements import requirement
from tests.interaction.auth._harness import (
+ REDIRECT_URI,
+ InMemoryTokenStorage,
RecordedRequest,
auth_settings,
connect_with_oauth,
@@ -174,6 +176,91 @@ async def test_a_400_from_the_registration_endpoint_surfaces_as_a_registration_e
assert [r.path for r in recorded if r.path in ("/authorize", "/token")] == []
+@requirement("client-auth:dcr:substituted-metadata")
+async def test_a_registration_response_with_substituted_metadata_completes_the_flow() -> None:
+ """A 201 whose echoed metadata differs from the request still yields a working client.
+
+ The shim replaces the real `/register` with a body that echoes an `application_type`
+ outside OIDC Registration's set, a null `redirect_uris`, and an extra grant type - a
+ substitution RFC 7591 §3.2.1 permits. The registration proceeds and, after `/register`
+ stopped answering, the client authorizes and exchanges a token as normal.
+ """
+ recorded, on_request = record_requests()
+ provider = InMemoryAuthorizationServerProvider()
+ server = Server("guarded", on_list_tools=list_tools)
+ client_id = "substituted-client"
+ provider.clients[client_id] = OAuthClientInformationFull(
+ client_id=client_id,
+ client_secret="s3cr3t",
+ redirect_uris=[AnyUrl(REDIRECT_URI)],
+ token_endpoint_auth_method="client_secret_post",
+ scope="mcp",
+ )
+ body = json.dumps(
+ {
+ "client_id": client_id,
+ "client_secret": "s3cr3t",
+ "token_endpoint_auth_method": "client_secret_post",
+ "application_type": "confidential",
+ "redirect_uris": None,
+ "grant_types": ["authorization_code", "refresh_token", "client_credentials"],
+ }
+ ).encode()
+ app_shim = shim(serve={"/register": (201, body)})
+ storage = InMemoryTokenStorage()
+
+ with anyio.fail_after(5):
+ async with connect_with_oauth(
+ server, provider=provider, storage=storage, app_shim=app_shim, on_request=on_request
+ ) as (client, _):
+ result = await client.list_tools()
+
+ assert result.tools[0].name == snapshot("probe")
+ assert storage.client_info is not None
+ assert storage.client_info.client_id == client_id
+ assert storage.client_info.application_type == "confidential"
+ assert [r.path for r in recorded].index("/register") < [r.path for r in recorded].index("/token")
+
+
+@requirement("client-auth:dcr:substituted-metadata")
+@pytest.mark.parametrize(
+ "credentials",
+ [
+ pytest.param(
+ {"client_secret": "s3cr3t", "token_endpoint_auth_method": "client_secret_jwt"}, id="unimplemented"
+ ),
+ pytest.param({"client_secret": "s3cr3t", "token_endpoint_auth_method": "private_key_jwt"}, id="unsignable"),
+ pytest.param({"token_endpoint_auth_method": "client_secret_post"}, id="secret-not-issued"),
+ ],
+)
+async def test_a_registration_assigning_unusable_credentials_surfaces_as_a_registration_error(
+ credentials: dict[str, str],
+) -> None:
+ """A 201 assigning credentials this flow cannot apply is a registration error.
+
+ RFC 7591 §3.2.1 leaves it to the client to judge whether a substituted value makes the
+ registration usable. The authorization-code flow authenticates with the minted secret, so
+ an unimplemented method, `private_key_jwt` (an assertion it has no key to sign), and a
+ secret-based method with no secret issued are all unusable; each is reported before
+ the record is stored or any authorize/token request is made.
+ """
+ recorded, on_request = record_requests()
+ provider = InMemoryAuthorizationServerProvider()
+ server = Server("guarded", on_list_tools=list_tools)
+ body = json.dumps({"client_id": "unusable", **credentials}).encode()
+ app_shim = shim(serve={"/register": (201, body)})
+ storage = InMemoryTokenStorage()
+
+ with anyio.fail_after(5):
+ with pytest.RaisesGroup(pytest.RaisesExc(OAuthRegistrationError), flatten_subgroups=True):
+ await connect_with_oauth(
+ server, provider=provider, storage=storage, app_shim=app_shim, on_request=on_request
+ ).__aenter__()
+
+ assert storage.client_info is None
+ assert [r.path for r in recorded if r.path in ("/authorize", "/token")] == []
+
+
@requirement("client-auth:prm-resource-mismatch")
async def test_prm_with_a_mismatched_resource_aborts_the_flow_before_authorize() -> None:
"""A PRM document whose `resource` does not cover the server URL aborts the flow.
diff --git a/tests/interaction/auth/test_lifecycle.py b/tests/interaction/auth/test_lifecycle.py
index c810f8c449..8f45a01510 100644
--- a/tests/interaction/auth/test_lifecycle.py
+++ b/tests/interaction/auth/test_lifecycle.py
@@ -372,7 +372,7 @@ async def test_client_credentials_provider_obtains_a_token_without_an_authorize_
storage=InMemoryTokenStorage(),
client_id="m2m-client",
client_secret="m2m-secret",
- scopes="mcp",
+ scope="mcp",
)
with anyio.fail_after(5):
@@ -423,7 +423,7 @@ async def assertion_provider(audience: str) -> str:
storage=InMemoryTokenStorage(),
client_id="m2m-jwt-client",
assertion_provider=assertion_provider,
- scopes="mcp",
+ scope="mcp",
)
with anyio.fail_after(5):
diff --git a/tests/interaction/conftest.py b/tests/interaction/conftest.py
index b918daf008..1e0e879bbb 100644
--- a/tests/interaction/conftest.py
+++ b/tests/interaction/conftest.py
@@ -6,10 +6,18 @@
bounds, and known-failure xfails declaratively.
"""
-from functools import partial
+from contextlib import AbstractAsyncContextManager
+from typing import Any
import pytest
+from mcp_types import SERVER_INFO_META_KEY
+from mcp_types.version import MODERN_PROTOCOL_VERSIONS
+from mcp.client.client import Client
+from mcp.server import Server
+from mcp.server.mcpserver import MCPServer
+from tests._stamp import R, Unstamp
+from tests._stamp import unstamped as _strip_required_stamp
from tests.interaction._connect import (
Connect,
connect_in_memory,
@@ -35,8 +43,23 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
metafunc.parametrize("connect", compute_cells(requirements), indirect=True)
+class CellConnect:
+ """The cell's connection factory, also naming the cell's `spec_version`.
+
+ Callable exactly like the `Connect` factories it wraps; the attribute lets
+ sibling fixtures (`unstamped`) key on the cell's era without re-deriving it.
+ """
+
+ def __init__(self, factory: Connect, spec_version: str) -> None:
+ self._factory = factory
+ self.spec_version = spec_version
+
+ def __call__(self, server: Server | MCPServer, **kwargs: Any) -> AbstractAsyncContextManager[Client]:
+ return self._factory(server, spec_version=self.spec_version, **kwargs)
+
+
@pytest.fixture
-def connect(request: pytest.FixtureRequest) -> Connect:
+def connect(request: pytest.FixtureRequest) -> CellConnect:
"""The transport-parametrized connection factory: a test using it runs once per matrix cell.
Tests that are tied to one transport (the wire-recording tests, the bare-ClientSession tests,
@@ -45,4 +68,24 @@ def connect(request: pytest.FixtureRequest) -> Connect:
transport, spec_version = request.param
assert isinstance(transport, str)
assert isinstance(spec_version, str)
- return partial(_FACTORIES[transport], spec_version=spec_version)
+ return CellConnect(_FACTORIES[transport], spec_version)
+
+
+@pytest.fixture
+def unstamped(connect: CellConnect) -> Unstamp:
+ """The cell's era-aware serverInfo-stamp normalizer, for full-result comparisons.
+
+ On a modern cell the stamp MUST be present (asserted) and is stripped so
+ one expected payload stays valid across eras; on a handshake-era cell the
+ result must not be stamped at all. Either direction failing is a runner
+ regression, so the same comparison line enforces both.
+ """
+ if connect.spec_version in MODERN_PROTOCOL_VERSIONS:
+ return _strip_required_stamp
+
+ def _assert_never_stamped(result: R) -> R:
+ meta = result.meta
+ assert meta is None or SERVER_INFO_META_KEY not in meta, "handshake-era results are never stamped"
+ return result
+
+ return _assert_never_stamped
diff --git a/tests/interaction/lowlevel/test_cancellation.py b/tests/interaction/lowlevel/test_cancellation.py
index 0e9d81afbc..74c32e304b 100644
--- a/tests/interaction/lowlevel/test_cancellation.py
+++ b/tests/interaction/lowlevel/test_cancellation.py
@@ -28,31 +28,51 @@
Tool,
)
-from mcp import MCPError
-from mcp.client import ClientRequestContext, ClientSession
+from mcp import Client, MCPError
+from mcp.client import ClientRequestContext, ClientSession, IncomingMessage
from mcp.server import Server, ServerRequestContext
+from mcp.server.streamable_http import REQUEST_CANCELLED
from mcp.shared.memory import MessageStream, create_client_server_memory_streams
from mcp.shared.message import SessionMessage
+from tests._stamp import Unstamp
from tests.interaction._connect import Connect
-from tests.interaction._helpers import IncomingMessage
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
+_LEGACY_HTTP_TERMINATOR = ErrorData(code=REQUEST_CANCELLED, message="Request cancelled")
+"""The one wire where a cancelled request is still answered: the 2025-era streamable HTTP
+transport ends a request only with a response, so it terminates the settled request with
+`REQUEST_CANCELLED`. Every other transport sends nothing at all."""
+
+
+async def _await_doomed_call(client: Client, outcomes: list[object]) -> None:
+ """Await the doomed `block` call and record whatever, if anything, the caller receives.
+
+ On the stream transports nothing ever arrives, so this parks until the task is abandoned;
+ over legacy streamable HTTP the transport's terminator arrives as an MCPError.
+ """
+ try:
+ outcomes.append(await client.call_tool("block", {}))
+ except MCPError as exc:
+ outcomes.append(exc.error)
+
@requirement("protocol:cancel:in-flight")
@requirement("protocol:cancel:handler-abort-propagates")
async def test_cancellation_stops_in_flight_handler(connect: Connect) -> None:
- """Cancelling an in-flight request interrupts its handler and fails the pending call.
+ """Cancelling an in-flight request interrupts its handler, and the server sends no response for it.
- The server answers the cancelled request with an error response (the spec says it should
- not respond at all; see the divergence note on the requirement), so the caller's pending
- request raises rather than hanging.
+ The cancellation is scripted by hand while a sibling task still awaits the call, which is
+ something a well-behaved sender never does (per spec it stops waiting once it cancels). That
+ lets the test prove the negative: after the handler is interrupted and the connection has
+ quiesced, no server response has reached the still-parked call - except the legacy
+ streamable HTTP terminator (`_LEGACY_HTTP_TERMINATOR`).
"""
started = anyio.Event()
handler_cancelled = anyio.Event()
request_ids: list[types.RequestId] = []
- errors: list[ErrorData] = []
+ outcomes: list[object] = []
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "block"
@@ -70,30 +90,26 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
async with connect(server) as client:
with anyio.fail_after(5):
- async with anyio.create_task_group() as task_group:
-
- async def call_and_capture_error() -> None:
- with pytest.raises(MCPError) as exc_info:
- await client.call_tool("block", {})
- errors.append(exc_info.value.error)
-
- task_group.start_soon(call_and_capture_error)
+ async with anyio.create_task_group() as task_group: # pragma: no branch
+ task_group.start_soon(_await_doomed_call, client, outcomes)
await started.wait()
await client.session.send_notification(
types.CancelledNotification(
params=types.CancelledNotificationParams(request_id=request_ids[0], reason="user aborted")
)
)
-
- await handler_cancelled.wait()
-
- assert errors == snapshot([ErrorData(code=0, message="Request cancelled")])
+ await handler_cancelled.wait()
+ # Let anything the server was going to send be delivered before checking.
+ await anyio.wait_all_tasks_blocked()
+ assert outcomes in ([], [_LEGACY_HTTP_TERMINATOR])
+ task_group.cancel_scope.cancel() # abandon the call if it is still parked
@requirement("protocol:cancel:server-survives")
async def test_session_serves_requests_after_cancellation(connect: Connect) -> None:
"""A request cancelled mid-flight does not poison the session: the next request succeeds."""
started = anyio.Event()
+ handler_cancelled = anyio.Event()
request_ids: list[types.RequestId] = []
async def list_tools(
@@ -112,7 +128,11 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
assert ctx.request_id is not None
request_ids.append(ctx.request_id)
started.set()
- await anyio.Event().wait() # blocks until cancelled
+ try:
+ await anyio.Event().wait() # blocks until cancelled
+ except anyio.get_cancelled_exc_class():
+ handler_cancelled.set()
+ raise
raise NotImplementedError # unreachable
server = Server("blocker", on_list_tools=list_tools, on_call_tool=call_tool)
@@ -120,16 +140,13 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
async with connect(server) as client:
with anyio.fail_after(5):
async with anyio.create_task_group() as task_group:
-
- async def call_and_swallow_cancellation_error() -> None:
- with pytest.raises(MCPError):
- await client.call_tool("block", {})
-
- task_group.start_soon(call_and_swallow_cancellation_error)
+ task_group.start_soon(_await_doomed_call, client, list[object]())
await started.wait()
await client.session.send_notification(
types.CancelledNotification(params=types.CancelledNotificationParams(request_id=request_ids[0]))
)
+ await handler_cancelled.wait()
+ task_group.cancel_scope.cancel() # abandon the parked call
result = await client.call_tool("echo", {})
@@ -137,7 +154,7 @@ async def call_and_swallow_cancellation_error() -> None:
@requirement("protocol:cancel:unknown-id-ignored")
-async def test_cancellation_for_unknown_request_is_ignored(connect: Connect) -> None:
+async def test_cancellation_for_unknown_request_is_ignored(connect: Connect, unstamped: Unstamp) -> None:
"""A cancellation referencing a request id that is not in flight is ignored without error."""
async def list_tools(
@@ -157,7 +174,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
)
result = await client.call_tool("echo", {})
- assert result == snapshot(CallToolResult(content=[TextContent(text="unbothered")]))
+ assert unstamped(result) == snapshot(CallToolResult(content=[TextContent(text="unbothered")]))
@requirement("protocol:cancel:server-to-client")
@@ -350,7 +367,7 @@ async def scripted_server(streams: MessageStream) -> None:
@requirement("protocol:cancel:abort-signal")
-async def test_abandoning_a_call_stops_the_server_handler(connect: Connect) -> None:
+async def test_abandoning_a_call_stops_the_server_handler(connect: Connect, unstamped: Unstamp) -> None:
"""Cancelling the task that awaits a call cancels the request itself, not just the local wait:
the server-side handler is interrupted, and the session serves later requests normally.
@@ -393,15 +410,16 @@ async def call_and_abandon() -> None:
with anyio.fail_after(5):
await handler_cancelled.wait()
- # Let the abandoned call's late error response (sent on the legacy arms) arrive and be
- # dropped while the client is still open, so teardown never races its delivery.
+ # Let anything still owed the abandoned call (the REQUEST_CANCELLED terminator over
+ # legacy streamable HTTP; nothing elsewhere) arrive and be dropped while the client is
+ # still open, so teardown never races its delivery.
await anyio.wait_all_tasks_blocked()
result = await client.call_tool("echo", {})
- assert result == snapshot(CallToolResult(content=[TextContent(text="ok")]))
+ assert unstamped(result) == snapshot(CallToolResult(content=[TextContent(text="ok")]))
@requirement("protocol:cancel:abort-scoped")
-async def test_abandoning_one_call_leaves_a_concurrent_call_running(connect: Connect) -> None:
+async def test_abandoning_one_call_leaves_a_concurrent_call_running(connect: Connect, unstamped: Unstamp) -> None:
"""Cancellation is scoped to the request it names: with two calls genuinely in flight,
abandoning the first interrupts only its handler and the second returns its result.
@@ -460,8 +478,11 @@ async def survivor_call() -> None:
await doomed_cancelled.wait()
release_survivor.set()
- # Let the abandoned call's late error response (sent on the legacy arms) arrive and be
- # dropped while the client is still open, so teardown never races its delivery.
+ # Let anything still owed the abandoned call (the REQUEST_CANCELLED terminator over
+ # legacy streamable HTTP; nothing elsewhere) arrive and be dropped while the client is
+ # still open, so teardown never races its delivery.
await anyio.wait_all_tasks_blocked()
- assert results == snapshot([CallToolResult(content=[TextContent(text="survived")])])
+ assert [unstamped(result) for result in results] == snapshot(
+ [CallToolResult(content=[TextContent(text="survived")])]
+ )
diff --git a/tests/interaction/lowlevel/test_client_connect.py b/tests/interaction/lowlevel/test_client_connect.py
index eda1b8423c..a992027a23 100644
--- a/tests/interaction/lowlevel/test_client_connect.py
+++ b/tests/interaction/lowlevel/test_client_connect.py
@@ -25,6 +25,7 @@
INVALID_REQUEST,
METHOD_NOT_FOUND,
PROTOCOL_VERSION_META_KEY,
+ SERVER_INFO_META_KEY,
UNSUPPORTED_PROTOCOL_VERSION,
DiscoverResult,
Implementation,
@@ -123,13 +124,13 @@ async def test_prior_discover_populates_state_with_zero_connect_time_traffic() -
"""`Client(..., mode=, prior_discover=...)` sends nothing on entry and exposes the prior server_info.
Requirement `lifecycle:mode:prior-discover-zero-rtt` (sdk-defined): a previously-obtained
- DiscoverResult is installed via `adopt()` so server_info and capabilities are available
- immediately with zero round trips.
+ DiscoverResult is installed via `adopt()` so server_info (read from the result's `_meta`
+ serverInfo stamp) and capabilities are available immediately with zero round trips.
"""
prior = DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(tools=ToolsCapability(list_changed=False)),
- server_info=Implementation(name="cached-server", version="9.9.9"),
+ _meta={SERVER_INFO_META_KEY: {"name": "cached-server", "version": "9.9.9"}},
)
requests, on_request = _request_recorder()
@@ -156,7 +157,8 @@ async def test_auto_mode_probes_server_discover_and_adopts_the_result() -> None:
Requirement `lifecycle:discover:basic` (spec basic/lifecycle#discover): the probe is a
single `server/discover` request whose result carries supported versions, capabilities,
- server_info and the cache-hint fields, after which the session is modern-negotiated.
+ the cache-hint fields, and the `_meta` serverInfo stamp, after which the session is
+ modern-negotiated.
"""
requests, on_request = _request_recorder()
server = _tools_server("discoverable")
@@ -167,6 +169,7 @@ async def test_auto_mode_probes_server_discover_and_adopts_the_result() -> None:
Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode="auto") as client,
):
assert client.protocol_version == LATEST_MODERN_VERSION
+ assert client.server_info is not None
assert client.server_info.name == "discoverable"
await client.list_tools()
@@ -198,7 +201,6 @@ async def discover(ctx: ServerRequestContext, params: types.RequestParams | None
return DiscoverResult(
supported_versions=list(MODERN_PROTOCOL_VERSIONS),
capabilities=ServerCapabilities(),
- server_info=Implementation(name="picky", version="1.0.0"),
)
server = _tools_server("picky")
@@ -309,6 +311,7 @@ async def scripted_transport() -> AsyncIterator[TransportStreams]:
with anyio.fail_after(5):
async with Client(scripted_transport(), mode="auto") as client:
assert client.protocol_version == LATEST_HANDSHAKE_VERSION
+ assert client.server_info is not None
assert client.server_info.name == "legacy-only"
assert methods_seen == ["server/discover", "initialize", "notifications/initialized"]
diff --git a/tests/interaction/lowlevel/test_completion.py b/tests/interaction/lowlevel/test_completion.py
index d75865a2f0..1ed5542734 100644
--- a/tests/interaction/lowlevel/test_completion.py
+++ b/tests/interaction/lowlevel/test_completion.py
@@ -15,6 +15,7 @@
from mcp import MCPError
from mcp.server import Server, ServerRequestContext
+from tests._stamp import Unstamp
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
@@ -23,7 +24,7 @@
@requirement("completion:prompt-arg")
@requirement("completion:result-shape")
-async def test_complete_prompt_argument(connect: Connect) -> None:
+async def test_complete_prompt_argument(connect: Connect, unstamped: Unstamp) -> None:
"""Completing a prompt argument delivers the ref, argument name, and current value to the handler.
The returned values are filtered by the argument's value, proving the value reached the handler.
@@ -44,13 +45,13 @@ async def completion(ctx: ServerRequestContext, params: types.CompleteRequestPar
PromptReference(name="code_review"), argument={"name": "language", "value": "py"}
)
- assert result == snapshot(
+ assert unstamped(result) == snapshot(
CompleteResult(completion=Completion(values=["python", "pytorch"], total=2, has_more=False))
)
@requirement("completion:resource-template-arg")
-async def test_complete_resource_template_variable(connect: Connect) -> None:
+async def test_complete_resource_template_variable(connect: Connect, unstamped: Unstamp) -> None:
"""Completing a URI template variable delivers the template URI and variable name to the handler."""
async def completion(ctx: ServerRequestContext, params: types.CompleteRequestParams) -> CompleteResult:
@@ -67,11 +68,11 @@ async def completion(ctx: ServerRequestContext, params: types.CompleteRequestPar
argument={"name": "owner", "value": "model"},
)
- assert result == snapshot(CompleteResult(completion=Completion(values=["modelcontextprotocol"])))
+ assert unstamped(result) == snapshot(CompleteResult(completion=Completion(values=["modelcontextprotocol"])))
@requirement("completion:context-arguments")
-async def test_complete_receives_context_arguments(connect: Connect) -> None:
+async def test_complete_receives_context_arguments(connect: Connect, unstamped: Unstamp) -> None:
"""Previously-resolved arguments passed as completion context reach the handler.
The returned value is derived from the context, proving it arrived.
@@ -92,7 +93,9 @@ async def completion(ctx: ServerRequestContext, params: types.CompleteRequestPar
context_arguments={"owner": "modelcontextprotocol"},
)
- assert result == snapshot(CompleteResult(completion=Completion(values=["modelcontextprotocol/python-sdk"])))
+ assert unstamped(result) == snapshot(
+ CompleteResult(completion=Completion(values=["modelcontextprotocol/python-sdk"]))
+ )
@requirement("completion:error:invalid-ref")
diff --git a/tests/interaction/lowlevel/test_elicitation.py b/tests/interaction/lowlevel/test_elicitation.py
index b8393dd316..7024ef9e0f 100644
--- a/tests/interaction/lowlevel/test_elicitation.py
+++ b/tests/interaction/lowlevel/test_elicitation.py
@@ -28,12 +28,11 @@
)
from mcp import MCPError, UrlElicitationRequiredError
-from mcp.client import ClientRequestContext, ClientSession
+from mcp.client import ClientRequestContext, ClientSession, IncomingMessage
from mcp.server import Server, ServerRequestContext
from mcp.shared.memory import MessageStream, create_client_server_memory_streams
from mcp.shared.message import SessionMessage
from tests.interaction._connect import Connect
-from tests.interaction._helpers import IncomingMessage
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
diff --git a/tests/interaction/lowlevel/test_flows.py b/tests/interaction/lowlevel/test_flows.py
index 19788db4a1..78ca716021 100644
--- a/tests/interaction/lowlevel/test_flows.py
+++ b/tests/interaction/lowlevel/test_flows.py
@@ -29,11 +29,11 @@
)
from mcp import MCPError, UrlElicitationRequiredError
-from mcp.client import ClientRequestContext
+from mcp.client import ClientRequestContext, IncomingMessage
from mcp.server import Server, ServerRequestContext
from mcp.server.session import ServerSession
+from tests._stamp import Unstamp
from tests.interaction._connect import Connect
-from tests.interaction._helpers import IncomingMessage
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
@@ -53,7 +53,9 @@ async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestPa
@requirement("flow:tool-result:resource-link-follow")
-async def test_a_resource_link_returned_by_a_tool_can_be_followed_with_read(connect: Connect) -> None:
+async def test_a_resource_link_returned_by_a_tool_can_be_followed_with_read(
+ connect: Connect, unstamped: Unstamp
+) -> None:
"""A tool returns a resource_link; reading that link's URI returns the referenced contents.
Steps: (1) call the tool, (2) extract the link from its content, (3) read_resource on the
@@ -78,8 +80,10 @@ async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceReq
assert isinstance(link, ResourceLink)
read = await client.read_resource(link.uri)
- assert called == snapshot(CallToolResult(content=[ResourceLink(name="report", uri="file:///report.txt")]))
- assert read == snapshot(
+ assert unstamped(called) == snapshot(
+ CallToolResult(content=[ResourceLink(name="report", uri="file:///report.txt")])
+ )
+ assert unstamped(read) == snapshot(
ReadResourceResult(contents=[TextResourceContents(uri="file:///report.txt", text="generated")])
)
diff --git a/tests/interaction/lowlevel/test_list_changed.py b/tests/interaction/lowlevel/test_list_changed.py
index e7d497ba2d..d978c0db51 100644
--- a/tests/interaction/lowlevel/test_list_changed.py
+++ b/tests/interaction/lowlevel/test_list_changed.py
@@ -26,9 +26,9 @@
ToolListChangedNotification,
)
+from mcp.client import IncomingMessage
from mcp.server import Server, ServerRequestContext
from tests.interaction._connect import Connect
-from tests.interaction._helpers import IncomingMessage
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
diff --git a/tests/interaction/lowlevel/test_logging.py b/tests/interaction/lowlevel/test_logging.py
index bfc86509d2..f827650e73 100644
--- a/tests/interaction/lowlevel/test_logging.py
+++ b/tests/interaction/lowlevel/test_logging.py
@@ -8,9 +8,18 @@
import mcp_types as types
import pytest
from inline_snapshot import snapshot
-from mcp_types import CallToolResult, EmptyResult, LoggingMessageNotificationParams, TextContent
+from mcp_types import (
+ INVALID_PARAMS,
+ LOG_LEVEL_META_KEY,
+ CallToolResult,
+ EmptyResult,
+ LoggingMessageNotificationParams,
+ TextContent,
+)
+from mcp import MCPError
from mcp.server import Server, ServerRequestContext
+from tests._stamp import Unstamp
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
@@ -46,7 +55,7 @@ async def set_logging_level(ctx: ServerRequestContext, params: types.SetLevelReq
@requirement("logging:message:fields")
@requirement("tools:call:logging-mid-execution")
-async def test_log_messages_reach_logging_callback_in_order(connect: Connect) -> None:
+async def test_log_messages_reach_logging_callback_in_order(connect: Connect, unstamped: Unstamp) -> None:
"""Log messages sent during a tool call arrive at the logging callback, in order, before the call returns.
The two messages pin the full notification shape: severity, optional logger name, and both
@@ -80,10 +89,10 @@ async def set_logging_level(ctx: ServerRequestContext, params: types.SetLevelReq
"logger", on_list_tools=list_tools, on_call_tool=call_tool, on_set_logging_level=set_logging_level
)
- async with connect(server, logging_callback=collect) as client:
+ async with connect(server, logging_callback=collect, log_level="debug") as client:
result = await client.call_tool("chatty", {})
- assert result == snapshot(CallToolResult(content=[TextContent(text="done")]))
+ assert unstamped(result) == snapshot(CallToolResult(content=[TextContent(text="done")]))
assert received == snapshot(
[
LoggingMessageNotificationParams(level="info", logger="app.lifecycle", data="starting up"),
@@ -121,7 +130,63 @@ async def set_logging_level(ctx: ServerRequestContext, params: types.SetLevelReq
"logger", on_list_tools=list_tools, on_call_tool=call_tool, on_set_logging_level=set_logging_level
)
- async with connect(server, logging_callback=collect) as client:
+ async with connect(server, logging_callback=collect, log_level="debug") as client:
await client.call_tool("siren", {})
assert [params.level for params in received] == list(ALL_LEVELS)
+
+
+def _siren_server() -> Server:
+ """A server whose `siren` tool logs one message at each of the eight severity levels.
+
+ The messages are sent without `related_request_id`: on 2026-07-28+ log delivery is
+ request-scoped by construction, so they still ride the requesting stream on every leg.
+ """
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(tools=[types.Tool(name="siren", input_schema={"type": "object"})])
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "siren"
+ for level in ALL_LEVELS:
+ await ctx.session.send_log_message(level=level, data=f"a {level} message") # pyright: ignore[reportDeprecated]
+ return CallToolResult(content=[TextContent(text="logged")])
+
+ return Server("logger", on_list_tools=list_tools, on_call_tool=call_tool)
+
+
+@requirement("logging:per-request:opt-in")
+@requirement("logging:per-request:threshold")
+async def test_log_delivery_follows_the_per_request_log_level(connect: Connect) -> None:
+ """Without io.modelcontextprotocol/logLevel in _meta a request gets no log messages;
+ with it, only entries at or above the requested level are delivered, in order.
+
+ The handler emits at every severity in both phases: the un-opted request receives
+ nothing (the log calls are dropped, not delivered on some other stream), and the request
+ opting in at `warning` receives warning and above.
+ """
+ received: list[types.LoggingLevel] = []
+
+ async def collect(params: LoggingMessageNotificationParams) -> None:
+ received.append(params.level)
+
+ async with connect(_siren_server(), logging_callback=collect) as client:
+ result = await client.call_tool("siren", {})
+ assert isinstance(result.content[0], TextContent) and result.content[0].text == "logged"
+ assert received == []
+
+ async with connect(_siren_server(), logging_callback=collect, log_level="warning") as client:
+ await client.call_tool("siren", {})
+ assert received == ["warning", "error", "critical", "alert", "emergency"]
+
+
+@requirement("logging:per-request:invalid-level")
+async def test_a_request_with_an_unrecognized_log_level_is_rejected(connect: Connect) -> None:
+ """A request whose _meta names an unrecognized log level is rejected with -32602 before the handler runs."""
+ async with connect(_siren_server()) as client:
+ with pytest.raises(MCPError) as exc_info:
+ await client.call_tool("siren", {}, meta={LOG_LEVEL_META_KEY: "verbose"})
+
+ assert exc_info.value.error.code == INVALID_PARAMS
diff --git a/tests/interaction/lowlevel/test_meta.py b/tests/interaction/lowlevel/test_meta.py
index 27cf25e30c..6e1403fea4 100644
--- a/tests/interaction/lowlevel/test_meta.py
+++ b/tests/interaction/lowlevel/test_meta.py
@@ -2,7 +2,8 @@
Meta is opaque pass-through data, so these tests assert identity against the value that was sent
rather than snapshotting a literal: the expected value and the sent value are the same variable,
-which also proves the SDK injected nothing alongside it.
+which also proves the SDK injected nothing alongside it beyond the 2026-era serverInfo stamp,
+stripped via `unstamped` before comparison.
"""
import mcp_types as types
@@ -10,6 +11,7 @@
from mcp_types import CallToolResult, RequestParamsMeta, TextContent
from mcp.server import Server, ServerRequestContext
+from tests._stamp import Unstamp
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
@@ -42,7 +44,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
@requirement("meta:result-to-client")
-async def test_result_meta_reaches_client(connect: Connect) -> None:
+async def test_result_meta_reaches_client(connect: Connect, unstamped: Unstamp) -> None:
"""The _meta object a handler attaches to its result is delivered to the client unchanged."""
result_meta = {"example.com/cost": 3}
@@ -60,4 +62,4 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
async with connect(server) as client:
result = await client.call_tool("metered", {})
- assert result == CallToolResult(content=[TextContent(text="done")], _meta=result_meta)
+ assert unstamped(result) == CallToolResult(content=[TextContent(text="done")], _meta=result_meta)
diff --git a/tests/interaction/lowlevel/test_pagination.py b/tests/interaction/lowlevel/test_pagination.py
index 01bc0a99bd..e59d99c083 100644
--- a/tests/interaction/lowlevel/test_pagination.py
+++ b/tests/interaction/lowlevel/test_pagination.py
@@ -22,6 +22,7 @@
from mcp import MCPError
from mcp.server import Server, ServerRequestContext
+from tests._stamp import Unstamp
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
@@ -29,7 +30,7 @@
@requirement("tools:list:pagination")
-async def test_next_cursor_round_trips_through_the_client(connect: Connect) -> None:
+async def test_next_cursor_round_trips_through_the_client(connect: Connect, unstamped: Unstamp) -> None:
"""The next_cursor a list handler returns reaches the client, and the cursor the client sends
back on the following call reaches the handler verbatim.
"""
@@ -55,7 +56,9 @@ async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestPa
assert first_page.next_cursor == cursor
assert seen_cursors == [None, cursor]
assert [tool.name for tool in first_page.tools] == ["alpha"]
- assert second_page == snapshot(ListToolsResult(tools=[Tool(name="beta", input_schema={"type": "object"})]))
+ assert unstamped(second_page) == snapshot(
+ ListToolsResult(tools=[Tool(name="beta", input_schema={"type": "object"})])
+ )
@requirement("pagination:exhaustion")
diff --git a/tests/interaction/lowlevel/test_progress.py b/tests/interaction/lowlevel/test_progress.py
index 7f75e18eeb..4d1f0d42af 100644
--- a/tests/interaction/lowlevel/test_progress.py
+++ b/tests/interaction/lowlevel/test_progress.py
@@ -15,11 +15,12 @@
from inline_snapshot import snapshot
from mcp_types import CallToolResult, ProgressNotification, ProgressNotificationParams, ProgressToken, TextContent
+from mcp.client import IncomingMessage
from mcp.server import Server, ServerRequestContext
from mcp.server.session import ServerSession
-from mcp.shared.session import ProgressFnT
+from mcp.shared.dispatcher import ProgressFnT
+from tests._stamp import Unstamp
from tests.interaction._connect import Connect
-from tests.interaction._helpers import IncomingMessage
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
@@ -27,7 +28,7 @@
@requirement("protocol:progress:callback")
@requirement("tools:call:progress")
-async def test_progress_during_tool_call_reaches_callback_in_order(connect: Connect) -> None:
+async def test_progress_during_tool_call_reaches_callback_in_order(connect: Connect, unstamped: Unstamp) -> None:
"""Progress notifications emitted by a tool handler reach the caller's progress callback in order."""
received: list[tuple[float, float | None, str | None]] = []
@@ -51,7 +52,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
async with connect(server) as client:
result = await client.call_tool("download", {}, progress_callback=collect)
- assert result == snapshot(CallToolResult(content=[TextContent(text="downloaded")]))
+ assert unstamped(result) == snapshot(CallToolResult(content=[TextContent(text="downloaded")]))
assert received == snapshot([(1.0, 3.0, "first chunk"), (2.0, 3.0, "second chunk"), (3.0, 3.0, "done")])
@@ -83,7 +84,7 @@ async def ignore(progress: float, total: float | None, message: str | None) -> N
@requirement("protocol:progress:no-token")
-async def test_no_progress_callback_means_no_token(connect: Connect) -> None:
+async def test_no_progress_callback_means_no_token(connect: Connect, unstamped: Unstamp) -> None:
"""Without a progress callback the request carries no progress token.
The low-level API has no way to report request-scoped progress without a token, so a handler
@@ -105,7 +106,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
async with connect(server) as client:
result = await client.call_tool("inspect", {})
- assert result == snapshot(CallToolResult(content=[TextContent(text="None")]))
+ assert unstamped(result) == snapshot(CallToolResult(content=[TextContent(text="None")]))
@requirement("protocol:progress:client-to-server")
diff --git a/tests/interaction/lowlevel/test_prompts.py b/tests/interaction/lowlevel/test_prompts.py
index eb19d4d60d..6048c8b24d 100644
--- a/tests/interaction/lowlevel/test_prompts.py
+++ b/tests/interaction/lowlevel/test_prompts.py
@@ -21,6 +21,7 @@
from mcp import MCPError
from mcp.server import Server, ServerRequestContext
+from tests._stamp import Unstamp
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
@@ -28,7 +29,7 @@
@requirement("prompts:list:basic")
-async def test_list_prompts_returns_registered_prompts(connect: Connect) -> None:
+async def test_list_prompts_returns_registered_prompts(connect: Connect, unstamped: Unstamp) -> None:
"""The prompts returned by the handler reach the client with their argument declarations intact."""
async def list_prompts(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListPromptsResult:
@@ -52,7 +53,7 @@ async def list_prompts(ctx: ServerRequestContext, params: types.PaginatedRequest
async with connect(server) as client:
result = await client.list_prompts()
- assert result == snapshot(
+ assert unstamped(result) == snapshot(
ListPromptsResult(
prompts=[
Prompt(
@@ -71,7 +72,7 @@ async def list_prompts(ctx: ServerRequestContext, params: types.PaginatedRequest
@requirement("prompts:get:with-args")
-async def test_get_prompt_substitutes_arguments(connect: Connect) -> None:
+async def test_get_prompt_substitutes_arguments(connect: Connect, unstamped: Unstamp) -> None:
"""Arguments supplied by the client reach the prompt handler; the templated message comes back."""
async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> GetPromptResult:
@@ -87,7 +88,7 @@ async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestPa
async with connect(server) as client:
result = await client.get_prompt("greet", {"name": "Ada"})
- assert result == snapshot(
+ assert unstamped(result) == snapshot(
GetPromptResult(
description="A personalised greeting.",
messages=[PromptMessage(role="user", content=TextContent(text="Hello, Ada!"))],
@@ -96,7 +97,7 @@ async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestPa
@requirement("prompts:get:multi-message")
-async def test_get_prompt_multiple_messages_preserve_roles_and_order(connect: Connect) -> None:
+async def test_get_prompt_multiple_messages_preserve_roles_and_order(connect: Connect, unstamped: Unstamp) -> None:
"""A prompt returning a user/assistant conversation reaches the client with roles and order intact."""
async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> GetPromptResult:
@@ -114,7 +115,7 @@ async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestPa
async with connect(server) as client:
result = await client.get_prompt("geography_quiz")
- assert result == snapshot(
+ assert unstamped(result) == snapshot(
GetPromptResult(
messages=[
PromptMessage(role="user", content=TextContent(text="What is the capital of France?")),
@@ -126,7 +127,7 @@ async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestPa
@requirement("prompts:get:no-args")
-async def test_get_prompt_without_arguments_returns_the_messages(connect: Connect) -> None:
+async def test_get_prompt_without_arguments_returns_the_messages(connect: Connect, unstamped: Unstamp) -> None:
"""A prompt fetched with no arguments delivers None as the handler's arguments and returns its messages."""
async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> GetPromptResult:
@@ -139,7 +140,7 @@ async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestPa
async with connect(server) as client:
result = await client.get_prompt("static")
- assert result == snapshot(
+ assert unstamped(result) == snapshot(
GetPromptResult(messages=[PromptMessage(role="user", content=TextContent(text="Say hello."))])
)
@@ -147,7 +148,7 @@ async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestPa
@requirement("prompts:get:content:image")
@requirement("prompts:get:content:audio")
@requirement("prompts:get:content:embedded-resource")
-async def test_get_prompt_with_non_text_content_round_trips(connect: Connect) -> None:
+async def test_get_prompt_with_non_text_content_round_trips(connect: Connect, unstamped: Unstamp) -> None:
"""Prompt messages can carry image, audio, and embedded-resource content; all reach the client.
A single full-result snapshot proves all three content types round-trip: each block in the result
@@ -175,7 +176,7 @@ async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestPa
async with connect(server) as client:
result = await client.get_prompt("media", {})
- assert result == snapshot(
+ assert unstamped(result) == snapshot(
GetPromptResult(
messages=[
PromptMessage(role="user", content=ImageContent(data="aW1n", mime_type="image/png")),
diff --git a/tests/interaction/lowlevel/test_resources.py b/tests/interaction/lowlevel/test_resources.py
index db7d4dfe60..9e18393707 100644
--- a/tests/interaction/lowlevel/test_resources.py
+++ b/tests/interaction/lowlevel/test_resources.py
@@ -26,9 +26,10 @@
)
from mcp import MCPError
+from mcp.client import IncomingMessage
from mcp.server import Server, ServerRequestContext
+from tests._stamp import Unstamp
from tests.interaction._connect import Connect
-from tests.interaction._helpers import IncomingMessage
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
@@ -36,7 +37,7 @@
@requirement("resources:list:basic")
@requirement("resources:annotations")
-async def test_list_resources_returns_registered_resources(connect: Connect) -> None:
+async def test_list_resources_returns_registered_resources(connect: Connect, unstamped: Unstamp) -> None:
"""Listed resources reach the client with their URIs, names, and optional descriptive fields intact.
The fully-populated entry includes annotations, so the snapshot also proves they round-trip.
@@ -71,7 +72,7 @@ async def list_resources(
async with connect(server) as client:
result = await client.list_resources()
- assert result == snapshot(
+ assert unstamped(result) == snapshot(
ListResourcesResult(
resources=[
Resource(uri="memo://minimal", name="minimal"),
@@ -93,7 +94,7 @@ async def list_resources(
@requirement("resources:read:text")
-async def test_read_resource_text(connect: Connect) -> None:
+async def test_read_resource_text(connect: Connect, unstamped: Unstamp) -> None:
"""Reading a text resource returns its contents with the URI, MIME type, and text supplied by the handler."""
async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult:
@@ -106,7 +107,7 @@ async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceReq
async with connect(server) as client:
result = await client.read_resource("file:///greeting.txt")
- assert result == snapshot(
+ assert unstamped(result) == snapshot(
ReadResourceResult(
contents=[TextResourceContents(uri="file:///greeting.txt", mime_type="text/plain", text="Hello, world!")]
)
@@ -114,7 +115,7 @@ async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceReq
@requirement("resources:read:blob")
-async def test_read_resource_binary(connect: Connect) -> None:
+async def test_read_resource_binary(connect: Connect, unstamped: Unstamp) -> None:
"""Reading a binary resource returns its contents base64-encoded in the blob field."""
async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult:
@@ -133,7 +134,7 @@ async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceReq
async with connect(server) as client:
result = await client.read_resource("file:///pixel.png")
- assert result == snapshot(
+ assert unstamped(result) == snapshot(
ReadResourceResult(
contents=[BlobResourceContents(uri="file:///pixel.png", mime_type="image/png", blob="iVBORw==")]
)
@@ -161,7 +162,7 @@ async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceReq
@requirement("resources:templates:list")
-async def test_list_resource_templates_returns_registered_templates(connect: Connect) -> None:
+async def test_list_resource_templates_returns_registered_templates(connect: Connect, unstamped: Unstamp) -> None:
"""Listed resource templates reach the client with their URI templates and descriptive fields intact."""
async def list_resource_templates(
@@ -186,7 +187,7 @@ async def list_resource_templates(
async with connect(server) as client:
result = await client.list_resource_templates()
- assert result == snapshot(
+ assert unstamped(result) == snapshot(
ListResourceTemplatesResult(
resource_templates=[
ResourceTemplate(uri_template="users://{user_id}", name="user"),
diff --git a/tests/interaction/lowlevel/test_tools.py b/tests/interaction/lowlevel/test_tools.py
index 861dd75e44..86fec356bc 100644
--- a/tests/interaction/lowlevel/test_tools.py
+++ b/tests/interaction/lowlevel/test_tools.py
@@ -22,6 +22,7 @@
from mcp import MCPError
from mcp.server import Server, ServerRequestContext
+from tests._stamp import Unstamp
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
@@ -29,7 +30,7 @@
@requirement("tools:call:content:text")
-async def test_call_tool_returns_text_content(connect: Connect) -> None:
+async def test_call_tool_returns_text_content(connect: Connect, unstamped: Unstamp) -> None:
"""Arguments reach the tool handler; its content comes back as the call result."""
async def list_tools(
@@ -49,11 +50,11 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
async with connect(server) as client:
result = await client.call_tool("add", {"a": 2, "b": 3})
- assert result == snapshot(CallToolResult(content=[TextContent(text="5")]))
+ assert unstamped(result) == snapshot(CallToolResult(content=[TextContent(text="5")]))
@requirement("tools:call:is-error")
-async def test_call_tool_execution_error_is_returned_as_result(connect: Connect) -> None:
+async def test_call_tool_execution_error_is_returned_as_result(connect: Connect, unstamped: Unstamp) -> None:
"""A tool reporting its own failure with is_error=True reaches the client as a result, not an exception.
Tool execution errors are part of the result so the caller (typically a model) can see
@@ -69,7 +70,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
async with connect(server) as client:
result = await client.call_tool("flux", {})
- assert result == snapshot(
+ assert unstamped(result) == snapshot(
CallToolResult(content=[TextContent(text="the flux capacitor is offline")], is_error=True)
)
@@ -117,7 +118,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
@requirement("tools:list:basic")
-async def test_list_tools_returns_registered_tools(connect: Connect) -> None:
+async def test_list_tools_returns_registered_tools(connect: Connect, unstamped: Unstamp) -> None:
"""The tools advertised by the server's list handler arrive at the client unchanged."""
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
@@ -141,7 +142,7 @@ async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestPa
async with connect(server) as client:
result = await client.list_tools()
- assert result == snapshot(
+ assert unstamped(result) == snapshot(
ListToolsResult(
tools=[
Tool(
@@ -163,7 +164,7 @@ async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestPa
@requirement("tools:input-schema:preserve-additional-properties")
@requirement("tools:input-schema:preserve-defs")
@requirement("tools:input-schema:preserve-schema-dialect")
-async def test_tools_list_preserves_arbitrary_input_schema_keywords(connect: Connect) -> None:
+async def test_tools_list_preserves_arbitrary_input_schema_keywords(connect: Connect, unstamped: Unstamp) -> None:
"""A rich JSON Schema 2020-12 inputSchema reaches the client unchanged and the tool is callable.
The single identity assertion below proves all four pass-through behaviours at once: the same
@@ -202,11 +203,11 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
called = await client.call_tool("typed", {"count": 3, "options": {"verbose": True}})
assert listed.tools[0].input_schema == schema
- assert called == snapshot(CallToolResult(content=[TextContent(text="ok")]))
+ assert unstamped(called) == snapshot(CallToolResult(content=[TextContent(text="ok")]))
@requirement("tools:list:metadata")
-async def test_list_tools_optional_fields_round_trip(connect: Connect) -> None:
+async def test_list_tools_optional_fields_round_trip(connect: Connect, unstamped: Unstamp) -> None:
"""Every optional Tool field the server supplies reaches the client unchanged."""
tool = Tool(
@@ -228,7 +229,7 @@ async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestPa
async with connect(server) as client:
result = await client.list_tools()
- assert result == snapshot(
+ assert unstamped(result) == snapshot(
ListToolsResult(
tools=[
Tool(
@@ -251,7 +252,7 @@ async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestPa
@requirement("tools:call:content:audio")
@requirement("tools:call:content:resource-link")
@requirement("tools:call:content:embedded-resource")
-async def test_call_tool_multiple_content_block_types(connect: Connect) -> None:
+async def test_call_tool_multiple_content_block_types(connect: Connect, unstamped: Unstamp) -> None:
"""A tool result can mix every content block type; all of them arrive in order.
The payloads are tiny fixed base64 strings ("aW1n" is b"img", "YXVk" is b"aud") so the
@@ -280,7 +281,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
async with connect(server) as client:
result = await client.call_tool("render", {})
- assert result == snapshot(
+ assert unstamped(result) == snapshot(
CallToolResult(
content=[
TextContent(text="all five content block types"),
@@ -296,7 +297,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
@requirement("tools:call:structured-content")
-async def test_call_tool_structured_content(connect: Connect) -> None:
+async def test_call_tool_structured_content(connect: Connect, unstamped: Unstamp) -> None:
"""A tool result carrying structured content alongside content delivers both to the client."""
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
@@ -311,11 +312,13 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
async with connect(server) as client:
result = await client.call_tool("sum", {})
- assert result == snapshot(CallToolResult(content=[TextContent(text="the sum is 5")], structured_content={"sum": 5}))
+ assert unstamped(result) == snapshot(
+ CallToolResult(content=[TextContent(text="the sum is 5")], structured_content={"sum": 5})
+ )
@requirement("tools:call:concurrent")
-async def test_concurrent_tool_calls_complete_independently(connect: Connect) -> None:
+async def test_concurrent_tool_calls_complete_independently(connect: Connect, unstamped: Unstamp) -> None:
"""Two tool calls in flight at once run concurrently and each caller gets its own answer.
Both handlers are held on a shared event after signalling that they have started, and the test
@@ -347,7 +350,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
async with anyio.create_task_group() as task_group: # pragma: no branch
async def call_and_record(tag: str) -> None:
- results[tag] = await client.call_tool("echo", {"tag": tag})
+ results[tag] = unstamped(await client.call_tool("echo", {"tag": tag}))
task_group.start_soon(call_and_record, "first")
task_group.start_soon(call_and_record, "second")
@@ -403,7 +406,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
@requirement("client:output-schema:skip-on-error")
-async def test_is_error_result_bypasses_client_output_schema_validation(connect: Connect) -> None:
+async def test_is_error_result_bypasses_client_output_schema_validation(connect: Connect, unstamped: Unstamp) -> None:
"""A tool result with isError true is returned as-is even when its structured content violates the schema.
The schema is cached up front so the client could validate, proving the bypass is specifically the
@@ -437,7 +440,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
await client.list_tools()
result = await client.call_tool("forecast", {})
- assert result == snapshot(
+ assert unstamped(result) == snapshot(
CallToolResult(content=[TextContent(text="boom")], structured_content={"temperature": "warm"}, is_error=True)
)
@@ -475,7 +478,9 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
@requirement("client:output-schema:auto-list")
-async def test_call_tool_populates_the_output_schema_cache_via_an_implicit_tools_list(connect: Connect) -> None:
+async def test_call_tool_populates_the_output_schema_cache_via_an_implicit_tools_list(
+ connect: Connect, unstamped: Unstamp
+) -> None:
"""Calling a tool whose schema is not cached issues exactly one implicit tools/list to populate it.
The first call_tool of an uncached tool triggers a tools/list the caller never asked for; the
@@ -509,5 +514,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
second = await client.call_tool("forecast", {})
assert list_calls == ["called"]
- assert first == snapshot(CallToolResult(content=[TextContent(text="21 C")], structured_content={"temperature": 21}))
- assert second == first
+ assert unstamped(first) == snapshot(
+ CallToolResult(content=[TextContent(text="21 C")], structured_content={"temperature": 21})
+ )
+ assert unstamped(second) == first
diff --git a/tests/interaction/lowlevel/test_wire.py b/tests/interaction/lowlevel/test_wire.py
index b3d286ca1d..8ee05fec38 100644
--- a/tests/interaction/lowlevel/test_wire.py
+++ b/tests/interaction/lowlevel/test_wire.py
@@ -353,8 +353,8 @@ async def call_and_abandon() -> None:
with anyio.fail_after(5):
await handler_cancelled.wait()
- # Let the cancelled call's late error response arrive and be dropped while the client
- # is still open, so teardown never races its delivery.
+ # Let any in-flight delivery for the abandoned call settle while the client is still
+ # open, so teardown never races it (nothing arrives here: a cancelled request is not answered).
await anyio.wait_all_tasks_blocked()
call, cancel = [message.message for message in recording.sent]
diff --git a/tests/interaction/mcpserver/test_context.py b/tests/interaction/mcpserver/test_context.py
index 27c0c70cc1..2b979b8936 100644
--- a/tests/interaction/mcpserver/test_context.py
+++ b/tests/interaction/mcpserver/test_context.py
@@ -17,11 +17,11 @@
from pydantic import BaseModel
from mcp import MCPError
-from mcp.client import ClientRequestContext
+from mcp.client import ClientRequestContext, IncomingMessage
from mcp.server.elicitation import AcceptedElicitation
from mcp.server.mcpserver import Context, MCPServer
+from tests._stamp import Unstamp
from tests.interaction._connect import Connect
-from tests.interaction._helpers import IncomingMessage
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
@@ -50,7 +50,7 @@ async def narrate(ctx: Context) -> str:
async def collect(params: LoggingMessageNotificationParams) -> None:
received.append(params)
- async with connect(mcp, logging_callback=collect) as client:
+ async with connect(mcp, logging_callback=collect, log_level="debug") as client:
result = await client.call_tool("narrate", {})
advertised_logging = client.server_capabilities.logging
@@ -68,7 +68,7 @@ async def collect(params: LoggingMessageNotificationParams) -> None:
@requirement("mcpserver:context:progress")
-async def test_context_report_progress_sends_progress_notifications(connect: Connect) -> None:
+async def test_context_report_progress_sends_progress_notifications(connect: Connect, unstamped: Unstamp) -> None:
"""Context.report_progress sends progress notifications correlated to the calling request.
The caller's progress callback receives each report, in order, before the tool call returns.
@@ -88,7 +88,7 @@ async def on_progress(progress: float, total: float | None, message: str | None)
async with connect(mcp) as client:
result = await client.call_tool("crunch", {}, progress_callback=on_progress)
- assert result == snapshot(
+ assert unstamped(result) == snapshot(
CallToolResult(content=[TextContent(text="crunched")], structured_content={"result": "crunched"})
)
assert received == snapshot([(1.0, 3.0, None), (2.0, 3.0, "halfway there")])
@@ -123,7 +123,7 @@ async def whoami(ctx: Context) -> str:
@requirement("mcpserver:context:logging")
@requirement("protocol:progress:no-token")
-async def test_report_progress_without_a_progress_token_sends_nothing(connect: Connect) -> None:
+async def test_report_progress_without_a_progress_token_sends_nothing(connect: Connect, unstamped: Unstamp) -> None:
"""When the caller supplied no progress callback, Context.report_progress is a silent no-op.
The tool also emits one log message as a sentinel: the message handler receives only that,
@@ -142,10 +142,10 @@ async def mill(ctx: Context) -> str:
async def collect(message: IncomingMessage) -> None:
received.append(message)
- async with connect(mcp, message_handler=collect) as client:
+ async with connect(mcp, message_handler=collect, log_level="debug") as client:
result = await client.call_tool("mill", {})
- assert result == snapshot(
+ assert unstamped(result) == snapshot(
CallToolResult(content=[TextContent(text="milled")], structured_content={"result": "milled"})
)
assert received == snapshot(
@@ -207,7 +207,7 @@ async def answer_form(context: ClientRequestContext, params: ElicitRequestParams
@requirement("mcpserver:context:read-resource")
-async def test_context_read_resource_reads_registered_resource(connect: Connect) -> None:
+async def test_context_read_resource_reads_registered_resource(connect: Connect, unstamped: Unstamp) -> None:
"""Context.read_resource lets a tool read a resource registered on the same server.
The tool reports the MIME type and content it read, proving the resource function ran and its
@@ -228,7 +228,7 @@ async def show_config(ctx: Context) -> str:
async with connect(mcp) as client:
result = await client.call_tool("show_config", {})
- assert result == snapshot(
+ assert unstamped(result) == snapshot(
CallToolResult(
content=[TextContent(text="text/plain: 'theme = dark'")],
structured_content={"result": "text/plain: 'theme = dark'"},
diff --git a/tests/interaction/mcpserver/test_extensions.py b/tests/interaction/mcpserver/test_extensions.py
index 205a7fd6ea..129324538f 100644
--- a/tests/interaction/mcpserver/test_extensions.py
+++ b/tests/interaction/mcpserver/test_extensions.py
@@ -15,6 +15,7 @@
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
from mcp.server.extension import Extension
from mcp.server.mcpserver import Context, MCPServer, require_client_extension
+from tests._stamp import Unstamp
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
@@ -79,7 +80,9 @@ def redeem(token: str) -> str:
@requirement("extensions:client:claimed-result-resolved")
-async def test_claimed_result_is_finished_by_the_owning_extensions_resolver(connect: Connect) -> None:
+async def test_claimed_result_is_finished_by_the_owning_extensions_resolver(
+ connect: Connect, unstamped: Unstamp
+) -> None:
"""The owning extension's claim resolver redeems the substituted `receipt` through
`ctx.session`, and `call_tool` returns the resolver's plain `CallToolResult`."""
received: list[ReceiptResult] = []
@@ -92,7 +95,7 @@ async def redeem_receipt(claimed: ReceiptResult, ctx: ClaimContext) -> CallToolR
result = await client.call_tool("buy", {"item": "lamp"})
assert [claimed.receipt_token for claimed in received] == ["r-117"]
- assert result == snapshot(
+ assert unstamped(result) == snapshot(
CallToolResult(content=[TextContent(text="goods for r-117")], structured_content={"result": "goods for r-117"})
)
diff --git a/tests/interaction/mcpserver/test_prompts.py b/tests/interaction/mcpserver/test_prompts.py
index 58c8b48c7f..8409e50207 100644
--- a/tests/interaction/mcpserver/test_prompts.py
+++ b/tests/interaction/mcpserver/test_prompts.py
@@ -14,6 +14,7 @@
from mcp import MCPError
from mcp.server.mcpserver import MCPServer
+from tests._stamp import Unstamp
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
@@ -21,7 +22,7 @@
@requirement("mcpserver:prompt:decorated")
-async def test_list_prompts_derives_arguments_from_signature(connect: Connect) -> None:
+async def test_list_prompts_derives_arguments_from_signature(connect: Connect, unstamped: Unstamp) -> None:
"""A decorated prompt is listed with arguments derived from the function signature.
Parameters without a default are required; the description comes from the docstring.
@@ -36,7 +37,7 @@ def code_review(code: str, style_guide: str = "pep8") -> str:
async with connect(mcp) as client:
result = await client.list_prompts()
- assert result == snapshot(
+ assert unstamped(result) == snapshot(
ListPromptsResult(
prompts=[
Prompt(
@@ -53,7 +54,7 @@ def code_review(code: str, style_guide: str = "pep8") -> str:
@requirement("mcpserver:prompt:decorated")
-async def test_get_prompt_renders_function_return(connect: Connect) -> None:
+async def test_get_prompt_renders_function_return(connect: Connect, unstamped: Unstamp) -> None:
"""The decorated function's string return value is rendered as a single user message."""
mcp = MCPServer("prompter")
@@ -65,7 +66,7 @@ def greet(name: str) -> str:
async with connect(mcp) as client:
result = await client.get_prompt("greet", {"name": "Ada"})
- assert result == snapshot(
+ assert unstamped(result) == snapshot(
GetPromptResult(
description="A personalised greeting.",
messages=[PromptMessage(role="user", content=TextContent(text="Say hello to Ada."))],
@@ -141,7 +142,9 @@ def repeat(phrase: str, count: int) -> str:
@requirement("mcpserver:prompt:optional-args")
-async def test_get_prompt_with_an_optional_argument_omitted_uses_the_default(connect: Connect) -> None:
+async def test_get_prompt_with_an_optional_argument_omitted_uses_the_default(
+ connect: Connect, unstamped: Unstamp
+) -> None:
"""A prompt rendered without one of its optional arguments uses that parameter's default value."""
mcp = MCPServer("prompter")
@@ -153,7 +156,7 @@ def review(code: str, style: str = "pep8") -> str:
async with connect(mcp) as client:
result = await client.get_prompt("review", {"code": "x = 1"})
- assert result == snapshot(
+ assert unstamped(result) == snapshot(
GetPromptResult(
description="Review a snippet of code against a style guide.",
messages=[PromptMessage(role="user", content=TextContent(text="Review x = 1 per pep8."))],
@@ -162,7 +165,9 @@ def review(code: str, style: str = "pep8") -> str:
@requirement("mcpserver:prompt:duplicate-name")
-async def test_registering_a_duplicate_prompt_name_warns_and_keeps_the_first(connect: Connect) -> None:
+async def test_registering_a_duplicate_prompt_name_warns_and_keeps_the_first(
+ connect: Connect, unstamped: Unstamp
+) -> None:
"""Registering a second prompt with an already-used name keeps the first registration.
The intended behaviour is rejection at registration time; MCPServer instead logs a warning
@@ -187,7 +192,7 @@ def greet_second() -> str:
result = await client.get_prompt("greet")
assert [prompt.name for prompt in listed.prompts] == ["greet"]
- assert result == snapshot(
+ assert unstamped(result) == snapshot(
GetPromptResult(
description="The first registration; this is the one that wins.",
messages=[PromptMessage(role="user", content=TextContent(text="first"))],
diff --git a/tests/interaction/mcpserver/test_resources.py b/tests/interaction/mcpserver/test_resources.py
index d7fd996051..eadf4794e6 100644
--- a/tests/interaction/mcpserver/test_resources.py
+++ b/tests/interaction/mcpserver/test_resources.py
@@ -14,6 +14,7 @@
from mcp import MCPError
from mcp.server.mcpserver import MCPServer
+from tests._stamp import Unstamp
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
@@ -21,7 +22,7 @@
@requirement("mcpserver:resource:static")
-async def test_read_static_resource(connect: Connect) -> None:
+async def test_read_static_resource(connect: Connect, unstamped: Unstamp) -> None:
"""A function registered for a fixed URI is served at that URI with its return value as text."""
mcp = MCPServer("library")
@@ -33,7 +34,7 @@ def app_config() -> str:
async with connect(mcp) as client:
result = await client.read_resource("config://app")
- assert result == snapshot(
+ assert unstamped(result) == snapshot(
ReadResourceResult(
contents=[TextResourceContents(uri="config://app", mime_type="text/plain", text="theme = dark")]
)
@@ -41,7 +42,7 @@ def app_config() -> str:
@requirement("mcpserver:resource:static")
-async def test_list_static_and_templated_resources(connect: Connect) -> None:
+async def test_list_static_and_templated_resources(connect: Connect, unstamped: Unstamp) -> None:
"""Statically-registered resources appear in resources/list; templated ones only in templates/list.
The name and description are derived from the function name and docstring; the MIME type
@@ -63,7 +64,7 @@ def user_profile(user_id: str) -> str:
resources = await client.list_resources()
templates = await client.list_resource_templates()
- assert resources == snapshot(
+ assert unstamped(resources) == snapshot(
ListResourcesResult(
resources=[
Resource(
@@ -75,7 +76,7 @@ def user_profile(user_id: str) -> str:
]
)
)
- assert templates == snapshot(
+ assert unstamped(templates) == snapshot(
ListResourceTemplatesResult(
resource_templates=[
ResourceTemplate(
@@ -91,7 +92,7 @@ def user_profile(user_id: str) -> str:
@requirement("mcpserver:resource:template")
@requirement("resources:read:template-vars")
-async def test_read_templated_resource(connect: Connect) -> None:
+async def test_read_templated_resource(connect: Connect, unstamped: Unstamp) -> None:
"""Reading a URI that matches a registered template invokes the function with the extracted parameters."""
mcp = MCPServer("library")
@@ -103,7 +104,7 @@ def user_profile(user_id: str) -> str:
async with connect(mcp) as client:
result = await client.read_resource("users://42/profile")
- assert result == snapshot(
+ assert unstamped(result) == snapshot(
ReadResourceResult(
contents=[TextResourceContents(uri="users://42/profile", mime_type="text/plain", text="profile for 42")]
)
@@ -152,7 +153,9 @@ def boom() -> str:
@requirement("mcpserver:resource:duplicate-name")
-async def test_registering_a_duplicate_resource_uri_warns_and_keeps_the_first(connect: Connect) -> None:
+async def test_registering_a_duplicate_resource_uri_warns_and_keeps_the_first(
+ connect: Connect, unstamped: Unstamp
+) -> None:
"""Registering a second static resource at an already-used URI keeps the first registration.
The intended behaviour is rejection at registration time; MCPServer instead logs a warning
@@ -178,6 +181,6 @@ def config_second() -> str:
assert [resource.uri for resource in listed.resources] == ["config://app"]
assert listed.resources[0].name == "config_first"
- assert result == snapshot(
+ assert unstamped(result) == snapshot(
ReadResourceResult(contents=[TextResourceContents(uri="config://app", mime_type="text/plain", text="first")])
)
diff --git a/tests/interaction/mcpserver/test_tools.py b/tests/interaction/mcpserver/test_tools.py
index a7791d7cda..a6418ac9c5 100644
--- a/tests/interaction/mcpserver/test_tools.py
+++ b/tests/interaction/mcpserver/test_tools.py
@@ -17,18 +17,19 @@
from pydantic import BaseModel, Field
from mcp import MCPError
+from mcp.client import IncomingMessage
from mcp.server.mcpserver import Context, MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.shared.exceptions import UrlElicitationRequiredError
+from tests._stamp import Unstamp
from tests.interaction._connect import Connect
-from tests.interaction._helpers import IncomingMessage
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
@requirement("tools:call:content:text")
-async def test_call_tool_returns_text_content(connect: Connect) -> None:
+async def test_call_tool_returns_text_content(connect: Connect, unstamped: Unstamp) -> None:
"""Arguments reach the tool function; its return value comes back as text content.
MCPServer also derives an output schema from the return annotation and attaches the
@@ -43,11 +44,15 @@ def add(a: int, b: int) -> str:
async with connect(mcp) as client:
result = await client.call_tool("add", {"a": 2, "b": 3})
- assert result == snapshot(CallToolResult(content=[TextContent(text="5")], structured_content={"result": "5"}))
+ assert unstamped(result) == snapshot(
+ CallToolResult(content=[TextContent(text="5")], structured_content={"result": "5"})
+ )
@requirement("mcpserver:tool:schema-variants")
-async def test_complex_parameter_types_are_validated_and_coerced_before_the_tool_runs(connect: Connect) -> None:
+async def test_complex_parameter_types_are_validated_and_coerced_before_the_tool_runs(
+ connect: Connect, unstamped: Unstamp
+) -> None:
"""Literal, nested-model, and constrained parameters are validated and coerced from the wire arguments.
The string "3" is coerced to `int` and the `point` dict to a `Point` instance before the function
@@ -67,7 +72,7 @@ def place(mode: Literal["fast", "slow"], point: Point, count: Annotated[int, Fie
async with connect(mcp) as client:
result = await client.call_tool("place", {"mode": "fast", "point": {"x": "3", "y": 4}, "count": 5})
- assert result == snapshot(
+ assert unstamped(result) == snapshot(
CallToolResult(
content=[TextContent(text="fast at (3, 4) x5")], structured_content={"result": "fast at (3, 4) x5"}
)
@@ -76,7 +81,7 @@ def place(mode: Literal["fast", "slow"], point: Point, count: Annotated[int, Fie
@requirement("mcpserver:tool:handler-throws")
@requirement("mcpserver:output-schema:skip-on-error")
-async def test_call_tool_function_exception_becomes_error_result(connect: Connect) -> None:
+async def test_call_tool_function_exception_becomes_error_result(connect: Connect, unstamped: Unstamp) -> None:
"""An exception raised by a tool function is returned as an is_error result, not a JSON-RPC error.
The function's `-> str` annotation gives the tool a derived output schema, but the error
@@ -92,13 +97,13 @@ def explode() -> str:
async with connect(mcp) as client:
result = await client.call_tool("explode", {})
- assert result == snapshot(
+ assert unstamped(result) == snapshot(
CallToolResult(content=[TextContent(text="Error executing tool explode: boom")], is_error=True)
)
@requirement("mcpserver:tool:handler-throws")
-async def test_call_tool_tool_error_becomes_error_result(connect: Connect) -> None:
+async def test_call_tool_tool_error_becomes_error_result(connect: Connect, unstamped: Unstamp) -> None:
"""A ToolError raised by a tool function is returned as an is_error result, not a JSON-RPC error."""
mcp = MCPServer("errors")
@@ -109,13 +114,13 @@ def flux() -> str:
async with connect(mcp) as client:
result = await client.call_tool("flux", {})
- assert result == snapshot(
+ assert unstamped(result) == snapshot(
CallToolResult(content=[TextContent(text="Error executing tool flux: flux capacitor offline")], is_error=True)
)
@requirement("mcpserver:tool:unknown-name")
-async def test_call_tool_unknown_name_returns_error_result(connect: Connect) -> None:
+async def test_call_tool_unknown_name_returns_error_result(connect: Connect, unstamped: Unstamp) -> None:
"""Calling a tool name that was never registered is reported as an is_error result.
The spec classifies unknown tools as a protocol error; see the divergence note on the
@@ -130,12 +135,14 @@ def add() -> None:
async with connect(mcp) as client:
result = await client.call_tool("nope", {})
- assert result == snapshot(CallToolResult(content=[TextContent(text="Unknown tool: nope")], is_error=True))
+ assert unstamped(result) == snapshot(
+ CallToolResult(content=[TextContent(text="Unknown tool: nope")], is_error=True)
+ )
@requirement("mcpserver:tool:output-schema:model")
@requirement("tools:call:structured-content:text-mirror")
-async def test_call_tool_model_return_becomes_structured_content(connect: Connect) -> None:
+async def test_call_tool_model_return_becomes_structured_content(connect: Connect, unstamped: Unstamp) -> None:
"""A tool returning a pydantic model advertises the model's schema as the tool's output schema
and returns the model's fields as structured content alongside a serialised text block.
"""
@@ -164,7 +171,7 @@ def get_weather() -> Weather:
"type": "object",
}
)
- assert result == snapshot(
+ assert unstamped(result) == snapshot(
CallToolResult(
content=[
TextContent(
@@ -182,7 +189,7 @@ def get_weather() -> Weather:
@requirement("mcpserver:tool:output-schema:wrapped")
-async def test_call_tool_list_return_is_wrapped_in_result_key(connect: Connect) -> None:
+async def test_call_tool_list_return_is_wrapped_in_result_key(connect: Connect, unstamped: Unstamp) -> None:
"""A tool returning a list wraps the value under a "result" key in both the generated output
schema and the structured content.
"""
@@ -204,7 +211,7 @@ def primes() -> list[int]:
"type": "object",
}
)
- assert result == snapshot(
+ assert unstamped(result) == snapshot(
CallToolResult(
content=[TextContent(text="2"), TextContent(text="3"), TextContent(text="5")],
structured_content={"result": [2, 3, 5]},
@@ -279,7 +286,9 @@ def missing() -> Annotated[CallToolResult, Weather]:
@requirement("mcpserver:tool:duplicate-name")
-async def test_registering_a_duplicate_tool_name_warns_and_keeps_the_first(connect: Connect) -> None:
+async def test_registering_a_duplicate_tool_name_warns_and_keeps_the_first(
+ connect: Connect, unstamped: Unstamp
+) -> None:
"""Registering a second tool with an already-used name keeps the first registration.
The intended behaviour is rejection at registration time; MCPServer instead logs a warning
@@ -304,14 +313,14 @@ def echo_second() -> str:
result = await client.call_tool("echo", {})
assert [tool.name for tool in listed.tools] == ["echo"]
- assert result == snapshot(
+ assert unstamped(result) == snapshot(
CallToolResult(content=[TextContent(text="first")], structured_content={"result": "first"})
)
@requirement("mcpserver:tool:naming-validation")
async def test_registering_a_tool_with_a_spec_invalid_name_warns_but_does_not_reject(
- connect: Connect, caplog: pytest.LogCaptureFixture
+ connect: Connect, caplog: pytest.LogCaptureFixture, unstamped: Unstamp
) -> None:
"""A tool name that violates the SEP-986 rules logs a warning at registration but is still registered.
@@ -340,7 +349,9 @@ def bad() -> str:
result = await client.call_tool("bad name!", {})
assert [tool.name for tool in listed.tools] == ["bad name!"]
- assert result == snapshot(CallToolResult(content=[TextContent(text="ok")], structured_content={"result": "ok"}))
+ assert unstamped(result) == snapshot(
+ CallToolResult(content=[TextContent(text="ok")], structured_content={"result": "ok"})
+ )
@requirement("mcpserver:tool:url-elicitation-error")
@@ -420,7 +431,7 @@ async def grow(ctx: Context) -> str:
async def collect(message: IncomingMessage) -> None:
received.append(message)
- async with connect(mcp, message_handler=collect) as client:
+ async with connect(mcp, message_handler=collect, log_level="debug") as client:
before = await client.list_tools()
await client.call_tool("grow", {})
after = await client.list_tools()
diff --git a/tests/interaction/transports/test_hosting_http_modern.py b/tests/interaction/transports/test_hosting_http_modern.py
index 301af558c1..9ebaa71460 100644
--- a/tests/interaction/transports/test_hosting_http_modern.py
+++ b/tests/interaction/transports/test_hosting_http_modern.py
@@ -22,6 +22,7 @@
INVALID_PARAMS,
METHOD_NOT_FOUND,
MISSING_REQUIRED_CLIENT_CAPABILITY,
+ SERVER_INFO_META_KEY,
CallToolRequestParams,
CallToolResult,
DiscoverResult,
@@ -77,7 +78,11 @@ def _meta_envelope() -> dict[str, object]:
def _server(*, on_meta: Callable[[dict[str, Any]], None] | None = None) -> Server:
- """A low-level server with one ``add`` tool for the raw-httpx2 tests below."""
+ """A low-level server with one `add` tool for the raw-httpx2 tests below.
+
+ The explicit version gives the `_meta` serverInfo stamp every 2026 result
+ carries a non-empty value for the wire-level snapshots.
+ """
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
tool = Tool(name="add", input_schema={"type": "object"})
@@ -91,7 +96,7 @@ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) ->
on_meta(dict(ctx.meta))
return CallToolResult(content=[TextContent(text=str(params.arguments["a"] + params.arguments["b"]))])
- return Server("modern", on_list_tools=list_tools, on_call_tool=call_tool)
+ return Server("modern", version="1.0.0", on_list_tools=list_tools, on_call_tool=call_tool)
@requirement("hosting:http:modern:tools-call-stateless")
@@ -99,9 +104,10 @@ async def test_modern_tools_call_returns_result_type_complete_without_initialize
"""A 2026-07-28 tools/call is served without an initialize handshake and returns resultType: complete.
Spec-mandated under the draft transport: the per-request ``_meta`` envelope replaces initialize,
- and ``resultType`` is the 2026 result-envelope discriminator (``complete`` for the monolith
- result). Asserted at the wire because the SDK client never surfaces ``resultType`` and because
- the absence of any prior request on the connection is the assertion.
+ `resultType` is the 2026 result-envelope discriminator (`complete` for the monolith
+ result), and the server identifies itself via the result `_meta` serverInfo stamp. Asserted at
+ the wire because the SDK client never surfaces `resultType` and because the absence of any
+ prior request on the connection is the assertion.
"""
body = {
"jsonrpc": "2.0",
@@ -117,7 +123,12 @@ async def test_modern_tools_call_returns_result_type_complete_without_initialize
parsed = JSONRPCResponse.model_validate(response.json())
assert parsed.id == 1
assert parsed.result == snapshot(
- {"content": [{"text": "5", "type": "text"}], "isError": False, "resultType": "complete"}
+ {
+ "content": [{"text": "5", "type": "text"}],
+ "isError": False,
+ "resultType": "complete",
+ "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "modern", "version": "1.0.0"}},
+ }
)
@@ -213,12 +224,13 @@ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) ->
@requirement("hosting:http:modern:discover-response-shape")
async def test_modern_server_discover_returns_capabilities_and_supported_versions() -> None:
- """A 2026-07-28 server/discover POST returns capabilities, serverInfo, and supportedVersions.
+ """A 2026-07-28 server/discover POST returns capabilities and supportedVersions, with serverInfo in `_meta`.
Spec-mandated under the draft: server/discover is the 2026 advertisement method that replaces
the initialize-response payload, and ``supportedVersions`` is the field a client picks its
- per-request envelope version from. Asserted at the wire because the SDK client never exposes
- the raw result body.
+ per-request envelope version from. The server's identity is no longer a result-body field: it
+ travels as the io.modelcontextprotocol/serverInfo result `_meta` stamp. Asserted at the wire
+ because the SDK client never exposes the raw result body.
"""
body = {"jsonrpc": "2.0", "id": 1, "method": "server/discover", "params": {"_meta": _meta_envelope()}}
async with mounted_app(_server()) as (http, _):
@@ -227,7 +239,8 @@ async def test_modern_server_discover_returns_capabilities_and_supported_version
assert response.status_code == 200
result = JSONRPCResponse.model_validate(response.json()).result
assert result["supportedVersions"] == snapshot(["2026-07-28"])
- assert result["serverInfo"]["name"] == "modern"
+ assert "serverInfo" not in result
+ assert result["_meta"][SERVER_INFO_META_KEY] == {"name": "modern", "version": "1.0.0"}
assert "capabilities" in result
@@ -282,7 +295,7 @@ async def cap_check(ctx: ServerRequestContext, params: RequestParams) -> EmptyRe
raise MCPError(
code=MISSING_REQUIRED_CLIENT_CAPABILITY,
message="sampling required",
- data={"requiredCapabilities": ["sampling"]},
+ data={"requiredCapabilities": {"sampling": {}}},
)
server = _server()
@@ -294,7 +307,7 @@ async def cap_check(ctx: ServerRequestContext, params: RequestParams) -> EmptyRe
assert response.status_code == 400
error = JSONRPCError.model_validate(response.json()).error
assert error.code == MISSING_REQUIRED_CLIENT_CAPABILITY
- assert error.data == {"requiredCapabilities": ["sampling"]}
+ assert error.data == {"requiredCapabilities": {"sampling": {}}}
@requirement("hosting:http:modern:tools-call-stateless")
@@ -340,7 +353,6 @@ async def on_response(response: httpx2.Response) -> None:
DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
- server_info=Implementation(name="srv", version="0"),
)
)
result = await session.call_tool(
@@ -350,7 +362,12 @@ async def on_response(response: httpx2.Response) -> None:
)
assert result.model_dump(by_alias=True, mode="json", exclude_none=True) == snapshot(
- {"content": [{"type": "text", "text": "5"}], "isError": False, "resultType": "complete"}
+ {
+ "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "modern", "version": "1.0.0"}},
+ "content": [{"type": "text", "text": "5"}],
+ "isError": False,
+ "resultType": "complete",
+ }
)
# Exactly the tools/call POST and the implicit tools/list POST -- no initialize, no
@@ -440,7 +457,6 @@ async def on_request(request: httpx2.Request) -> None:
discover = DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
- server_info=Implementation(name="srv", version="0"),
)
with anyio.fail_after(5):
async with (
@@ -487,7 +503,6 @@ async def on_request(request: httpx2.Request) -> None:
discover = DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
- server_info=Implementation(name="srv", version="0"),
)
with anyio.fail_after(5):
async with (
@@ -541,7 +556,6 @@ async def on_request(request: httpx2.Request) -> None:
discover = DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
- server_info=Implementation(name="srv", version="0"),
)
with anyio.fail_after(5):
async with (
@@ -596,7 +610,6 @@ async def on_request(request: httpx2.Request) -> None:
discover = DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
- server_info=Implementation(name="srv", version="0"),
)
with anyio.fail_after(5):
async with (
diff --git a/tests/interaction/transports/test_stdio.py b/tests/interaction/transports/test_stdio.py
index dbb7de3459..8fd21ab426 100644
--- a/tests/interaction/transports/test_stdio.py
+++ b/tests/interaction/transports/test_stdio.py
@@ -90,6 +90,7 @@ async def collect(params: LoggingMessageNotificationParams) -> None:
# Must exceed session time plus the patched PROCESS_TERMINATION_TIMEOUT (20s).
with anyio.fail_after(30):
async with Client(transport, mode="legacy", logging_callback=collect) as client:
+ assert client.server_info is not None
assert client.server_info.name == "stdio-echo"
result = await client.call_tool("echo", {"text": "across\nprocesses"})
@@ -113,7 +114,8 @@ async def test_stdio_server_writes_one_jsonrpc_message_per_line() -> None:
"""Every `stdio_server` write is one valid JSON-RPC message on its own line.
Each line is newline-terminated with payload newlines JSON-escaped. This proves the
- transport's own framing; it does not guard `sys.stdout` against handler code (see the
+ transport's own framing over injected streams; the descriptor-level guard that keeps
+ handler code off the wire is pinned by tests/server/test_stdio.py (see the narrowed
divergence on `transport:stdio:stream-purity`).
"""
captured = io.StringIO()
diff --git a/tests/interaction/transports/test_streamable_http.py b/tests/interaction/transports/test_streamable_http.py
index 779a46054a..2176b27823 100644
--- a/tests/interaction/transports/test_streamable_http.py
+++ b/tests/interaction/transports/test_streamable_http.py
@@ -12,23 +12,37 @@
from inline_snapshot import snapshot
from mcp_types import (
INVALID_REQUEST,
+ CallToolRequestParams,
CallToolResult,
ElicitRequestParams,
ElicitResult,
+ ErrorData,
+ JSONRPCError,
+ JSONRPCMessage,
+ JSONRPCRequest,
LoggingMessageNotification,
LoggingMessageNotificationParams,
ResourceUpdatedNotification,
ResourceUpdatedNotificationParams,
TextContent,
+ jsonrpc_message_adapter,
)
from pydantic import BaseModel
-from mcp.client import ClientRequestContext
+from mcp.client import ClientRequestContext, IncomingMessage
+from mcp.server import Server, ServerRequestContext
from mcp.server.elicitation import AcceptedElicitation
from mcp.server.mcpserver import Context, MCPServer
+from mcp.server.streamable_http import REQUEST_CANCELLED
from mcp.shared.exceptions import MCPError
-from tests.interaction._connect import connect_over_streamable_http
-from tests.interaction._helpers import IncomingMessage
+from tests.interaction._connect import (
+ base_headers,
+ connect_over_streamable_http,
+ initialize_body,
+ initialize_via_http,
+ mounted_app,
+ post_jsonrpc,
+)
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
@@ -50,7 +64,8 @@ class Confirmation(BaseModel):
async def ask(ctx: Context) -> str:
"""Elicit a confirmation from the client and report the outcome."""
answer = await ctx.elicit("Proceed?", Confirmation)
- # In stateless mode the elicit raises before this point: there is no session to call back through.
+ # In stateless and JSON-response modes the elicit raises before this point: there is no
+ # request-scoped channel to call back through.
assert isinstance(answer, AcceptedElicitation)
return f"confirmed={answer.data.confirmed}"
@@ -69,6 +84,7 @@ async def announce(ctx: Context) -> str:
async def test_tool_call_over_streamable_http_with_json_responses() -> None:
"""The round trip works when the server answers with a single JSON body instead of an SSE stream."""
async with connect_over_streamable_http(_smoke_server(), json_response=True) as client:
+ assert client.server_info is not None
assert client.server_info.name == "smoke"
result = await client.call_tool("echo", {"text": "as json"})
@@ -105,6 +121,48 @@ async def test_stateless_streamable_http_rejects_server_initiated_requests() ->
assert exc_info.value.error.code == INVALID_REQUEST
+@requirement("transport:streamable-http:json-response-restrictions")
+async def test_json_response_streamable_http_rejects_request_scoped_server_requests() -> None:
+ """A handler that calls back to the client mid-request fails fast when the server answers with
+ JSON: the one response body cannot carry the nested `elicitation/create`, so the request-scoped
+ channel raises `NoBackChannelError` (a top-level `MCPError`) instead of parking a waiter no reply
+ could ever reach. Bounded, because before the fix this call hung until it timed out."""
+ async with connect_over_streamable_http(_smoke_server(), json_response=True) as client:
+ with anyio.fail_after(5), pytest.raises(MCPError) as exc_info:
+ await client.call_tool("ask", {})
+
+ assert exc_info.value.error.code == INVALID_REQUEST
+
+
+@requirement("transport:streamable-http:json-response-restrictions")
+@requirement("transport:streamable-http:unrelated-messages")
+@requirement("hosting:http:standalone-sse")
+async def test_json_response_streamable_http_delivers_only_unrelated_notifications() -> None:
+ """In JSON-response mode the call's own log notification has no stream to ride and never
+ reaches the client, while the tool result comes back as the JSON body and the unrelated
+ resource-updated notification arrives on the standalone stream. The handler writes both
+ notifications before returning, so once the result and the unrelated message are in, no
+ request-scoped message can still be in flight."""
+ received: list[IncomingMessage] = []
+ server_message_seen = anyio.Event()
+
+ async def collect(message: IncomingMessage) -> None:
+ received.append(message)
+ server_message_seen.set()
+
+ async with connect_over_streamable_http(_smoke_server(), json_response=True, message_handler=collect) as client:
+ with anyio.fail_after(5):
+ result = await client.call_tool("announce", {})
+ await server_message_seen.wait()
+
+ assert result == snapshot(
+ CallToolResult(content=[TextContent(text="announced")], structured_content={"result": "announced"})
+ )
+ assert received == snapshot(
+ [ResourceUpdatedNotification(params=ResourceUpdatedNotificationParams(uri="file:///watched.txt"))]
+ )
+
+
@requirement("transport:streamable-http:notifications")
@requirement("transport:streamable-http:unrelated-messages")
@requirement("hosting:http:standalone-sse")
@@ -168,3 +226,80 @@ async def answer(context: ClientRequestContext, params: ElicitRequestParams) ->
CallToolResult(content=[TextContent(text="confirmed=True")], structured_content={"result": "confirmed=True"})
)
assert [params.message for params in asked] == snapshot(["Proceed?"])
+
+
+@requirement("transport:streamable-http:cancelled-request-terminated")
+@pytest.mark.parametrize("json_response", [True, False], ids=["json-response", "sse-response"])
+async def test_cancelled_request_is_terminated_with_request_cancelled(json_response: bool) -> None:
+ """A cancelled request's POST completes carrying the `REQUEST_CANCELLED` terminal error.
+
+ The 2025-era wire ends a request only with a response, so this transport answers the
+ settled-unanswered request with `REQUEST_CANCELLED` (the dispatcher writes nothing on the
+ other transports). Driven with raw httpx2 because the observable is the HTTP exchange
+ itself, which a Client abandoning its own call would tear down first.
+ """
+ handler_started = anyio.Event()
+ handler_cancelled = anyio.Event()
+ call_request_id = 2
+
+ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
+ handler_started.set()
+ try:
+ await anyio.sleep_forever()
+ except anyio.get_cancelled_exc_class():
+ handler_cancelled.set()
+ raise
+ raise NotImplementedError # unreachable: only cancellation ends the sleep
+
+ server = Server("blocker", on_call_tool=call_tool)
+ call_body = JSONRPCRequest(
+ jsonrpc="2.0",
+ id=call_request_id,
+ method="tools/call",
+ params=CallToolRequestParams(name="block", arguments={}).model_dump(by_alias=True, mode="json"),
+ ).model_dump(by_alias=True, exclude_none=True)
+ cancel_body = {
+ "jsonrpc": "2.0",
+ "method": "notifications/cancelled",
+ "params": {"requestId": call_request_id},
+ }
+ call_answers: list[JSONRPCMessage] = []
+
+ async with mounted_app(server, json_response=json_response) as (http, _manager):
+ if json_response:
+ # The SSE-reading handshake helper does not apply: JSON mode answers initialize with JSON.
+ initialized = await http.post("/mcp", json=initialize_body(), headers=base_headers())
+ session_id = initialized.headers["mcp-session-id"]
+ ready = await http.post(
+ "/mcp",
+ json={"jsonrpc": "2.0", "method": "notifications/initialized"},
+ headers=base_headers(session_id=session_id),
+ )
+ assert ready.status_code == 202
+ else:
+ session_id = await initialize_via_http(http)
+
+ async def post_call() -> None:
+ if json_response:
+ response = await http.post("/mcp", json=call_body, headers=base_headers(session_id=session_id))
+ call_answers.append(jsonrpc_message_adapter.validate_json(response.content))
+ else:
+ _, messages = await post_jsonrpc(http, call_body, session_id=session_id)
+ call_answers.extend(messages)
+
+ with anyio.fail_after(5):
+ async with anyio.create_task_group() as task_group: # pragma: no branch
+ task_group.start_soon(post_call)
+ await handler_started.wait()
+ cancelled = await http.post("/mcp", json=cancel_body, headers=base_headers(session_id=session_id))
+ assert cancelled.status_code == 202
+ await handler_cancelled.wait()
+ # The call's POST must now complete on its own; the task group waits for it.
+
+ assert call_answers == [
+ JSONRPCError(
+ jsonrpc="2.0",
+ id=call_request_id,
+ error=ErrorData(code=REQUEST_CANCELLED, message="Request cancelled"),
+ )
+ ]
diff --git a/tests/server/lowlevel/test_server_discover.py b/tests/server/lowlevel/test_server_discover.py
index 05d57d846a..23a29327ee 100644
--- a/tests/server/lowlevel/test_server_discover.py
+++ b/tests/server/lowlevel/test_server_discover.py
@@ -2,17 +2,27 @@
These call the registered handler via the public `Server.get_request_handler`
accessor without spinning up a `ServerRunner` or any transport, so they verify
-the handler's contract in isolation from the dispatch pipeline.
+the handler's contract in isolation from the dispatch pipeline. The exception
+is the server-identity pair: the serverInfo `_meta` stamp is applied by the
+runner (spec 2026-07-28, #3002), not the handler, so those two drive one
+request through `serve_one` to observe it.
"""
-import importlib.metadata
+from collections.abc import Mapping
+from dataclasses import dataclass, field
from typing import Any, cast
+import anyio
import mcp_types as types
import pytest
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
from mcp.server import NotificationOptions, Server, ServerRequestContext
+from mcp.server.connection import Connection
+from mcp.server.runner import serve_one
+from mcp.shared.dispatcher import CallOptions
+from mcp.shared.message import MessageMetadata
+from mcp.shared.transport_context import TransportContext
# `Server._handle_discover` reads only `ctx.protocol_version` (capabilities are
@@ -36,6 +46,47 @@ async def _discover(server: Server[Any], protocol_version: str = MODERN_PROTOCOL
return result
+@dataclass
+class _StubDispatchContext:
+ """Minimal `DispatchContext` for the `serve_one`-driven identity tests.
+
+ Satisfies the protocol structurally; the discover handler never touches
+ the back-channel.
+ """
+
+ request_id: int | str | None = 1
+ transport: TransportContext = field(default_factory=lambda: TransportContext(kind="direct", can_send_request=False))
+ message_metadata: MessageMetadata = None
+ cancel_requested: anyio.Event = field(default_factory=anyio.Event)
+ can_send_request: bool = False
+
+ async def send_raw_request(
+ self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None
+ ) -> dict[str, Any]:
+ raise NotImplementedError
+
+ async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
+ raise NotImplementedError
+
+ async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
+ raise NotImplementedError
+
+
+async def _discover_over_runner(server: Server[Any]) -> dict[str, Any]:
+ """Serve one `server/discover` through the runner - the layer that stamps
+ server identity into the result `_meta`."""
+ connection = Connection.from_envelope(MODERN_PROTOCOL_VERSIONS[0], None, None)
+ params: dict[str, Any] = {
+ "_meta": {
+ types.PROTOCOL_VERSION_META_KEY: MODERN_PROTOCOL_VERSIONS[0],
+ types.CLIENT_CAPABILITIES_META_KEY: {},
+ }
+ }
+ return await serve_one(
+ server, _StubDispatchContext(), "server/discover", params, connection=connection, lifespan_state={}
+ )
+
+
def test_registered_by_default() -> None:
"""SDK-defined: a bare `Server` registers a `server/discover` handler out of
the box, typed for the base `RequestParams`."""
@@ -55,7 +106,8 @@ async def test_supported_versions_is_modern_set() -> None:
@pytest.mark.anyio
async def test_server_info_reflects_constructor_fields() -> None:
- """SDK-defined: `serverInfo` is built field-for-field from the `Server`
+ """Server identity travels as the discover result's `_meta` serverInfo
+ stamp (spec 2026-07-28, #3002), built field-for-field from the `Server`
constructor arguments."""
icons = [types.Icon(src="https://example.test/icon.png")]
server = Server(
@@ -66,8 +118,9 @@ async def test_server_info_reflects_constructor_fields() -> None:
website_url="https://example.test",
icons=icons,
)
- result = await _discover(server)
- assert result.server_info == types.Implementation(
+ result = await _discover_over_runner(server)
+ stamp = result["_meta"][types.SERVER_INFO_META_KEY]
+ assert types.Implementation.model_validate(stamp) == types.Implementation(
name="info-server",
version="9.9.9",
title="Info Server",
@@ -78,11 +131,12 @@ async def test_server_info_reflects_constructor_fields() -> None:
@pytest.mark.anyio
-async def test_server_info_version_falls_back_to_package() -> None:
- """SDK-defined: when no explicit version is supplied, `serverInfo.version`
- falls back to the installed `mcp` package version."""
- result = await _discover(Server("unversioned"))
- assert result.server_info.version == importlib.metadata.version("mcp")
+async def test_an_unversioned_server_reports_an_empty_version() -> None:
+ """SDK-defined: when no explicit version is supplied, the stamped
+ `serverInfo` version is an empty string - the SDK never substitutes its
+ own package version for the server's."""
+ result = await _discover_over_runner(Server("unversioned"))
+ assert result["_meta"][types.SERVER_INFO_META_KEY] == {"name": "unversioned", "version": ""}
@pytest.mark.anyio
@@ -143,7 +197,6 @@ async def test_overridable_via_add_request_handler() -> None:
custom = types.DiscoverResult(
supported_versions=list(MODERN_PROTOCOL_VERSIONS),
capabilities=types.ServerCapabilities(),
- server_info=types.Implementation(name="custom-server", version="1.0.0"),
instructions="overridden",
ttl_ms=60_000,
cache_scope="public",
diff --git a/tests/server/mcpserver/resources/test_file_resources.py b/tests/server/mcpserver/resources/test_file_resources.py
index 94885113a9..042ea422aa 100644
--- a/tests/server/mcpserver/resources/test_file_resources.py
+++ b/tests/server/mcpserver/resources/test_file_resources.py
@@ -1,8 +1,10 @@
+import codecs
import os
from pathlib import Path
from tempfile import NamedTemporaryFile
import pytest
+from pydantic import ValidationError
from mcp.server.mcpserver.resources import FileResource
@@ -24,93 +26,173 @@ def temp_file():
pass # File was already deleted by the test
-class TestFileResource:
- """Test FileResource functionality."""
+def test_file_resource_creation(temp_file: Path):
+ resource = FileResource(
+ uri=temp_file.as_uri(),
+ name="test",
+ description="test file",
+ path=temp_file,
+ )
+ assert str(resource.uri) == temp_file.as_uri()
+ assert resource.name == "test"
+ assert resource.description == "test file"
+ assert resource.mime_type == "text/plain"
+ assert resource.path == temp_file
+ assert resource.encoding == "utf-8-sig"
- def test_file_resource_creation(self, temp_file: Path):
- """Test creating a FileResource."""
- resource = FileResource(
- uri=temp_file.as_uri(),
- name="test",
- description="test file",
- path=temp_file,
- )
- assert str(resource.uri) == temp_file.as_uri()
- assert resource.name == "test"
- assert resource.description == "test file"
- assert resource.mime_type == "text/plain" # default
- assert resource.path == temp_file
- assert resource.is_binary is False # default
-
- def test_file_resource_str_path_conversion(self, temp_file: Path):
- """Test FileResource handles string paths."""
- resource = FileResource(
- uri=f"file://{temp_file}",
- name="test",
- path=Path(str(temp_file)),
- )
- assert isinstance(resource.path, Path)
- assert resource.path.is_absolute()
- @pytest.mark.anyio
- async def test_read_text_file(self, temp_file: Path):
- """Test reading a text file."""
- resource = FileResource(
- uri=f"file://{temp_file}",
+def test_file_resource_str_path_conversion(temp_file: Path):
+ resource = FileResource(
+ uri=f"file://{temp_file}",
+ name="test",
+ path=Path(str(temp_file)),
+ )
+ assert isinstance(resource.path, Path)
+ assert resource.path.is_absolute()
+
+
+@pytest.mark.anyio
+async def test_read_text_file(temp_file: Path):
+ resource = FileResource(
+ uri=f"file://{temp_file}",
+ name="test",
+ path=temp_file,
+ )
+ content = await resource.read()
+ assert content == "test content"
+ assert resource.mime_type == "text/plain"
+
+
+@pytest.mark.anyio
+async def test_encoding_none_reads_bytes(temp_file: Path):
+ resource = FileResource(
+ uri=f"file://{temp_file}",
+ name="test",
+ path=temp_file,
+ encoding=None,
+ )
+ content = await resource.read()
+ assert isinstance(content, bytes)
+ assert content == b"test content"
+
+
+@pytest.mark.parametrize(
+ "mime_type",
+ [
+ "text/plain",
+ "text/html",
+ "application/json",
+ "application/xml",
+ "application/vnd.api+json",
+ "image/svg+xml",
+ ],
+)
+def test_textual_mime_types_default_to_utf8_sig(temp_file: Path, mime_type: str):
+ resource = FileResource(uri=temp_file.as_uri(), path=temp_file, mime_type=mime_type)
+ assert resource.encoding == "utf-8-sig"
+
+
+@pytest.mark.parametrize("mime_type", ["image/png", "application/octet-stream", "application/pdf"])
+def test_binary_mime_types_default_to_no_encoding(temp_file: Path, mime_type: str):
+ resource = FileResource(uri=temp_file.as_uri(), path=temp_file, mime_type=mime_type)
+ assert resource.encoding is None
+
+
+@pytest.mark.parametrize(
+ "mime_type",
+ ["text/plain; charset=iso-8859-1", 'text/plain; format=flowed; charset="iso-8859-1"'],
+)
+def test_declared_charset_becomes_default_encoding(temp_file: Path, mime_type: str):
+ resource = FileResource(uri=temp_file.as_uri(), path=temp_file, mime_type=mime_type)
+ assert resource.encoding == "iso-8859-1"
+
+
+def test_removed_is_binary_kwarg_is_rejected(temp_file: Path):
+ """The v1 `is_binary` parameter fails loudly at construction rather than being ignored."""
+ with pytest.raises(ValidationError, match="is_binary"):
+ FileResource.model_validate({"uri": temp_file.as_uri(), "path": temp_file, "is_binary": True})
+
+
+def test_unknown_encoding_is_rejected(temp_file: Path):
+ """A codec typo fails at construction rather than on the first read."""
+ with pytest.raises(ValidationError, match="unknown encoding: not-a-codec"):
+ FileResource(uri=temp_file.as_uri(), path=temp_file, encoding="not-a-codec")
+
+
+def test_unknown_declared_charset_is_rejected(temp_file: Path):
+ with pytest.raises(ValidationError, match="unknown encoding"):
+ FileResource(uri=temp_file.as_uri(), path=temp_file, mime_type="text/plain; charset=not-a-codec")
+
+
+def test_multibyte_encoding_is_accepted(temp_file: Path):
+ """UTF-16 can't decode a lone probe byte but is still a valid text encoding."""
+ resource = FileResource(uri=temp_file.as_uri(), path=temp_file, encoding="utf-16")
+ assert resource.encoding == "utf-16"
+
+
+def test_non_text_codec_is_rejected(temp_file: Path):
+ """A registered codec that isn't a text encoding (bytes-to-bytes) is not a usable encoding."""
+ with pytest.raises(ValidationError, match="not a text encoding"):
+ FileResource(uri=temp_file.as_uri(), path=temp_file, encoding="base64_codec")
+
+
+@pytest.mark.anyio
+async def test_json_file_is_served_as_text_by_default(temp_file: Path):
+ """The mime type that motivated the encoding field: JSON must not become a base64 blob."""
+ temp_file.write_text('{"a": 1}', encoding="utf-8")
+ resource = FileResource(uri=temp_file.as_uri(), path=temp_file, mime_type="application/json")
+ assert await resource.read() == '{"a": 1}'
+
+
+@pytest.mark.anyio
+async def test_utf8_bom_is_stripped_by_default(temp_file: Path):
+ """The default utf-8-sig decoding drops a byte-order mark that would otherwise break JSON parsers."""
+ temp_file.write_bytes(codecs.BOM_UTF8 + b'{"a": 1}')
+ resource = FileResource(uri=temp_file.as_uri(), path=temp_file, mime_type="application/json")
+ assert await resource.read() == '{"a": 1}'
+
+
+@pytest.mark.anyio
+async def test_explicit_encoding_overrides_default(temp_file: Path):
+ """An explicit encoding wins over the mime-type default and is what decodes the file."""
+ temp_file.write_bytes("naïve".encode("latin-1"))
+ resource = FileResource(uri=temp_file.as_uri(), path=temp_file, mime_type="image/png", encoding="latin-1")
+ assert resource.encoding == "latin-1"
+ assert await resource.read() == "naïve"
+
+
+def test_relative_path_error():
+ with pytest.raises(ValueError, match="Path must be absolute"):
+ FileResource(
+ uri="file:///test.txt",
name="test",
- path=temp_file,
+ path=Path("test.txt"),
)
- content = await resource.read()
- assert content == "test content"
- assert resource.mime_type == "text/plain"
- @pytest.mark.anyio
- async def test_read_binary_file(self, temp_file: Path):
- """Test reading a file as binary."""
+
+@pytest.mark.anyio
+async def test_missing_file_error(temp_file: Path):
+ missing = temp_file.parent / "missing.txt"
+ resource = FileResource(
+ uri="file:///missing.txt",
+ name="test",
+ path=missing,
+ )
+ with pytest.raises(ValueError, match="Error reading file"):
+ await resource.read()
+
+
+@pytest.mark.skipif(os.name == "nt", reason="File permissions behave differently on Windows")
+@pytest.mark.anyio
+async def test_permission_error(temp_file: Path): # pragma: lax no cover
+ temp_file.chmod(0o000) # Remove all permissions
+ try:
resource = FileResource(
- uri=f"file://{temp_file}",
+ uri=temp_file.as_uri(),
name="test",
path=temp_file,
- is_binary=True,
- )
- content = await resource.read()
- assert isinstance(content, bytes)
- assert content == b"test content"
-
- def test_relative_path_error(self):
- """Test error on relative path."""
- with pytest.raises(ValueError, match="Path must be absolute"):
- FileResource(
- uri="file:///test.txt",
- name="test",
- path=Path("test.txt"),
- )
-
- @pytest.mark.anyio
- async def test_missing_file_error(self, temp_file: Path):
- """Test error when file doesn't exist."""
- # Create path to non-existent file
- missing = temp_file.parent / "missing.txt"
- resource = FileResource(
- uri="file:///missing.txt",
- name="test",
- path=missing,
)
with pytest.raises(ValueError, match="Error reading file"):
await resource.read()
-
- @pytest.mark.skipif(os.name == "nt", reason="File permissions behave differently on Windows")
- @pytest.mark.anyio
- async def test_permission_error(self, temp_file: Path): # pragma: lax no cover
- """Test reading a file without permissions."""
- temp_file.chmod(0o000) # Remove all permissions
- try:
- resource = FileResource(
- uri=temp_file.as_uri(),
- name="test",
- path=temp_file,
- )
- with pytest.raises(ValueError, match="Error reading file"):
- await resource.read()
- finally:
- temp_file.chmod(0o644) # Restore permissions
+ finally:
+ temp_file.chmod(0o644) # Restore permissions
diff --git a/tests/server/mcpserver/test_extension.py b/tests/server/mcpserver/test_extension.py
index b6ff0283d6..5aa05b1e3d 100644
--- a/tests/server/mcpserver/test_extension.py
+++ b/tests/server/mcpserver/test_extension.py
@@ -2,11 +2,11 @@
These exercise the closed set of extension contribution kinds - tools,
resources, request methods, and the single `tools/call` interceptor - through
-the highest-level public surface (in-memory `Client`), plus the
-`compose_tool_call_interceptor` helper directly.
+the highest-level public surface (in-memory `Client`).
"""
-from typing import Any, Literal, cast
+from dataclasses import replace
+from typing import Any, Literal
import mcp_types as types
import pytest
@@ -14,6 +14,7 @@
from mcp_types import (
METHOD_NOT_FOUND,
MISSING_REQUIRED_CLIENT_CAPABILITY,
+ SERVER_INFO_META_KEY,
CallToolResult,
TextContent,
)
@@ -26,7 +27,6 @@
MethodBinding,
ResourceBinding,
ToolBinding,
- compose_tool_call_interceptor,
)
from mcp.server.mcpserver import Context, MCPServer, require_client_extension
from mcp.server.mcpserver.resources import TextResource
@@ -150,10 +150,17 @@ async def test_additive_extension_registers_its_tool_and_resource() -> None:
assert [t.name for t in tools.tools] == ["ping"]
assert tools.tools[0].meta == _TOOL_META
- assert called == snapshot(CallToolResult(content=[TextContent(text="pong")], structured_content={"result": "pong"}))
+ assert called == snapshot(
+ CallToolResult(
+ _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
+ content=[TextContent(text="pong")],
+ structured_content={"result": "pong"},
+ )
+ )
assert resources == snapshot(
types.ListResourcesResult(
- resources=[types.Resource(name="greeting", uri="ext://greeting", mime_type="text/plain")]
+ _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
+ resources=[types.Resource(name="greeting", uri="ext://greeting", mime_type="text/plain")],
)
)
@@ -195,7 +202,9 @@ async def test_extension_method_reachable_via_session_send_request() -> None:
request = _PingRequest(params=_PingParams())
result = await client.session.send_request(request, _PingResult)
- assert result == snapshot(_PingResult(pong=True))
+ assert result == snapshot(
+ _PingResult(_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, pong=True)
+ )
async def test_pass_through_interceptor_leaves_tool_result_unchanged() -> None:
@@ -207,7 +216,13 @@ async def test_pass_through_interceptor_leaves_tool_result_unchanged() -> None:
async with Client(server) as client:
result = await client.call_tool("echo", {"value": "hi"})
- assert result == snapshot(CallToolResult(content=[TextContent(text="hi")], structured_content={"result": "hi"}))
+ assert result == snapshot(
+ CallToolResult(
+ _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
+ content=[TextContent(text="hi")],
+ structured_content={"result": "hi"},
+ )
+ )
async def test_short_circuiting_interceptor_replaces_tool_result() -> None:
@@ -219,26 +234,36 @@ async def test_short_circuiting_interceptor_replaces_tool_result() -> None:
async with Client(server) as client:
result = await client.call_tool("echo", {"value": "hi"})
- assert result == snapshot(CallToolResult(content=[TextContent(text="intercepted")]))
+ assert result == snapshot(
+ CallToolResult(
+ _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
+ content=[TextContent(text="intercepted")],
+ )
+ )
-def test_plain_extension_installs_no_tool_call_interceptor() -> None:
- """SDK-defined: an extension that does not override `intercept_tool_call` adds no
- middleware - the composed interceptor exists only when at least one extension
- overrides it."""
- baseline = len(MCPServer("test")._lowlevel_server.middleware)
+def test_plain_extension_leaves_the_tool_call_handler_bare() -> None:
+ """SDK-defined: an extension that does not override `intercept_tool_call` leaves
+ `tools/call` registered as the server's own handler - the interceptor chain is
+ composed only when at least one extension overrides it."""
server = MCPServer("test", extensions=[_AdditiveExt()])
- assert len(server._lowlevel_server.middleware) == baseline
+ entry = server._lowlevel_server.get_request_handler("tools/call")
+ assert entry is not None
+ assert entry.handler == server._handle_call_tool
-def test_overriding_extension_installs_one_tool_call_interceptor() -> None:
- """SDK-defined: an extension that overrides `intercept_tool_call` composes exactly
- one additional `tools/call` middleware."""
+def test_overriding_extension_wraps_the_tool_call_handler() -> None:
+ """SDK-defined: an extension that overrides `intercept_tool_call` re-registers
+ `tools/call` with the interceptor chain wrapped around the server's own handler,
+ and installs no middleware."""
baseline = len(MCPServer("test")._lowlevel_server.middleware)
server = MCPServer("test", extensions=[_ReplacingExt()])
- assert len(server._lowlevel_server.middleware) == baseline + 1
+ entry = server._lowlevel_server.get_request_handler("tools/call")
+ assert entry is not None
+ assert entry.handler != server._handle_call_tool
+ assert len(server._lowlevel_server.middleware) == baseline
async def test_default_interceptor_passes_through_alongside_an_overriding_one() -> None:
@@ -251,11 +276,17 @@ async def test_default_interceptor_passes_through_alongside_an_overriding_one()
async with Client(server) as client:
result = await client.call_tool("echo", {"value": "hi"})
- assert result == snapshot(CallToolResult(content=[TextContent(text="hi")], structured_content={"result": "hi"}))
+ assert result == snapshot(
+ CallToolResult(
+ _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
+ content=[TextContent(text="hi")],
+ structured_content={"result": "hi"},
+ )
+ )
async def test_interceptors_run_in_registration_order_with_threaded_params() -> None:
- """SDK-defined: `compose_tool_call_interceptor` nests extensions first-outermost, so
+ """SDK-defined: `compose_tool_call_handler` nests extensions first-outermost, so
two passing-through interceptors record in registration order, each seeing the
validated `tools/call` params (the real tool name)."""
log: list[tuple[str, str]] = []
@@ -271,26 +302,48 @@ async def test_interceptors_run_in_registration_order_with_threaded_params() ->
assert log == [("com.example/first", "echo"), ("com.example/second", "echo")]
-async def test_compose_tool_call_interceptor_passes_through_non_tools_call() -> None:
- """SDK-defined: the composed middleware is a no-op for any method other than
- `tools/call` - it forwards to `call_next` without touching the interceptors."""
- sentinel = types.EmptyResult()
+async def test_an_interceptor_context_rewrite_does_not_change_the_tool_invocation() -> None:
+ """SDK-defined: the validated `params` argument is authoritative - an
+ interceptor passing a rewritten context through `call_next` adjusts what
+ the handler observes on `ctx`, not which tool call runs. Wire-level
+ request rewriting belongs to `Server.middleware`, above params validation."""
+
+ class _RewritingExt(Extension):
+ identifier = "com.example/rewriting"
- async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult:
- return sentinel
+ async def intercept_tool_call(
+ self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
+ ) -> HandlerResult:
+ rewritten = {**(ctx.params or {}), "arguments": {"value": "rewritten"}}
+ return await call_next(replace(ctx, params=rewritten))
- middleware = compose_tool_call_interceptor([_ReplacingExt()])
- ctx = ServerRequestContext(
- session=cast("Any", None),
- lifespan_context={},
- protocol_version="2026-07-28",
- method="tasks/get",
- params={"taskId": "t-1"},
+ server = MCPServer("test", extensions=[_RewritingExt()])
+ server.tool(name="echo")(_echo)
+
+ async with Client(server) as client:
+ result = await client.call_tool("echo", {"value": "original"})
+
+ assert result == snapshot(
+ CallToolResult(
+ _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
+ content=[TextContent(text="original")],
+ structured_content={"result": "original"},
+ )
)
- result = await middleware(ctx, call_next)
- assert result is sentinel
+async def test_short_circuited_interceptor_result_carries_the_server_info_stamp() -> None:
+ """Spec-mandated (2026-07-28, #3002): an interceptor that answers without running
+ the tool still produces a stamped result - interception happens at the handler
+ layer, below the runner's outbound envelope pass."""
+ server = MCPServer("test", extensions=[_ReplacingExt()])
+ server.tool(name="echo", structured_output=False)(_echo)
+
+ async with Client(server) as client:
+ result = await client.call_tool("echo", {"value": "hi"})
+
+ assert result.content == [TextContent(text="intercepted")]
+ assert result.meta == {SERVER_INFO_META_KEY: {"name": "test", "version": ""}}
def test_extension_subclass_without_prefixed_identifier_is_rejected_at_definition() -> None:
@@ -345,7 +398,9 @@ async def test_version_pinned_method_is_served_at_an_allowed_version() -> None:
request = _VersionPinnedRequest(params=_VersionPinnedParams())
result = await client.session.send_request(request, _VersionPinnedResult)
- assert result == snapshot(_VersionPinnedResult(ok=True))
+ assert result == snapshot(
+ _VersionPinnedResult(_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, ok=True)
+ )
async def test_version_pinned_method_is_method_not_found_at_a_disallowed_version() -> None:
@@ -425,7 +480,13 @@ async def test_require_client_extension_passes_when_client_declared_it() -> None
async with Client(server, extensions=[advertise(_NEEDS_EXT)]) as client:
result = await client.call_tool("guarded", {})
- assert result == snapshot(CallToolResult(content=[TextContent(text="ok")], structured_content={"result": "ok"}))
+ assert result == snapshot(
+ CallToolResult(
+ _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
+ content=[TextContent(text="ok")],
+ structured_content={"result": "ok"},
+ )
+ )
async def test_require_client_extension_raises_minus_32021_when_client_did_not_declare_it() -> None:
diff --git a/tests/server/mcpserver/test_integration.py b/tests/server/mcpserver/test_integration.py
index f6361f7574..2c57aab086 100644
--- a/tests/server/mcpserver/test_integration.py
+++ b/tests/server/mcpserver/test_integration.py
@@ -14,7 +14,6 @@
import pytest
from inline_snapshot import snapshot
from mcp_types import (
- ClientResult,
CreateMessageRequestParams,
CreateMessageResult,
ElicitRequestParams,
@@ -30,7 +29,6 @@
ResourceListChangedNotification,
ResourceTemplateReference,
ServerNotification,
- ServerRequest,
TextContent,
TextResourceContents,
ToolListChangedNotification,
@@ -48,8 +46,7 @@
structured_output,
tool_progress,
)
-from mcp.client import Client, ClientRequestContext
-from mcp.shared.session import RequestResponder
+from mcp.client import Client, ClientRequestContext, IncomingMessage
pytestmark = pytest.mark.anyio
@@ -63,9 +60,7 @@ def __init__(self):
self.resource_notifications: list[NotificationParams | None] = []
self.tool_notifications: list[NotificationParams | None] = []
- async def handle_generic_notification(
- self, message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception
- ) -> None:
+ async def handle_generic_notification(self, message: IncomingMessage) -> None:
"""Handle any server notification and route to appropriate handler."""
if isinstance(message, ServerNotification): # pragma: no branch
if isinstance(message, ProgressNotification):
@@ -180,7 +175,7 @@ async def test_tool_progress() -> None:
"""Test tool progress reporting."""
collector = NotificationCollector()
- async def message_handler(message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception):
+ async def message_handler(message: IncomingMessage):
await collector.handle_generic_notification(message)
if isinstance(message, Exception): # pragma: no cover
raise message
@@ -259,7 +254,7 @@ async def test_notifications() -> None:
"""Test notifications and logging functionality."""
collector = NotificationCollector()
- async def message_handler(message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception):
+ async def message_handler(message: IncomingMessage):
await collector.handle_generic_notification(message)
if isinstance(message, Exception): # pragma: no cover
raise message
diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py
index 3103a50f38..48e900dcab 100644
--- a/tests/server/mcpserver/test_server.py
+++ b/tests/server/mcpserver/test_server.py
@@ -10,6 +10,7 @@
from mcp_types import (
INTERNAL_ERROR,
INVALID_PARAMS,
+ INVALID_REQUEST,
MISSING_REQUIRED_CLIENT_CAPABILITY,
AudioContent,
BlobResourceContents,
@@ -998,7 +999,8 @@ def get_csv(user: str) -> str:
result = await client.read_resource("resource://bob/csv")
assert result == snapshot(
ReadResourceResult(
- contents=[TextResourceContents(uri="resource://bob/csv", mime_type="text/csv", text="csv for bob")]
+ _meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}},
+ contents=[TextResourceContents(uri="resource://bob/csv", mime_type="text/csv", text="csv for bob")],
)
)
@@ -1065,6 +1067,7 @@ def get_data() -> str:
result = await client.read_resource("resource://data")
assert result == snapshot(
ReadResourceResult(
+ _meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}},
contents=[
TextResourceContents(
uri="resource://data",
@@ -1072,7 +1075,7 @@ def get_data() -> str:
meta={"version": "1.0", "category": "config"}, # type: ignore[reportUnknownMemberType]
text="test data",
)
- ]
+ ],
)
)
@@ -1234,11 +1237,12 @@ def resource_no_context(name: str) -> str:
result = await client.read_resource("resource://nocontext/test")
assert result == snapshot(
ReadResourceResult(
+ _meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}},
contents=[
TextResourceContents(
uri="resource://nocontext/test", mime_type="text/plain", text="Resource test works"
)
- ]
+ ],
)
)
@@ -1262,11 +1266,12 @@ def resource_custom_ctx(id: str, my_ctx: Context) -> str:
result = await client.read_resource("resource://custom/123")
assert result == snapshot(
ReadResourceResult(
+ _meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}},
contents=[
TextResourceContents(
uri="resource://custom/123", mime_type="text/plain", text="Resource 123 with context"
)
- ]
+ ],
)
)
@@ -1394,6 +1399,7 @@ def fn(name: str, optional: str = "default") -> str: ... # pragma: no branch
result = await client.list_prompts()
assert result == snapshot(
ListPromptsResult(
+ _meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}},
prompts=[
Prompt(
name="fn",
@@ -1403,7 +1409,7 @@ def fn(name: str, optional: str = "default") -> str: ... # pragma: no branch
PromptArgument(name="optional", required=False),
],
)
- ]
+ ],
)
)
@@ -1419,6 +1425,7 @@ def fn(name: str) -> str:
result = await client.get_prompt("fn", {"name": "World"})
assert result == snapshot(
GetPromptResult(
+ _meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}},
description="",
messages=[PromptMessage(role="user", content=TextContent(text="Hello, World!"))],
)
@@ -1449,6 +1456,7 @@ def fn(name: str) -> str:
result = await client.get_prompt("fn", {"name": "World"})
assert result == snapshot(
GetPromptResult(
+ _meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}},
description="This is the function docstring.",
messages=[PromptMessage(role="user", content=TextContent(text="Hello, World!"))],
)
@@ -1471,6 +1479,7 @@ def fn() -> Message:
result = await client.get_prompt("fn")
assert result == snapshot(
GetPromptResult(
+ _meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}},
description="",
messages=[
PromptMessage(
@@ -2337,3 +2346,53 @@ def greeting() -> str: # pragma: no cover
assert mcp._prompt_manager.list_prompts() == []
with pytest.raises(ValueError, match="Unknown prompt: greeting"):
mcp.remove_prompt("greeting")
+
+
+@pytest.mark.anyio
+async def test_middleware_kwarg_and_property_share_the_low_level_chain() -> None:
+ """SDK-defined: `MCPServer(middleware=[...])` appends to the low-level chain after
+ the SDK's built-ins, and `mcp.middleware` is that same live list, so a
+ middleware appended later still wraps requests."""
+ seen: list[str] = []
+
+ async def from_ctor(ctx: ServerRequestContext[Any, Any], call_next: Any) -> Any:
+ seen.append(f"ctor:{ctx.method}")
+ return await call_next(ctx)
+
+ async def appended(ctx: ServerRequestContext[Any, Any], call_next: Any) -> Any:
+ seen.append(f"appended:{ctx.method}")
+ return await call_next(ctx)
+
+ mcp = MCPServer("mw", middleware=[from_ctor])
+ assert mcp.middleware is mcp._lowlevel_server.middleware
+ assert mcp.middleware[-1] is from_ctor # after the built-ins, outermost-first
+ mcp.middleware.append(appended)
+
+ @mcp.tool()
+ def ping() -> str:
+ return "pong"
+
+ async with Client(mcp) as client:
+ await client.call_tool("ping", {})
+ assert "ctor:tools/call" in seen
+ assert seen.index("ctor:tools/call") < seen.index("appended:tools/call")
+
+
+@pytest.mark.anyio
+async def test_middleware_can_refuse_subscriptions_listen_before_the_ack() -> None:
+ """Spec-adjacent: a middleware that raises on `subscriptions/listen` refuses the
+ request in-band - the client gets the error and no stream is opened."""
+
+ async def refuse_listen(ctx: ServerRequestContext[Any, Any], call_next: Any) -> Any:
+ if ctx.method == "subscriptions/listen":
+ raise MCPError(INVALID_REQUEST, "not permitted to watch the requested resources")
+ return await call_next(ctx)
+
+ mcp = MCPServer("mw", middleware=[refuse_listen])
+
+ async with Client(mcp) as client:
+ with pytest.raises(MCPError) as exc_info:
+ async with client.listen(resource_subscriptions=["files://payroll.csv"]):
+ pass # pragma: no cover - the refusal precedes the stream
+ assert exc_info.value.error.code == INVALID_REQUEST
+ assert exc_info.value.error.message == "not permitted to watch the requested resources"
diff --git a/tests/server/mcpserver/test_title.py b/tests/server/mcpserver/test_title.py
index 3e36f22579..ff76bdc0af 100644
--- a/tests/server/mcpserver/test_title.py
+++ b/tests/server/mcpserver/test_title.py
@@ -25,10 +25,12 @@ async def test_server_name_title_description_version():
# Start server and connect client
async with Client(mcp) as client:
- assert client.server_info.name == "TestServer"
- assert client.server_info.title == "Test Server Title"
- assert client.server_info.description == "This is a test server description."
- assert client.server_info.version == "1.0"
+ server_info = client.server_info
+ assert server_info is not None
+ assert server_info.name == "TestServer"
+ assert server_info.title == "Test Server Title"
+ assert server_info.description == "This is a test server description."
+ assert server_info.version == "1.0"
@pytest.mark.anyio
diff --git a/tests/server/test_apps.py b/tests/server/test_apps.py
index 262bdfe7a1..8d8bf74b74 100644
--- a/tests/server/test_apps.py
+++ b/tests/server/test_apps.py
@@ -62,13 +62,14 @@ async def test_add_html_resource_serves_ui_resource_at_app_mime_type() -> None:
result = await client.read_resource("ui://clock/app.html")
assert result == snapshot(
ReadResourceResult(
+ _meta={"io.modelcontextprotocol/serverInfo": {"name": "clock", "version": ""}},
contents=[
TextResourceContents(
uri="ui://clock/app.html",
mime_type="text/html;profile=mcp-app",
text="Clock",
)
- ]
+ ],
)
)
assert isinstance(result.contents[0], TextResourceContents)
diff --git a/tests/server/test_caching.py b/tests/server/test_caching.py
index 0a6adc2aa1..9095f6172f 100644
--- a/tests/server/test_caching.py
+++ b/tests/server/test_caching.py
@@ -133,19 +133,26 @@ async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestPar
assert "cache_scope" not in result.model_fields_set
-async def test_an_input_required_shaped_dict_is_never_stamped() -> None:
+async def test_an_input_required_shaped_dict_never_gets_cache_hints() -> None:
"""Spec carve-out: interim `input_required` results carry no cache hints, even on a hinted method."""
async def read_resource(ctx: ServerRequestContext[Any], params: ReadResourceRequestParams) -> dict[str, Any]:
return {"resultType": "input_required", "requestState": "s1"}
- server = Server("srv", cache_hints={"resources/read": CacheHint(ttl_ms=60_000, scope="public")})
+ server = Server(
+ "srv",
+ cache_hints={"resources/read": CacheHint(ttl_ms=60_000, scope="public")},
+ )
server.add_request_handler("resources/read", ReadResourceRequestParams, read_resource)
async with Client(server) as client:
result = await client.session.read_resource("res://x", allow_input_required=True)
assert isinstance(result, InputRequiredResult)
assert result.model_dump(by_alias=True, exclude_none=True) == snapshot(
- {"resultType": "input_required", "requestState": "s1"}
+ {
+ "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "srv", "version": ""}},
+ "resultType": "input_required",
+ "requestState": "s1",
+ }
)
diff --git a/tests/server/test_cancel_handling.py b/tests/server/test_cancel_handling.py
index 3d32adb3c8..fd0e4b28af 100644
--- a/tests/server/test_cancel_handling.py
+++ b/tests/server/test_cancel_handling.py
@@ -22,7 +22,6 @@
from mcp import Client
from mcp.server import Server, ServerRequestContext
-from mcp.shared.exceptions import MCPError
from mcp.shared.message import SessionMessage
@@ -33,6 +32,7 @@ async def test_server_remains_functional_after_cancel():
# Track tool calls
call_count = 0
ev_first_call = anyio.Event()
+ ev_first_call_cancelled = anyio.Event()
first_request_id = None
async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
@@ -53,38 +53,45 @@ async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestPar
if call_count == 1:
first_request_id = ctx.request_id
ev_first_call.set()
- await anyio.sleep(5) # First call is slow
+ try:
+ await anyio.sleep_forever() # First call blocks until cancelled
+ except anyio.get_cancelled_exc_class():
+ ev_first_call_cancelled.set()
+ raise
return CallToolResult(content=[TextContent(type="text", text=f"Call number: {call_count}")])
raise ValueError(f"Unknown tool: {params.name}") # pragma: no cover
server = Server("test-server", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool)
async with Client(server, mode="legacy") as client:
- # First request (will be cancelled)
+ # First request (will be cancelled server-side, then abandoned here: a
+ # cancelled request is never answered, so nothing would wake this call)
async def first_request():
- try:
- await client.session.send_request(
- CallToolRequest(params=CallToolRequestParams(name="test_tool", arguments={})),
- CallToolResult,
- )
- pytest.fail("First request should have been cancelled") # pragma: no cover
- except MCPError:
- pass # Expected
+ await client.session.send_request(
+ CallToolRequest(params=CallToolRequestParams(name="test_tool", arguments={})),
+ CallToolResult,
+ )
+ raise NotImplementedError # unreachable: the task is cancelled before any answer
# Start first request
- async with anyio.create_task_group() as tg:
- tg.start_soon(first_request)
-
- # Wait for it to start
- await ev_first_call.wait()
-
- # Cancel it
- assert first_request_id is not None
- await client.session.send_notification(
- CancelledNotification(
- params=CancelledNotificationParams(request_id=first_request_id, reason="Testing server recovery"),
+ with anyio.fail_after(5):
+ async with anyio.create_task_group() as tg:
+ tg.start_soon(first_request)
+
+ # Wait for it to start
+ await ev_first_call.wait()
+
+ # Cancel it
+ assert first_request_id is not None
+ await client.session.send_notification(
+ CancelledNotification(
+ params=CancelledNotificationParams(
+ request_id=first_request_id, reason="Testing server recovery"
+ ),
+ )
)
- )
+ await ev_first_call_cancelled.wait()
+ tg.cancel_scope.cancel() # abandon the parked call
# Second request (should work normally)
result = await client.call_tool("test_tool", {})
diff --git a/tests/server/test_connection.py b/tests/server/test_connection.py
index d448905a96..683473d620 100644
--- a/tests/server/test_connection.py
+++ b/tests/server/test_connection.py
@@ -22,6 +22,7 @@
ElicitationCapability,
EmptyResult,
Implementation,
+ InitializeRequestParams,
ListRootsRequest,
ListRootsResult,
PingRequest,
@@ -282,6 +283,16 @@ async def test_connection_log_sends_logging_message_notification():
assert params["logger"] == "my.logger"
+@pytest.mark.anyio
+async def test_connection_log_sends_nothing_on_a_modern_connection():
+ """2026 log delivery is a per-request opt-in on the requesting stream; the
+ connection-scoped standalone entry has no request to opt in, so it never sends."""
+ out = StubOutbound()
+ conn = Connection.from_envelope(LATEST_MODERN_VERSION, None, None, outbound=out)
+ await conn.log("emergency", "unheard") # pyright: ignore[reportDeprecated]
+ assert out.notifications == []
+
+
@pytest.mark.anyio
async def test_connection_log_with_meta_includes_meta_in_params():
out = StubOutbound()
@@ -321,8 +332,8 @@ async def test_connection_send_tool_list_changed_with_meta_includes_meta_only_pa
# --- check_capability ----------------------------------------------------------
-def test_connection_check_capability_false_when_no_client_params_recorded():
- """SDK-defined: `check_capability` returns False when no `client_params`
+def test_connection_check_capability_false_when_no_capabilities_recorded():
+ """SDK-defined: `check_capability` returns False when no capabilities
were recorded, regardless of which factory built the connection."""
conn = Connection.for_loop(StubOutbound())
assert conn.check_capability(ClientCapabilities(sampling=SamplingCapability())) is False
@@ -330,6 +341,33 @@ def test_connection_check_capability_false_when_no_client_params_recorded():
assert Connection.from_envelope(LATEST_MODERN_VERSION, None, None).check_capability(ClientCapabilities()) is False
+def test_from_envelope_records_capabilities_without_client_info():
+ """Spec-mandated (spec PR #3002): the envelope requires capabilities but not
+ client info, so a pair-only request still gets working capability checks -
+ `client_capabilities` is recorded on its own while `client_params` stays
+ `None`."""
+ caps = ClientCapabilities(sampling=SamplingCapability())
+ conn = Connection.from_envelope(LATEST_MODERN_VERSION, None, caps)
+ assert conn.client_params is None
+ assert conn.client_capabilities == caps
+ assert conn.check_capability(ClientCapabilities(sampling=SamplingCapability())) is True
+
+
+def test_client_params_assignment_keeps_capabilities_in_lockstep():
+ """SDK-defined: recording `client_params` (the loop path's handshake
+ commit) is the sync point that also records `client_capabilities`, so the
+ two facts cannot drift."""
+ conn = Connection.for_loop(StubOutbound())
+ assert conn.client_capabilities is None
+ conn.client_params = InitializeRequestParams(
+ protocol_version=LATEST_HANDSHAKE_VERSION,
+ capabilities=ClientCapabilities(roots=RootsCapability()),
+ client_info=Implementation(name="c", version="0"),
+ )
+ assert conn.client_capabilities == ClientCapabilities(roots=RootsCapability())
+ assert conn.check_capability(ClientCapabilities(roots=RootsCapability())) is True
+
+
@pytest.mark.parametrize(
("have", "want", "expected"),
[
diff --git a/tests/server/test_runner.py b/tests/server/test_runner.py
index 29d3f07fa6..50e77f7134 100644
--- a/tests/server/test_runner.py
+++ b/tests/server/test_runner.py
@@ -7,6 +7,8 @@
`aclose_shielded`) follow at the bottom.
"""
+import contextvars
+import logging
from collections.abc import AsyncIterator, Mapping
from contextlib import asynccontextmanager
from dataclasses import dataclass, field, replace
@@ -15,6 +17,7 @@
import anyio
import anyio.abc
+import anyio.lowlevel
import pytest
from mcp_types import (
CLIENT_CAPABILITIES_META_KEY,
@@ -23,15 +26,22 @@
INVALID_PARAMS,
INVALID_REQUEST,
LATEST_PROTOCOL_VERSION,
+ LOG_LEVEL_META_KEY,
METHOD_NOT_FOUND,
PROTOCOL_VERSION_META_KEY,
+ SERVER_INFO_META_KEY,
UNSUPPORTED_PROTOCOL_VERSION,
+ CallToolRequestParams,
ClientCapabilities,
+ EmptyResult,
ErrorData,
+ Icon,
Implementation,
InitializeRequestParams,
JSONRPCRequest,
ListToolsResult,
+ LoggingMessageNotification,
+ LoggingMessageNotificationParams,
NotificationParams,
PaginatedRequestParams,
ProgressNotificationParams,
@@ -64,6 +74,8 @@
serve_one,
)
from mcp.server.session import ServerSession
+from mcp.server.subscriptions import SUBSCRIPTION_ID_META_KEY, InMemorySubscriptionBus, ListenHandler
+from mcp.shared._context_streams import create_context_streams
from mcp.shared.dispatcher import CallOptions
from mcp.shared.exceptions import MCPError, NoBackChannelError
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
@@ -953,7 +965,14 @@ async def echo(ctx: Ctx, params: RequestParams) -> dict[str, Any]:
born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None)
async with connected_runner(server, initialized=False, connection=born_ready) as (client, _):
result = await client.send_raw_request("myorg/echo", None)
- assert result == {"echoed": True}
+ # Custom-method results served at a modern version carry the required
+ # `resultType` discriminator and the serverInfo `_meta` stamp like any
+ # other result (spec 2026-07-28, #3002).
+ assert result == {
+ "echoed": True,
+ "resultType": "complete",
+ "_meta": {SERVER_INFO_META_KEY: {"name": "test-server", "version": "0.0.1"}},
+ }
@pytest.mark.anyio
@@ -987,6 +1006,188 @@ async def custom(ctx: Ctx, params: RequestParams) -> dict[str, Any]:
assert result == {"anything": "goes"}
+@pytest.mark.anyio
+async def test_modern_short_circuit_middleware_owns_its_result_envelope(server: SrvT):
+ """SDK-defined: a middleware that answers without calling `call_next` is
+ trusted to return its own well-formed result, response envelope included -
+ the outbound pipeline (and its serverInfo stamp) never patches it up."""
+
+ async def short_circuit(ctx: Ctx, call_next: Any) -> Any:
+ return {"ok": True}
+
+ server.middleware.append(short_circuit)
+ born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None)
+ async with connected_runner(server, initialized=False, connection=born_ready) as (client, _):
+ result = await client.send_raw_request("myorg/anything", None)
+ assert result == {"ok": True}
+
+
+@pytest.mark.anyio
+async def test_a_handler_authored_server_info_stamp_is_not_overwritten(server: SrvT):
+ """SDK-defined: a handler that stamps its own serverInfo `_meta` value owns
+ it; the runner fills the key only when it is absent (spec 2026-07-28, #3002)."""
+
+ async def custom(ctx: Ctx, params: RequestParams) -> dict[str, Any]:
+ return {"ok": True, "_meta": {SERVER_INFO_META_KEY: {"name": "authored", "version": "9"}}}
+
+ server.add_request_handler("myorg/authored", RequestParams, custom)
+ born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None)
+ async with connected_runner(server, initialized=False, connection=born_ready) as (client, _):
+ result = await client.send_raw_request("myorg/authored", None)
+ assert result["_meta"][SERVER_INFO_META_KEY] == {"name": "authored", "version": "9"}
+
+
+@pytest.mark.anyio
+async def test_a_non_mapping_custom_result_meta_is_left_alone(server: SrvT):
+ """SDK-defined: a custom-method handler that returns a non-mapping `_meta`
+ owns that shape; the stamp neither clobbers it nor fails the request."""
+
+ async def custom(ctx: Ctx, params: RequestParams) -> dict[str, Any]:
+ return {"_meta": "not-a-mapping"}
+
+ server.add_request_handler("myorg/odd-meta", RequestParams, custom)
+ born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None)
+ async with connected_runner(server, initialized=False, connection=born_ready) as (client, _):
+ result = await client.send_raw_request("myorg/odd-meta", None)
+ assert result == {"_meta": "not-a-mapping", "resultType": "complete"}
+
+
+@pytest.mark.anyio
+async def test_stamping_never_mutates_a_handler_retained_result_dict(server: SrvT):
+ """SDK-defined: the outbound pass dumps the handler's dict to a copy
+ (`_dump_result`) and the stamp writes into that copy, so a dict the handler
+ retains (module-level, cached, shared) is never mutated underneath it."""
+
+ retained: dict[str, Any] = {"ok": True}
+
+ async def custom(ctx: Ctx, params: RequestParams) -> dict[str, Any]:
+ return retained
+
+ server.add_request_handler("myorg/cached", RequestParams, custom)
+ born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None)
+ async with connected_runner(server, initialized=False, connection=born_ready) as (client, _):
+ result = await client.send_raw_request("myorg/cached", None)
+ assert result["_meta"][SERVER_INFO_META_KEY] == {"name": "test-server", "version": "0.0.1"}
+ assert retained == {"ok": True}
+
+
+@pytest.mark.anyio
+async def test_mutating_a_stamped_response_never_corrupts_later_stamps():
+ """SDK-defined: every response gets a fresh stamp dict, nested values
+ included - a caller mutating one stamped response (a middleware, or an
+ application holding an in-memory result) cannot corrupt the identity
+ stamped into later responses."""
+
+ async def custom(ctx: Ctx, params: RequestParams) -> dict[str, Any]:
+ return {}
+
+ server: SrvT = Server(name="test-server", version="0.0.1", icons=[Icon(src="https://example.com/icon.png")])
+ server.add_request_handler("myorg/empty", RequestParams, custom)
+ born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None)
+ async with connected_runner(server, initialized=False, connection=born_ready) as (client, _):
+ first = await client.send_raw_request("myorg/empty", None)
+ first["_meta"][SERVER_INFO_META_KEY]["icons"][0]["src"] = "https://evil.example/pwned.png"
+ second = await client.send_raw_request("myorg/empty", None)
+ assert second["_meta"][SERVER_INFO_META_KEY] == {
+ "name": "test-server",
+ "version": "0.0.1",
+ "icons": [{"src": "https://example.com/icon.png"}],
+ }
+
+
+@pytest.mark.anyio
+async def test_a_claimed_result_type_on_a_legacy_session_is_sieved_not_leaked(server: SrvT):
+ """SDK-defined: claimed extension shapes are 2026-era vocabulary, so on a
+ handshake-era session the per-version sieve still applies - a claimed
+ shape (not a valid legacy result) surfaces as INTERNAL_ERROR instead of
+ leaking a shape the client cannot resolve."""
+
+ async def custom(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]:
+ return {"resultType": "voucher", "voucherCode": "v-42"}
+
+ server.add_request_handler("tools/call", CallToolRequestParams, custom)
+ async with connected_runner(server) as (client, _):
+ with pytest.raises(MCPError) as exc:
+ await client.send_raw_request("tools/call", {"name": "issue"})
+ assert exc.value.error.code == INTERNAL_ERROR
+ assert exc.value.error.message == "Handler returned an invalid result"
+
+
+@pytest.mark.anyio
+async def test_a_handshake_era_custom_result_gains_no_discriminator(server: SrvT):
+ """The `resultType` fill is modern-only: handshake-era results keep the
+ handler's exact shape (the field does not exist pre-2026)."""
+
+ async def echo(ctx: Ctx, params: RequestParams) -> dict[str, Any]:
+ return {"echoed": True}
+
+ server.add_request_handler("myorg/echo", RequestParams, echo)
+ async with connected_runner(server) as (client, _):
+ result = await client.send_raw_request("myorg/echo", None)
+ assert result == {"echoed": True}
+
+
+@pytest.mark.anyio
+async def test_an_explicit_null_result_type_is_filled_on_the_modern_path(server: SrvT):
+ """A handler-authored `"resultType": null` reads as absent (null is not a
+ valid discriminator) and is filled, mirroring the null posture of the
+ identity stamp."""
+
+ async def custom(ctx: Ctx, params: RequestParams) -> dict[str, Any]:
+ return {"echoed": True, "resultType": None}
+
+ server.add_request_handler("myorg/echo", RequestParams, custom)
+ born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None)
+ async with connected_runner(server, initialized=False, connection=born_ready) as (client, _):
+ result = await client.send_raw_request("myorg/echo", None)
+ assert result == {
+ "echoed": True,
+ "resultType": "complete",
+ "_meta": {SERVER_INFO_META_KEY: {"name": "test-server", "version": "0.0.1"}},
+ }
+
+
+@pytest.mark.anyio
+async def test_a_claimed_extension_result_type_bypasses_the_sieve_and_is_stamped(server: SrvT):
+ """SDK-defined: a spec-method result carrying an extension `resultType` is a
+ claimed shape the extension owns - the per-version sieve would strip its
+ vendor fields, so it applies to core-vocabulary results only. The identity
+ stamp still lands: claimed shapes are results like any other."""
+
+ async def custom(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]:
+ return {"resultType": "voucher", "voucherCode": "v-42"}
+
+ server.add_request_handler("tools/call", CallToolRequestParams, custom)
+ born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None)
+ async with connected_runner(server, initialized=False, connection=born_ready) as (client, _):
+ result = await client.send_raw_request("tools/call", _modern_params(name="issue"))
+ assert result == {
+ "resultType": "voucher",
+ "voucherCode": "v-42",
+ "_meta": {SERVER_INFO_META_KEY: {"name": "test-server", "version": "0.0.1"}},
+ }
+
+
+@pytest.mark.anyio
+async def test_an_empty_result_on_the_modern_path_carries_the_discriminator_and_stamp(server: SrvT):
+ """Spec-mandated (2026-07-28): `resultType` is required on every result a
+ modern server sends (the absent-means-complete bridge is for clients of
+ older servers only), and serverInfo is stamped into every result too - so
+ a result that dumps as `{}` goes on the modern wire with both."""
+
+ async def custom(ctx: Ctx, params: RequestParams) -> EmptyResult:
+ return EmptyResult()
+
+ server.add_request_handler("myorg/empty", RequestParams, custom)
+ born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None)
+ async with connected_runner(server, initialized=False, connection=born_ready) as (client, _):
+ result = await client.send_raw_request("myorg/empty", None)
+ assert result == {
+ "resultType": "complete",
+ "_meta": {SERVER_INFO_META_KEY: {"name": "test-server", "version": "0.0.1"}},
+ }
+
+
@pytest.mark.anyio
async def test_runner_initialize_result_reflects_init_options():
async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToolsResult:
@@ -1162,6 +1363,8 @@ async def _append_async(dst: list[int], v: int) -> None:
_LIFESPAN: dict[str, Any] = {}
+_SENDER_VAR: contextvars.ContextVar[str] = contextvars.ContextVar("dual_era_sender_var", default="unset")
+
@pytest.mark.anyio
async def test_serve_one_runs_handler_and_returns_result_dict(server: SrvT):
@@ -1346,7 +1549,7 @@ async def test_dual_era_loop_initialize_locks_legacy_and_rejects_modern_traffic(
assert result["tools"][0]["name"] == "t"
assert discover_exc.value.error.code == INVALID_REQUEST
assert envelope_exc.value.error.code == INVALID_REQUEST
- assert "locked to the legacy handshake era" in discover_exc.value.error.message
+ assert "serves the handshake protocol era" in envelope_exc.value.error.message
@pytest.mark.anyio
@@ -1365,44 +1568,49 @@ async def test_dual_era_loop_bare_discover_after_legacy_lock_is_byte_identical(s
@pytest.mark.anyio
-async def test_dual_era_loop_unsupported_modern_version_rejects_without_locking(server: SrvT):
- """A probe at an unknown modern version gets -32022 with the supported
- list, and the rejection does not lock the era: the legacy handshake still
- succeeds afterwards (the released auto clients' retry/fallback contract)."""
+async def test_dual_era_loop_unsupported_modern_version_gets_the_supported_list(server: SrvT):
+ """A probe at an unknown modern version is answered -32022 with the
+ supported list; the connection is a 2026 one regardless, so the client
+ picks a listed version instead of falling back to `initialize` - which the
+ modern connection refuses with -32022 too, exactly as stdio.mdx directs."""
async with dual_era_client(server) as (client, _):
with pytest.raises(MCPError) as exc_info:
await client.send_raw_request("server/discover", _modern_params(version="2099-01-01"))
- init = await client.send_raw_request("initialize", _initialize_params())
- assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION
+ with pytest.raises(MCPError) as init_exc:
+ await client.send_raw_request("initialize", _initialize_params())
assert exc_info.value.error.code == UNSUPPORTED_PROTOCOL_VERSION
assert exc_info.value.error.data == {
"supported": list(MODERN_PROTOCOL_VERSIONS),
"requested": "2099-01-01",
}
+ assert init_exc.value.error.code == UNSUPPORTED_PROTOCOL_VERSION
@pytest.mark.anyio
-async def test_dual_era_loop_bare_discover_rejects_without_locking(server: SrvT):
- """A `server/discover` with no envelope triple is INVALID_PARAMS - never
- -32022, so a released auto client's code-keyed fallback predicate takes the
- legacy branch - and the connection can still complete the handshake."""
+async def test_dual_era_loop_bare_discover_opens_legacy_and_keeps_the_handshake_available(server: SrvT):
+ """`server/discover` without the envelope is not 2026 vocabulary, so it
+ opens a handshake connection: the answer is METHOD_NOT_FOUND (never -32022,
+ the one code a probing client must not treat as "fall back"), and the
+ client's fallback `initialize` then succeeds on the same connection."""
async with dual_era_client(server) as (client, _):
with pytest.raises(MCPError) as exc_info:
await client.send_raw_request("server/discover", None)
init = await client.send_raw_request("initialize", _initialize_params())
assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION
- assert exc_info.value.error.code == INVALID_PARAMS
+ assert exc_info.value.error.code == METHOD_NOT_FOUND
assert exc_info.value.error.code != UNSUPPORTED_PROTOCOL_VERSION
@pytest.mark.anyio
-async def test_dual_era_loop_ping_before_any_lock_stays_exempt_and_neutral(server: SrvT):
- """A pre-handshake `ping` is answered (the init-gate exemption) and does
- not lock an era: the connection can still go modern."""
+async def test_dual_era_loop_an_envelopeless_first_request_opens_a_legacy_connection(server: SrvT):
+ """A first request without the 2026 envelope - here a pre-handshake `ping`,
+ answered under the init-gate exemption - is handshake-era vocabulary and
+ opens a legacy connection: enveloped requests are refused after it."""
async with dual_era_client(server) as (client, _):
assert await client.send_raw_request("ping", None) == {}
- result = await client.send_raw_request("tools/list", _modern_params())
- assert result["tools"][0]["name"] == "t"
+ with pytest.raises(MCPError) as exc_info:
+ await client.send_raw_request("tools/list", _modern_params())
+ assert exc_info.value.error.code == INVALID_REQUEST
@pytest.mark.anyio
@@ -1417,45 +1625,92 @@ async def test_dual_era_loop_modern_request_without_envelope_rejects(server: Srv
@pytest.mark.anyio
-async def test_dual_era_loop_rejects_subscriptions_listen_on_modern(server: SrvT):
- """`subscriptions/listen` is rejected before dispatch on the stream-pair
- modern path (the registered handler assumes the HTTP entry's stream
- semantics) - and like every failed request it does not lock the era, so
- the legacy handshake stays available."""
+async def test_dual_era_loop_serves_subscriptions_listen_over_the_stream_pair():
+ """`subscriptions/listen` is served over the duplex stream like any other
+ modern request: the acknowledgement notification rides the pipe first,
+ and the server ending the stream yields the request's graceful-close
+ result, stamped with the subscription id."""
+ listen = ListenHandler(InMemorySubscriptionBus())
+ listener = Server(name="listener-server", version="0.0.1", on_subscriptions_listen=listen)
+ async with dual_era_client(listener) as (client, recorder):
+ result: dict[str, Any] = {}
+
+ async def open_listen() -> None:
+ params = _modern_params(notifications={"toolsListChanged": True})
+ result.update(await client.send_raw_request("subscriptions/listen", params))
+
+ async with anyio.create_task_group() as tg:
+ tg.start_soon(open_listen)
+ await recorder.notified.wait()
+ listen.close()
+ assert recorder.notifications[0][0] == "notifications/subscriptions/acknowledged"
+ assert result["resultType"] == "complete"
+ assert SUBSCRIPTION_ID_META_KEY in result["_meta"]
+
+
+@pytest.mark.anyio
+async def test_dual_era_loop_malformed_envelope_content_is_a_modern_era_error(server: SrvT):
+ """An envelope with mis-shaped values still declares the 2026 era: the
+ request is rejected INVALID_PARAMS in modern vocabulary, and the connection
+ is a modern one, so a handshake sent afterwards is refused rather than
+ served on a connection that already speaks the other era."""
+ params: dict[str, Any] = {"_meta": {**_modern_envelope(), CLIENT_INFO_META_KEY: 42}}
async with dual_era_client(server) as (client, _):
with pytest.raises(MCPError) as exc_info:
- await client.send_raw_request("subscriptions/listen", _modern_params())
- init = await client.send_raw_request("initialize", _initialize_params())
- assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION
- assert exc_info.value.error.code == METHOD_NOT_FOUND
- assert "not served over this transport" in exc_info.value.error.message
+ await client.send_raw_request("tools/list", params)
+ with pytest.raises(MCPError) as init_exc:
+ await client.send_raw_request("initialize", _initialize_params())
+ assert exc_info.value.error.code == INVALID_PARAMS
+ assert init_exc.value.error.code == UNSUPPORTED_PROTOCOL_VERSION
@pytest.mark.anyio
-async def test_dual_era_loop_malformed_envelope_content_never_locks(server: SrvT):
- """The envelope triple with mis-shaped values fails the request but never
- locks the era: the lock commits only when a modern request SUCCEEDS, so a
- buggy client's initialize fallback still works (it must never see -32022
- for a request that failed)."""
- params: dict[str, Any] = {"_meta": {**_modern_envelope(), CLIENT_INFO_META_KEY: 42}}
+async def test_dual_era_loop_pair_only_envelope_serves_modern_and_locks(server: SrvT):
+ """Spec-mandated (spec PR #3002): the required envelope pair (protocol version
+ + client capabilities) without the optional clientInfo is a complete
+ modern request - it is served, records the declared capabilities without
+ client params, locks the era modern, and a later legacy `initialize` is
+ rejected with -32022."""
+ params = _modern_params()
+ del params["_meta"][CLIENT_INFO_META_KEY]
+ async with dual_era_client(server) as (client, _):
+ result = await client.send_raw_request("tools/list", params)
+ assert result["tools"][0]["name"] == "t"
+ with pytest.raises(MCPError) as exc_info:
+ await client.send_raw_request("initialize", _initialize_params())
+ assert exc_info.value.error.code == UNSUPPORTED_PROTOCOL_VERSION
+ ctx = _seen_ctx[-1]
+ assert ctx.session.client_params is None
+ assert ctx.session.client_capabilities == ClientCapabilities()
+
+
+@pytest.mark.anyio
+async def test_dual_era_loop_version_without_capabilities_rejects_naming_the_key(server: SrvT):
+ """A `_meta` declaring the protocol version but missing the required
+ client-capabilities key routes modern - never the legacy path with its
+ generic 'Invalid request parameters' - and is rejected INVALID_PARAMS
+ naming the missing key; a correctly enveloped request then serves."""
+ params = _modern_params()
+ del params["_meta"][CLIENT_CAPABILITIES_META_KEY]
async with dual_era_client(server) as (client, _):
with pytest.raises(MCPError) as exc_info:
await client.send_raw_request("tools/list", params)
- init = await client.send_raw_request("initialize", _initialize_params())
- assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION
+ result = await client.send_raw_request("tools/list", _modern_params())
+ assert result["tools"][0]["name"] == "t"
assert exc_info.value.error.code == INVALID_PARAMS
- assert exc_info.value.error.code != UNSUPPORTED_PROTOCOL_VERSION
+ assert CLIENT_CAPABILITIES_META_KEY in exc_info.value.error.message
@pytest.mark.anyio
-async def test_dual_era_loop_failed_modern_request_never_locks(server: SrvT):
- """A well-formed modern request for an unknown method fails without
- locking; the next modern request locks on its own success."""
+async def test_dual_era_loop_failed_modern_request_leaves_the_connection_modern(server: SrvT):
+ """A well-formed modern request for an unknown method fails on its own;
+ the connection is a 2026 one either way, so the next modern request
+ serves."""
async with dual_era_client(server) as (client, _):
with pytest.raises(MCPError) as exc_info:
await client.send_raw_request("nope/missing", _modern_params())
- init = await client.send_raw_request("initialize", _initialize_params())
- assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION
+ result = await client.send_raw_request("tools/list", _modern_params())
+ assert result["tools"][0]["name"] == "t"
assert exc_info.value.error.code == METHOD_NOT_FOUND
@@ -1474,9 +1729,9 @@ async def test_dual_era_loop_initialize_with_envelope_takes_the_handshake_path(s
@pytest.mark.anyio
-async def test_dual_era_loop_modern_notification_dispatches_at_locked_version(server: SrvT):
- """Notifications carry no envelope, so on a modern-locked connection they
- dispatch with the locked protocol version."""
+async def test_dual_era_loop_modern_notification_dispatches_at_the_served_version(server: SrvT):
+ """Notifications carry no envelope, so on a 2026 connection they dispatch
+ at the modern version the server serves."""
seen_versions: list[str] = []
handled = anyio.Event()
@@ -1508,20 +1763,46 @@ async def on_custom(ctx: Ctx, params: NotificationParams | None) -> None:
await handled.wait()
+@pytest.mark.anyio
+async def test_dual_era_loop_a_notifications_meta_never_opens_the_log_gate(server: SrvT):
+ """The 2026 log-delivery opt-in is a request's `_meta`: an inbound notification
+ has no request to opt in, so a handler logging in response to one - even
+ with the log-level key in the notification's own `_meta` - sends nothing."""
+ logged = anyio.Event()
+
+ async def log_it(ctx: Ctx, params: NotificationParams | None) -> None:
+ await ctx.session.send_log_message("emergency", "no request opted in") # pyright: ignore[reportDeprecated]
+ logged.set()
+
+ server.add_notification_handler("notifications/custom", NotificationParams, log_it)
+ async with dual_era_client(server) as (client, recorder):
+ await client.send_raw_request("tools/list", _modern_params())
+ meta = {**_modern_envelope(), LOG_LEVEL_META_KEY: "debug"}
+ await client.notify("notifications/custom", {"_meta": meta})
+ await logged.wait()
+ assert [method for method, _ in recorder.notifications] == []
+
+
@pytest.mark.anyio
async def test_dual_era_loop_modern_server_notifications_ride_the_pipe(server: SrvT):
"""A modern handler's standalone notification reaches the client over the
- duplex stream - the notify-only outbound forwards it."""
+ duplex stream - the notify-only outbound forwards it. Change notifications
+ are the exception: at this era they reach clients only via
+ `subscriptions/listen` streams, so a bare one is dropped rather than sent
+ unrequested."""
async def emit(ctx: Ctx, params: RequestParams | None) -> dict[str, Any]:
- await ctx.session.send_tool_list_changed()
+ await ctx.session.send_tool_list_changed() # dropped: an unrequested change notification
+ await ctx.session.send_notification(
+ LoggingMessageNotification(params=LoggingMessageNotificationParams(level="info", data="hi"))
+ )
return {}
server.add_request_handler("x/emit", RequestParams, emit)
async with dual_era_client(server) as (client, recorder):
await client.send_raw_request("x/emit", _modern_params())
await recorder.notified.wait()
- assert recorder.notifications[0][0] == "notifications/tools/list_changed"
+ assert [method for method, _ in recorder.notifications] == ["notifications/message"]
@pytest.mark.anyio
@@ -1543,11 +1824,11 @@ async def wants_roots(ctx: Ctx, params: RequestParams | None) -> dict[str, Any]:
@pytest.mark.anyio
-async def test_dual_era_loop_late_modern_success_does_not_overwrite_a_committed_legacy_lock():
- """The era settles exactly once, on the FIRST client-visible success: a
- modern request that was already in flight when a legacy handshake
- committed may still complete - its response stands - but the connection
- stays legacy, so the handshaked client is never stranded."""
+async def test_dual_era_loop_initialize_during_an_in_flight_modern_request_is_refused():
+ """The era is fixed by the opening request, not by what has completed: a
+ handshake arriving while the opening modern request is still parked in
+ its handler is refused with -32022, and the parked request finishes on
+ the modern connection it opened."""
entered = anyio.Event()
release = anyio.Event()
@@ -1565,60 +1846,12 @@ async def modern_call() -> None:
async with anyio.create_task_group() as tg:
tg.start_soon(modern_call)
- # The modern dispatch is parked in its handler before the
- # handshake frame is even written, so the initialize commits first.
await entered.wait()
- init = await client.send_raw_request("initialize", _initialize_params())
- assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION
- release.set()
- assert modern_result["tools"][0]["name"] == "t"
- # The straggler's success did not move the era: plain legacy requests
- # still serve (a modern overwrite would demand the envelope triple).
- result = await client.send_raw_request("tools/list", None)
- assert result["tools"][0]["name"] == "t"
-
-
-@pytest.mark.anyio
-async def test_dual_era_loop_modern_success_cancelled_away_at_the_response_write_never_locks():
- """A peer cancel that lands while the handler is finishing means the
- dispatcher replaces the computed result with "Request cancelled" - the
- client never sees the success, so the era must not lock and the legacy
- handshake must stay available."""
- entered = anyio.Event()
- release = anyio.Event()
-
- async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToolsResult:
- entered.set()
- # Survive the interrupt-mode scope cancel so the handler completes
- # with the cancel pending - the cancellation is then delivered at the
- # dispatcher's response-write checkpoint, after the era commit ran.
- with anyio.CancelScope(shield=True):
- await release.wait()
- return ListToolsResult(tools=[])
-
- parked = Server(name="parked-server", version="0.0.1", on_list_tools=list_tools)
- async with dual_era_client(parked) as (client, _):
- failures: list[MCPError] = []
-
- async def modern_call() -> None:
with pytest.raises(MCPError) as exc_info:
- await client.send_raw_request("tools/list", _modern_params())
- failures.append(exc_info.value)
-
- async with anyio.create_task_group() as tg:
- tg.start_soon(modern_call)
- await entered.wait()
- # First request on a fresh dispatcher pair, so its id is 1.
- await client.notify("notifications/cancelled", {"requestId": 1})
- # The read loop handles frames in order: this marker's response
- # proves the cancel was processed before the handler resumes.
- with pytest.raises(MCPError):
- await client.send_raw_request("probe/marker", None)
+ await client.send_raw_request("initialize", _initialize_params())
+ assert exc_info.value.error.code == UNSUPPORTED_PROTOCOL_VERSION
release.set()
- assert failures[0].error.message == "Request cancelled"
- # The cancelled-away success never locked the era.
- init = await client.send_raw_request("initialize", _initialize_params())
- assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION
+ assert modern_result["tools"][0]["name"] == "t"
@pytest.mark.anyio
@@ -1626,8 +1859,7 @@ async def test_dual_era_loop_maps_unmapped_handler_exceptions_like_the_modern_ht
"""An unmapped handler exception on a modern request surfaces as the
generic INTERNAL_ERROR - the same boundary as the modern HTTP entry - so
handler internals never reach the wire. (The dispatcher's code-0
- catch-all is a handshake-era compat pin and stays legacy-only.) The
- failed request never locks, so the handshake stays available."""
+ catch-all is a handshake-era compat pin and stays legacy-only.)"""
async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToolsResult:
raise RuntimeError("handler internals")
@@ -1636,8 +1868,6 @@ async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToo
async with dual_era_client(exploding) as (client, _):
with pytest.raises(MCPError) as exc_info:
await client.send_raw_request("tools/list", _modern_params())
- init = await client.send_raw_request("initialize", _initialize_params())
- assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION
assert exc_info.value.error.code == INTERNAL_ERROR
assert exc_info.value.error.message == "Internal server error"
assert "handler internals" not in str(exc_info.value.error)
@@ -1688,17 +1918,27 @@ async def greet(ctx: Ctx, params: RequestParams | None) -> dict[str, Any]:
params["_meta"][CLIENT_INFO_META_KEY] = "not-an-object"
async with dual_era_client(greeter) as (client, _):
result = await client.send_raw_request("custom/greet", params)
- assert result == {"ok": True}
+ assert result == {
+ "ok": True,
+ "resultType": "complete",
+ "_meta": {SERVER_INFO_META_KEY: {"name": "greeter-server", "version": "0.0.1"}},
+ }
assert seen == [None]
-def test_has_modern_envelope_requires_the_full_key_triple():
+def test_has_modern_envelope_keys_on_the_protocol_version_key():
+ """Era evidence is the reserved protocol-version `_meta` key: legacy traffic
+ never mints it (bare `_meta` / `progressToken` is not evidence), and a
+ half-built envelope still routes modern so the classifier - not the legacy
+ path - names its missing required key."""
assert not _has_modern_envelope(None)
assert not _has_modern_envelope({})
assert not _has_modern_envelope({"_meta": None})
assert not _has_modern_envelope({"_meta": {"progressToken": 1}})
- partial_meta = {k: v for k, v in _modern_envelope().items() if k != CLIENT_CAPABILITIES_META_KEY}
- assert not _has_modern_envelope({"_meta": partial_meta})
+ version_only = {PROTOCOL_VERSION_META_KEY: LATEST_MODERN_VERSION}
+ assert _has_modern_envelope({"_meta": version_only})
+ pair_only = {k: v for k, v in _modern_envelope().items() if k != CLIENT_INFO_META_KEY}
+ assert _has_modern_envelope({"_meta": pair_only})
assert _has_modern_envelope(_modern_params())
@@ -1761,12 +2001,108 @@ def test_no_server_requests_dispatch_context_passes_an_already_denying_transport
async def test_notify_only_outbound_forwards_notifications_and_refuses_requests():
inner = _RecordingInnerDctx()
outbound = NotifyOnlyOutbound(inner)
- await outbound.notify("notifications/tools/list_changed", None)
- assert inner.notifies == ["notifications/tools/list_changed"]
+ await outbound.notify("notifications/message", None)
+ assert inner.notifies == ["notifications/message"]
with pytest.raises(NoBackChannelError):
await outbound.send_raw_request("ping", None)
+@pytest.mark.anyio
+@pytest.mark.parametrize(
+ "method",
+ [
+ "notifications/tools/list_changed",
+ "notifications/prompts/list_changed",
+ "notifications/resources/list_changed",
+ "notifications/resources/updated",
+ ],
+)
+async def test_notify_only_outbound_drops_change_notifications(method: str, caplog: pytest.LogCaptureFixture):
+ """Spec: the server never sends a notification type a subscription did not
+ request. At the modern era change notifications reach a client only through
+ a `subscriptions/listen` stream, so a bare copy on the shared channel would
+ be an unrequested notification - the standalone channel drops it."""
+ inner = _RecordingInnerDctx()
+ outbound = NotifyOnlyOutbound(inner)
+ with caplog.at_level(logging.DEBUG, logger="mcp.server.connection"):
+ await outbound.notify(method, None)
+ assert inner.notifies == []
+ assert f"dropped {method}: delivered via subscriptions/listen at this era" in caplog.text
+
+
+@pytest.mark.anyio
+async def test_dual_era_loop_carries_the_sender_context_through_the_replay():
+ """A context-aware read stream's per-message sender context still reaches
+ handlers over the dual-era loop (the SSE transport delivers requests this
+ way): the opening-request replay forwards each frame's captured context."""
+ seen: list[str] = []
+
+ async def probe(ctx: Ctx, params: RequestParams | None) -> dict[str, Any]:
+ seen.append(_SENDER_VAR.get())
+ return {}
+
+ server = Server(name="ctx-server", version="0.0.1")
+ server.add_request_handler("x/probe", RequestParams, probe)
+ c2s_send, c2s_recv = create_context_streams[SessionMessage | Exception](8)
+ s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](8)
+ frame = JSONRPCRequest(jsonrpc="2.0", id=1, method="x/probe", params=_modern_params())
+ async with anyio.create_task_group() as tg, s2c_recv:
+ tg.start_soon(partial(serve_dual_era_loop, server, c2s_recv, s2c_send, lifespan_state=_LIFESPAN))
+ token = _SENDER_VAR.set("from-the-sender")
+ try:
+ await c2s_send.send(SessionMessage(message=frame))
+ finally:
+ _SENDER_VAR.reset(token)
+ with anyio.fail_after(5):
+ await s2c_recv.receive()
+ tg.cancel_scope.cancel()
+ await c2s_send.aclose()
+ assert seen == ["from-the-sender"]
+
+
+@pytest.mark.anyio
+async def test_dual_era_loop_leading_notifications_never_decide_the_era(server: SrvT):
+ """Frames a peer sends ahead of its first request never decide the era: a
+ stream of leading notifications well past the retained lead is followed by
+ an enveloped request, which still opens a 2026 connection and serves. The
+ lead is bounded, so the flood cannot grow the loop's buffer either."""
+ async with dual_era_client(server) as (client, _):
+ for _ in range(50):
+ await client.notify("notifications/lead", None)
+ result = await client.send_raw_request("tools/list", _modern_params())
+ assert result["tools"][0]["name"] == "t"
+
+
+@pytest.mark.anyio
+async def test_dual_era_loop_treats_a_read_stream_closed_before_the_first_request_as_end_of_input(server: SrvT):
+ """A transport closing the read stream's receive end (the stateless teardown
+ pattern) ends the loop like end-of-input rather than surfacing a
+ closed-resource error - here before the client has sent anything."""
+ c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](8)
+ s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](8)
+ with anyio.fail_after(5):
+ async with anyio.create_task_group() as tg, c2s_send, s2c_recv:
+ tg.start_soon(partial(serve_dual_era_loop, server, c2s_recv, s2c_send, lifespan_state=_LIFESPAN))
+ await anyio.lowlevel.checkpoint()
+ c2s_recv.close() # the transport tears down under the waiting loop
+
+
+@pytest.mark.anyio
+async def test_dual_era_loop_treats_a_read_stream_closed_mid_connection_as_end_of_input(server: SrvT):
+ """The same teardown after the connection is open ends the era loop
+ cleanly: the relay behind the opening request treats the closed receive
+ end as end-of-input."""
+ c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](8)
+ s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](8)
+ ping = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping", params=None)
+ with anyio.fail_after(5):
+ async with anyio.create_task_group() as tg, c2s_send, s2c_recv:
+ tg.start_soon(partial(serve_dual_era_loop, server, c2s_recv, s2c_send, lifespan_state=_LIFESPAN))
+ await c2s_send.send(SessionMessage(message=ping))
+ await s2c_recv.receive() # the connection is open and answered
+ c2s_recv.close()
+
+
@pytest.mark.anyio
async def test_dual_era_client_propagates_body_exception_unwrapped(server: SrvT):
"""The harness re-raises body exceptions as-is, not as `ExceptionGroup`."""
diff --git a/tests/server/test_server_context.py b/tests/server/test_server_context.py
index 9a9eaa3d97..9907e18013 100644
--- a/tests/server/test_server_context.py
+++ b/tests/server/test_server_context.py
@@ -11,6 +11,8 @@
import anyio
import pytest
+from mcp_types import LOG_LEVEL_META_KEY
+from mcp_types.version import LATEST_MODERN_VERSION
from mcp.server.connection import Connection
from mcp.server.context import Context
@@ -73,6 +75,34 @@ async def server_on_request(dctx: DCtx, method: str, params: Mapping[str, Any] |
assert params is not None and params["level"] == "debug" and params["data"] == "hello"
+@pytest.mark.anyio
+async def test_context_log_is_gated_by_the_request_log_level_at_2026():
+ """On a 2026 connection an un-opted request delivers nothing; opting in at
+ `warning` delivers `warning`+ and drops what falls below."""
+ crec = Recorder()
+ _, c_notify = echo_handlers(crec)
+
+ async def server_on_request(dctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
+ modern = Connection.from_envelope(LATEST_MODERN_VERSION, None, None, outbound=dctx)
+ silent: Context[_Lifespan] = Context(dctx, lifespan=_Lifespan("app"), connection=modern)
+ await silent.log("emergency", "dropped: no opt-in") # pyright: ignore[reportDeprecated]
+ opted: Context[_Lifespan] = Context(
+ dctx, lifespan=_Lifespan("app"), connection=modern, meta={LOG_LEVEL_META_KEY: "warning"}
+ )
+ await opted.log("info", "dropped: below level") # pyright: ignore[reportDeprecated]
+ await opted.log("warning", "delivered") # pyright: ignore[reportDeprecated]
+ return {}
+
+ async with running_pair(direct_pair, server_on_request=server_on_request, client_on_notify=c_notify) as (
+ client,
+ *_,
+ ):
+ with anyio.fail_after(5):
+ await client.send_raw_request("t", None)
+ await crec.notified.wait()
+ assert [p["data"] for _, p in crec.notifications if p is not None] == ["delivered"]
+
+
@pytest.mark.anyio
async def test_context_log_includes_logger_and_meta_when_supplied():
crec = Recorder()
diff --git a/tests/server/test_session.py b/tests/server/test_session.py
index 49e3b4615c..9d039fe59b 100644
--- a/tests/server/test_session.py
+++ b/tests/server/test_session.py
@@ -12,6 +12,7 @@
import mcp_types as types
import pytest
from mcp_types import (
+ LOG_LEVEL_META_KEY,
ClientCapabilities,
Implementation,
SamplingCapability,
@@ -157,6 +158,54 @@ async def test_send_notification_routes_by_related_request_id():
assert [m for m, _ in request_ch.notifications] == ["notifications/progress"]
+def _modern_session(
+ request_ch: StubOutbound, standalone_ch: StubOutbound, *, request_meta: types.RequestParamsMeta | None = None
+) -> ServerSession:
+ """A 2026-era session with distinct channels, carrying the inbound request's `_meta`."""
+ conn = Connection.from_envelope(LATEST_MODERN_VERSION, None, None, outbound=standalone_ch)
+ return ServerSession(request_ch, conn, request_meta=request_meta)
+
+
+@pytest.mark.anyio
+async def test_send_log_message_drops_everything_without_a_log_level_opt_in_at_2026():
+ """No `_meta` log-level opt-in on a 2026 request means no `notifications/message` at all."""
+ request_ch, standalone_ch = StubOutbound(), StubOutbound()
+ session = _modern_session(request_ch, standalone_ch)
+ await session.send_log_message("emergency", "on fire", related_request_id="req-1") # pyright: ignore[reportDeprecated]
+ assert request_ch.notifications == [] and standalone_ch.notifications == []
+
+
+@pytest.mark.anyio
+async def test_send_log_message_drops_levels_below_the_requested_one_at_2026():
+ request_ch, standalone_ch = StubOutbound(), StubOutbound()
+ session = _modern_session(request_ch, standalone_ch, request_meta={LOG_LEVEL_META_KEY: "warning"})
+ await session.send_log_message("info", "quiet") # pyright: ignore[reportDeprecated]
+ await session.send_log_message("error", "loud") # pyright: ignore[reportDeprecated]
+ assert [p["level"] for _, p in request_ch.notifications if p is not None] == ["error"]
+
+
+@pytest.mark.anyio
+async def test_send_log_message_is_request_scoped_at_2026_even_without_related_request_id():
+ """The spec forbids 2026 log delivery on any stream but the requesting one, so
+ `related_request_id` no longer selects the standalone channel there."""
+ request_ch, standalone_ch = StubOutbound(), StubOutbound()
+ session = _modern_session(request_ch, standalone_ch, request_meta={LOG_LEVEL_META_KEY: "debug"})
+ await session.send_log_message("info", "hello") # pyright: ignore[reportDeprecated]
+ assert [m for m, _ in request_ch.notifications] == ["notifications/message"]
+ assert standalone_ch.notifications == []
+
+
+@pytest.mark.anyio
+async def test_send_log_message_on_a_handshake_version_still_routes_by_related_request_id():
+ """Handshake versions keep the pre-2026 semantics: every level sends, channel by `related_request_id`."""
+ request_ch, standalone_ch = StubOutbound(), StubOutbound()
+ session = _two_channel_session(request_ch, standalone_ch)
+ await session.send_log_message("debug", "loose") # pyright: ignore[reportDeprecated]
+ await session.send_log_message("debug", "tied", related_request_id="req-1") # pyright: ignore[reportDeprecated]
+ assert [m for m, _ in standalone_ch.notifications] == ["notifications/message"]
+ assert [m for m, _ in request_ch.notifications] == ["notifications/message"]
+
+
@pytest.mark.anyio
async def test_report_progress_delegates_to_the_request_dispatch_context():
"""`report_progress` calls the per-request `DispatchContext.progress` seam, never the
diff --git a/tests/server/test_stateless_mode.py b/tests/server/test_stateless_mode.py
index 1124d69b71..6b9785bc3a 100644
--- a/tests/server/test_stateless_mode.py
+++ b/tests/server/test_stateless_mode.py
@@ -12,7 +12,7 @@
import mcp_types as types
import pytest
-from mcp_types import LATEST_PROTOCOL_VERSION
+from mcp_types import LATEST_PROTOCOL_VERSION, LOG_LEVEL_META_KEY
from mcp.server.connection import Connection
from mcp.server.session import ServerSession
@@ -53,13 +53,15 @@ async def progress(self, progress: float, total: float | None = None, message: s
raise NotImplementedError # pragma: no cover
-def _no_channel_session(request_ch: StubOutbound | None = None) -> tuple[ServerSession, StubOutbound]:
+def _no_channel_session(
+ request_ch: StubOutbound | None = None, *, request_meta: types.RequestParamsMeta | None = None
+) -> tuple[ServerSession, StubOutbound]:
"""A session whose standalone channel is the connection's no-channel
sentinel; the request channel is a working stub."""
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None)
assert conn.has_standalone_channel is False
request = request_ch if request_ch is not None else StubOutbound()
- return ServerSession(request, conn), request
+ return ServerSession(request, conn, request_meta=request_meta), request
@pytest.fixture
@@ -141,10 +143,11 @@ async def test_elicit_form_with_related_id_rides_the_request_channel():
@pytest.mark.anyio
-async def test_send_log_message_with_related_id_rides_the_request_channel():
- """SDK-defined: the deprecated ``send_log_message`` notification with a related id
- rides the per-request channel, so it is delivered even with no standalone back-channel."""
- session, request_ch = _no_channel_session()
+async def test_send_log_message_rides_the_request_channel_when_opted_in():
+ """SDK-defined: the deprecated `send_log_message` notification rides the per-request
+ channel on a 2026 connection (log delivery is request-scoped by spec), so it is delivered
+ even with no standalone back-channel - once the request opted in via its `_meta`."""
+ session, request_ch = _no_channel_session(request_meta={LOG_LEVEL_META_KEY: "debug"})
await session.send_log_message( # pyright: ignore[reportDeprecated]
level="info", data="hello", logger="test", related_request_id=3
)
diff --git a/tests/server/test_stdio.py b/tests/server/test_stdio.py
index 218e34d5ac..eafd1fca59 100644
--- a/tests/server/test_stdio.py
+++ b/tests/server/test_stdio.py
@@ -1,16 +1,20 @@
+import gc
import io
+import os
import sys
import threading
-from collections.abc import AsyncIterator
-from contextlib import asynccontextmanager
+from collections.abc import AsyncIterator, Iterator
+from contextlib import asynccontextmanager, contextmanager
from io import TextIOWrapper
import anyio
+import anyio.to_thread
import pytest
from mcp_types import (
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
PROTOCOL_VERSION_META_KEY,
+ SERVER_INFO_META_KEY,
JSONRPCMessage,
JSONRPCRequest,
JSONRPCResponse,
@@ -25,11 +29,7 @@
@pytest.mark.anyio
async def test_stdio_server_round_trips_messages_over_injected_streams() -> None:
- """stdio_server frames JSON-RPC messages as one line each in both directions.
-
- Parses one message per stdin line and writes each outgoing message as exactly one
- line, driven over injected in-process streams.
- """
+ """stdio_server frames JSON-RPC messages as one line each in both directions."""
stdin = io.StringIO()
stdout = io.StringIO()
@@ -77,17 +77,11 @@ async def test_stdio_server_round_trips_messages_over_injected_streams() -> None
@pytest.mark.anyio
async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch) -> None:
- """Non-UTF-8 stdin bytes surface as an in-stream exception without killing the stream.
-
- Invalid bytes are replaced with U+FFFD, fail JSON parsing, and arrive as an in-stream
- exception; subsequent valid messages are still processed.
- """
- # \xff\xfe are invalid UTF-8 start bytes.
+ """Non-UTF-8 stdin bytes surface as an in-stream exception without killing the stream."""
valid = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
raw_stdin = io.BytesIO(b"\xff\xfe\n" + valid.model_dump_json(by_alias=True, exclude_none=True).encode() + b"\n")
- # Replace sys.stdin with a wrapper whose .buffer is our raw bytes, so that
- # stdio_server()'s default path wraps it with errors='replace'.
+ # stdio_server()'s default path wraps sys.stdin.buffer with errors='replace'.
monkeypatch.setattr(sys, "stdin", TextIOWrapper(raw_stdin, encoding="utf-8"))
monkeypatch.setattr(sys, "stdout", TextIOWrapper(io.BytesIO(), encoding="utf-8"))
@@ -95,24 +89,462 @@ async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch) -> Non
async with stdio_server() as (read_stream, write_stream):
await write_stream.aclose()
async with read_stream: # pragma: no branch
- # First line: \xff\xfe -> U+FFFD U+FFFD -> JSON parse fails -> exception in stream
first = await read_stream.receive()
assert isinstance(first, Exception)
- # Second line: valid message still comes through
second = await read_stream.receive()
assert isinstance(second, SessionMessage)
assert second.message == valid
+@contextmanager
+def _pipe_planted_on_fd0(monkeypatch: pytest.MonkeyPatch) -> Iterator[tuple[int, int]]:
+ """Plants a fresh pipe on fd 0 and rebinds sys.stdin over it; yields (read_fd, write_fd).
+
+ Close ownership: the caller closes the write end exactly once; the helper closes only what
+ it created - a double close lands on a recycled fd and destroys whatever unrelated file now
+ lives there (pytest's capture files). os.dup2 is captured up front to survive monkeypatching.
+ """
+ real_dup2 = os.dup2
+ in_r, in_w = os.pipe()
+ saved0 = os.dup(0)
+ os.dup2(in_r, 0)
+ # Created after the plant so its cached stream state describes the pipe.
+ stdin_double = TextIOWrapper(open(0, "rb", closefd=False), encoding="utf-8")
+ try:
+ monkeypatch.setattr(sys, "stdin", stdin_double)
+ yield in_r, in_w
+ finally:
+ stdin_double.close()
+ real_dup2(saved0, 0)
+ os.close(saved0)
+ os.close(in_r)
+
+
+@contextmanager
+def _pipe_planted_on_fd1(monkeypatch: pytest.MonkeyPatch) -> Iterator[tuple[int, int]]:
+ """Like _pipe_planted_on_fd0 but plants fd 1 and rebinds sys.stdout; yields (read_fd, write_fd)."""
+ real_dup2 = os.dup2
+ out_r, out_w = os.pipe()
+ saved1 = os.dup(1)
+ os.dup2(out_w, 1)
+ stdout_double = TextIOWrapper(open(1, "wb", closefd=False), encoding="utf-8")
+ try:
+ monkeypatch.setattr(sys, "stdout", stdout_double)
+ yield out_r, out_w
+ finally:
+ stdout_double.close()
+ real_dup2(saved1, 1)
+ os.close(saved1)
+ os.close(out_r)
+ os.close(out_w)
+
+
+@contextmanager
+def _pipe_planted_on_fd2() -> Iterator[int]:
+ """Like _pipe_planted_on_fd0 but plants fd 2 to observe the stdout diversion; yields the read end."""
+ real_dup2 = os.dup2
+ err_r, err_w = os.pipe()
+ saved2 = os.dup(2)
+ try:
+ os.dup2(err_w, 2)
+ yield err_r
+ finally:
+ real_dup2(saved2, 2)
+ os.close(saved2)
+ os.close(err_r)
+ os.close(err_w)
+
+
+def _frame(message: JSONRPCRequest | JSONRPCResponse) -> bytes:
+ """One JSON-RPC message as the newline-terminated wire line the transport reads."""
+ return (message.model_dump_json(by_alias=True, exclude_none=True) + "\n").encode()
+
+
+async def _read_from(fd: int) -> bytes:
+ """One os.read from fd, in a worker thread.
+
+ A regression can leave the pipe empty; abandoning turns a read that would outlive
+ fail_after on the loop thread into a red TimeoutError instead.
+ """
+ return await anyio.to_thread.run_sync(os.read, fd, 65536, abandon_on_cancel=True)
+
+
+@pytest.mark.anyio
+async def test_stdio_server_takes_stdin_off_the_descriptor_table_while_serving(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """On the real process stdin, the transport claims the protocol pipe and releases it on exit.
+
+ SDK-defined behavior: while serving, fd 0 is the null device, so an inheriting child
+ cannot consume protocol bytes or, on Windows, hang at startup (CPython gh-78961).
+ """
+ with _pipe_planted_on_fd0(monkeypatch) as (in_r, in_w):
+ out_r, out_w = os.pipe()
+ stdout_double = TextIOWrapper(open(out_w, "wb", closefd=False), encoding="utf-8")
+ try:
+ monkeypatch.setattr(sys, "stdout", stdout_double)
+
+ request = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
+ response = JSONRPCResponse(jsonrpc="2.0", id=1, result={})
+ with anyio.fail_after(5):
+ async with stdio_server() as (read_stream, write_stream):
+ async with read_stream:
+ # fd 0 is the null device: instant EOF instead of protocol bytes.
+ assert await anyio.to_thread.run_sync(os.read, 0, 1, abandon_on_cancel=True) == b""
+
+ os.write(in_w, _frame(request))
+ received = await read_stream.receive()
+ assert isinstance(received, SessionMessage)
+ assert received.message == request
+
+ await write_stream.send(SessionMessage(response))
+ line = await _read_from(out_r)
+ assert jsonrpc_message_adapter.validate_json(line.decode().strip()) == response
+
+ os.close(in_w) # EOF lets the reader finish so the context can exit
+ await write_stream.aclose()
+
+ # samestat is trivially-true for pipes on Windows; POSIX legs carry these assertions.
+ assert os.path.sameopenfile(0, in_r)
+ finally:
+ stdout_double.close()
+ os.close(out_r)
+ os.close(out_w)
+
+
+@pytest.mark.anyio
+@pytest.mark.parametrize("failing_call", ["dup", "dup2", "dup2_destroys_target"])
+async def test_stdio_server_reads_stdin_in_place_when_descriptor_isolation_fails(
+ failing_call: str, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """A descriptor failure while claiming stdin degrades to reading sys.stdin in place.
+
+ SDK-defined behavior: isolation is best-effort; when duplicating fd 0 or diverting
+ it fails, the transport serves over the original stdin exactly as v1 did. A dup2
+ that closes its target before failing (Windows UCRT) still leaves fd 0 on the wire.
+ """
+ request = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
+ with _pipe_planted_on_fd0(monkeypatch) as (in_r, in_w):
+ os.write(in_w, _frame(request))
+ os.close(in_w)
+ monkeypatch.setattr(sys, "stdout", TextIOWrapper(io.BytesIO(), encoding="utf-8"))
+
+ if failing_call == "dup":
+
+ def failing_dup_above_std(fd: int) -> int:
+ raise OSError("injected descriptor failure")
+
+ monkeypatch.setattr("mcp.server.stdio._dup_above_std", failing_dup_above_std)
+ else:
+ # Fires once at the divert, then passes through: pytest's capture
+ # machinery also calls os.dup2 at phase transitions. The destroying
+ # variant closes the target first, as Windows UCRT dup2 does.
+ real_dup2 = os.dup2
+ armed = [True]
+
+ def failing_dup2(fd: int, fd2: int, inheritable: bool = True) -> int:
+ if armed[0]:
+ armed[0] = False
+ if failing_call == "dup2_destroys_target":
+ os.close(fd2)
+ raise OSError("injected descriptor failure")
+ return real_dup2(fd, fd2, inheritable)
+
+ monkeypatch.setattr(os, "dup2", failing_dup2)
+
+ with anyio.fail_after(5):
+ async with stdio_server() as (read_stream, write_stream): # pragma: no branch
+ async with read_stream: # pragma: no branch
+ # Isolation was skipped: fd 0 is still the protocol pipe.
+ assert os.path.sameopenfile(0, in_r)
+ received = await read_stream.receive()
+ assert isinstance(received, SessionMessage)
+ assert received.message == request
+ await write_stream.aclose()
+
+
+@pytest.mark.anyio
+async def test_stdio_server_exits_cleanly_when_the_stdin_restore_fails(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A failed fd 0 restore on exit is swallowed, not raised, and the fd stays claimed.
+
+ SDK-defined behavior: the restore must never mask what ended the transport, and a
+ still-diverted fd must refuse later transports rather than serve them the diversion.
+ """
+ request = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
+ monkeypatch.setattr("mcp.server.stdio._claims", {}) # this test leaves fd 0 claimed
+ with _pipe_planted_on_fd0(monkeypatch) as (_, in_w):
+ os.write(in_w, _frame(request))
+ os.close(in_w)
+ monkeypatch.setattr(sys, "stdout", TextIOWrapper(io.BytesIO(), encoding="utf-8"))
+
+ # The claim's dup2 (first call) succeeds; only the restore's (second) fails.
+ real_dup2 = os.dup2
+ dup2_calls: list[tuple[int, int]] = []
+
+ def flaky_dup2(fd: int, fd2: int, inheritable: bool = True) -> int:
+ dup2_calls.append((fd, fd2))
+ if len(dup2_calls) == 2:
+ raise OSError("injected restore failure")
+ return real_dup2(fd, fd2, inheritable)
+
+ monkeypatch.setattr(os, "dup2", flaky_dup2)
+
+ with anyio.fail_after(5):
+ async with stdio_server() as (read_stream, write_stream): # pragma: no branch
+ async with read_stream: # pragma: no branch
+ received = await read_stream.receive()
+ assert isinstance(received, SessionMessage)
+ assert received.message == request
+ await write_stream.aclose()
+
+ # Restore attempted (second dup2), failure swallowed, fd 0 left on the null device.
+ assert dup2_calls[1] == (dup2_calls[1][0], 0)
+ devnull_probe = os.open(os.devnull, os.O_RDONLY)
+ try:
+ assert os.path.sameopenfile(0, devnull_probe)
+ finally:
+ os.close(devnull_probe)
+
+ # The still-diverted fd stays claimed: a later transport is refused,
+ # not handed the null device as its wire.
+ with pytest.raises(RuntimeError, match="already claimed fd 0"):
+ async with stdio_server():
+ pytest.fail("unreachable") # pragma: no cover
+
+
+@pytest.mark.anyio
+async def test_stdio_server_takes_stdout_off_the_descriptor_table_while_serving(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """On the real process stdout, the transport claims the wire and diverts fd 1 to stderr.
+
+ SDK-defined behavior: stray writes to fd 1 land in the client's log, not the JSON-RPC stream.
+ """
+ response = JSONRPCResponse(jsonrpc="2.0", id=1, result={})
+ with _pipe_planted_on_fd1(monkeypatch) as (out_r, out_w), _pipe_planted_on_fd2() as err_r:
+ with anyio.fail_after(5):
+ async with stdio_server(stdin=anyio.AsyncFile(io.StringIO())) as (read_stream, write_stream):
+ read_stream.close()
+ os.write(1, b"stray child output\n")
+ assert await _read_from(err_r) == b"stray child output\n"
+
+ # The text layer writes os.linesep, hence CRLF on Windows.
+ print("stray print", flush=True)
+ assert await _read_from(err_r) == b"stray print" + os.linesep.encode()
+
+ await write_stream.send(SessionMessage(response))
+ line = await _read_from(out_r)
+ assert jsonrpc_message_adapter.validate_json(line.decode().strip()) == response
+
+ await write_stream.aclose()
+
+ assert os.path.sameopenfile(1, out_w)
+
+
+@pytest.mark.anyio
+async def test_stdio_server_diverts_stdout_to_the_null_device_when_stderr_is_unusable(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """When stderr cannot be duplicated, fd 1 is diverted to the null device instead.
+
+ SDK-defined behavior: a process with unusable fd 2 still gets stdout claimed; the wire stays pure.
+ """
+ response = JSONRPCResponse(jsonrpc="2.0", id=1, result={})
+ with _pipe_planted_on_fd1(monkeypatch) as (out_r, out_w):
+ # One-shot injector, as in the isolation-failure test.
+ real_dup = os.dup
+ armed = [True]
+
+ def failing_dup(fd: int) -> int:
+ if fd == 2 and armed[0]:
+ armed[0] = False
+ raise OSError("injected stderr failure")
+ return real_dup(fd)
+
+ monkeypatch.setattr(os, "dup", failing_dup)
+
+ with anyio.fail_after(5):
+ async with stdio_server(stdin=anyio.AsyncFile(io.StringIO())) as (read_stream, write_stream):
+ read_stream.close()
+ # The spent injector passes later duplications through untouched.
+ os.close(os.dup(0))
+ devnull_probe = os.open(os.devnull, os.O_WRONLY)
+ try:
+ assert os.path.sameopenfile(1, devnull_probe)
+ finally:
+ os.close(devnull_probe)
+
+ os.write(1, b"discarded\n")
+ await write_stream.send(SessionMessage(response))
+ line = await _read_from(out_r)
+ assert jsonrpc_message_adapter.validate_json(line.decode().strip()) == response
+ await write_stream.aclose()
+
+ assert os.path.sameopenfile(1, out_w)
+
+
+@pytest.mark.anyio
+async def test_a_second_stdio_server_on_the_same_process_streams_is_refused(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A concurrent stdio_server() on already-claimed streams raises instead of contending."""
+ request = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
+ response = JSONRPCResponse(jsonrpc="2.0", id=1, result={})
+ with _pipe_planted_on_fd0(monkeypatch) as (in_r, in_w), _pipe_planted_on_fd1(monkeypatch) as (out_r, out_w):
+ with anyio.fail_after(5):
+ async with stdio_server() as (read_stream, write_stream):
+ async with read_stream: # pragma: no branch
+ with pytest.raises(RuntimeError, match="already claimed fd 0"):
+ async with stdio_server():
+ pytest.fail("unreachable") # pragma: no cover
+
+ os.write(in_w, _frame(request))
+ received = await read_stream.receive()
+ assert isinstance(received, SessionMessage)
+ assert received.message == request
+ await write_stream.send(SessionMessage(response))
+ line = await _read_from(out_r)
+ assert jsonrpc_message_adapter.validate_json(line.decode().strip()) == response
+ os.close(in_w)
+ await write_stream.aclose()
+
+ assert os.path.sameopenfile(0, in_r)
+ assert os.path.sameopenfile(1, out_w)
+
+
+@pytest.mark.anyio
+async def test_a_refused_claim_releases_the_stream_it_already_took(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A transport refused halfway through claiming restores what it claimed first.
+
+ The first transport claims only stdout; the second claims stdin, is refused on stdout, and must release stdin.
+ """
+ with _pipe_planted_on_fd0(monkeypatch) as (in_r, in_w), _pipe_planted_on_fd1(monkeypatch) as (_, out_w):
+ with anyio.fail_after(5):
+ async with stdio_server(stdin=anyio.AsyncFile(io.StringIO())) as (read_stream, write_stream):
+ read_stream.close()
+ with pytest.raises(RuntimeError, match="already claimed fd 1"):
+ async with stdio_server():
+ pytest.fail("unreachable") # pragma: no cover
+
+ # fd 0 is back on the protocol pipe, not the null device.
+ assert os.path.sameopenfile(0, in_r)
+ await write_stream.aclose()
+
+ assert os.path.sameopenfile(1, out_w)
+ os.close(in_w)
+
+
+@pytest.mark.anyio
+@pytest.mark.skipif(sys.platform == "win32", reason="atomic above-range dup is POSIX-only (F_DUPFD)")
+async def test_the_claim_engages_even_when_stderr_is_closed( # pragma: lax no cover
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A process missing fd 2 still gets full isolation on POSIX.
+
+ F_DUPFD allocates the wire duplicate above the standard range atomically, so
+ the hole in slot 2 cannot capture it; the stdout diversion falls back to the
+ null device. Windows has no atomic minfd dup: the duplicate can land in the
+ hole and the transport degrades to serving in place (a documented residue),
+ which is safe but not this test's isolation contract, and this test's blocking
+ read of the still-piped fd 0 would then never return.
+ """
+ request = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
+ response = JSONRPCResponse(jsonrpc="2.0", id=1, result={})
+ with _pipe_planted_on_fd0(monkeypatch) as (in_r, in_w), _pipe_planted_on_fd1(monkeypatch) as (out_r, out_w):
+ saved2 = os.dup(2)
+ os.close(2)
+ try:
+ with anyio.fail_after(5):
+ async with stdio_server() as (read_stream, write_stream):
+ async with read_stream: # pragma: no branch
+ # Claimed: fd 0 reads the null device, not the pipe.
+ devnull_probe = os.open(os.devnull, os.O_RDONLY)
+ try:
+ assert os.path.sameopenfile(0, devnull_probe)
+ finally:
+ os.close(devnull_probe)
+
+ os.write(in_w, _frame(request))
+ received = await read_stream.receive()
+ assert isinstance(received, SessionMessage)
+ assert received.message == request
+ await write_stream.send(SessionMessage(response))
+ line = await _read_from(out_r)
+ assert jsonrpc_message_adapter.validate_json(line.decode().strip()) == response
+ os.close(in_w)
+ await write_stream.aclose()
+
+ assert os.path.sameopenfile(0, in_r)
+ assert os.path.sameopenfile(1, out_w)
+ finally:
+ os.dup2(saved2, 2)
+ os.close(saved2)
+
+
+@pytest.mark.anyio
+async def test_stdio_server_serves_in_place_when_the_diversion_cannot_be_opened(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A diversion that cannot be opened leaves fd 0 untouched and serves in place."""
+ request = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
+ with _pipe_planted_on_fd0(monkeypatch) as (in_r, in_w):
+ os.write(in_w, _frame(request))
+ os.close(in_w)
+ monkeypatch.setattr(sys, "stdout", TextIOWrapper(io.BytesIO(), encoding="utf-8"))
+
+ def failing_diversion() -> int:
+ raise OSError("injected diversion failure")
+
+ monkeypatch.setattr("mcp.server.stdio._open_stdin_diversion", failing_diversion)
+
+ with anyio.fail_after(5):
+ async with stdio_server() as (read_stream, write_stream): # pragma: no branch
+ async with read_stream: # pragma: no branch
+ assert os.path.sameopenfile(0, in_r)
+ received = await read_stream.receive()
+ assert isinstance(received, SessionMessage)
+ assert received.message == request
+ await write_stream.aclose()
+
+
+@pytest.mark.anyio
+async def test_a_degraded_session_does_not_close_the_sys_stream_it_served(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """The transport's text layer never closes a buffer it does not own.
+
+ Regression for the issue #1933 class: in the in-place paths the transport wraps
+ the sys stream's own buffer, and wrapper garbage collection must not close it.
+ """
+ with _pipe_planted_on_fd0(monkeypatch) as (_, in_w):
+ os.close(in_w)
+ monkeypatch.setattr(sys, "stdout", TextIOWrapper(io.BytesIO(), encoding="utf-8"))
+
+ def failing_dup_above_std(fd: int) -> int:
+ raise OSError("forced degrade")
+
+ monkeypatch.setattr("mcp.server.stdio._dup_above_std", failing_dup_above_std)
+
+ with anyio.fail_after(5):
+ async with stdio_server() as (read_stream, write_stream):
+ read_stream.close()
+ await write_stream.aclose()
+
+ gc.collect()
+ assert not sys.stdin.buffer.closed
+ assert not sys.stdout.buffer.closed
+
+
class _GatedStdin(io.RawIOBase):
"""Raw stdin double: serves its frames, then blocks until released before EOF.
- A real stdio client keeps stdin open until it has read the responses it is
- awaiting; an immediate EOF after the last frame races the dispatcher's
- EOF-time cancellation of in-flight handlers (only inline-handled methods
- would deterministically answer first). The blocked read sits in
- `stdio_server`'s reader worker thread and unblocks on `release()`.
+ A real client holds stdin open until it reads its responses; instant EOF races the
+ dispatcher's EOF-time cancellation of in-flight handlers.
"""
name = ""
@@ -131,8 +563,7 @@ def readinto(self, b: Buffer) -> int:
view[:n] = self._pending[:n]
self._pending = self._pending[n:]
return n
- # A missed release falls through to EOF after the bound; the caller's
- # own response assertions then report what actually arrived.
+ # A missed release falls through to EOF after the bound.
self._released.wait(5)
return 0
@@ -143,8 +574,7 @@ def release(self) -> None:
class _NotifyingStdout(io.RawIOBase):
"""Raw stdout double that counts newline-terminated lines and can be awaited on.
- Survives wrapper close (`close()` is a no-op) so the test can read what was
- written after `run()` has torn its TextIOWrapper down.
+ close() is a no-op so the test can read what was written after run() tears down.
"""
name = ""
@@ -182,14 +612,8 @@ def _serve_stdio_and_collect(
) -> list[JSONRPCMessage]:
"""Serve `frames` over process stdio and return the parsed response lines.
- Runs the blocking `server.run("stdio")` in a daemon thread (it creates its
- own event loop, so a sync test cannot arm `anyio.fail_after`) and signals
- stdin EOF only after `responses` lines arrive on stdout - the way a real
- client closes the pipe - so spawned in-flight handlers never race the
- dispatcher's EOF cancellation. The join bound turns a run loop that never
- returns on stdin EOF into a red test instead of a silent CI hang; an
- exception escaping `run()` still fails the test via pytest's
- unhandled-thread warning, escalated by `filterwarnings = ["error"]`.
+ Runs the blocking server.run("stdio") in a daemon thread and signals stdin EOF only after
+ `responses` lines arrive - as a real client would - so handlers never race EOF cancellation.
"""
payload = "".join(f.model_dump_json(by_alias=True, exclude_none=True) + "\n" for f in frames).encode()
stdin = _GatedStdin(payload)
@@ -211,11 +635,7 @@ def target() -> None:
def test_mcpserver_run_stdio_serves_until_stdin_closes(monkeypatch: pytest.MonkeyPatch) -> None:
- """`MCPServer.run("stdio")` serves over process stdio and returns at stdin EOF.
-
- Answers a request over the process's stdio and returns when stdin reaches EOF,
- rather than serving forever.
- """
+ """`MCPServer.run("stdio")` serves over process stdio and returns at stdin EOF."""
ping = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
responses = _serve_stdio_and_collect(monkeypatch, MCPServer(name="RunStdioServer"), [ping], 1)
@@ -226,8 +646,7 @@ def test_mcpserver_run_stdio_serves_until_stdin_closes(monkeypatch: pytest.Monke
def test_mcpserver_run_stdio_runs_lifespan_cleanup_after_stdin_closes(monkeypatch: pytest.MonkeyPatch) -> None:
"""Code after `yield` in a lifespan runs when stdin EOF ends `run("stdio")`.
- Regression lock for the issue #1027 shutdown chain: the run loop must end on
- stdin EOF and unwind the lifespan rather than be killed before returning.
+ Regression lock for the issue #1027 shutdown chain.
"""
events: list[str] = []
@@ -251,10 +670,7 @@ async def lifespan(server: MCPServer) -> AsyncIterator[None]:
def test_mcpserver_run_stdio_serves_a_modern_connection(monkeypatch: pytest.MonkeyPatch) -> None:
"""`MCPServer.run("stdio")` serves the modern era over process stdio.
- A `server/discover` probe gets a DiscoverResult (no initialize handshake)
- and a subsequent envelope-bearing request is served at the discovered
- version - the wire exchange `Client(mode='auto')` drives against a stdio
- server.
+ A `server/discover` probe (no initialize handshake), then a request served at the discovered version.
"""
envelope = {
PROTOCOL_VERSION_META_KEY: "2026-07-28",
@@ -264,13 +680,15 @@ def test_mcpserver_run_stdio_serves_a_modern_connection(monkeypatch: pytest.Monk
discover = JSONRPCRequest(jsonrpc="2.0", id=1, method="server/discover", params={"_meta": envelope})
tools = JSONRPCRequest(jsonrpc="2.0", id=2, method="tools/list", params={"_meta": envelope})
- responses = _serve_stdio_and_collect(monkeypatch, MCPServer(name="ModernStdioServer"), [discover, tools], 2)
+ server = MCPServer(name="ModernStdioServer", version="1.2.3")
+ responses = _serve_stdio_and_collect(monkeypatch, server, [discover, tools], 2)
assert isinstance(responses[0], JSONRPCResponse) and responses[0].id == 1
assert "2026-07-28" in responses[0].result["supportedVersions"]
- assert responses[0].result["serverInfo"]["name"] == "ModernStdioServer"
+ # Server identity travels as the result `_meta` stamp, not a DiscoverResult
+ # body field (spec 2026-07-28, #3002).
+ assert responses[0].result["_meta"][SERVER_INFO_META_KEY] == {"name": "ModernStdioServer", "version": "1.2.3"}
assert isinstance(responses[1], JSONRPCResponse) and responses[1].id == 2
- # `resultType` is the modern-only wire field: its presence proves the
- # request was served at the discovered version, not the handshake era.
+ # resultType is modern-only: proves the request was served at the discovered version.
assert responses[1].result["tools"] == []
assert responses[1].result["resultType"] == "complete"
diff --git a/tests/server/test_streamable_http_modern.py b/tests/server/test_streamable_http_modern.py
index 45a1e7e1c4..cbf826d3f7 100644
--- a/tests/server/test_streamable_http_modern.py
+++ b/tests/server/test_streamable_http_modern.py
@@ -24,8 +24,10 @@
METHOD_NOT_FOUND,
PARSE_ERROR,
PROTOCOL_VERSION_META_KEY,
+ SERVER_INFO_META_KEY,
CallToolRequestParams,
CallToolResult,
+ ClientCapabilities,
ErrorData,
JSONRPCError,
JSONRPCResponse,
@@ -158,6 +160,40 @@ def _list_tools_body() -> dict[str, Any]:
return {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {"_meta": meta}}
+async def test_handle_modern_request_serves_pair_only_envelope_without_client_info() -> None:
+ """Spec-mandated (spec PR #3002): `clientInfo` is optional - a request whose
+ `_meta` carries only the protocol-version + client-capabilities pair is
+ served, with the declared capabilities recorded and `client_params` None."""
+ seen: list[tuple[object, object]] = []
+
+ async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
+ seen.append((ctx.session.client_params, ctx.session.client_capabilities))
+ return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public")
+
+ server: Server[Any] = Server("test", on_list_tools=list_tools)
+ body = _list_tools_body()
+ del body["params"]["_meta"][CLIENT_INFO_META_KEY]
+ async with _asgi_client(server) as http:
+ response = await http.post("/mcp", json=body, headers={MCP_METHOD_HEADER: "tools/list"})
+ assert response.status_code == 200
+ assert response.json()["result"]["tools"] == []
+ assert seen == [(None, ClientCapabilities())]
+
+
+async def test_handle_modern_request_missing_capabilities_rejects_naming_the_key() -> None:
+ """Spec-mandated (basic/index.mdx): the protocol version without the
+ required client-capabilities key is malformed - INVALID_PARAMS (HTTP 400)
+ with a message naming the missing key."""
+ body = _list_tools_body()
+ del body["params"]["_meta"][CLIENT_CAPABILITIES_META_KEY]
+ async with _asgi_client(Server("test")) as http:
+ response = await http.post("/mcp", json=body, headers={MCP_METHOD_HEADER: "tools/list"})
+ assert response.status_code == 400
+ error = response.json()["error"]
+ assert error["code"] == INVALID_PARAMS
+ assert CLIENT_CAPABILITIES_META_KEY in error["message"]
+
+
async def test_handle_modern_request_routes_with_mis_shaped_envelope_client_info() -> None:
"""SDK-defined: a mis-shaped ``clientInfo`` envelope value is treated as not supplied —
the request still routes (200 + result) and the handler observes ``client_params is None``
@@ -178,7 +214,12 @@ async def greet(ctx: ServerRequestContext, params: PaginatedRequestParams) -> di
async with _asgi_client(server) as http:
response = await http.post("/mcp", json=body, headers={MCP_METHOD_HEADER: "custom/greet"})
assert response.status_code == 200
- assert response.json()["result"] == {"ok": True}
+ result = response.json()["result"]
+ assert result == {
+ "_meta": {SERVER_INFO_META_KEY: {"name": "test", "version": ""}},
+ "ok": True,
+ "resultType": "complete",
+ }
assert seen == [None]
@@ -709,6 +750,20 @@ async def test_modern_tools_call_accepts_matching_mcp_param_header() -> None:
assert response.json()["result"]["content"] == []
+async def test_modern_tools_call_validates_mcp_param_headers_for_a_pair_only_envelope() -> None:
+ """The schema-resolving `tools/list` walk builds its synthetic envelope from the caller's:
+ a pair-only caller (spec PR #3002, no clientInfo) omits the optional key rather than sending
+ null, so header validation still runs and a mismatched header is still rejected."""
+ body = _tool_call_body({"region": "east"})
+ del body["params"]["_meta"][CLIENT_INFO_META_KEY]
+ async with _asgi_client(_x_mcp_server()) as http:
+ matched = await http.post("/mcp", json=body, headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "east"})
+ mismatched = await http.post("/mcp", json=body, headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "west"})
+ assert matched.status_code == 200
+ assert mismatched.status_code == 400
+ assert mismatched.json()["error"]["code"] == HEADER_MISMATCH
+
+
@pytest.mark.parametrize("json_response", [True, False])
async def test_modern_tools_call_rejects_mcp_param_mismatch_with_400_and_header_mismatch(
json_response: bool,
@@ -1063,7 +1118,7 @@ async def test_json_response_mode_still_streams_subscriptions_listen() -> None:
the SSE path, acks first, and ends with the stamped result on close()."""
bus = _OpenSignalBus()
handler = ListenHandler(bus)
- server = Server("test", on_subscriptions_listen=handler)
+ server = Server("test", version="1.2.3", on_subscriptions_listen=handler)
body = _listen_body()
responses: list[httpx2.Response] = []
@@ -1086,4 +1141,9 @@ async def post() -> None:
events = _sse_payloads(response.text)
assert events[0]["method"] == "notifications/subscriptions/acknowledged"
assert events[1]["id"] == 9
- assert events[1]["result"]["_meta"] == {"io.modelcontextprotocol/subscriptionId": 9}
+ # The terminal listen result is a modern-era result like any other, so it
+ # carries the serverInfo stamp alongside the subscription id.
+ assert events[1]["result"]["_meta"] == {
+ "io.modelcontextprotocol/subscriptionId": 9,
+ SERVER_INFO_META_KEY: {"name": "test", "version": "1.2.3"},
+ }
diff --git a/tests/server/test_streamable_http_router.py b/tests/server/test_streamable_http_router.py
index 3086dca990..07aa063499 100644
--- a/tests/server/test_streamable_http_router.py
+++ b/tests/server/test_streamable_http_router.py
@@ -25,6 +25,25 @@ async def replay_events_after(self, last_event_id: EventId, send_callback: Event
raise NotImplementedError
+class _AsgiPost:
+ """A one-shot POST driven straight at `handle_request`, capturing what the transport sends."""
+
+ def __init__(self, body: bytes, headers: list[tuple[bytes, bytes]]) -> None:
+ self.scope: Scope = {"type": "http", "method": "POST", "path": "/", "query_string": b"", "headers": headers}
+ self.sent: list[Message] = []
+ self._body = body
+ self._body_sent = False
+
+ async def receive(self) -> Message:
+ if not self._body_sent:
+ self._body_sent = True
+ return {"type": "http.request", "body": self._body, "more_body": False}
+ raise NotImplementedError
+
+ async def send(self, message: Message) -> None:
+ self.sent.append(message)
+
+
@pytest.mark.anyio
async def test_router_unconsumed_request_stream_does_not_block_siblings() -> None:
"""A response whose `sse_writer` is not yet receiving must not park the router (#1764).
@@ -73,35 +92,18 @@ async def test_priming_store_failure_leaves_no_per_request_state() -> None:
event_store=_PrimingFailingStore(),
)
- body = b'{"jsonrpc":"2.0","id":"req-1","method":"tools/list","params":{}}'
- scope: Scope = {
- "type": "http",
- "method": "POST",
- "path": "/",
- "query_string": b"",
- "headers": [
+ post = _AsgiPost(
+ b'{"jsonrpc":"2.0","id":"req-1","method":"tools/list","params":{}}',
+ [
(b"accept", b"application/json, text/event-stream"),
(b"content-type", b"application/json"),
(b"mcp-protocol-version", b"2025-11-25"),
],
- }
- body_sent = False
-
- async def receive() -> Message:
- nonlocal body_sent
- if not body_sent:
- body_sent = True
- return {"type": "http.request", "body": body, "more_body": False}
- raise NotImplementedError
-
- sent: list[Message] = []
-
- async def asgi_send(message: Message) -> None:
- sent.append(message)
+ )
async with transport.connect() as (read_stream, _write_stream):
async with anyio.create_task_group() as tg:
- tg.start_soon(transport.handle_request, scope, receive, asgi_send)
+ tg.start_soon(transport.handle_request, post.scope, post.receive, post.send)
with anyio.fail_after(5):
forwarded = await read_stream.receive()
assert isinstance(forwarded, Exception)
@@ -110,7 +112,32 @@ async def asgi_send(message: Message) -> None:
assert transport._request_streams == {}
assert transport._sse_stream_writers == {}
- assert sent[0]["type"] == "http.response.start"
- assert sent[0]["status"] == 500
- body = b"".join(m.get("body", b"") for m in sent if m["type"] == "http.response.body")
+ assert post.sent[0]["type"] == "http.response.start"
+ assert post.sent[0]["status"] == 500
+ body = b"".join(m.get("body", b"") for m in post.sent if m["type"] == "http.response.body")
assert b"backend unavailable" not in body
+
+
+@pytest.mark.anyio
+async def test_json_post_answers_500_when_session_terminates_mid_request() -> None:
+ """A JSON-mode POST whose session is torn down before the handler answers gets a 500, not a stall."""
+ transport = StreamableHTTPServerTransport(mcp_session_id="sid", is_json_response_enabled=True)
+ post = _AsgiPost(
+ b'{"jsonrpc":"2.0","id":"req-1","method":"tools/list","params":{}}',
+ [
+ (b"accept", b"application/json"),
+ (b"content-type", b"application/json"),
+ (b"mcp-session-id", b"sid"),
+ (b"mcp-protocol-version", b"2025-11-25"),
+ ],
+ )
+
+ async with transport.connect() as (read_stream, _write_stream):
+ async with anyio.create_task_group() as tg:
+ tg.start_soon(transport.handle_request, post.scope, post.receive, post.send)
+ with anyio.fail_after(5):
+ await read_stream.receive() # the request reached the session; the POST is parked
+ await transport.terminate()
+
+ assert post.sent[0]["type"] == "http.response.start"
+ assert post.sent[0]["status"] == 500
diff --git a/tests/shared/test_auth.py b/tests/shared/test_auth.py
index 7463bc5a8a..5286b93834 100644
--- a/tests/shared/test_auth.py
+++ b/tests/shared/test_auth.py
@@ -1,9 +1,9 @@
"""Tests for OAuth 2.0 shared code."""
import pytest
-from pydantic import ValidationError
+from pydantic import AnyUrl, ValidationError
-from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthMetadata
+from mcp.shared.auth import InvalidRedirectUriError, OAuthClientInformationFull, OAuthClientMetadata, OAuthMetadata
def test_oauth():
@@ -110,8 +110,8 @@ def test_valid_url_passes_through_unchanged():
def test_information_full_inherits_coercion():
- """OAuthClientInformationFull subclasses OAuthClientMetadata, so the
- same coercion applies to DCR responses parsed via the full model."""
+ """OAuthClientInformationFull shares the metadata base, so the same
+ coercion applies to DCR responses parsed via the full model."""
data = {
"client_id": "abc123",
"redirect_uris": ["https://example.com/callback"],
@@ -130,6 +130,107 @@ def test_information_full_inherits_coercion():
assert info.jwks_uri is None
+# RFC 7591 §3.2.1 lets the authorization server reject or replace any requested metadata
+# value in its registration response. Real servers echo values outside the sets the client
+# would send (an unregistered application_type, an explicit null, an auth method the SDK
+# does not implement, an empty redirect_uris array); a parse failure there discards a
+# registration whose client_id the server has already provisioned.
+
+
+@pytest.mark.parametrize(
+ "substituted",
+ [
+ pytest.param({"application_type": "confidential"}, id="unregistered-application-type"),
+ pytest.param({"application_type": ""}, id="empty-application-type"),
+ pytest.param({"application_type": None}, id="null-application-type"),
+ pytest.param({"token_endpoint_auth_method": "client_secret_jwt"}, id="unimplemented-auth-method"),
+ pytest.param({"grant_types": ["authorization_code", "client_credentials"]}, id="extra-grant-type"),
+ pytest.param({"redirect_uris": []}, id="empty-redirect-uris"),
+ ],
+)
+def test_client_information_accepts_server_substituted_metadata(substituted: dict[str, object]):
+ data = {"client_id": "abc123", "client_secret": "s3cr3t", **substituted}
+ info = OAuthClientInformationFull.model_validate(data)
+ assert info.client_id == "abc123"
+ assert info.client_secret == "s3cr3t"
+
+
+def test_client_information_without_echoed_metadata_still_parses():
+ """A response holding only the credentials the server minted is a usable registration."""
+ info = OAuthClientInformationFull.model_validate({"client_id": "abc123"})
+ assert info.client_id == "abc123"
+ assert info.redirect_uris is None
+ assert info.application_type is None
+
+
+def test_every_request_metadata_field_exists_on_the_client_record():
+ """The registration handler builds its 201 echo from the request's dump; every request
+ field must exist on the record so none can be silently dropped from the response."""
+ assert set(OAuthClientMetadata.model_fields) <= set(OAuthClientInformationFull.model_fields)
+
+
+def test_a_registration_response_without_a_client_id_is_rejected():
+ """RFC 7591 §3.2.1 makes client_id REQUIRED; a body without one is not a registration,
+ however permissive the parse is about the metadata around it."""
+ with pytest.raises(ValidationError):
+ OAuthClientInformationFull.model_validate({"application_type": "web"})
+
+
+@pytest.mark.parametrize("placeholder", [None, ""], ids=["null", "empty-string"])
+@pytest.mark.parametrize(
+ "member",
+ ["grant_types", "response_types", "redirect_uris", "application_type", "token_endpoint_auth_method", "scope"],
+)
+def test_client_information_reads_a_placeholder_member_as_an_omitted_key(member: str, placeholder: object):
+ """A server that dumps unset members as null, or echoes them as "", still yields a
+ usable registration: a placeholder and an absent key mean the same, so the field's
+ default applies - including for list fields, where the placeholder is not a valid list."""
+ info = OAuthClientInformationFull.model_validate({"client_id": "abc123", member: placeholder})
+ defaults = OAuthClientInformationFull.model_validate({"client_id": "abc123"})
+ assert getattr(info, member) == getattr(defaults, member)
+
+
+def test_a_placeholder_client_id_is_a_missing_client_id():
+ """The placeholder rule applies to the credential too: an empty client_id is no client_id,
+ so the body is rejected rather than parsing as a registration with an empty identifier."""
+ with pytest.raises(ValidationError):
+ OAuthClientInformationFull.model_validate({"client_id": ""})
+
+
+def test_client_information_that_is_not_an_object_still_fails_the_parse():
+ """The null-as-omitted coercion only touches JSON objects; a body that is not one is
+ passed through and rejected as a normal validation failure rather than swallowed."""
+ with pytest.raises(ValidationError):
+ OAuthClientInformationFull.model_validate("not-an-object")
+
+
+@pytest.mark.parametrize("redirect_uris", [None, []], ids=["absent", "empty"])
+@pytest.mark.parametrize(
+ "redirect_uri", [None, AnyUrl("https://example.com/callback")], ids=["unspecified", "specified"]
+)
+def test_client_with_no_registered_redirect_uris_cannot_resolve_a_redirect(
+ redirect_uris: list[str] | None, redirect_uri: AnyUrl | None
+):
+ """With no registered redirect URIs (absent or empty), no redirect resolves - neither a
+ supplied one (nothing to match against) nor an unspecified one (no single default)."""
+ info = OAuthClientInformationFull.model_validate({"client_id": "abc123", "redirect_uris": redirect_uris})
+ with pytest.raises(InvalidRedirectUriError):
+ info.validate_redirect_uri(redirect_uri)
+
+
+def test_request_metadata_restricts_application_type_to_the_values_the_sdk_sends():
+ """What the SDK sends stays narrow even though what it accepts back is wide."""
+ with pytest.raises(ValidationError):
+ OAuthClientMetadata.model_validate(
+ {"redirect_uris": ["https://example.com/callback"], "application_type": "confidential"}
+ )
+
+
+def test_request_metadata_requires_at_least_one_redirect_uri():
+ with pytest.raises(ValidationError):
+ OAuthClientMetadata.model_validate({"redirect_uris": []})
+
+
def test_invalid_non_empty_url_still_rejected():
"""Coercion must only touch empty strings — garbage URLs still raise."""
data = {
diff --git a/tests/shared/test_inbound.py b/tests/shared/test_inbound.py
index 2bf9c36411..7712e73e5e 100644
--- a/tests/shared/test_inbound.py
+++ b/tests/shared/test_inbound.py
@@ -98,19 +98,55 @@ def assert_rejected(result: object, code: int) -> InboundLadderRejection:
@pytest.mark.parametrize(
- "body",
+ ("body", "named"),
[
- pytest.param({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, id="no-params"),
- pytest.param({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}, id="no-meta"),
- pytest.param(envelope(drop=frozenset({PROTOCOL_VERSION_META_KEY})), id="meta-missing-version"),
- pytest.param(envelope(drop=frozenset({CLIENT_INFO_META_KEY})), id="meta-missing-client-info"),
- pytest.param(envelope(drop=frozenset({CLIENT_CAPABILITIES_META_KEY})), id="meta-missing-client-caps"),
+ pytest.param(
+ {"jsonrpc": "2.0", "id": 1, "method": "tools/list"},
+ [PROTOCOL_VERSION_META_KEY, CLIENT_CAPABILITIES_META_KEY],
+ id="no-params",
+ ),
+ pytest.param(
+ {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}},
+ [PROTOCOL_VERSION_META_KEY, CLIENT_CAPABILITIES_META_KEY],
+ id="no-meta",
+ ),
+ pytest.param(
+ envelope(drop=frozenset({PROTOCOL_VERSION_META_KEY})),
+ [PROTOCOL_VERSION_META_KEY],
+ id="meta-missing-version",
+ ),
+ pytest.param(
+ envelope(drop=frozenset({CLIENT_CAPABILITIES_META_KEY})),
+ [CLIENT_CAPABILITIES_META_KEY],
+ id="meta-missing-client-caps",
+ ),
+ pytest.param(
+ envelope(drop=frozenset({PROTOCOL_VERSION_META_KEY, CLIENT_CAPABILITIES_META_KEY})),
+ [PROTOCOL_VERSION_META_KEY, CLIENT_CAPABILITIES_META_KEY],
+ id="meta-missing-both",
+ ),
],
)
-def test_envelope_rung_rejects_missing_keys(body: dict[str, Any]) -> None:
- """Spec-mandated: a modern request lacking any of the three reserved `_meta` keys is rejected INVALID_PARAMS."""
+def test_envelope_rung_rejects_missing_required_keys(body: dict[str, Any], named: list[str]) -> None:
+ """Spec-mandated (basic/index.mdx per-request protocol fields): a modern
+ request lacking a required `_meta` envelope key (protocol version or
+ client capabilities) is rejected INVALID_PARAMS with a message naming the
+ missing key(s)."""
rejection = assert_rejected(classify_inbound_request(body), INVALID_PARAMS)
assert rejection.data is None
+ for key in named:
+ assert key in rejection.message
+
+
+def test_envelope_rung_accepts_pair_only_envelope_without_client_info() -> None:
+ """Spec-mandated (spec PR #3002): `clientInfo` is optional - a request whose
+ `_meta` carries only the protocol-version + client-capabilities pair
+ routes, with `client_info` read as `None`."""
+ result = classify_inbound_request(envelope(drop=frozenset({CLIENT_INFO_META_KEY})))
+ assert isinstance(result, InboundModernRoute)
+ assert result.protocol_version == LATEST_MODERN_VERSION
+ assert result.client_info is None
+ assert result.client_capabilities == CLIENT_CAPS
@pytest.mark.parametrize(
diff --git a/tests/shared/test_jsonrpc_dispatcher.py b/tests/shared/test_jsonrpc_dispatcher.py
index 5c29c7e3ce..9bee8b2c3b 100644
--- a/tests/shared/test_jsonrpc_dispatcher.py
+++ b/tests/shared/test_jsonrpc_dispatcher.py
@@ -22,6 +22,7 @@
CancelledNotificationParams,
ErrorData,
JSONRPCError,
+ JSONRPCMessage,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
@@ -34,10 +35,11 @@
from mcp.server import Server, ServerRequestContext
from mcp.shared._compat import resync_tracer
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream
-from mcp.shared.dispatcher import CallOptions, DispatchContext, coerce_request_id
+from mcp.shared.dispatcher import CallOptions, DispatchContext, OnRequest, coerce_request_id
from mcp.shared.exceptions import MCPError, NoBackChannelError
from mcp.shared.jsonrpc_dispatcher import ( # pyright: ignore[reportPrivateUsage]
JSONRPCDispatcher,
+ PeerCancelMode,
_OutboundPlan,
_Pending,
_plan_outbound,
@@ -122,113 +124,171 @@ async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] |
assert exc.value.__cause__ is None # cause does not survive the wire
-@pytest.mark.anyio
-async def test_peer_cancel_interrupt_mode_writes_cancelled_error_response():
- """Matches the existing server: a peer-cancelled request is answered with code=0."""
+async def _drive_cancelled_request(
+ on_request: OnRequest, *, peer_cancel_mode: PeerCancelMode = "interrupt", done: anyio.Event
+) -> tuple[list[JSONRPCMessage], list[RequestId]]:
+ """Send request 1, cancel it, then send an uncancelled control request 2.
+
+ Returns (answers written, ids settled unanswered). The control request proves the write
+ path is live, so an empty answer for id 1 is the suppression under test. Both requests
+ carry an `on_request_unanswered` callback; only cancelled request 1 should fire it.
+ Server-only, since the cancelled request is never answered. `done` is set by the
+ handler once request 1 reaches the state under test.
+ """
+ c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](4)
+ recording = RecordingWriteStream()
+ server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(
+ c2s_recv, recording, peer_cancel_mode=peer_cancel_mode
+ )
handler_started = anyio.Event()
+ unanswered: list[RequestId] = []
+
+ async def on_request_with_control(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
+ if method == "control":
+ return {"control": True}
+ handler_started.set()
+ return await on_request(ctx, method, params)
+
+ async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> None:
+ pass # the cancelled notification is teed here; nothing to observe
+
+ def request(request_id: RequestId, method: str) -> SessionMessage:
+ async def on_unanswered() -> None:
+ unanswered.append(request_id)
+
+ return SessionMessage(
+ message=JSONRPCRequest(jsonrpc="2.0", id=request_id, method=method, params=None),
+ metadata=ServerMessageMetadata(on_request_unanswered=on_unanswered),
+ )
+
+ cancel = JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled", params={"requestId": 1})
+ try:
+ async with anyio.create_task_group() as tg:
+ await tg.start(server.run, on_request_with_control, on_notify)
+ await c2s_send.send(request(1, "t"))
+ with anyio.fail_after(5):
+ await handler_started.wait()
+ await c2s_send.send(SessionMessage(message=cancel))
+ await done.wait()
+ await c2s_send.send(request(2, "control"))
+ # Quiesce: let both handler tasks run to the end of `_handle_request`,
+ # so every write they were going to make has been recorded.
+ await anyio.wait_all_tasks_blocked()
+ tg.cancel_scope.cancel()
+ finally:
+ c2s_send.close()
+ c2s_recv.close()
+ return [m.message for m in recording.sent], unanswered
+
+
+_CONTROL_ONLY: list[JSONRPCMessage] = [JSONRPCResponse(jsonrpc="2.0", id=2, result={"control": True})]
+"""Everything on the wire after a cancelled request 1: only the uncancelled control request 2 is answered."""
+
+
+@pytest.mark.anyio
+async def test_peer_cancel_interrupt_mode_interrupts_handler_and_writes_no_response():
+ """Spec MUST NOT: a cancelled request is never answered - not even with an error - and settles unanswered."""
handler_exited = anyio.Event()
seen_ctx: list[DCtx] = []
- async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
+ async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
seen_ctx.append(ctx)
- handler_started.set()
try:
await anyio.sleep_forever()
finally:
handler_exited.set()
raise NotImplementedError
- seen_error: list[ErrorData] = []
- async with running_pair(jsonrpc_pair, server_on_request=server_on_request) as (client, *_):
- with anyio.fail_after(5):
- async with anyio.create_task_group() as tg: # pragma: no branch
+ assert await _drive_cancelled_request(on_request, done=handler_exited) == (_CONTROL_ONLY, [1])
+ assert seen_ctx[0].cancel_requested.is_set()
- async def call_then_record() -> None:
- with pytest.raises(MCPError) as exc:
- await client.send_raw_request("slow", None)
- seen_error.append(exc.value.error)
- tg.start_soon(call_then_record)
- await handler_started.wait()
- await client.notify("notifications/cancelled", {"requestId": 1})
- await handler_exited.wait()
- assert seen_ctx[0].cancel_requested.is_set()
- assert seen_error == [ErrorData(code=0, message="Request cancelled")]
+@pytest.mark.anyio
+async def test_peer_cancel_signal_mode_sets_event_and_drops_the_completed_handlers_result():
+ """`"signal"` mode lets the handler run to completion, but the cancelled request is still not answered."""
+ cancel_seen = anyio.Event()
+
+ async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
+ await ctx.cancel_requested.wait()
+ cancel_seen.set()
+ return {"finished": True}
+
+ assert await _drive_cancelled_request(on_request, peer_cancel_mode="signal", done=cancel_seen) == (
+ _CONTROL_ONLY,
+ [1],
+ )
+
+
+@pytest.mark.anyio
+@pytest.mark.parametrize(
+ "handler_failure",
+ [RuntimeError("cleanup failed"), MCPError(code=INTERNAL_ERROR, message="cleanup failed")],
+ ids=["unmapped-exception", "mcp-error"],
+)
+async def test_peer_cancel_drops_the_error_of_a_handler_that_fails_after_cancel(handler_failure: Exception):
+ """A handler that turns its cancellation into an exception - mapped or not - writes no error response either."""
+ handler_failed = anyio.Event()
+
+ async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
+ try:
+ await anyio.sleep_forever()
+ except anyio.get_cancelled_exc_class():
+ handler_failed.set()
+ raise handler_failure from None
+ raise NotImplementedError
+
+ assert await _drive_cancelled_request(on_request, done=handler_failed) == (_CONTROL_ONLY, [1])
@pytest.mark.anyio
-async def test_peer_cancel_landing_after_handlers_last_checkpoint_writes_only_the_result():
- """A peer cancel that fails to interrupt the handler writes only the result: one answer per
- id goes on the wire (SDK-defined). The recording stream is needed because a memory stream's
- `send` checkpoints, letting the deferred cancellation land mid-write and hide a double answer."""
+@pytest.mark.parametrize(
+ "hook_error",
+ [RuntimeError("hook failed"), anyio.ClosedResourceError()],
+ ids=["hook-bug", "connection-closing"],
+)
+async def test_a_raising_unanswered_hook_is_contained(hook_error: Exception):
+ """A transport `on_request_unanswered` hook that raises is contained, not fatal: the
+ dispatcher keeps serving (the control request written after it is still answered)."""
c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](4)
recording = RecordingWriteStream()
server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(c2s_recv, recording)
- handler_started = anyio.Event()
+ handler_exited = anyio.Event()
+
+ async def failing_hook() -> None:
+ raise hook_error
async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
- handler_started.set()
- await ctx.cancel_requested.wait()
- return {"completed": "after-cancel"}
+ if method == "control":
+ return {"control": True}
+ try:
+ await anyio.sleep_forever()
+ finally:
+ handler_exited.set()
+ raise NotImplementedError
async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> None:
- pass # the cancelled notification is teed here; nothing to observe
+ pass
+ request_1 = SessionMessage(
+ message=JSONRPCRequest(jsonrpc="2.0", id=1, method="t", params=None),
+ metadata=ServerMessageMetadata(on_request_unanswered=failing_hook),
+ )
+ cancel = JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled", params={"requestId": 1})
+ control = SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=2, method="control", params=None))
try:
async with anyio.create_task_group() as tg:
await tg.start(server.run, on_request, on_notify)
- await c2s_send.send(SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=1, method="t", params=None)))
with anyio.fail_after(5):
- await handler_started.wait()
- # The cancel is also the handler's wakeup, so anyio defers it and the handler completes.
- await c2s_send.send(
- SessionMessage(
- message=JSONRPCNotification(
- jsonrpc="2.0", method="notifications/cancelled", params={"requestId": 1}
- )
- )
- )
- # Quiesce: the handler has resumed, completed, and exited its scope.
+ await c2s_send.send(request_1)
+ await c2s_send.send(SessionMessage(message=cancel))
+ await handler_exited.wait()
+ await c2s_send.send(control)
await anyio.wait_all_tasks_blocked()
tg.cancel_scope.cancel()
finally:
c2s_send.close()
c2s_recv.close()
- assert [m.message for m in recording.sent] == [
- JSONRPCResponse(jsonrpc="2.0", id=1, result={"completed": "after-cancel"})
- ]
-
-
-@pytest.mark.anyio
-async def test_peer_cancel_signal_mode_sets_event_but_handler_runs_to_completion():
- handler_started = anyio.Event()
- cancel_seen = anyio.Event()
-
- async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
- handler_started.set()
- await ctx.cancel_requested.wait()
- cancel_seen.set()
- return {"finished": True}
-
- def factory(*, can_send_request: bool = True):
- client, server, close = jsonrpc_pair(can_send_request=can_send_request)
- assert isinstance(server, JSONRPCDispatcher)
- server._peer_cancel_mode = "signal" # pyright: ignore[reportPrivateUsage]
- return client, server, close
-
- result_box: list[dict[str, Any]] = []
- async with running_pair(factory, server_on_request=server_on_request) as (client, *_):
- with anyio.fail_after(5):
- async with anyio.create_task_group() as tg: # pragma: no branch
-
- async def call() -> None:
- result_box.append(await client.send_raw_request("slow", None))
-
- tg.start_soon(call)
- await handler_started.wait()
- await client.notify("notifications/cancelled", {"requestId": 1})
- await cancel_seen.wait()
- assert result_box == [{"finished": True}]
+ assert [m.message for m in recording.sent] == _CONTROL_ONLY
@pytest.mark.anyio
@@ -1269,6 +1329,45 @@ async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) ->
assert seen[0] is metadata # the exact object, passed through verbatim
+@pytest.mark.anyio
+async def test_transport_stamped_can_send_request_makes_the_request_channel_refuse():
+ """A transport that marks a message `can_send_request=False` on its metadata gets a request-scoped
+ channel that raises `NoBackChannelError` immediately - the default builder reads the transport's
+ verdict off the message, so no driver has to wire it."""
+ c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
+ s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
+ server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(c2s_recv, s2c_send)
+ outcomes: list[bool | str] = []
+
+ async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
+ outcomes.append(ctx.can_send_request)
+ try:
+ await ctx.send_raw_request("elicitation/create", {})
+ except NoBackChannelError as exc:
+ outcomes.append(exc.method)
+ return {}
+
+ async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> None:
+ raise NotImplementedError
+
+ try:
+ async with anyio.create_task_group() as tg:
+ await tg.start(server.run, on_request, on_notify)
+ await c2s_send.send(
+ SessionMessage(
+ message=JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params=None),
+ metadata=ServerMessageMetadata(can_send_request=False),
+ )
+ )
+ with anyio.fail_after(5):
+ await s2c_recv.receive() # response sent => the handler has run
+ tg.cancel_scope.cancel()
+ finally:
+ for s in (c2s_send, c2s_recv, s2c_send, s2c_recv):
+ s.close()
+ assert outcomes == [False, "elicitation/create"]
+
+
@pytest.mark.anyio
async def test_ctx_message_metadata_carries_inbound_notification_metadata():
"""Notifications get the same metadata pass-through as requests."""
@@ -1592,14 +1691,16 @@ async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] |
async with anyio.create_task_group() as tg: # pragma: no branch
async def call() -> None:
- with pytest.raises(MCPError):
- await client.send_raw_request("slow", None)
+ # Never answered; abandoned below without a courtesy cancel so `srec` sees only the peer's.
+ await client.send_raw_request("slow", None, {"cancel_on_abandon": False})
+ raise NotImplementedError # unreachable: the task is cancelled first
tg.start_soon(call)
await handler_started.wait()
await client.notify("notifications/cancelled", {"requestId": 1})
await handler_exited.wait()
await srec.notified.wait()
+ tg.cancel_scope.cancel() # abandon the parked call
assert srec.notifications == [("notifications/cancelled", {"requestId": 1})]
@@ -2057,8 +2158,8 @@ async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] |
async with anyio.create_task_group() as tg: # pragma: no branch
async def call() -> None:
- with pytest.raises(MCPError):
- await client.send_raw_request("slow", None)
+ await client.send_raw_request("slow", None) # never answered; abandoned below
+ raise NotImplementedError # unreachable: the task is cancelled first
tg.start_soon(call)
await handler_started.wait()
@@ -2068,6 +2169,7 @@ async def call() -> None:
assert not handler_exited.is_set()
await client.notify("notifications/cancelled", {"requestId": 1})
await handler_exited.wait()
+ tg.cancel_scope.cancel() # abandon the parked call
@pytest.mark.anyio
@@ -2160,11 +2262,15 @@ async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) ->
async def test_cancelled_correlates_across_string_and_int_request_id_forms(request_id: RequestId, cancel_id: object):
"""A peer that stringifies the id between request and cancel still cancels (same `coerce_request_id` path)."""
c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
- s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
- server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(c2s_recv, s2c_send)
+ recording = RecordingWriteStream()
+ server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(c2s_recv, recording)
+ handler_interrupted = anyio.Event()
async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
- await anyio.sleep_forever()
+ try:
+ await anyio.sleep_forever()
+ finally:
+ handler_interrupted.set()
raise NotImplementedError
async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> None:
@@ -2184,15 +2290,12 @@ async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) ->
)
)
with anyio.fail_after(5):
- resp = await s2c_recv.receive()
- assert isinstance(resp, SessionMessage)
- assert isinstance(resp.message, JSONRPCError)
- assert resp.message.id == request_id # response echoes the peer's id form verbatim
- assert resp.message.error == ErrorData(code=0, message="Request cancelled")
+ await handler_interrupted.wait() # the cancel reached the handler despite the id form
tg.cancel_scope.cancel()
finally:
- for s in (c2s_send, c2s_recv, s2c_send, s2c_recv):
- s.close()
+ c2s_send.close()
+ c2s_recv.close()
+ assert recording.sent == [] # cancelled: no response, in either id form
@pytest.mark.anyio
@@ -2245,11 +2348,7 @@ async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) ->
)
)
)
- resp2 = await s2c_recv.receive()
- assert isinstance(resp2, SessionMessage)
- assert isinstance(resp2.message, JSONRPCError)
- assert resp2.message.error == ErrorData(code=0, message="Request cancelled")
- assert second_exited.is_set()
+ await second_exited.wait() # the cancel reached the surviving entry
tg.cancel_scope.cancel()
finally:
for s in (c2s_send, c2s_recv, s2c_send, s2c_recv):
@@ -2308,11 +2407,7 @@ async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) ->
)
)
)
- resp2 = await s2c_recv.receive()
- assert isinstance(resp2, SessionMessage)
- assert isinstance(resp2.message, JSONRPCError)
- assert resp2.message.error == ErrorData(code=0, message="Request cancelled")
- assert second_exited.is_set()
+ await second_exited.wait() # the cancel reached the surviving entry
tg.cancel_scope.cancel()
finally:
for s in (c2s_send, c2s_recv, s2c_send, s2c_recv):
@@ -2366,11 +2461,12 @@ async def observe(ctx: Any, call_next: Any) -> Any:
async with anyio.create_task_group() as tg: # pragma: no branch
async def call() -> None:
- with pytest.raises(MCPError):
- await client.session.send_request(
- CallToolRequest(params=CallToolRequestParams(name="t", arguments={})),
- CallToolResult,
- )
+ # Never answered once cancelled; abandoned below.
+ await client.session.send_request(
+ CallToolRequest(params=CallToolRequestParams(name="t", arguments={})),
+ CallToolResult,
+ )
+ raise NotImplementedError # unreachable: the task is cancelled first
tg.start_soon(call)
await handler_started.wait()
@@ -2381,10 +2477,8 @@ async def call() -> None:
)
)
await cancel_observed.wait()
- assert len(observed) == 1
- assert observed[0][0] == "notifications/cancelled"
- assert observed[0][1]["requestId"] == request_id
- assert observed[0][1]["reason"] == "user clicked stop"
+ tg.cancel_scope.cancel() # abandon the parked call (sends its own courtesy cancel)
+ assert observed[0] == ("notifications/cancelled", {"requestId": request_id, "reason": "user clicked stop"})
@pytest.mark.anyio
diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py
index d7eeccdfdb..aeef25a278 100644
--- a/tests/shared/test_streamable_http.py
+++ b/tests/shared/test_streamable_http.py
@@ -43,7 +43,7 @@
from starlette.types import Message, Scope
from mcp import MCPError
-from mcp.client import ClientRequestContext
+from mcp.client import ClientRequestContext, IncomingMessage
from mcp.client.session import ClientSession
from mcp.client.streamable_http import StreamableHTTPTransport, streamable_http_client
from mcp.server import Server, ServerRequestContext
@@ -64,7 +64,6 @@
from mcp.shared._compat import resync_tracer
from mcp.shared._context_streams import create_context_streams
from mcp.shared.message import ClientMessageMetadata, ServerMessageMetadata, SessionMessage
-from mcp.shared.session import RequestResponder
from tests.interaction.transports import StreamingASGITransport
# Test constants
@@ -968,9 +967,7 @@ async def test_streamable_http_client_get_stream(basic_app: Starlette) -> None:
notifications_received: list[types.ServerNotification] = []
# Define message handler to capture notifications
- async def message_handler( # pragma: no branch
- message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
- ) -> None:
+ async def message_handler(message: IncomingMessage) -> None: # pragma: no branch
if isinstance(message, types.ServerNotification): # pragma: no branch
notifications_received.append(message)
@@ -1128,9 +1125,7 @@ async def test_streamable_http_client_resumption(event_app: tuple[SimpleEventSto
first_notification_received = anyio.Event()
resumption_token_received = anyio.Event()
- async def message_handler( # pragma: no branch
- message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
- ) -> None:
+ async def message_handler(message: IncomingMessage) -> None: # pragma: no branch
if isinstance(message, types.ServerNotification): # pragma: no branch
captured_notifications.append(message)
# Look for our first notification
@@ -1798,14 +1793,11 @@ async def test_streamable_http_client_auto_reconnects(
_, app = event_app
captured_notifications: list[str] = []
- async def message_handler(
- message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
- ) -> None:
+ async def message_handler(message: IncomingMessage) -> None:
if isinstance(message, Exception): # pragma: no branch
return # pragma: no cover
- if isinstance(message, types.ServerNotification): # pragma: no branch
- if isinstance(message, types.LoggingMessageNotification): # pragma: no branch
- captured_notifications.append(str(message.params.data))
+ if isinstance(message, types.LoggingMessageNotification): # pragma: no branch
+ captured_notifications.append(str(message.params.data))
async with (
make_client(app) as http_client,
@@ -1866,14 +1858,11 @@ async def test_streamable_http_sse_polling_full_cycle(
_, app = event_app
all_notifications: list[str] = []
- async def message_handler(
- message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
- ) -> None:
+ async def message_handler(message: IncomingMessage) -> None:
if isinstance(message, Exception): # pragma: no branch
return # pragma: no cover
- if isinstance(message, types.ServerNotification): # pragma: no branch
- if isinstance(message, types.LoggingMessageNotification): # pragma: no branch
- all_notifications.append(str(message.params.data))
+ if isinstance(message, types.LoggingMessageNotification): # pragma: no branch
+ all_notifications.append(str(message.params.data))
async with (
make_client(app) as http_client,
@@ -1909,14 +1898,11 @@ async def test_streamable_http_events_replayed_after_disconnect(
_, app = event_app
notification_data: list[str] = []
- async def message_handler(
- message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
- ) -> None:
+ async def message_handler(message: IncomingMessage) -> None:
if isinstance(message, Exception): # pragma: no branch
return # pragma: no cover
- if isinstance(message, types.ServerNotification): # pragma: no branch
- if isinstance(message, types.LoggingMessageNotification): # pragma: no branch
- notification_data.append(str(message.params.data))
+ if isinstance(message, types.LoggingMessageNotification): # pragma: no branch
+ notification_data.append(str(message.params.data))
async with (
make_client(app) as http_client,
@@ -2042,14 +2028,11 @@ async def test_standalone_get_stream_reconnection(event_app: tuple[SimpleEventSt
_, app = event_app
received_notifications: list[str] = []
- async def message_handler(
- message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
- ) -> None:
+ async def message_handler(message: IncomingMessage) -> None:
if isinstance(message, Exception):
return # pragma: no cover
- if isinstance(message, types.ServerNotification): # pragma: no branch
- if isinstance(message, types.ResourceUpdatedNotification): # pragma: no branch
- received_notifications.append(str(message.params.uri))
+ if isinstance(message, types.ResourceUpdatedNotification): # pragma: no branch
+ received_notifications.append(str(message.params.uri))
async with (
make_client(app) as http_client,
@@ -2183,9 +2166,7 @@ async def test_standalone_stream_teardown_mid_listen_is_not_an_error(caplog: pyt
app = Starlette(routes=[Mount("/mcp", app=session_manager.handle_request)])
notified = anyio.Event()
- async def message_handler(
- message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
- ) -> None:
+ async def message_handler(message: IncomingMessage) -> None:
# Only the standalone-stream notification is teed to the handler here.
assert isinstance(message, types.ResourceUpdatedNotification)
notified.set()
diff --git a/tests/test_examples.py b/tests/test_examples.py
index 0104d5398b..5b2997fdf1 100644
--- a/tests/test_examples.py
+++ b/tests/test_examples.py
@@ -9,19 +9,31 @@
import pytest
from inline_snapshot import snapshot
-from mcp_types import CallToolResult, TextContent, TextResourceContents
+from mcp_types import SERVER_INFO_META_KEY, CallToolResult, TextContent, TextResourceContents
from pytest_examples import CodeExample, EvalExample, find_examples
from mcp import Client
+def strip_server_info(result: CallToolResult, server_name: str) -> CallToolResult:
+ """Assert the 2026-era serverInfo stamp, then drop it from the result's meta.
+
+ The example servers set no explicit version, so the stamp's version is
+ empty; the snapshots stay about the behavior under test, not identity.
+ """
+ assert result.meta is not None
+ assert result.meta[SERVER_INFO_META_KEY] == {"name": server_name, "version": ""}
+ remaining = {k: v for k, v in result.meta.items() if k != SERVER_INFO_META_KEY}
+ return result.model_copy(update={"meta": remaining or None})
+
+
@pytest.mark.anyio
async def test_simple_echo():
"""Test the simple echo server"""
from examples.mcpserver.simple_echo import mcp
async with Client(mcp) as client:
- result = await client.call_tool("echo", {"text": "hello"})
+ result = strip_server_info(await client.call_tool("echo", {"text": "hello"}), "Echo Server")
assert result == snapshot(
CallToolResult(content=[TextContent(text="hello")], structured_content={"result": "hello"})
)
@@ -35,6 +47,7 @@ async def test_complex_inputs():
async with Client(mcp) as client:
tank = {"shrimp": [{"name": "bob"}, {"name": "alice"}]}
result = await client.call_tool("name_shrimp", {"tank": tank, "extra_names": ["charlie"]})
+ result = strip_server_info(result, "Shrimp Tank")
assert result == snapshot(
CallToolResult(
content=[
@@ -53,7 +66,8 @@ async def test_direct_call_tool_result_return():
from examples.mcpserver.direct_call_tool_result_return import mcp
async with Client(mcp) as client:
- result = await client.call_tool("echo", {"text": "hello"})
+ # The serverInfo stamp merges alongside the handler-authored meta.
+ result = strip_server_info(await client.call_tool("echo", {"text": "hello"}), "Echo Server")
assert result == snapshot(
CallToolResult(
meta={"some": "metadata"}, # type: ignore[reportUnknownMemberType]
@@ -80,7 +94,7 @@ async def test_desktop(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
async with Client(mcp) as client:
# Test the sum function
- result = await client.call_tool("sum", {"a": 1, "b": 2})
+ result = strip_server_info(await client.call_tool("sum", {"a": 1, "b": 2}), "Demo")
assert result == snapshot(CallToolResult(content=[TextContent(text="3")], structured_content={"result": 3}))
# Test the desktop resource
diff --git a/tests/test_types.py b/tests/test_types.py
index 3083774200..e338280507 100644
--- a/tests/test_types.py
+++ b/tests/test_types.py
@@ -1,5 +1,12 @@
+import subprocess
+import sys
+from types import ModuleType
from typing import Any
+import mcp_types
+import mcp_types.jsonrpc
+import mcp_types.methods
+import mcp_types.version
import pytest
from inline_snapshot import snapshot
from mcp_types import (
@@ -38,6 +45,12 @@
)
from pydantic import ValidationError
+import mcp
+import mcp.types
+import mcp.types.jsonrpc
+import mcp.types.methods
+import mcp.types.version
+
@pytest.mark.anyio
async def test_jsonrpc_request():
@@ -394,7 +407,6 @@ def test_concrete_wire_results_always_dump_result_type_complete():
DiscoverResult(
supported_versions=["2026-07-28"],
capabilities=ServerCapabilities(),
- server_info=Implementation(name="server", version="1.0"),
),
]
for result in carriers:
@@ -414,7 +426,6 @@ def test_cacheable_results_default_to_immediately_stale_private():
DiscoverResult(
supported_versions=["2026-07-28"],
capabilities=ServerCapabilities(),
- server_info=Implementation(name="server", version="1.0"),
),
]
for result in cacheable:
@@ -447,3 +458,48 @@ def test_input_required_result_requires_at_least_one_of_input_requests_or_reques
with pytest.raises(ValidationError):
InputRequiredResult(input_requests={})
assert InputRequiredResult(request_state="s").input_requests is None
+
+
+def _assert_mirrors(mirror: ModuleType, source: ModuleType) -> None:
+ # The mirror shares the source's `__all__` list object by construction (`from source import
+ # __all__`), so the meaningful proof is that every exported name is the identical object.
+ assert all(getattr(mirror, name) is getattr(source, name) for name in source.__all__)
+
+
+def test_mcp_types_namespace_mirrors_mcp_types_exactly():
+ """SDK-defined: `mcp.types` is a permanent alias whose every name is the `mcp_types` object."""
+ _assert_mirrors(mcp.types, mcp_types)
+
+
+@pytest.mark.parametrize(
+ ("mirror", "source"),
+ [
+ (mcp.types.jsonrpc, mcp_types.jsonrpc),
+ (mcp.types.methods, mcp_types.methods),
+ (mcp.types.version, mcp_types.version),
+ ],
+ ids=["jsonrpc", "methods", "version"],
+)
+def test_mcp_types_submodules_mirror_mcp_types_submodules_exactly(mirror: ModuleType, source: ModuleType):
+ """SDK-defined: every supported `mcp_types` submodule has an `mcp.types` mirror, name for name."""
+ _assert_mirrors(mirror, source)
+
+
+def test_bare_import_mcp_binds_the_types_submodule():
+ """SDK-defined: `import mcp` alone binds `mcp.types`, so v1's `mcp.types.Tool` idiom works.
+
+ A fresh interpreter is required to observe `import mcp` in isolation: this test process
+ has already imported `mcp.types`, and reloading `mcp` here would rebind classes that other
+ tests hold references to.
+ """
+ # A regression hangs forever, so the bound only has to beat never (matches the suite's
+ # other subprocess.run calls).
+ result = subprocess.run(
+ [sys.executable, "-c", "import mcp; print(mcp.types.Tool.__name__)"],
+ capture_output=True,
+ text=True,
+ check=False,
+ timeout=20,
+ )
+ assert result.returncode == 0, result.stderr
+ assert result.stdout == snapshot("Tool\n")
diff --git a/tests/transports/stdio/test_lifecycle.py b/tests/transports/stdio/test_lifecycle.py
index 8a370c10f6..c9046927b9 100644
--- a/tests/transports/stdio/test_lifecycle.py
+++ b/tests/transports/stdio/test_lifecycle.py
@@ -15,12 +15,15 @@
import threading
from contextlib import AsyncExitStack
from pathlib import Path
+from textwrap import dedent
import anyio
import anyio.abc
import pytest
+from mcp_types import TextContent
from mcp.client import stdio
+from mcp.client.client import Client
from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.os.win32.utilities import FallbackProcess
from tests.transports.stdio._liveness import (
@@ -274,3 +277,42 @@ async def test_fallback_process_wait_is_cancellable_while_the_child_lives() -> N
popen.wait()
popen.stdin.close()
popen.stdout.close()
+
+
+@pytest.mark.anyio
+async def test_a_tool_spawned_childs_stdout_writes_never_reach_the_wire(tmp_path: Path) -> None:
+ """A child writing to its inherited stdout pollutes the server's stderr, never the protocol.
+
+ Pre-isolation the junk line landed in the JSON-RPC stream (fails on base);
+ fd 1 has exactly one target, so stderr delivery proves the wire never saw it.
+ """
+ server = dedent(
+ """
+ import subprocess, sys
+ from mcp.server import MCPServer
+
+ mcp = MCPServer("noisy-spawner")
+
+ @mcp.tool()
+ def run_noisy_child() -> str:
+ # No redirection: the child inherits the server's stdout.
+ proc = subprocess.run([sys.executable, "-c", "print('this is not json')"], timeout=20)
+ return str(proc.returncode)
+
+ mcp.run()
+ """
+ )
+
+ with (tmp_path / "server-stderr.txt").open("w+", encoding="utf-8") as errlog:
+ transport = stdio_client(StdioServerParameters(command=sys.executable, args=["-c", server]), errlog=errlog)
+ # Bound covers three interpreter cold starts; a regressed Windows leg hangs rather than corrupts.
+ with anyio.fail_after(40):
+ async with Client(transport) as client:
+ result = await client.call_tool("run_noisy_child")
+ errlog.seek(0)
+ server_stderr = errlog.read()
+
+ content = result.content[0]
+ assert isinstance(content, TextContent)
+ assert content.text == "0"
+ assert "this is not json" in server_stderr
diff --git a/tests/transports/stdio/test_windows.py b/tests/transports/stdio/test_windows.py
index 2d4eeac826..a5c9b4e7b7 100644
--- a/tests/transports/stdio/test_windows.py
+++ b/tests/transports/stdio/test_windows.py
@@ -15,12 +15,14 @@
import sys
from contextlib import AsyncExitStack
from pathlib import Path
+from textwrap import dedent
import anyio
import anyio.abc
import pytest
-from mcp_types import JSONRPCRequest, JSONRPCResponse
+from mcp_types import JSONRPCRequest, JSONRPCResponse, TextContent
+from mcp.client.client import Client
from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.os.win32.utilities import FallbackProcess
from mcp.shared.message import SessionMessage
@@ -238,3 +240,46 @@ async def test_a_native_server_emitting_crlf_line_endings_round_trips_messages()
# here instead of a parsed message.
assert isinstance(received, SessionMessage)
assert received.message == JSONRPCResponse(jsonrpc="2.0", id=1, result={})
+
+
+async def test_a_tool_spawned_python_child_with_default_stdin_completes_promptly() -> None: # pragma: no cover
+ """A tool that runs a Python subprocess without redirecting stdin returns promptly.
+
+ Regression for #671: pre-isolation the child inherited the protocol stdin pipe
+ and hung in interpreter startup (CPython gh-78961) until the next inbound message.
+ """
+ server = dedent(
+ """
+ import subprocess, sys
+ from mcp.server import MCPServer
+
+ mcp = MCPServer("spawner")
+
+ @mcp.tool()
+ def run_child() -> str:
+ proc = subprocess.run([sys.executable, "-c", "print('ok')"], capture_output=True, timeout=20)
+ return proc.stdout.decode().strip()
+
+ @mcp.tool()
+ def run_child_bare() -> str:
+ # Even without redirection the console subsystem hands the child the standard handles.
+ proc = subprocess.run([sys.executable, "-c", "pass"], timeout=20)
+ return str(proc.returncode)
+
+ mcp.run()
+ """
+ )
+ transport = stdio_client(StdioServerParameters(command=sys.executable, args=["-c", server]))
+
+ # A regression hangs forever, so the bound only has to beat "never".
+ with anyio.fail_after(40.0):
+ async with Client(transport) as client:
+ result = await client.call_tool("run_child")
+ bare = await client.call_tool("run_child_bare")
+
+ content = result.content[0]
+ assert isinstance(content, TextContent)
+ assert content.text == "ok"
+ bare_content = bare.content[0]
+ assert isinstance(bare_content, TextContent)
+ assert bare_content.text == "0"
diff --git a/tests/types/test_methods.py b/tests/types/test_methods.py
index 126e06c291..3e25bba23d 100644
--- a/tests/types/test_methods.py
+++ b/tests/types/test_methods.py
@@ -6,8 +6,8 @@
from typing import Any, get_args
import mcp_types as types
-import mcp_types.v2025_11_25 as v2025
-import mcp_types.v2026_07_28 as v2026
+import mcp_types._v2025_11_25 as v2025
+import mcp_types._v2026_07_28 as v2026
import pydantic
import pytest
from mcp_types import methods
@@ -295,19 +295,21 @@
# Pre-2026 versions share the 2025-11-25 surface package.
PACKAGE_BY_VERSION = {
- "2024-11-05": "mcp_types.v2025_11_25",
- "2025-03-26": "mcp_types.v2025_11_25",
- "2025-06-18": "mcp_types.v2025_11_25",
- "2025-11-25": "mcp_types.v2025_11_25",
- "2026-07-28": "mcp_types.v2026_07_28",
+ "2024-11-05": "mcp_types._v2025_11_25",
+ "2025-03-26": "mcp_types._v2025_11_25",
+ "2025-06-18": "mcp_types._v2025_11_25",
+ "2025-11-25": "mcp_types._v2025_11_25",
+ "2026-07-28": "mcp_types._v2026_07_28",
}
-# The three reserved `params._meta` entries the 2026 surface requires on every request.
+# The reserved `params._meta` entries the 2026 surface accepts on every request.
+# `clientInfo` is optional (SHOULD-include, spec PR #3002); the other two are required.
META_TRIPLE: dict[str, Any] = {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "client", "version": "1.0"},
"io.modelcontextprotocol/clientCapabilities": {},
}
+META_REQUIRED_KEYS = ("io.modelcontextprotocol/protocolVersion", "io.modelcontextprotocol/clientCapabilities")
# One minimal valid params mapping per surface request class.
REQUEST_PARAMS_FIXTURES: dict[type[BaseModel], dict[str, Any] | None] = {
@@ -397,7 +399,6 @@
v2026.DiscoverResult: {
"supportedVersions": ["2026-07-28"],
"capabilities": {},
- "serverInfo": {"name": "server", "version": "1.0"},
"resultType": "complete",
"ttlMs": 0,
"cacheScope": "private",
@@ -651,8 +652,8 @@ def test_unknown_version_strings_raise_value_error_on_every_parse_function():
assert "2099-01-01" in str(excinfo.value)
-def test_2026_07_28_requests_missing_a_reserved_meta_entry_reject_as_missing():
- for absent_key in META_TRIPLE:
+def test_2026_07_28_requests_missing_a_required_meta_entry_reject_as_missing():
+ for absent_key in META_REQUIRED_KEYS:
partial_meta = {key: value for key, value in META_TRIPLE.items() if key != absent_key}
with pytest.raises(pydantic.ValidationError) as excinfo:
methods.parse_client_request("tools/list", "2026-07-28", {"_meta": partial_meta})
@@ -661,6 +662,14 @@ def test_2026_07_28_requests_missing_a_reserved_meta_entry_reject_as_missing():
]
+def test_2026_07_28_requests_accept_meta_without_the_optional_client_info():
+ """spec PR #3002: `clientInfo` is optional on the 2026 surface - the required
+ pair alone validates."""
+ pair_meta = {key: value for key, value in META_TRIPLE.items() if key != "io.modelcontextprotocol/clientInfo"}
+ parsed = methods.parse_client_request("tools/list", "2026-07-28", {"_meta": pair_meta})
+ assert isinstance(parsed, types.ListToolsRequest)
+
+
def test_2026_07_28_results_require_result_type():
with pytest.raises(pydantic.ValidationError):
methods.parse_server_result("tools/call", "2026-07-28", {"content": []})
@@ -861,7 +870,6 @@ def test_validate_functions_accept_reject_and_gate_like_their_parse_siblings():
"server/discover": types.DiscoverResult(
supported_versions=["2026-07-28"],
capabilities=types.ServerCapabilities(),
- server_info=types.Implementation(name="server", version="1.0"),
ttl_ms=0,
cache_scope="private",
),
@@ -927,6 +935,24 @@ def test_serialize_server_result_preserves_open_type_extras():
assert sieved["tools"][0]["_meta"] == nested_meta
+def test_serialize_server_result_drops_top_level_server_info_on_discover_but_keeps_the_meta_stamp():
+ """Server identity moved from the discover body to result `_meta` (spec PR #3002):
+ the sieve drops the removed body key and preserves the `_meta` stamp."""
+ stamp = {"name": "server", "version": "1.0"}
+ dumped: dict[str, Any] = {
+ "supportedVersions": ["2026-07-28"],
+ "capabilities": {},
+ "serverInfo": stamp,
+ "_meta": {types.SERVER_INFO_META_KEY: stamp},
+ "resultType": "complete",
+ "ttlMs": 0,
+ "cacheScope": "private",
+ }
+ sieved = methods.serialize_server_result("server/discover", "2026-07-28", dumped)
+ assert "serverInfo" not in sieved
+ assert sieved["_meta"] == {types.SERVER_INFO_META_KEY: stamp}
+
+
def test_serialize_server_result_drops_an_unknown_nested_tool_field():
tool = {"name": "echo", "inputSchema": {"type": "object"}, "unknownField": 1}
sieved = methods.serialize_server_result("tools/list", "2025-11-25", {"tools": [tool], "resultType": "complete"})
diff --git a/tests/types/test_parity.py b/tests/types/test_parity.py
index 080f343c3d..3531992141 100644
--- a/tests/types/test_parity.py
+++ b/tests/types/test_parity.py
@@ -7,8 +7,8 @@
import mcp_types as monolith
import mcp_types._types as _types
-import mcp_types.v2025_11_25 as v2025_11_25
-import mcp_types.v2026_07_28 as v2026_07_28
+import mcp_types._v2025_11_25 as v2025_11_25
+import mcp_types._v2026_07_28 as v2026_07_28
import pytest
from pydantic import BaseModel
@@ -19,115 +19,116 @@
# Surface classes whose monolith counterpart has a different name (key: ".").
NAME_MAP: dict[str, type[BaseModel]] = {
- # v2025_11_25
- "v2025_11_25.Argument": monolith.CompletionArgument,
- "v2025_11_25.Context": monolith.CompletionContext,
- "v2025_11_25.Data": monolith.ElicitationRequiredErrorData,
- "v2025_11_25.Elicitation": monolith.ElicitationCapability,
- "v2025_11_25.Elicitation1": monolith.TasksElicitationCapability,
- "v2025_11_25.ElicitationCompleteNotification": monolith.ElicitCompleteNotification,
- "v2025_11_25.Params": monolith.CancelTaskRequestParams,
- "v2025_11_25.Params1": monolith.ElicitCompleteNotificationParams,
- "v2025_11_25.Params2": monolith.GetTaskPayloadRequestParams,
- "v2025_11_25.Params3": monolith.GetTaskRequestParams,
- "v2025_11_25.Error": monolith.ErrorData,
- "v2025_11_25.JSONRPCErrorResponse": monolith.JSONRPCError,
- "v2025_11_25.JSONRPCResultResponse": monolith.JSONRPCResponse,
- "v2025_11_25.Prompts": monolith.PromptsCapability,
- "v2025_11_25.Requests": monolith.ClientTasksRequestsCapability,
- "v2025_11_25.Requests1": monolith.ServerTasksRequestsCapability,
- "v2025_11_25.Resources": monolith.ResourcesCapability,
- "v2025_11_25.Roots": monolith.RootsCapability,
- "v2025_11_25.Sampling": monolith.SamplingCapability,
- "v2025_11_25.Sampling1": monolith.TasksSamplingCapability,
- "v2025_11_25.Tasks": monolith.ClientTasksCapability,
- "v2025_11_25.Tasks1": monolith.ServerTasksCapability,
- "v2025_11_25.Tools": monolith.TasksToolsCapability,
- "v2025_11_25.Tools1": monolith.ToolsCapability,
- # v2026_07_28
- "v2026_07_28.Argument": monolith.CompletionArgument,
- "v2026_07_28.Context": monolith.CompletionContext,
- "v2026_07_28.Data": monolith.MissingRequiredClientCapabilityErrorData,
- "v2026_07_28.Data1": monolith.UnsupportedProtocolVersionErrorData,
- "v2026_07_28.Elicitation": monolith.ElicitationCapability,
- "v2026_07_28.Error": monolith.ErrorData,
- "v2026_07_28.JSONRPCErrorResponse": monolith.JSONRPCError,
- "v2026_07_28.JSONRPCResultResponse": monolith.JSONRPCResponse,
- "v2026_07_28.Prompts": monolith.PromptsCapability,
- "v2026_07_28.Resources": monolith.ResourcesCapability,
- "v2026_07_28.Sampling": monolith.SamplingCapability,
- "v2026_07_28.Tools": monolith.ToolsCapability,
+ # _v2025_11_25
+ "_v2025_11_25.Argument": monolith.CompletionArgument,
+ "_v2025_11_25.Context": monolith.CompletionContext,
+ "_v2025_11_25.Data": monolith.ElicitationRequiredErrorData,
+ "_v2025_11_25.Elicitation": monolith.ElicitationCapability,
+ "_v2025_11_25.Elicitation1": monolith.TasksElicitationCapability,
+ "_v2025_11_25.ElicitationCompleteNotification": monolith.ElicitCompleteNotification,
+ "_v2025_11_25.Params": monolith.CancelTaskRequestParams,
+ "_v2025_11_25.Params1": monolith.ElicitCompleteNotificationParams,
+ "_v2025_11_25.Params2": monolith.GetTaskPayloadRequestParams,
+ "_v2025_11_25.Params3": monolith.GetTaskRequestParams,
+ "_v2025_11_25.Error": monolith.ErrorData,
+ "_v2025_11_25.JSONRPCErrorResponse": monolith.JSONRPCError,
+ "_v2025_11_25.JSONRPCResultResponse": monolith.JSONRPCResponse,
+ "_v2025_11_25.Prompts": monolith.PromptsCapability,
+ "_v2025_11_25.Requests": monolith.ClientTasksRequestsCapability,
+ "_v2025_11_25.Requests1": monolith.ServerTasksRequestsCapability,
+ "_v2025_11_25.Resources": monolith.ResourcesCapability,
+ "_v2025_11_25.Roots": monolith.RootsCapability,
+ "_v2025_11_25.Sampling": monolith.SamplingCapability,
+ "_v2025_11_25.Sampling1": monolith.TasksSamplingCapability,
+ "_v2025_11_25.Tasks": monolith.ClientTasksCapability,
+ "_v2025_11_25.Tasks1": monolith.ServerTasksCapability,
+ "_v2025_11_25.Tools": monolith.TasksToolsCapability,
+ "_v2025_11_25.Tools1": monolith.ToolsCapability,
+ # _v2026_07_28
+ "_v2026_07_28.Argument": monolith.CompletionArgument,
+ "_v2026_07_28.Context": monolith.CompletionContext,
+ "_v2026_07_28.Data": monolith.MissingRequiredClientCapabilityErrorData,
+ "_v2026_07_28.Data1": monolith.UnsupportedProtocolVersionErrorData,
+ "_v2026_07_28.Elicitation": monolith.ElicitationCapability,
+ "_v2026_07_28.Error": monolith.ErrorData,
+ "_v2026_07_28.JSONRPCErrorResponse": monolith.JSONRPCError,
+ "_v2026_07_28.JSONRPCResultResponse": monolith.JSONRPCResponse,
+ "_v2026_07_28.Prompts": monolith.PromptsCapability,
+ "_v2026_07_28.Resources": monolith.ResourcesCapability,
+ "_v2026_07_28.Sampling": monolith.SamplingCapability,
+ "_v2026_07_28.Tools": monolith.ToolsCapability,
}
# Surface classes with no monolith equivalent (envelope wrappers, JSON-Schema fragments modelled as `dict`).
SKIP: frozenset[str] = frozenset(
{
- # v2025_11_25
- "v2025_11_25.AnyOfItem",
- "v2025_11_25.BooleanSchema",
- "v2025_11_25.Error1",
- "v2025_11_25.Icons",
- "v2025_11_25.InputSchema",
- "v2025_11_25.Items",
- "v2025_11_25.Items1",
- "v2025_11_25.LegacyTitledEnumSchema",
- "v2025_11_25.Meta",
- "v2025_11_25.NumberSchema",
- "v2025_11_25.OneOfItem",
- "v2025_11_25.OutputSchema",
- "v2025_11_25.RequestedSchema",
- "v2025_11_25.ResourceRequestParams",
- "v2025_11_25.StringSchema",
- "v2025_11_25.TaskAugmentedRequestParams",
- "v2025_11_25.TitledMultiSelectEnumSchema",
- "v2025_11_25.TitledSingleSelectEnumSchema",
- "v2025_11_25.URLElicitationRequiredError",
- "v2025_11_25.UntitledMultiSelectEnumSchema",
- "v2025_11_25.UntitledSingleSelectEnumSchema",
- # v2026_07_28
- "v2026_07_28.AnyOfItem",
- "v2026_07_28.BooleanSchema",
- "v2026_07_28.CallToolResultResponse",
- "v2026_07_28.ClientNotification",
- "v2026_07_28.CompleteResultResponse",
- "v2026_07_28.DiscoverResultResponse",
- "v2026_07_28.Error1",
- "v2026_07_28.Error2",
- "v2026_07_28.Error3",
- "v2026_07_28.GetPromptResultResponse",
- "v2026_07_28.HeaderMismatchError",
- "v2026_07_28.Icons",
- "v2026_07_28.InputSchema",
- "v2026_07_28.InternalError",
- "v2026_07_28.InvalidParamsError",
- "v2026_07_28.InvalidRequestError",
- "v2026_07_28.Items",
- "v2026_07_28.Items1",
- "v2026_07_28.LegacyTitledEnumSchema",
- "v2026_07_28.ListPromptsResultResponse",
- "v2026_07_28.ListResourceTemplatesResultResponse",
- "v2026_07_28.ListResourcesResultResponse",
- "v2026_07_28.ListToolsResultResponse",
- "v2026_07_28.MetaObject",
- "v2026_07_28.MethodNotFoundError",
- "v2026_07_28.MissingRequiredClientCapabilityError",
- "v2026_07_28.NotificationMetaObject",
- "v2026_07_28.NumberSchema",
- "v2026_07_28.OneOfItem",
- "v2026_07_28.OutputSchema",
- "v2026_07_28.Params",
- "v2026_07_28.ParseError",
- "v2026_07_28.ReadResourceResultResponse",
- "v2026_07_28.RequestMetaObject",
- "v2026_07_28.RequestedSchema",
- "v2026_07_28.ResourceRequestParams",
- "v2026_07_28.StringSchema",
- "v2026_07_28.SubscriptionsListenResultMeta",
- "v2026_07_28.TitledMultiSelectEnumSchema",
- "v2026_07_28.TitledSingleSelectEnumSchema",
- "v2026_07_28.UnsupportedProtocolVersionError",
- "v2026_07_28.UntitledMultiSelectEnumSchema",
- "v2026_07_28.UntitledSingleSelectEnumSchema",
+ # _v2025_11_25
+ "_v2025_11_25.AnyOfItem",
+ "_v2025_11_25.BooleanSchema",
+ "_v2025_11_25.Error1",
+ "_v2025_11_25.Icons",
+ "_v2025_11_25.InputSchema",
+ "_v2025_11_25.Items",
+ "_v2025_11_25.Items1",
+ "_v2025_11_25.LegacyTitledEnumSchema",
+ "_v2025_11_25.Meta",
+ "_v2025_11_25.NumberSchema",
+ "_v2025_11_25.OneOfItem",
+ "_v2025_11_25.OutputSchema",
+ "_v2025_11_25.RequestedSchema",
+ "_v2025_11_25.ResourceRequestParams",
+ "_v2025_11_25.StringSchema",
+ "_v2025_11_25.TaskAugmentedRequestParams",
+ "_v2025_11_25.TitledMultiSelectEnumSchema",
+ "_v2025_11_25.TitledSingleSelectEnumSchema",
+ "_v2025_11_25.URLElicitationRequiredError",
+ "_v2025_11_25.UntitledMultiSelectEnumSchema",
+ "_v2025_11_25.UntitledSingleSelectEnumSchema",
+ # _v2026_07_28
+ "_v2026_07_28.AnyOfItem",
+ "_v2026_07_28.BooleanSchema",
+ "_v2026_07_28.CallToolResultResponse",
+ "_v2026_07_28.ClientNotification",
+ "_v2026_07_28.CompleteResultResponse",
+ "_v2026_07_28.DiscoverResultResponse",
+ "_v2026_07_28.Error1",
+ "_v2026_07_28.Error2",
+ "_v2026_07_28.Error3",
+ "_v2026_07_28.GetPromptResultResponse",
+ "_v2026_07_28.HeaderMismatchError",
+ "_v2026_07_28.Icons",
+ "_v2026_07_28.InputSchema",
+ "_v2026_07_28.InternalError",
+ "_v2026_07_28.InvalidParamsError",
+ "_v2026_07_28.InvalidRequestError",
+ "_v2026_07_28.Items",
+ "_v2026_07_28.Items1",
+ "_v2026_07_28.LegacyTitledEnumSchema",
+ "_v2026_07_28.ListPromptsResultResponse",
+ "_v2026_07_28.ListResourceTemplatesResultResponse",
+ "_v2026_07_28.ListResourcesResultResponse",
+ "_v2026_07_28.ListToolsResultResponse",
+ "_v2026_07_28.MetaObject",
+ "_v2026_07_28.MethodNotFoundError",
+ "_v2026_07_28.MissingRequiredClientCapabilityError",
+ "_v2026_07_28.NotificationMetaObject",
+ "_v2026_07_28.NumberSchema",
+ "_v2026_07_28.OneOfItem",
+ "_v2026_07_28.OutputSchema",
+ "_v2026_07_28.Params",
+ "_v2026_07_28.ParseError",
+ "_v2026_07_28.ReadResourceResultResponse",
+ "_v2026_07_28.RequestMetaObject",
+ "_v2026_07_28.RequestedSchema",
+ "_v2026_07_28.ResourceRequestParams",
+ "_v2026_07_28.ResultMetaObject",
+ "_v2026_07_28.StringSchema",
+ "_v2026_07_28.SubscriptionsListenResultMeta",
+ "_v2026_07_28.TitledMultiSelectEnumSchema",
+ "_v2026_07_28.TitledSingleSelectEnumSchema",
+ "_v2026_07_28.UnsupportedProtocolVersionError",
+ "_v2026_07_28.UntitledMultiSelectEnumSchema",
+ "_v2026_07_28.UntitledSingleSelectEnumSchema",
}
)
diff --git a/uv.lock b/uv.lock
index 53228bc1a0..b992b278aa 100644
--- a/uv.lock
+++ b/uv.lock
@@ -838,7 +838,6 @@ dependencies = [
{ name = "mcp-types" },
{ name = "opentelemetry-api" },
{ name = "pydantic" },
- { name = "pydantic-settings" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "python-multipart" },
{ name = "pywin32", marker = "sys_platform == 'win32'" },
@@ -871,6 +870,7 @@ dev = [
{ name = "mcp-example-stories" },
{ name = "opentelemetry-sdk" },
{ name = "pillow" },
+ { name = "pydantic-settings" },
{ name = "pyright" },
{ name = "pytest" },
{ name = "pytest-examples" },
@@ -899,7 +899,6 @@ requires-dist = [
{ name = "mcp-types", editable = "src/mcp-types" },
{ name = "opentelemetry-api", specifier = ">=1.28.0" },
{ name = "pydantic", specifier = ">=2.12.0" },
- { name = "pydantic-settings", specifier = ">=2.5.2" },
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.10.1" },
{ name = "python-dotenv", marker = "extra == 'cli'", specifier = ">=1.0.0" },
{ name = "python-multipart", specifier = ">=0.0.9" },
@@ -926,6 +925,7 @@ dev = [
{ name = "mcp-example-stories", editable = "examples" },
{ name = "opentelemetry-sdk", specifier = ">=1.39.1" },
{ name = "pillow", specifier = ">=12.0" },
+ { name = "pydantic-settings", specifier = ">=2.5.2" },
{ name = "pyright", specifier = ">=1.1.400" },
{ name = "pytest", specifier = ">=8.4.0" },
{ name = "pytest-examples", specifier = ">=0.0.14" },