diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml
deleted file mode 100644
index d9362afd57..0000000000
--- a/.github/workflows/deploy-docs.yml
+++ /dev/null
@@ -1,57 +0,0 @@
-name: Deploy Docs
-
-on:
- push:
- branches:
- - main
- - v1.x
- paths:
- - docs/**
- - mkdocs.yml
- - src/mcp/**
- - scripts/build-docs.sh
- - pyproject.toml
- - uv.lock
- - .github/workflows/deploy-docs.yml
- workflow_dispatch:
-
-concurrency:
- group: deploy-docs
- cancel-in-progress: false
-
-jobs:
- deploy-docs:
- runs-on: ubuntu-latest
-
- permissions:
- contents: read
- pages: write
- id-token: write
-
- environment:
- name: github-pages
- url: ${{ steps.deployment.outputs.page_url }}
-
- steps:
- - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
-
- - name: Install uv
- uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1
- with:
- enable-cache: true
- version: 0.9.5
-
- - name: Build combined docs (v1.x at /, main at /v2/)
- run: bash scripts/build-docs.sh site
-
- - name: Configure Pages
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
-
- - name: Upload Pages artifact
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
- with:
- path: site
-
- - name: Deploy to GitHub Pages
- id: deployment
- uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
diff --git a/.github/workflows/shared.yml b/.github/workflows/shared.yml
index 16ba250cde..468359fef4 100644
--- a/.github/workflows/shared.yml
+++ b/.github/workflows/shared.yml
@@ -76,3 +76,20 @@ jobs:
- name: Check doc snippets are up to date
run: uv run --frozen scripts/update_doc_snippets.py --check
+
+ # The published site is built and deployed from main, which builds this
+ # branch's docs (via scripts/docs/build.sh) under /v1/; this branch has no
+ # deploy workflow of its own. This job runs that same script so a change
+ # that breaks the v1 docs fails here rather than at main's next deploy.
+ docs:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+
+ - uses: astral-sh/setup-uv@v7
+ with:
+ enable-cache: true
+ version: 0.9.5
+
+ - name: Build docs
+ run: bash scripts/docs/build.sh
diff --git a/README.md b/README.md
index 9eaef01af8..325060dfb1 100644
--- a/README.md
+++ b/README.md
@@ -13,11 +13,9 @@
-> **This documents v1.x, the stable release line of the MCP Python SDK. v2 is in alpha.**
+> **This documents v1.x, the maintenance line of the MCP Python SDK.** v2 is the current stable release: `pip install mcp` now installs 2.x. See the [v2 documentation](https://py.sdk.modelcontextprotocol.io/) and the [migration guide](https://py.sdk.modelcontextprotocol.io/migration/) for what changed and how to upgrade.
>
-> v2 pre-releases are published to PyPI as `2.0.0aN`. Installers never select a pre-release unless you opt in (for example `pip install mcp==2.0.0a1`), so v1.x users 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 v2 release lands.** See the [v2 documentation](https://github.com/modelcontextprotocol/python-sdk/blob/main/README.v2.md) and the [migration guide](https://github.com/modelcontextprotocol/python-sdk/blob/main/docs/migration.md) for what's changing.
->
-> v1.x remains recommended for production use. It is in maintenance mode and continues to receive critical bug fixes and security patches.
+> **Staying on v1.x?** Keep a `<2` upper bound on your requirement (for example `mcp>=1.28,<2`) so an unpinned resolve stays on the 1.x line. v1.x remains supported for existing deployments and continues to receive critical bug fixes and security patches; its documentation is at .
## Table of Contents
@@ -38,7 +36,7 @@
[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://modelcontextprotocol.github.io/python-sdk/
+[docs-url]: https://py.sdk.modelcontextprotocol.io/v1/
[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
@@ -69,13 +67,13 @@ If you haven't created a uv-managed project yet, create one:
Then add MCP to your project dependencies:
```bash
- uv add "mcp[cli]"
+ uv add "mcp[cli]<2"
```
Alternatively, for projects using pip for dependencies:
```bash
-pip install "mcp[cli]"
+pip install "mcp[cli]<2"
```
### Running the standalone MCP development tools
@@ -143,7 +141,7 @@ _Full example: [examples/snippets/servers/fastmcp_quickstart.py](https://github.
You can install this server in [Claude Code](https://docs.claude.com/en/docs/claude-code/mcp) and interact with it right away. First, run the server:
```bash
-uv run --with mcp examples/snippets/servers/fastmcp_quickstart.py
+uv run --with "mcp<2" examples/snippets/servers/fastmcp_quickstart.py
```
Then add it to Claude Code:
@@ -177,8 +175,8 @@ The [Model Context Protocol (MCP)](https://modelcontextprotocol.io) lets you bui
- [Low-Level Server](docs/low-level-server.md) -- direct handler registration for advanced use cases
- [Protocol Features](docs/protocol.md) -- MCP primitives, server capabilities
- [Testing](docs/testing.md) -- in-memory transport testing with pytest
-- [API Reference](https://modelcontextprotocol.github.io/python-sdk/api/)
-- [Experimental Features (Tasks)](https://modelcontextprotocol.github.io/python-sdk/experimental/tasks/)
+- [API Reference](https://py.sdk.modelcontextprotocol.io/v1/api/)
+- [Experimental Features (Tasks)](https://py.sdk.modelcontextprotocol.io/v1/experimental/tasks/)
- [Model Context Protocol documentation](https://modelcontextprotocol.io)
- [Model Context Protocol specification](https://modelcontextprotocol.io/specification/latest)
- [Officially supported servers](https://github.com/modelcontextprotocol/servers)
diff --git a/docs/hooks/llms_txt.py b/docs/hooks/llms_txt.py
new file mode 100644
index 0000000000..e3a696f232
--- /dev/null
+++ b/docs/hooks/llms_txt.py
@@ -0,0 +1,178 @@
+"""Generate llms.txt, llms-full.txt, and per-page markdown (https://llmstxt.org/).
+
+The hook publishes three artifacts into the built site:
+
+- `llms.txt`: a markdown index of the documentation, one link per page,
+ grouped by nav section.
+- a `.md` rendition of every prose page next to its HTML (e.g.
+ `server/index.md`), which is what the llms.txt links point at.
+- `llms-full.txt`: every prose page concatenated for single-fetch consumption.
+
+Page markdown is the source markdown with `--8<--` snippet includes resolved
+and relative links rewritten to absolute URLs. The API reference page
+(`api.md`) is a mkdocstrings stub with no markdown source, so it is linked as
+rendered HTML from an Optional section instead of being embedded.
+
+Incremental builds (`mkdocs build --dirty`) are rejected: they skip unmodified
+pages, which would silently truncate the generated artifacts.
+"""
+
+from __future__ import annotations
+
+import posixpath
+import re
+from dataclasses import dataclass, field
+from pathlib import Path
+
+from mkdocs.config.defaults import MkDocsConfig
+from mkdocs.exceptions import PluginError
+from mkdocs.structure.files import File, Files
+from mkdocs.structure.nav import Navigation, Section
+from mkdocs.structure.pages import Page
+
+# Pages with no markdown source, linked as HTML under "## Optional".
+_OPTIONAL_PAGES = [
+ ("api.md", "API reference", "Auto-generated API reference for the mcp package (rendered HTML)"),
+]
+
+_SNIPPET_LINE = re.compile(r'^(?P[ \t]*)--8<-- "(?P[^"\n]+)"$', flags=re.MULTILINE)
+_MD_LINK = re.compile(r'(\]\()([^)\s]+\.md)(#[^)\s]*)?( +"[^"]*")?(\))')
+
+
+@dataclass
+class _State:
+ page_markdown: dict[str, str] = field(default_factory=dict)
+ rendition_uris: set[str] = field(default_factory=set)
+ nav: Navigation | None = None
+ files: Files | None = None
+
+
+_state = _State()
+
+
+def _site_url(config: MkDocsConfig) -> str:
+ assert config.site_url is not None
+ return config.site_url.rstrip("/") + "/"
+
+
+def _md_uri(file: File) -> str:
+ return re.sub(r"\.html$", ".md", file.dest_uri)
+
+
+def on_config(config: MkDocsConfig) -> None:
+ # `mkdocs serve` rebuilds reuse the imported module; start each build clean.
+ _state.page_markdown.clear()
+ _state.rendition_uris.clear()
+ _state.nav = _state.files = None
+
+
+def on_nav(nav: Navigation, config: MkDocsConfig, files: Files) -> None:
+ _state.nav = nav
+ _state.files = files
+ _state.rendition_uris.update(page.file.src_uri for page in nav.pages if page.file.src_uri != "api.md")
+
+
+def on_page_markdown(markdown: str, page: Page, config: MkDocsConfig, files: Files) -> str | None:
+ if page.file.src_uri not in _state.rendition_uris:
+ return None
+
+ # Same anchor as the pymdownx.snippets `base_path` in mkdocs.yml.
+ repo_root = Path(config.config_file_path).parent
+
+ def include(match: re.Match[str]) -> str:
+ indent, path = match["indent"], match["path"]
+ # Mirror the snippets extension's restrict_base_path: reject paths
+ # that resolve outside the repo root.
+ resolved_path = (repo_root / path).resolve()
+ if not resolved_path.is_relative_to(repo_root.resolve()):
+ raise PluginError(f"llms_txt: snippet path {path!r} in {page.file.src_uri} escapes the repo root")
+ try:
+ content = resolved_path.read_text(encoding="utf-8").rstrip("\n")
+ except OSError as exc:
+ raise PluginError(f"llms_txt: cannot read snippet {path!r} in {page.file.src_uri}") from exc
+ # Keep a pointer to the embedded file so readers can find it on disk.
+ if path.endswith(".py"):
+ content = f"# {path}\n{content}"
+ if indent:
+ content = "\n".join(indent + line if line else line for line in content.split("\n"))
+ return content
+
+ resolved, substitutions = _SNIPPET_LINE.subn(include, markdown)
+ if substitutions != sum("--8<--" in line for line in markdown.splitlines()):
+ raise PluginError(f"llms_txt: unresolved snippet include in {page.file.src_uri}")
+
+ site_url = _site_url(config)
+ src_dir = posixpath.dirname(page.file.src_uri)
+
+ def rewrite(match: re.Match[str]) -> str:
+ opening, target, anchor, title, closing = match.groups()
+ if "://" in target:
+ return match.group(0)
+ linked = files.get_file_from_path(posixpath.normpath(posixpath.join(src_dir, target)))
+ if linked is None:
+ raise PluginError(f"llms_txt: cannot resolve link target {target!r} in {page.file.src_uri}")
+ # Pages without a markdown rendition (the api.md stub) link to their HTML instead.
+ url = _md_uri(linked) if linked.src_uri in _state.rendition_uris else linked.url
+ return f"{opening}{site_url}{url}{anchor or ''}{title or ''}{closing}"
+
+ _state.page_markdown[page.file.src_uri] = _MD_LINK.sub(rewrite, resolved)
+ return None
+
+
+def _section_pages(section: Section) -> list[Page]:
+ pages: list[Page] = []
+ for child in section.children:
+ if isinstance(child, Page) and child.file.src_uri in _state.rendition_uris:
+ pages.append(child)
+ elif isinstance(child, Section):
+ pages.extend(_section_pages(child))
+ return pages
+
+
+def on_post_build(config: MkDocsConfig) -> None:
+ assert _state.nav is not None and _state.files is not None
+ missing = _state.rendition_uris - _state.page_markdown.keys()
+ if missing:
+ raise PluginError(f"llms_txt: pages skipped this build (is this a --dirty build?): {sorted(missing)}")
+
+ site_dir = Path(config.site_dir)
+ site_url = _site_url(config)
+
+ top_level = [
+ item for item in _state.nav.items if isinstance(item, Page) and item.file.src_uri in _state.rendition_uris
+ ]
+ sections: list[tuple[str, list[Page]]] = [("Docs", top_level)] if top_level else []
+ for item in _state.nav.items:
+ if isinstance(item, Section):
+ pages = _section_pages(item)
+ if pages:
+ sections.append((item.title, pages))
+
+ index = [f"# {config.site_name}", "", f"> {config.site_description}", ""]
+ full: list[str] = []
+ for title, pages in sections:
+ index += [f"## {title}", ""]
+ for page in pages:
+ markdown = _state.page_markdown[page.file.src_uri]
+ (site_dir / _md_uri(page.file)).write_text(markdown, encoding="utf-8")
+
+ description = page.meta.get("description")
+ tail = f": {description}" if description else ""
+ index.append(f"- [{page.title}]({site_url}{_md_uri(page.file)}){tail}")
+
+ body, h1_found = re.subn(r"\A\s*# .+\n", "", markdown)
+ if not h1_found:
+ raise PluginError(f"llms_txt: page {page.file.src_uri} does not start with an H1")
+ full += [f"# {page.title}", "", f"Source: {page.canonical_url}", "", body.strip(), ""]
+ index.append("")
+
+ index += ["## Optional", ""]
+ for src_uri, title, description in _OPTIONAL_PAGES:
+ linked = _state.files.get_file_from_path(src_uri)
+ if linked is None:
+ raise PluginError(f"llms_txt: optional page {src_uri} not found")
+ index.append(f"- [{title}]({site_url}{linked.url}): {description}")
+ index.append("")
+
+ (site_dir / "llms.txt").write_text("\n".join(index), encoding="utf-8")
+ (site_dir / "llms-full.txt").write_text("\n".join(full), encoding="utf-8")
diff --git a/docs/index.md b/docs/index.md
index 48f3ace1ce..c44891c137 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,7 +1,9 @@
# MCP Python SDK
-!!! tip "Looking for the upcoming v2?"
- See the [v2 development documentation](https://py.sdk.modelcontextprotocol.io/v2/).
+!!! tip "You are viewing the v1.x maintenance-line documentation"
+ v2 is the current stable release: its documentation is at
+ . Staying on v1.x for now? Pin `mcp<2`
+ (for example `mcp>=1.28,<2`) so an unpinned install doesn't move you to 2.x.
The **Model Context Protocol (MCP)** allows applications to provide context for LLMs in a standardized way, separating the concerns of providing context from the actual LLM interaction.
@@ -48,7 +50,7 @@ if __name__ == "__main__":
Run the server:
```bash
-uv run --with mcp server.py
+uv run --with "mcp<2" server.py
```
Then open the [MCP Inspector](https://github.com/modelcontextprotocol/inspector) and connect to `http://localhost:8000/mcp`:
@@ -70,3 +72,9 @@ npx -y @modelcontextprotocol/inspector
## API Reference
Full API documentation is available in the [API Reference](api.md).
+
+## llms.txt
+
+Reading with an LLM? This documentation is also published in the [llms.txt](https://llmstxt.org/) format:
+[llms.txt](https://py.sdk.modelcontextprotocol.io/v1/llms.txt) is an index of the pages, and
+[llms-full.txt](https://py.sdk.modelcontextprotocol.io/v1/llms-full.txt) contains every page in a single file.
diff --git a/docs/installation.md b/docs/installation.md
index 6e20706a84..2352a1132f 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -1,16 +1,18 @@
# Installation
-The Python SDK is available on PyPI as [`mcp`](https://pypi.org/project/mcp/) so installation is as simple as:
+The Python SDK is available on PyPI as [`mcp`](https://pypi.org/project/mcp/). These docs describe the **v1.x maintenance line**;
+the `<2` bound keeps you on it now that `pip install mcp` selects the 2.x stable release by default (the
+[v2 documentation](https://py.sdk.modelcontextprotocol.io/) covers that line):
=== "pip"
```bash
- pip install mcp
+ pip install "mcp<2"
```
=== "uv"
```bash
- uv add mcp
+ uv add "mcp<2"
```
The following dependencies are automatically installed:
diff --git a/docs/server.md b/docs/server.md
index 6340687c31..6402596a57 100644
--- a/docs/server.md
+++ b/docs/server.md
@@ -1253,6 +1253,7 @@ The FastMCP server instance accessible via `ctx.fastmcp` provides access to serv
- `host` and `port` - Server network configuration
- `mount_path`, `sse_path`, `streamable_http_path` - Transport paths
- `stateless_http` - Whether the server operates in stateless mode
+ - `max_request_body_size` - Maximum Streamable HTTP POST body size in bytes
- And other configuration options
```python
@@ -1417,6 +1418,14 @@ Note that `uv run mcp run` or `uv run mcp dev` only supports server using FastMC
> **Note**: Streamable HTTP transport is the recommended transport for production deployments. Use `stateless_http=True` and `json_response=True` for optimal scalability.
+Streamable HTTP POST bodies are limited to 4 MiB by default. Larger requests receive HTTP 413
+before parsing or session creation. If your server intentionally accepts larger MCP messages,
+configure the smallest suitable byte limit:
+
+```python
+mcp = FastMCP("Large messages", max_request_body_size=8 * 1024 * 1024)
+```
+
```python
"""
diff --git a/mkdocs.yml b/mkdocs.yml
index 7245f239b4..684c4a1232 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -1,11 +1,11 @@
-site_name: MCP Server
-site_description: MCP Server
+site_name: MCP Python SDK
+site_description: The official Python SDK for the Model Context Protocol
strict: true
repo_name: modelcontextprotocol/python-sdk
repo_url: https://github.com/modelcontextprotocol/python-sdk
edit_uri: edit/v1.x/docs/
-site_url: https://py.sdk.modelcontextprotocol.io/
+site_url: https://py.sdk.modelcontextprotocol.io/v1/
# TODO(Marcelo): Add Anthropic copyright?
# copyright: © Model Context Protocol 2025 to present
@@ -85,7 +85,10 @@ markdown_extensions:
- pymdownx.critic
- pymdownx.mark
- pymdownx.superfences
- - pymdownx.snippets
+ # Resolve snippet includes against the repo root regardless of the build's
+ # working directory (the extension's default base_path is the CWD).
+ - pymdownx.snippets:
+ base_path: !relative $config_dir
- pymdownx.tilde
- pymdownx.inlinehilite
- pymdownx.highlight:
@@ -111,6 +114,9 @@ markdown_extensions:
watch:
- src/mcp
+hooks:
+ - docs/hooks/llms_txt.py
+
plugins:
- search
- social
diff --git a/pyproject.toml b/pyproject.toml
index 8611f5c4b6..6bee7f4923 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -69,7 +69,9 @@ dev = [
"coverage[toml]==7.10.7",
]
docs = [
- "mkdocs>=1.6.1",
+ # MkDocs 2.0 is a ground-up rewrite (no plugin system) that is incompatible
+ # with mkdocs-material and every plugin below; stay on the 1.x line.
+ "mkdocs>=1.6.1,<2",
"mkdocs-glightbox>=0.4.0",
"mkdocs-material[imaging]>=9.6.19",
"mkdocstrings-python>=1.12.2",
@@ -89,7 +91,7 @@ bump = true
[project.urls]
Homepage = "https://modelcontextprotocol.io"
-Documentation = "https://py.sdk.modelcontextprotocol.io/"
+Documentation = "https://py.sdk.modelcontextprotocol.io/v1/"
Repository = "https://github.com/modelcontextprotocol/python-sdk"
Issues = "https://github.com/modelcontextprotocol/python-sdk/issues"
diff --git a/scripts/build-docs.sh b/scripts/build-docs.sh
deleted file mode 100755
index 5a61309acf..0000000000
--- a/scripts/build-docs.sh
+++ /dev/null
@@ -1,54 +0,0 @@
-#!/usr/bin/env bash
-#
-# Build combined v1 + v2 MkDocs 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/.
-#
-# Both branches are fetched fresh from origin, so the output is identical
-# regardless of which branch triggered the workflow. This script is intended
-# to run in CI; for local single-branch preview use `uv run mkdocs serve`.
-#
-# Usage:
-# scripts/build-docs.sh [output-dir]
-#
-# Default output directory: site
-#
-set -euo pipefail
-
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
-OUTPUT_DIR="$(cd "$REPO_ROOT" && mkdir -p "${1:-site}" && cd "${1:-site}" && pwd)"
-V1_WORKTREE="$REPO_ROOT/.worktrees/v1-docs"
-V2_WORKTREE="$REPO_ROOT/.worktrees/v2-docs"
-
-cleanup() {
- cd "$REPO_ROOT"
- git worktree remove --force "$V1_WORKTREE" 2>/dev/null || true
- git worktree remove --force "$V2_WORKTREE" 2>/dev/null || true
- rmdir "$REPO_ROOT/.worktrees" 2>/dev/null || true
-}
-trap cleanup EXIT
-
-rm -rf "${OUTPUT_DIR:?}"/*
-
-build_branch() {
- local branch="$1" worktree="$2" dest="$3"
-
- echo "=== Building docs for ${branch} ==="
- git fetch origin "$branch"
- git worktree remove --force "$worktree" 2>/dev/null || true
- rm -rf "$worktree"
- git worktree add --detach "$worktree" "origin/${branch}"
-
- (
- cd "$worktree"
- uv sync --frozen --group docs
- uv run --frozen --no-sync mkdocs build --site-dir "$dest"
- )
-}
-
-build_branch v1.x "$V1_WORKTREE" "$OUTPUT_DIR"
-build_branch main "$V2_WORKTREE" "$OUTPUT_DIR/v2"
-
-echo "=== Combined docs built at $OUTPUT_DIR ==="
diff --git a/scripts/docs/build.sh b/scripts/docs/build.sh
new file mode 100755
index 0000000000..6c5ab88f62
--- /dev/null
+++ b/scripts/docs/build.sh
@@ -0,0 +1,19 @@
+#!/usr/bin/env bash
+#
+# Build the v1.x documentation into ./site.
+#
+# This is the single build recipe for this branch's docs. The combined site
+# is assembled and deployed from main, which fetches this branch and runs this
+# script to build it under /v1/ (main's scripts/build-docs.sh picks
+# scripts/docs/build.sh from each branch it builds). The `docs` CI job on
+# this branch runs the same script, so what CI checks is what gets published.
+#
+# Usage:
+# scripts/docs/build.sh
+#
+set -euo pipefail
+
+cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+
+uv sync --frozen --group docs
+NO_MKDOCS_2_WARNING=1 uv run --frozen --no-sync mkdocs build --site-dir site
diff --git a/src/mcp/server/fastmcp/resources/templates.py b/src/mcp/server/fastmcp/resources/templates.py
index 89a8ceb36b..99534a4ff5 100644
--- a/src/mcp/server/fastmcp/resources/templates.py
+++ b/src/mcp/server/fastmcp/resources/templates.py
@@ -86,7 +86,7 @@ def matches(self, uri: str) -> dict[str, Any] | None:
"""Check if URI matches template and extract parameters."""
# Convert template to regex pattern
pattern = self.uri_template.replace("{", "(?P<").replace("}", ">[^/]+)")
- match = re.match(f"^{pattern}$", uri)
+ match = re.fullmatch(pattern, uri)
if match:
return match.groupdict()
return None
diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py
index 8f62ce2e54..e915a12bfd 100644
--- a/src/mcp/server/fastmcp/server.py
+++ b/src/mcp/server/fastmcp/server.py
@@ -62,7 +62,7 @@
from mcp.server.sse import SseServerTransport
from mcp.server.stdio import stdio_server
from mcp.server.streamable_http import EventStore
-from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
+from mcp.server.streamable_http_manager import DEFAULT_MAX_REQUEST_BODY_SIZE, StreamableHTTPSessionManager
from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared.context import LifespanContextT, RequestContext, RequestT
from mcp.types import Annotations, AnyFunction, ContentBlock, GetPromptResult, Icon, ToolAnnotations
@@ -106,6 +106,7 @@ class Settings(BaseSettings, Generic[LifespanResultT]):
json_response: bool
stateless_http: bool
"""Define if the server should create a new transport per request."""
+ max_request_body_size: int
# resource settings
warn_on_duplicate_resources: bool
@@ -166,6 +167,7 @@ def __init__( # noqa: PLR0913
streamable_http_path: str = "/mcp",
json_response: bool = False,
stateless_http: bool = False,
+ max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE,
warn_on_duplicate_resources: bool = True,
warn_on_duplicate_tools: bool = True,
warn_on_duplicate_prompts: bool = True,
@@ -193,6 +195,7 @@ def __init__( # noqa: PLR0913
streamable_http_path=streamable_http_path,
json_response=json_response,
stateless_http=stateless_http,
+ max_request_body_size=max_request_body_size,
warn_on_duplicate_resources=warn_on_duplicate_resources,
warn_on_duplicate_tools=warn_on_duplicate_tools,
warn_on_duplicate_prompts=warn_on_duplicate_prompts,
@@ -960,6 +963,7 @@ def streamable_http_app(self) -> Starlette:
json_response=self.settings.json_response,
stateless=self.settings.stateless_http, # Use the stateless setting
security_settings=self.settings.transport_security,
+ max_request_body_size=self.settings.max_request_body_size,
)
# Create the ASGI handler
@@ -1177,6 +1181,7 @@ async def report_progress(self, progress: float, total: float | None = None, mes
progress=progress,
total=total,
message=message,
+ related_request_id=self.request_id,
)
async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContents]:
diff --git a/src/mcp/server/streamable_http_manager.py b/src/mcp/server/streamable_http_manager.py
index 1a1a85721d..0ee6d362b5 100644
--- a/src/mcp/server/streamable_http_manager.py
+++ b/src/mcp/server/streamable_http_manager.py
@@ -4,15 +4,17 @@
import contextlib
import logging
+from collections import deque
from collections.abc import AsyncIterator
-from typing import Any
+from typing import Any, Final
from uuid import uuid4
import anyio
from anyio.abc import TaskStatus
+from starlette.datastructures import Headers
from starlette.requests import Request
from starlette.responses import Response
-from starlette.types import Receive, Scope, Send
+from starlette.types import ASGIApp, Message, Receive, Scope, Send
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, AuthorizationContext, authorization_context
from mcp.server.lowlevel.server import Server as MCPServer
@@ -26,6 +28,9 @@
logger = logging.getLogger(__name__)
+DEFAULT_MAX_REQUEST_BODY_SIZE: Final = 4 * 1024 * 1024
+"""Default maximum Streamable HTTP request body size in bytes (4 MiB)."""
+
class StreamableHTTPSessionManager:
"""
@@ -60,6 +65,8 @@ class StreamableHTTPSessionManager:
retry_interval is also configured, ensure the idle timeout comfortably exceeds the retry interval to
avoid reaping sessions during normal SSE polling gaps. Default is None (no timeout). A value of 1800
(30 minutes) is recommended for most deployments.
+ max_request_body_size: Maximum size in bytes for Streamable HTTP POST request bodies. Requests that
+ exceed this limit receive a 413 response before parsing or session creation. Defaults to 4 MiB.
"""
def __init__(
@@ -71,11 +78,14 @@ def __init__(
security_settings: TransportSecuritySettings | None = None,
retry_interval: int | None = None,
session_idle_timeout: float | None = None,
+ max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE,
):
if session_idle_timeout is not None and session_idle_timeout <= 0:
raise ValueError("session_idle_timeout must be a positive number of seconds")
if stateless and session_idle_timeout is not None:
raise RuntimeError("session_idle_timeout is not supported in stateless mode")
+ if max_request_body_size <= 0:
+ raise ValueError("max_request_body_size must be a positive number of bytes")
self.app = app
self.event_store = event_store
@@ -84,6 +94,8 @@ def __init__(
self.security_settings = security_settings
self.retry_interval = retry_interval
self.session_idle_timeout = session_idle_timeout
+ self.max_request_body_size = max_request_body_size
+ self.asgi_app = RequestBodyLimitMiddleware(self._handle_request, max_request_body_size)
# Session tracking (only used if not stateless)
self._session_creation_lock = anyio.Lock()
@@ -156,6 +168,14 @@ async def handle_request(
receive: ASGI receive function
send: ASGI send function
"""
+ await self.asgi_app(scope, receive, send)
+
+ async def _handle_request(
+ self,
+ scope: Scope,
+ receive: Receive,
+ send: Send,
+ ) -> None:
if self._task_group is None:
raise RuntimeError("Task group is not initialized. Make sure to use run().")
@@ -341,3 +361,63 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE
body.model_dump_json(by_alias=True, exclude_none=True), status_code=404, media_type="application/json"
)
await response(scope, receive, send)
+
+
+class RequestBodyLimitMiddleware:
+ """Reject oversized HTTP request bodies before invoking an ASGI application."""
+
+ def __init__(self, app: ASGIApp, max_body_size: int) -> None:
+ self.app = app
+ self.max_body_size = max_body_size
+
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
+ if scope["type"] != "http" or scope["method"] != "POST":
+ await self.app(scope, receive, send)
+ return
+
+ headers = Headers(scope=scope)
+ content_length = headers.get("content-length")
+ if content_length is not None:
+ try:
+ declared_size = int(content_length)
+ except ValueError:
+ pass
+ else:
+ if declared_size > self.max_body_size:
+ response = Response("Request body too large", status_code=413)
+ return await response(scope, receive, send)
+
+ received_body = bytearray()
+ received_request = False
+ body_complete = False
+ trailing_message: Message | None = None
+ while True:
+ message = await receive()
+ if message["type"] != "http.request":
+ trailing_message = message
+ break
+
+ received_request = True
+ body = message.get("body", b"")
+ if len(received_body) + len(body) > self.max_body_size:
+ response = Response("Request body too large", status_code=413)
+ return await response(scope, receive, send)
+ received_body.extend(body)
+ if not message.get("more_body", False):
+ body_complete = True
+ break
+
+ cached_messages: deque[Message] = deque()
+ if received_request:
+ cached_messages.append(
+ {"type": "http.request", "body": bytes(received_body), "more_body": not body_complete}
+ )
+ if trailing_message is not None:
+ cached_messages.append(trailing_message)
+
+ async def replay() -> Message:
+ if cached_messages:
+ return cached_messages.popleft()
+ return await receive()
+
+ await self.app(scope, replay, send)
diff --git a/src/mcp/shared/tool_name_validation.py b/src/mcp/shared/tool_name_validation.py
index f35efa5a61..96c34f7826 100644
--- a/src/mcp/shared/tool_name_validation.py
+++ b/src/mcp/shared/tool_name_validation.py
@@ -77,7 +77,7 @@ def validate_tool_name(name: str) -> ToolNameValidationResult:
warnings.append("Tool name starts or ends with a dot, which may cause parsing issues in some contexts")
# Check for invalid characters
- if not TOOL_NAME_REGEX.match(name):
+ if not TOOL_NAME_REGEX.fullmatch(name):
# Find all invalid characters (unique, preserving order)
invalid_chars: list[str] = []
seen: set[str] = set()
diff --git a/tests/issues/test_176_progress_token.py b/tests/issues/test_176_progress_token.py
index eb5f19d64c..a81e9ba18c 100644
--- a/tests/issues/test_176_progress_token.py
+++ b/tests/issues/test_176_progress_token.py
@@ -36,6 +36,12 @@ async def test_progress_token_zero_first_call():
# Verify progress notifications
assert mock_session.send_progress_notification.call_count == 3, "All progress notifications should be sent"
- mock_session.send_progress_notification.assert_any_call(progress_token=0, progress=0.0, total=10.0, message=None)
- mock_session.send_progress_notification.assert_any_call(progress_token=0, progress=5.0, total=10.0, message=None)
- mock_session.send_progress_notification.assert_any_call(progress_token=0, progress=10.0, total=10.0, message=None)
+ mock_session.send_progress_notification.assert_any_call(
+ progress_token=0, progress=0.0, total=10.0, message=None, related_request_id="test-request"
+ )
+ mock_session.send_progress_notification.assert_any_call(
+ progress_token=0, progress=5.0, total=10.0, message=None, related_request_id="test-request"
+ )
+ mock_session.send_progress_notification.assert_any_call(
+ progress_token=0, progress=10.0, total=10.0, message=None, related_request_id="test-request"
+ )
diff --git a/tests/server/fastmcp/resources/test_resource_template.py b/tests/server/fastmcp/resources/test_resource_template.py
index f3d3ba5e45..ebef4ca227 100644
--- a/tests/server/fastmcp/resources/test_resource_template.py
+++ b/tests/server/fastmcp/resources/test_resource_template.py
@@ -48,6 +48,21 @@ def my_func(key: str, value: int) -> dict[str, Any]: # pragma: no cover
assert template.matches("test://foo") is None
assert template.matches("other://foo/123") is None
+ def test_template_matches_rejects_trailing_newline_after_literal(self):
+ """A trailing newline after a literal segment slipped past `$` with re.match."""
+
+ def my_func(key: str) -> str: # pragma: no cover
+ return key
+
+ template = ResourceTemplate.from_function(
+ fn=my_func,
+ uri_template="test://{key}/data",
+ name="test",
+ )
+
+ assert template.matches("test://foo/data") == {"key": "foo"}
+ assert template.matches("test://foo/data\n") is None
+
@pytest.mark.anyio
async def test_create_resource(self):
"""Test creating a resource from a template."""
diff --git a/tests/server/fastmcp/test_server.py b/tests/server/fastmcp/test_server.py
index 7f7d27e8c3..b134489bc5 100644
--- a/tests/server/fastmcp/test_server.py
+++ b/tests/server/fastmcp/test_server.py
@@ -1490,3 +1490,12 @@ def test_streamable_http_no_redirect() -> None:
# Verify path values
assert streamable_routes[0].path == "/mcp", "Streamable route path should be /mcp"
+
+
+def test_streamable_http_app_passes_the_configured_request_body_limit_to_its_manager() -> None:
+ """SDK-defined: FastMCP forwards its public request-body setting to the Streamable HTTP manager."""
+ mcp = FastMCP(max_request_body_size=8)
+
+ mcp.streamable_http_app()
+
+ assert mcp.session_manager.max_request_body_size == 8
diff --git a/tests/server/test_streamable_http_manager.py b/tests/server/test_streamable_http_manager.py
index 0ae07c43ad..9deeeeb37a 100644
--- a/tests/server/test_streamable_http_manager.py
+++ b/tests/server/test_streamable_http_manager.py
@@ -1,19 +1,24 @@
"""Tests for StreamableHTTPSessionManager."""
import json
+from collections.abc import Iterator
from typing import Any
from unittest.mock import AsyncMock, patch
import anyio
import pytest
-from starlette.types import Message, Scope
+from starlette.types import Message, Receive, Scope, Send
from mcp.server import streamable_http_manager
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
from mcp.server.auth.provider import AccessToken
from mcp.server.lowlevel import Server
from mcp.server.streamable_http import MCP_SESSION_ID_HEADER, StreamableHTTPServerTransport
-from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
+from mcp.server.streamable_http_manager import (
+ DEFAULT_MAX_REQUEST_BODY_SIZE,
+ RequestBodyLimitMiddleware,
+ StreamableHTTPSessionManager,
+)
from mcp.types import INVALID_REQUEST
@@ -68,9 +73,9 @@ async def test_handle_request_without_run_raises_error():
manager = StreamableHTTPSessionManager(app=app)
# Mock ASGI parameters
- scope = {"type": "http", "method": "POST", "path": "/test"}
+ scope: Scope = {"type": "http", "method": "POST", "path": "/test", "headers": []}
- async def receive(): # pragma: no cover
+ async def receive() -> Message:
return {"type": "http.request", "body": b""}
async def send(message: Message): # pragma: no cover
@@ -83,6 +88,126 @@ async def send(message: Message): # pragma: no cover
assert "Task group is not initialized. Make sure to use run()." in str(excinfo.value)
+@pytest.mark.anyio
+async def test_oversized_content_length_is_rejected_before_body_read_or_session_creation() -> None:
+ """SDK-defined: an oversized declared body gets HTTP 413 before the server reads it or creates a session."""
+ manager = StreamableHTTPSessionManager(app=Server("test-size-limit"), max_request_body_size=8)
+ sent_messages: list[Message] = []
+ receive = AsyncMock(return_value={"type": "http.request", "body": b"123456789", "more_body": False})
+
+ async def send(message: Message) -> None:
+ sent_messages.append(message)
+
+ scope: Scope = {
+ "type": "http",
+ "method": "POST",
+ "path": "/mcp",
+ "headers": [(b"content-length", b"9")],
+ }
+ async with manager.run():
+ await manager.handle_request(scope, receive, send)
+ assert manager._server_instances == {}
+
+ response_start = next(message for message in sent_messages if message["type"] == "http.response.start")
+ assert response_start["status"] == 413
+ receive.assert_not_awaited()
+
+
+@pytest.mark.anyio
+@pytest.mark.parametrize("headers", [[], [(b"content-length", b"invalid")], [(b"content-length", b"8")]])
+async def test_oversized_streamed_body_is_rejected_before_session_creation(
+ headers: list[tuple[bytes, bytes]],
+) -> None:
+ """SDK-defined: streamed bodies enforce the limit with missing, invalid, or understated length."""
+ manager = StreamableHTTPSessionManager(app=Server("test-streamed-size-limit"), max_request_body_size=8)
+ sent_messages: list[Message] = []
+ request_messages: Iterator[Message] = iter(
+ [
+ {"type": "http.request", "body": b"1234", "more_body": True},
+ {"type": "http.request", "body": b"56789", "more_body": False},
+ ]
+ )
+
+ async def receive() -> Message:
+ return next(request_messages)
+
+ async def send(message: Message) -> None:
+ sent_messages.append(message)
+
+ scope: Scope = {"type": "http", "method": "POST", "path": "/mcp", "headers": headers}
+ async with manager.run():
+ await manager.handle_request(scope, receive, send)
+ assert manager._server_instances == {}
+
+ response_start = next(message for message in sent_messages if message["type"] == "http.response.start")
+ assert response_start["status"] == 413
+
+
+@pytest.mark.anyio
+async def test_request_body_chunks_are_replayed_as_one_message() -> None:
+ """SDK-defined: raw ASGI proves chunk overhead is discarded before the body reaches the transport."""
+ request_messages: Iterator[Message] = iter(
+ [
+ {"type": "http.request", "body": b"12", "more_body": True},
+ {"type": "http.request", "body": b"34", "more_body": True},
+ {"type": "http.request", "body": b"56", "more_body": False},
+ {"type": "http.disconnect"},
+ ]
+ )
+ received_messages: list[Message] = []
+
+ async def receive() -> Message:
+ return next(request_messages)
+
+ async def app(scope: Scope, receive: Receive, send: Send) -> None:
+ received_messages.append(await receive())
+ received_messages.append(await receive())
+
+ scope: Scope = {"type": "http", "method": "POST", "path": "/mcp", "headers": []}
+ middleware = RequestBodyLimitMiddleware(app, max_body_size=8)
+
+ await middleware(scope, receive, AsyncMock())
+
+ assert received_messages == [
+ {"type": "http.request", "body": b"123456", "more_body": False},
+ {"type": "http.disconnect"},
+ ]
+
+
+@pytest.mark.anyio
+async def test_disconnect_before_request_body_is_replayed() -> None:
+ """SDK-defined: raw ASGI proves a disconnect before the first body message reaches the transport."""
+ disconnect: Message = {"type": "http.disconnect"}
+ received_messages: list[Message] = []
+
+ async def receive() -> Message:
+ return disconnect
+
+ async def app(scope: Scope, receive: Receive, send: Send) -> None:
+ received_messages.append(await receive())
+
+ scope: Scope = {"type": "http", "method": "POST", "path": "/mcp", "headers": []}
+ middleware = RequestBodyLimitMiddleware(app, max_body_size=8)
+
+ await middleware(scope, receive, AsyncMock())
+
+ assert received_messages == [disconnect]
+
+
+def test_request_body_limit_defaults_to_four_mib() -> None:
+ """SDK-defined: Streamable HTTP request bodies are limited to 4 MiB by default."""
+ manager = StreamableHTTPSessionManager(app=Server("test-default-size-limit"))
+ assert manager.max_request_body_size == DEFAULT_MAX_REQUEST_BODY_SIZE == 4 * 1024 * 1024
+
+
+@pytest.mark.parametrize("max_request_body_size", [0, -1])
+def test_request_body_limit_rejects_non_positive_values(max_request_body_size: int) -> None:
+ """SDK-defined: callers cannot disable request-size protection with a non-positive value."""
+ with pytest.raises(ValueError) as exc_info:
+ StreamableHTTPSessionManager(app=Server("test-invalid-size-limit"), max_request_body_size=max_request_body_size)
+ assert str(exc_info.value) == "max_request_body_size must be a positive number of bytes"
+
+
class TestException(Exception):
__test__ = False # Prevent pytest from collecting this as a test class
pass
@@ -118,7 +243,7 @@ async def mock_send(message: Message):
"headers": [(b"content-type", b"application/json")],
}
- async def mock_receive(): # pragma: no cover
+ async def mock_receive():
return {"type": "http.request", "body": b"", "more_body": False}
# Trigger session creation
@@ -177,7 +302,7 @@ async def mock_send(message: Message):
"headers": [(b"content-type", b"application/json")],
}
- async def mock_receive(): # pragma: no cover
+ async def mock_receive():
return {"type": "http.request", "body": b"", "more_body": False}
# Trigger session creation
@@ -297,7 +422,7 @@ async def mock_send(message: Message):
}
async def mock_receive():
- return {"type": "http.request", "body": b"{}", "more_body": False} # pragma: no cover
+ return {"type": "http.request", "body": b"{}", "more_body": False}
await manager.handle_request(scope, mock_receive, mock_send)
@@ -336,7 +461,7 @@ async def mock_send(message: Message):
"headers": [(b"content-type", b"application/json")],
}
- async def mock_receive(): # pragma: no cover
+ async def mock_receive():
return {"type": "http.request", "body": b"", "more_body": False}
await manager.handle_request(scope, mock_receive, mock_send)
diff --git a/tests/shared/test_tool_name_validation.py b/tests/shared/test_tool_name_validation.py
index 4746f3f9f8..e24be1cf7b 100644
--- a/tests/shared/test_tool_name_validation.py
+++ b/tests/shared/test_tool_name_validation.py
@@ -66,12 +66,17 @@ def test_rejects_name_exceeding_max_length(self) -> None:
("get,user,profile", "','"),
("user/profile/update", "'/'"),
("user@domain.com", "'@'"),
+ # a single trailing newline slipped past `$` with re.match
+ ("valid_name\n", "'\\n'"),
+ ("a" * 127 + "\n", "'\\n'"),
],
ids=[
"with_spaces",
"with_commas",
"with_slashes",
"with_at_symbol",
+ "with_trailing_newline",
+ "max_length_with_trailing_newline",
],
)
def test_rejects_invalid_characters(self, tool_name: str, expected_char: str) -> None:
diff --git a/uv.lock b/uv.lock
index 43e6218c0b..031ba38f04 100644
--- a/uv.lock
+++ b/uv.lock
@@ -865,7 +865,7 @@ dev = [
{ name = "trio", specifier = ">=0.26.2" },
]
docs = [
- { name = "mkdocs", specifier = ">=1.6.1" },
+ { name = "mkdocs", specifier = ">=1.6.1,<2" },
{ name = "mkdocs-glightbox", specifier = ">=0.4.0" },
{ name = "mkdocs-material", extras = ["imaging"], specifier = ">=9.6.19" },
{ name = "mkdocstrings-python", specifier = ">=1.12.2" },