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/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 4726de1dd0..b14a8e1209 100644 --- a/README.md +++ b/README.md @@ -13,18 +13,18 @@ -> [!CAUTION] -> **This README documents v2 of the MCP Python SDK, currently a release candidate (`2.0.0rc1`); the stable v2 release is planned for 2026-07-28.** Do not use v2 in production yet. Pre-releases are published to PyPI as `2.0.0aN` / `2.0.0bN` / `2.0.0rcN`, and **a pre-release may still 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://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) (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-28, 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.0rc1" # or: pip install "mcp[cli]==2.0.0rc1" +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.0rc1"` 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..1785da1225 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.28.Z` | 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.28.Z`) + +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.28.Z --latest=false` + fixes it — release metadata only, no re-cut. + +```shell +gh release create v1.28.Z --title v1.28.Z --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..8bcb1d4028 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.28.x (`v1.x` branch) | maintenance | critical bug fixes and security fixes | +| < 1.28, and all pre-release versions | unsupported | upgrade to the newest 1.28.x 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, constrain to `mcp>=1.28,<2` 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 715c5bbdc8..de7937fc75 100644 --- a/docs/advanced/extensions.md +++ b/docs/advanced/extensions.md @@ -128,7 +128,7 @@ 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" ``` @@ -158,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" ``` @@ -188,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" ``` @@ -226,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 dd5fb427a2..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" ``` @@ -117,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" ``` @@ -144,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 3d5808550a..3ad58ce05d 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. The signature and semantics are + expected to change before v2 is final. 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,7 +59,10 @@ 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 @@ -98,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 78de2a59f1..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" ``` diff --git a/docs/client/callbacks.md b/docs/client/callbacks.md index 53f6e563ee..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,7 +133,7 @@ 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 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. diff --git a/docs/client/index.md b/docs/client/index.md index f70966e1ab..1e1df3c01b 100644 --- a/docs/client/index.md +++ b/docs/client/index.md @@ -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" ``` diff --git a/docs/client/oauth-clients.md b/docs/client/oauth-clients.md index 08ccdca1df..cd7de35626 100644 --- a/docs/client/oauth-clients.md +++ b/docs/client/oauth-clients.md @@ -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 70ad86833a..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" ``` 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 a0c6100f15..728a86cfd0 100644 --- a/docs/get-started/installation.md +++ b/docs/get-started/installation.md @@ -2,38 +2,32 @@ The Python SDK is on PyPI as [`mcp`](https://pypi.org/project/mcp/). It requires **Python 3.10+**. -These docs describe **v2**, currently a release candidate, 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.0rc1" + uv add "mcp[cli]" ``` === "pip" ```bash - pip install "mcp[cli]==2.0.0rc1" + 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 pre-release before you copy - the line above. - - The same applies to one-off commands: `uv run --with "mcp==2.0.0rc1" ...`, 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`](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 ee056257c2..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.0rc1" 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 a release candidate, 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.0rc1", + "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.0rc1" 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.0rc1", "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.0rc1", "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.0rc1" 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. @@ -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.0rc1" mcp run /absolute/path/to/server.py`: version-pinned, no venv to activate, works from any directory. The pin is mandatory while v2 is pre-release. -* **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 2c3f9fca74..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 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/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 99b1bc98ad..4fbf7e9ffb 100644 --- a/docs/handlers/subscriptions.md +++ b/docs/handlers/subscriptions.md @@ -43,20 +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. +## 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" ``` @@ -119,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 4c2584f904..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.0rc1" + uv add "mcp[cli]" ``` === "pip" ```bash - pip install "mcp[cli]==2.0.0rc1" + 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 pre-release" - 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 pre-release 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 5eb7659c23..931d470d1a 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -6,7 +6,7 @@ Version 2 of the MCP Python SDK introduces several breaking changes to improve t ## 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 +14,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 +101,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 +129,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 +186,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 +196,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 +236,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 +275,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 +341,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 +377,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 +447,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 +471,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 +490,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 +546,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 +597,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 +662,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. @@ -622,17 +742,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):** @@ -655,7 +777,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") @@ -664,25 +786,43 @@ 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. -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. +**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`). ### `MCP_*` environment variables and `.env` files are no longer read @@ -721,6 +861,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. @@ -733,7 +884,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) ``` @@ -744,7 +895,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) ``` @@ -812,8 +963,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 @@ -836,6 +990,25 @@ 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. @@ -935,10 +1108,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. @@ -963,6 +1151,8 @@ 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". @@ -988,13 +1178,19 @@ client_id = token.client_id if token else None 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):** @@ -1002,20 +1198,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. @@ -1034,7 +1229,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` @@ -1056,7 +1251,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: @@ -1077,6 +1272,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. @@ -1102,7 +1320,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, @@ -1151,13 +1369,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: @@ -1280,7 +1498,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: @@ -1382,17 +1600,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 @@ -1414,7 +1642,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: @@ -1461,6 +1689,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):** @@ -1477,11 +1707,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`. -`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: +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)). + +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:** @@ -1538,7 +1778,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 @@ -1546,11 +1786,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 @@ -1585,7 +1825,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):** @@ -1597,12 +1837,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. @@ -1671,7 +1925,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):** @@ -1692,7 +1946,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", {}) @@ -1703,34 +1957,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). -- **`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. +Migrating a request callback is a signature-only change (sampling and roots callbacks have the same shape): -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`. +**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 + + +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. @@ -1774,11 +2098,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: @@ -1824,11 +2156,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 @@ -1875,7 +2211,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: @@ -1969,6 +2305,57 @@ The WebSocket transport has been removed: `mcp.client.websocket.websocket_client ## 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 @@ -2067,6 +2454,19 @@ ClientCredentialsOAuthProvider(..., scopes="read write") 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. @@ -2083,7 +2483,7 @@ provider = OAuthClientProvider(server_url, client_metadata, storage, timeout=120 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): ...`. +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` @@ -2179,6 +2579,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. @@ -2225,9 +2646,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) @@ -2279,7 +2702,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`: @@ -2305,6 +2730,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`: @@ -2312,7 +2751,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: @@ -2320,12 +2759,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()` @@ -2334,7 +2775,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 @@ -2355,8 +2796,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/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 adaa0c85d3..dbea20d0fe 100644 --- a/docs/run/index.md +++ b/docs/run/index.md @@ -65,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 e8042b83ab..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" ``` 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 58c863f41d..75a6652ecc 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -291,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 @@ -305,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: @@ -329,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` @@ -407,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 9826fa5909..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 release candidate" - `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): - before the stable release, 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 @@ -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`, `mcp.shared.progress`, and `mcp.shared.session` (with the `RequestResponder` stub v1 `message_handler` annotations imported) 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,13 +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. +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 (since `2.0.0rc1`; [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. +* **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)**. @@ -208,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/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 65d89f198c..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.", 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/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/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 4b03421f44..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__) 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 62e02e70ab..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 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/client.py b/examples/stories/dual_era/client.py index b884c70609..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 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/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/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/server.py b/examples/stories/middleware/server.py index 076120dccd..e2164eb668 100644 --- a/examples/stories/middleware/server.py +++ b/examples/stories/middleware/server.py @@ -7,8 +7,7 @@ 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/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/client.py b/examples/stories/reconnect/client.py index 0bf3c7af95..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 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/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/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/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/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 d2054ca8df..7e9801db22 100644 --- a/examples/stories/standalone_get/client.py +++ b/examples/stories/standalone_get/client.py @@ -1,8 +1,8 @@ """Receive `notifications/resources/list_changed` over the standalone GET stream, then re-list.""" import anyio -import mcp_types as types +import mcp.types as types from mcp.client import Client, IncomingMessage from stories._harness import Target, run_client 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/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 a5b0e41dad..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 +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 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..c485399b71 100644 --- a/examples/stories/streaming/README.md +++ b/examples/stories/streaming/README.md @@ -56,10 +56,12 @@ uv run python -m stories.streaming.client --http --server server_lowlevel 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. +- 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 5ce8812540..3c814106d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -142,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" @@ -217,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/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 0c7b85289b..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]`. @@ -270,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/_types.py b/src/mcp-types/mcp_types/_types.py index e19ebe0b94..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 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 100% rename from src/mcp-types/mcp_types/v2026_07_28/__init__.py rename to src/mcp-types/mcp_types/_v2026_07_28/__init__.py 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/auth/oauth2.py b/src/mcp/client/auth/oauth2.py index 07e224148a..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.""" @@ -190,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 @@ -199,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="") @@ -208,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 @@ -664,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 aaf7c83b0b..ed7c40f123 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -312,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.""" @@ -425,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, diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 80539eaeee..895339ca18 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -19,6 +19,7 @@ CLIENT_INFO_META_KEY, CONNECTION_CLOSED, INTERNAL_ERROR, + LOG_LEVEL_META_KEY, METHOD_NOT_FOUND, PROTOCOL_VERSION_META_KEY, SERVER_INFO_META_KEY, @@ -121,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", {}) @@ -128,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 @@ -372,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, @@ -393,6 +402,7 @@ 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 @@ -645,7 +655,9 @@ 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 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/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 ca05928dff..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) @@ -373,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 diff --git a/src/mcp/server/context.py b/src/mcp/server/context.py index 903b7ef6f8..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 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/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/server.py b/src/mcp/server/mcpserver/server.py index 33a0b08c84..7bf1993e29 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -59,7 +59,7 @@ 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, @@ -172,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,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 @@ -257,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 is + expected to change before v2 is final; see the middleware guide. + """ + return self._lowlevel_server.middleware + @property def title(self) -> str | None: return self._lowlevel_server.title diff --git a/src/mcp/server/runner.py b/src/mcp/server/runner.py index 6f9f7a8f74..26e8efbe57 100644 --- a/src/mcp/server/runner.py +++ b/src/mcp/server/runner.py @@ -321,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, diff --git a/src/mcp/server/session.py b/src/mcp/server/session.py index 69ad5ecadb..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,9 +44,22 @@ 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: @@ -106,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")) @@ -122,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, @@ -131,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: 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/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/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/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/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/client/auth/extensions/test_client_credentials.py b/tests/client/auth/extensions/test_client_credentials.py index cb8fd5e2b6..16336f8002 100644 --- a/tests/client/auth/extensions/test_client_credentials.py +++ b/tests/client/auth/extensions/test_client_credentials.py @@ -149,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", - scope="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.""" diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 6bea7bcf70..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, @@ -1008,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_session.py b/tests/client/test_session.py index 12fab4c8c9..6e5e0a14d8 100644 --- a/tests/client/test_session.py +++ b/tests/client/test_session.py @@ -13,6 +13,7 @@ CONNECTION_CLOSED, INTERNAL_ERROR, INVALID_PARAMS, + LOG_LEVEL_META_KEY, METHOD_NOT_FOUND, PROTOCOL_VERSION_META_KEY, REQUEST_TIMEOUT, @@ -1548,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 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/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/_requirements.py b/tests/interaction/_requirements.py index 17bc3a4b5a..6406bdb8bc 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -537,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=( @@ -1520,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." @@ -1529,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." @@ -1538,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) # ═══════════════════════════════════════════════════════════════════════════ @@ -2559,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=( @@ -2940,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=( @@ -3660,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=( 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/lowlevel/test_cancellation.py b/tests/interaction/lowlevel/test_cancellation.py index ecbf1a088d..74c32e304b 100644 --- a/tests/interaction/lowlevel/test_cancellation.py +++ b/tests/interaction/lowlevel/test_cancellation.py @@ -28,9 +28,10 @@ Tool, ) -from mcp import MCPError +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 @@ -39,20 +40,39 @@ 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", {}) @@ -393,8 +410,9 @@ 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 unstamped(result) == snapshot(CallToolResult(content=[TextContent(text="ok")])) @@ -460,8 +478,9 @@ 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 [unstamped(result) for result in results] == snapshot( diff --git a/tests/interaction/lowlevel/test_logging.py b/tests/interaction/lowlevel/test_logging.py index 7f2f89fd1a..f827650e73 100644 --- a/tests/interaction/lowlevel/test_logging.py +++ b/tests/interaction/lowlevel/test_logging.py @@ -8,8 +8,16 @@ 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 @@ -81,7 +89,7 @@ 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 unstamped(result) == snapshot(CallToolResult(content=[TextContent(text="done")])) @@ -122,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_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 0f07a128b7..2b979b8936 100644 --- a/tests/interaction/mcpserver/test_context.py +++ b/tests/interaction/mcpserver/test_context.py @@ -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 @@ -142,7 +142,7 @@ 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 unstamped(result) == snapshot( diff --git a/tests/interaction/mcpserver/test_tools.py b/tests/interaction/mcpserver/test_tools.py index 21a7163360..a6418ac9c5 100644 --- a/tests/interaction/mcpserver/test_tools.py +++ b/tests/interaction/mcpserver/test_tools.py @@ -431,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_streamable_http.py b/tests/interaction/transports/test_streamable_http.py index cb22e7ab87..2176b27823 100644 --- a/tests/interaction/transports/test_streamable_http.py +++ b/tests/interaction/transports/test_streamable_http.py @@ -12,22 +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, 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._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 @@ -49,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}" @@ -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/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 98d59e98cb..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, @@ -2345,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/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 31caf87bf2..683473d620 100644 --- a/tests/server/test_connection.py +++ b/tests/server/test_connection.py @@ -283,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() diff --git a/tests/server/test_runner.py b/tests/server/test_runner.py index eb212dafdb..50e77f7134 100644 --- a/tests/server/test_runner.py +++ b/tests/server/test_runner.py @@ -8,6 +8,7 @@ """ import contextvars +import logging from collections.abc import AsyncIterator, Mapping from contextlib import asynccontextmanager from dataclasses import dataclass, field, replace @@ -25,6 +26,7 @@ INVALID_PARAMS, INVALID_REQUEST, LATEST_PROTOCOL_VERSION, + LOG_LEVEL_META_KEY, METHOD_NOT_FOUND, PROTOCOL_VERSION_META_KEY, SERVER_INFO_META_KEY, @@ -38,6 +40,8 @@ InitializeRequestParams, JSONRPCRequest, ListToolsResult, + LoggingMessageNotification, + LoggingMessageNotificationParams, NotificationParams, PaginatedRequestParams, ProgressNotificationParams, @@ -1759,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 @@ -1971,12 +2001,35 @@ 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 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_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_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/test_types.py b/tests/test_types.py index ad481d2c0a..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(): @@ -445,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/types/test_methods.py b/tests/types/test_methods.py index 959743e596..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,11 +295,11 @@ # 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 reserved `params._meta` entries the 2026 surface accepts on every request. diff --git a/tests/types/test_parity.py b/tests/types/test_parity.py index 8c75205636..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,116 +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.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", + # _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", } )