From 9678a3b34784dc175f4a8f06ad50b509a53b8ac5 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:43:15 +0200 Subject: [PATCH 1/7] [v1.x] Route Context.report_progress() to the originating request stream (#2994) --- src/mcp/server/fastmcp/server.py | 1 + tests/issues/test_176_progress_token.py | 12 +++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index 8f62ce2e54..7b0de09836 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -1177,6 +1177,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/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" + ) From 0e3a6046360bd88d670355d426e2c9ce2d3d7002 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:30:59 +0100 Subject: [PATCH 2/7] [v1.x] docs: publish llms.txt and markdown renditions of the docs (#3029) --- docs/hooks/llms_txt.py | 178 +++++++++++++++++++++++++++++++++++++++++ docs/index.md | 6 ++ mkdocs.yml | 12 ++- 3 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 docs/hooks/llms_txt.py 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..8462445a60 100644 --- a/docs/index.md +++ b/docs/index.md @@ -70,3 +70,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/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/mkdocs.yml b/mkdocs.yml index 7245f239b4..f19a0ee3ee 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,5 +1,5 @@ -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 @@ -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 From ba33472033a4430d07f1751bc6c450288efac743 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:51:59 +0100 Subject: [PATCH 3/7] [v1.x] docs: pin mkdocs<2 (#3074) --- pyproject.toml | 4 +++- uv.lock | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8611f5c4b6..9a4cf051ef 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", 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" }, From 5f0b6af9f2b0aad3be3ba5cbfe80a9e4ea32e189 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Thu, 16 Jul 2026 10:54:29 +0200 Subject: [PATCH 4/7] [v1.x] Add Streamable HTTP request body limits (#3101) --- docs/server.md | 9 ++ src/mcp/server/fastmcp/server.py | 6 +- src/mcp/server/streamable_http_manager.py | 84 ++++++++++- tests/server/fastmcp/test_server.py | 9 ++ tests/server/test_streamable_http_manager.py | 141 +++++++++++++++++-- 5 files changed, 238 insertions(+), 11 deletions(-) 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/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index 7b0de09836..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 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/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) From e8283746d01eb66fff678190e8e3da81d2f36924 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:19:59 +0100 Subject: [PATCH 5/7] [v1.x] fix: reject trailing newline in tool-name validation (#3086) Co-authored-by: otiscuilei --- src/mcp/server/fastmcp/resources/templates.py | 2 +- src/mcp/shared/tool_name_validation.py | 2 +- .../fastmcp/resources/test_resource_template.py | 15 +++++++++++++++ tests/shared/test_tool_name_validation.py | 5 +++++ 4 files changed, 22 insertions(+), 2 deletions(-) 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/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/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/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: From c0c5a9d64253ebf89a67955b2979dfa198707d67 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:37:13 +0100 Subject: [PATCH 6/7] [v1.x] ci: pick the docs toolchain per worktree in build-docs.sh (#3082) --- scripts/build-docs.sh | 36 +++++++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/scripts/build-docs.sh b/scripts/build-docs.sh index 5a61309acf..8286786091 100755 --- a/scripts/build-docs.sh +++ b/scripts/build-docs.sh @@ -1,13 +1,17 @@ #!/usr/bin/env bash # -# Build combined v1 + v2 MkDocs documentation for GitHub Pages. +# 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/. # -# 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`. +# 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`. # # Usage: # scripts/build-docs.sh [output-dir] @@ -30,7 +34,21 @@ cleanup() { } trap cleanup EXIT -rm -rf "${OUTPUT_DIR:?}"/* +# Build the checked-out worktree into its local `site/`, picking the toolchain +# from the branch's own files rather than hard-coding it here: a branch that +# ships the Zensical build recipe (scripts/docs/build.sh) builds with it, +# otherwise it falls back to MkDocs. This keeps the combined build correct +# regardless of which branch triggered it. Zensical requires site_dir to live +# within the project root, so both paths build to the local `site/` and let +# the caller copy it to its destination. +build_site() { + if [[ -f scripts/docs/build.sh ]]; then + bash scripts/docs/build.sh + else + uv sync --frozen --group docs + NO_MKDOCS_2_WARNING=1 uv run --frozen --no-sync mkdocs build --site-dir site + fi +} build_branch() { local branch="$1" worktree="$2" dest="$3" @@ -43,11 +61,15 @@ build_branch() { ( cd "$worktree" - uv sync --frozen --group docs - uv run --frozen --no-sync mkdocs build --site-dir "$dest" + rm -rf site + build_site + mkdir -p "$dest" + cp -a site/. "$dest/" ) } +rm -rf "${OUTPUT_DIR:?}"/* + build_branch v1.x "$V1_WORKTREE" "$OUTPUT_DIR" build_branch main "$V2_WORKTREE" "$OUTPUT_DIR/v2" From 98b7159cb89274964055d2c016e3360a551280d0 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:57:32 +0100 Subject: [PATCH 7/7] [v1.x] Move the v1.x docs to /v1/ and mark v1.x as the maintenance line (#3177) --- .github/workflows/deploy-docs.yml | 57 ----------------------- .github/workflows/shared.yml | 17 +++++++ README.md | 18 ++++---- docs/index.md | 12 +++-- docs/installation.md | 8 ++-- mkdocs.yml | 2 +- pyproject.toml | 2 +- scripts/build-docs.sh | 76 ------------------------------- scripts/docs/build.sh | 19 ++++++++ 9 files changed, 58 insertions(+), 153 deletions(-) delete mode 100644 .github/workflows/deploy-docs.yml delete mode 100755 scripts/build-docs.sh create mode 100755 scripts/docs/build.sh 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/index.md b/docs/index.md index 8462445a60..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`: @@ -74,5 +76,5 @@ 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/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. +[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/mkdocs.yml b/mkdocs.yml index f19a0ee3ee..684c4a1232 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -5,7 +5,7 @@ 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 diff --git a/pyproject.toml b/pyproject.toml index 9a4cf051ef..6bee7f4923 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,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 8286786091..0000000000 --- a/scripts/build-docs.sh +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env bash -# -# 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 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`. -# -# 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 - -# Build the checked-out worktree into its local `site/`, picking the toolchain -# from the branch's own files rather than hard-coding it here: a branch that -# ships the Zensical build recipe (scripts/docs/build.sh) builds with it, -# otherwise it falls back to MkDocs. This keeps the combined build correct -# regardless of which branch triggered it. Zensical requires site_dir to live -# within the project root, so both paths build to the local `site/` and let -# the caller copy it to its destination. -build_site() { - if [[ -f scripts/docs/build.sh ]]; then - bash scripts/docs/build.sh - else - uv sync --frozen --group docs - NO_MKDOCS_2_WARNING=1 uv run --frozen --no-sync mkdocs build --site-dir site - fi -} - -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" - rm -rf site - build_site - mkdir -p "$dest" - cp -a site/. "$dest/" - ) -} - -rm -rf "${OUTPUT_DIR:?}"/* - -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