diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 7aea8e63b6..d28d3721f2 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -7,6 +7,9 @@ on: - v1.x paths: - docs/** + # docs pages include their code blocks from these files via `--8<--`, so a + # change here changes the rendered site even when no .md file moves. + - docs_src/** - mkdocs.yml - src/mcp/** - scripts/build-docs.sh diff --git a/.github/workflows/shared.yml b/.github/workflows/shared.yml index 21a70f46ef..8989639b51 100644 --- a/.github/workflows/shared.yml +++ b/.github/workflows/shared.yml @@ -110,3 +110,26 @@ jobs: - name: Check README snippets are up to date run: uv run --frozen scripts/update_readme_snippets.py --check --readme README.v2.md + + # `mkdocs.yml` sets `strict: true` and `pymdownx.snippets: check_paths: true`, + # but until this job existed the docs were only ever built post-merge by + # `deploy-docs.yml`, so a broken link, a missing nav target, or a deleted + # `docs_src/` include went green on the PR and broke the next deploy of main. + # This is the check path; `deploy-docs.yml` stays the deploy path. + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: true + version: 0.9.5 + + - name: Install dependencies + run: uv sync --frozen --all-extras --python 3.10 + + - name: Build the docs in strict mode + run: uv run --frozen --no-sync mkdocs build --strict diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 42c12fdedd..f88f229ed5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -65,5 +65,5 @@ repos: name: Check README snippets are up to date entry: uv run --frozen python scripts/update_readme_snippets.py --check language: system - files: ^(README\.v2\.md|examples/.*\.py|scripts/update_readme_snippets\.py)$ + files: ^(README\.v2\.md|docs_src/.*\.py|examples/.*\.py|scripts/update_readme_snippets\.py)$ pass_filenames: false diff --git a/README.v2.md b/README.v2.md index b9896d9412..9b9971ec32 100644 --- a/README.v2.md +++ b/README.v2.md @@ -17,2512 +17,116 @@ > **Important: this documents v2 of the SDK, which is in alpha.** Pre-releases are published to PyPI as `2.0.0aN`, and each alpha may contain breaking changes from the previous one. > -> 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 the [migration guide](https://github.com/modelcontextprotocol/python-sdk/blob/main/docs/migration.md) for what's changed. We're targeting a beta on 2026-06-30 and a stable v2 on 2026-07-27, alongside the spec release. Before stable, we plan to add a significant set of backwards compatibility shims so the final upgrade is much smaller than today's diff. +> 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 the [migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/) for what's changed. We're targeting a beta on 2026-06-30 and a stable v2 on 2026-07-27, alongside the spec release. Before stable, we plan to add a significant set of backwards compatibility shims so the final upgrade is much smaller than today's diff. > -> **v1.x is the only stable release line and remains recommended for production.** It is in maintenance mode and continues to receive critical bug fixes and security patches. Installers never select a pre-release unless you opt in (for example `pip install mcp==2.0.0aN`), 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.** +> **v1.x is the only stable release line and remains recommended for production.** It is in maintenance mode and continues to receive critical bug fixes and security patches. Installers never select a pre-release unless you opt in (for example `pip install mcp==2.0.0a3`), 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.** > > Try the alpha and tell us what breaks: [#python-sdk-dev on the MCP Contributors Discord](https://discord.gg/6CSzBmMkjX). For v1 documentation, see [the v1.x README](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/README.md). - -## Table of Contents - -- [MCP Python SDK](#mcp-python-sdk) - - [Overview](#overview) - - [Installation](#installation) - - [Adding MCP to your python project](#adding-mcp-to-your-python-project) - - [Running the standalone MCP development tools](#running-the-standalone-mcp-development-tools) - - [Quickstart](#quickstart) - - [What is MCP?](#what-is-mcp) - - [Core Concepts](#core-concepts) - - [Server](#server) - - [Resources](#resources) - - [Tools](#tools) - - [Structured Output](#structured-output) - - [Prompts](#prompts) - - [Images](#images) - - [Context](#context) - - [Getting Context in Functions](#getting-context-in-functions) - - [Context Properties and Methods](#context-properties-and-methods) - - [Completions](#completions) - - [Elicitation](#elicitation) - - [Sampling](#sampling) - - [Logging and Notifications](#logging-and-notifications) - - [Authentication](#authentication) - - [MCPServer Properties](#mcpserver-properties) - - [Session Properties and Methods](#session-properties-and-methods) - - [Request Context Properties](#request-context-properties) - - [Running Your Server](#running-your-server) - - [Development Mode](#development-mode) - - [Claude Desktop Integration](#claude-desktop-integration) - - [Direct Execution](#direct-execution) - - [Streamable HTTP Transport](#streamable-http-transport) - - [CORS Configuration for Browser-Based Clients](#cors-configuration-for-browser-based-clients) - - [Mounting to an Existing ASGI Server](#mounting-to-an-existing-asgi-server) - - [StreamableHTTP servers](#streamablehttp-servers) - - [Basic mounting](#basic-mounting) - - [Host-based routing](#host-based-routing) - - [Multiple servers with path configuration](#multiple-servers-with-path-configuration) - - [Path configuration at initialization](#path-configuration-at-initialization) - - [SSE servers](#sse-servers) - - [Advanced Usage](#advanced-usage) - - [Low-Level Server](#low-level-server) - - [Structured Output Support](#structured-output-support) - - [Pagination (Advanced)](#pagination-advanced) - - [Writing MCP Clients](#writing-mcp-clients) - - [Client Display Utilities](#client-display-utilities) - - [OAuth Authentication for Clients](#oauth-authentication-for-clients) - - [Parsing Tool Results](#parsing-tool-results) - - [MCP Primitives](#mcp-primitives) - - [Server Capabilities](#server-capabilities) - - [Documentation](#documentation) - - [Contributing](#contributing) - - [License](#license) - -[pypi-badge]: https://img.shields.io/pypi/v/mcp.svg -[pypi-url]: https://pypi.org/project/mcp/ -[mit-badge]: https://img.shields.io/pypi/l/mcp.svg -[mit-url]: https://github.com/modelcontextprotocol/python-sdk/blob/main/LICENSE -[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/ -[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 -[spec-url]: https://modelcontextprotocol.io/specification/latest - -## Overview - -The Model Context Protocol allows applications to provide context for LLMs in a standardized way, separating the concerns of providing context from the actual LLM interaction. This Python SDK implements the full MCP specification, making it easy to: - -- Build MCP clients that can connect to any MCP server -- Create MCP servers that expose resources, prompts and tools -- Use standard transports like stdio, SSE, and Streamable HTTP -- Handle all MCP protocol messages and lifecycle events +## Documentation -## Installation +**The documentation lives at .** -### Adding MCP to your python project +It has the full [tutorial](https://py.sdk.modelcontextprotocol.io/v2/tutorial/), the [API reference](https://py.sdk.modelcontextprotocol.io/v2/api/mcp/), and the [migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/). -We recommend using [uv](https://docs.astral.sh/uv/) to manage your Python projects. +## What is MCP? -If you haven't created a uv-managed project yet, create one: +The [Model Context Protocol](https://modelcontextprotocol.io) lets you build servers that expose data and functionality to LLM applications in a secure, standardized way. Think of it like a web API, but designed for LLM interactions. With this SDK you can: - ```bash - uv init mcp-server-demo - cd mcp-server-demo - ``` +- **Build MCP servers** that expose tools, resources, and prompts to any MCP host +- **Build MCP clients** that connect to any MCP server +- Speak every standard transport: stdio, Streamable HTTP, and SSE - Then add MCP to your project dependencies: +## Requirements - ```bash - uv add "mcp[cli]==2.0.0a1" - ``` +Python 3.10+. -Alternatively, for projects using pip for dependencies: +## Installation ```bash -pip install "mcp[cli]==2.0.0a1" +uv add "mcp[cli]==2.0.0a3" # or: pip install "mcp[cli]==2.0.0a3" ``` -> While v2 is in pre-release, you must pin the version explicitly: unpinned installs resolve to the latest stable v1.x release, which these docs do not describe. Check the [release history](https://pypi.org/project/mcp/#history) for the newest pre-release. The same applies to ad-hoc commands: use `uv run --with "mcp==2.0.0a1"` rather than `uv run --with mcp`. - -### Running the standalone MCP development tools - -To run the mcp command with uv: - -```bash -uv run mcp -``` +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.0a3"` for one-off commands. -## Quickstart +## A server in 15 lines -Let's create a simple MCP server that exposes a calculator tool and some data: +Create a `server.py`: - + ```python -"""MCPServer quickstart example. - -Run from the repository root: - uv run examples/snippets/servers/mcpserver_quickstart.py -""" - -from mcp.server.mcpserver import MCPServer +from mcp.server import MCPServer -# Create an MCP server mcp = MCPServer("Demo") -# Add an addition tool @mcp.tool() def add(a: int, b: int) -> int: - """Add two numbers""" + """Add two numbers.""" return a + b -# Add a dynamic greeting resource @mcp.resource("greeting://{name}") -def get_greeting(name: str) -> str: - """Get a personalized greeting""" +def greeting(name: str) -> str: + """Greet someone by name.""" return f"Hello, {name}!" - - -# Add a prompt -@mcp.prompt() -def greet_user(name: str, style: str = "friendly") -> str: - """Generate a greeting prompt""" - styles = { - "friendly": "Please write a warm, friendly greeting", - "formal": "Please write a formal, professional greeting", - "casual": "Please write a casual, relaxed greeting", - } - - return f"{styles.get(style, styles['friendly'])} for someone named {name}." - - -# Run with streamable HTTP transport -if __name__ == "__main__": - mcp.run(transport="streamable-http", json_response=True) ``` -_Full example: [examples/snippets/servers/mcpserver_quickstart.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/mcpserver_quickstart.py)_ +_Full example: [docs_src/index/tutorial001.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/docs_src/index/tutorial001.py)_ -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==2.0.0a1" examples/snippets/servers/mcpserver_quickstart.py -``` - -Then add it to Claude Code: - -```bash -claude mcp add --transport http my-server http://localhost:8000/mcp -``` - -Alternatively, you can test it with the MCP Inspector. Start the server as above, then in a separate terminal: +That's a complete MCP server: one tool, one templated resource. Open it in the [MCP Inspector](https://github.com/modelcontextprotocol/inspector): ```bash -npx -y @modelcontextprotocol/inspector -``` - -In the inspector UI, connect to `http://localhost:8000/mcp`. - -## What is MCP? - -The [Model Context Protocol (MCP)](https://modelcontextprotocol.io) lets you build servers that expose data and functionality to LLM applications in a secure, standardized way. Think of it like a web API, but specifically designed for LLM interactions. - -MCP follows a **client-server model**, where LLM applications act as clients and connect to MCP servers to access capabilities such as data retrieval and tool execution in a consistent format. - -MCP servers can: - -- Expose data through **Resources** (think of these sort of like GET endpoints; they are used to load information into the LLM's context) -- Provide functionality through **Tools** (sort of like POST endpoints; they are used to execute code or otherwise produce a side effect) -- Define interaction patterns through **Prompts** (reusable templates for LLM interactions) -- And more! - -## Core Concepts - -### Server - -The MCPServer server is your core interface to the MCP protocol. It handles connection management, protocol compliance, and message routing: - - -```python -"""Example showing lifespan support for startup/shutdown with strong typing.""" - -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from dataclasses import dataclass - -from mcp.server.mcpserver import Context, MCPServer - - -# Mock database class for example -class Database: - """Mock database class for example.""" - - @classmethod - async def connect(cls) -> "Database": - """Connect to database.""" - return cls() - - async def disconnect(self) -> None: - """Disconnect from database.""" - pass - - def query(self) -> str: - """Execute a query.""" - return "Query result" - - -@dataclass -class AppContext: - """Application context with typed dependencies.""" - - db: Database - - -@asynccontextmanager -async def app_lifespan(server: MCPServer) -> AsyncIterator[AppContext]: - """Manage application lifecycle with type-safe context.""" - # Initialize on startup - db = await Database.connect() - try: - yield AppContext(db=db) - finally: - # Cleanup on shutdown - await db.disconnect() - - -# Pass lifespan to server -mcp = MCPServer("My App", lifespan=app_lifespan) - - -# Access type-safe lifespan context in tools -@mcp.tool() -def query_db(ctx: Context[AppContext]) -> str: - """Tool that uses initialized resources.""" - db = ctx.request_context.lifespan_context.db - return db.query() -``` - -_Full example: [examples/snippets/servers/lifespan_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lifespan_example.py)_ - - -### Resources - -Resources are how you expose data to LLMs. They're similar to GET endpoints in a REST API - they provide data but shouldn't perform significant computation or have side effects: - - -```python -from mcp.server.mcpserver import MCPServer - -mcp = MCPServer(name="Resource Example") - - -@mcp.resource("file://documents/{name}") -def read_document(name: str) -> str: - """Read a document by name.""" - # This would normally read from disk - return f"Content of {name}" - - -@mcp.resource("config://settings") -def get_settings() -> str: - """Get application settings.""" - return """{ - "theme": "dark", - "language": "en", - "debug": false -}""" -``` - -_Full example: [examples/snippets/servers/basic_resource.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/basic_resource.py)_ - - -### Tools - -Tools let LLMs take actions through your server. Unlike resources, tools are expected to perform computation and have side effects: - - -```python -from mcp.server.mcpserver import MCPServer - -mcp = MCPServer(name="Tool Example") - - -@mcp.tool() -def sum(a: int, b: int) -> int: - """Add two numbers together.""" - return a + b - - -@mcp.tool() -def get_weather(city: str, unit: str = "celsius") -> str: - """Get weather for a city.""" - # This would normally call a weather API - return f"Weather in {city}: 22degrees{unit[0].upper()}" -``` - -_Full example: [examples/snippets/servers/basic_tool.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/basic_tool.py)_ - - -Tools can optionally receive a Context object by including a parameter with the `Context` type annotation. This context is automatically injected by the MCPServer framework and provides access to MCP capabilities: - - -```python -from mcp.server.mcpserver import Context, MCPServer - -mcp = MCPServer(name="Progress Example") - - -@mcp.tool() -async def long_running_task(task_name: str, ctx: Context, steps: int = 5) -> str: - """Execute a task with progress updates.""" - await ctx.info(f"Starting: {task_name}") # pyright: ignore[reportDeprecated] - - for i in range(steps): - progress = (i + 1) / steps - await ctx.report_progress( - progress=progress, - total=1.0, - message=f"Step {i + 1}/{steps}", - ) - await ctx.debug(f"Completed step {i + 1}") # pyright: ignore[reportDeprecated] - - return f"Task '{task_name}' completed" -``` - -_Full example: [examples/snippets/servers/tool_progress.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/tool_progress.py)_ - - -#### Structured Output - -Tools will return structured results by default, if their return type -annotation is compatible. Otherwise, they will return unstructured results. - -Structured output supports these return types: - -- Pydantic models (BaseModel subclasses) -- TypedDicts -- Dataclasses and other classes with type hints -- `dict[str, T]` (where T is any JSON-serializable type) -- Primitive types (str, int, float, bool, bytes, None) - wrapped in `{"result": value}` -- Generic types (list, tuple, Union, Optional, etc.) - wrapped in `{"result": value}` - -Classes without type hints cannot be serialized for structured output. Only -classes with properly annotated attributes will be converted to Pydantic models -for schema generation and validation. - -Structured results are automatically validated against the output schema -generated from the annotation. This ensures the tool returns well-typed, -validated data that clients can easily process. - -**Note:** For backward compatibility, unstructured results are also -returned. Unstructured results are provided for backward compatibility -with previous versions of the MCP specification, and are quirks-compatible -with previous versions of MCPServer in the current version of the SDK. - -**Note:** In cases where a tool function's return type annotation -causes the tool to be classified as structured _and this is undesirable_, -the classification can be suppressed by passing `structured_output=False` -to the `@tool` decorator. - -##### Advanced: Direct CallToolResult - -For full control over tool responses including the `_meta` field (for passing data to client applications without exposing it to the model), you can return `CallToolResult` directly: - - -```python -"""Example showing direct CallToolResult return for advanced control.""" - -from typing import Annotated - -from mcp_types import CallToolResult, TextContent -from pydantic import BaseModel - -from mcp.server.mcpserver import MCPServer - -mcp = MCPServer("CallToolResult Example") - - -class ValidationModel(BaseModel): - """Model for validating structured output.""" - - status: str - data: dict[str, int] - - -@mcp.tool() -def advanced_tool() -> CallToolResult: - """Return CallToolResult directly for full control including _meta field.""" - return CallToolResult( - content=[TextContent(type="text", text="Response visible to the model")], - _meta={"hidden": "data for client applications only"}, - ) - - -@mcp.tool() -def validated_tool() -> Annotated[CallToolResult, ValidationModel]: - """Return CallToolResult with structured output validation.""" - return CallToolResult( - content=[TextContent(type="text", text="Validated response")], - structured_content={"status": "success", "data": {"result": 42}}, - _meta={"internal": "metadata"}, - ) - - -@mcp.tool() -def empty_result_tool() -> CallToolResult: - """For empty results, return CallToolResult with empty content.""" - return CallToolResult(content=[]) -``` - -_Full example: [examples/snippets/servers/direct_call_tool_result.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/direct_call_tool_result.py)_ - - -**Important:** `CallToolResult` must always be returned (no `Optional` or `Union`). For empty results, use `CallToolResult(content=[])`. For optional simple types, use `str | None` without `CallToolResult`. - - -```python -"""Example showing structured output with tools.""" - -from typing import TypedDict - -from pydantic import BaseModel, Field - -from mcp.server.mcpserver import MCPServer - -mcp = MCPServer("Structured Output Example") - - -# Using Pydantic models for rich structured data -class WeatherData(BaseModel): - """Weather information structure.""" - - temperature: float = Field(description="Temperature in Celsius") - humidity: float = Field(description="Humidity percentage") - condition: str - wind_speed: float - - -@mcp.tool() -def get_weather(city: str) -> WeatherData: - """Get weather for a city - returns structured data.""" - # Simulated weather data - return WeatherData( - temperature=22.5, - humidity=45.0, - condition="sunny", - wind_speed=5.2, - ) - - -# Using TypedDict for simpler structures -class LocationInfo(TypedDict): - latitude: float - longitude: float - name: str - - -@mcp.tool() -def get_location(address: str) -> LocationInfo: - """Get location coordinates""" - return LocationInfo(latitude=51.5074, longitude=-0.1278, name="London, UK") - - -# Using dict[str, Any] for flexible schemas -@mcp.tool() -def get_statistics(data_type: str) -> dict[str, float]: - """Get various statistics""" - return {"mean": 42.5, "median": 40.0, "std_dev": 5.2} - - -# Ordinary classes with type hints work for structured output -class UserProfile: - name: str - age: int - email: str | None = None - - def __init__(self, name: str, age: int, email: str | None = None): - self.name = name - self.age = age - self.email = email - - -@mcp.tool() -def get_user(user_id: str) -> UserProfile: - """Get user profile - returns structured data""" - return UserProfile(name="Alice", age=30, email="alice@example.com") - - -# Classes WITHOUT type hints cannot be used for structured output -class UntypedConfig: - def __init__(self, setting1, setting2): # type: ignore[reportMissingParameterType] - self.setting1 = setting1 - self.setting2 = setting2 - - -@mcp.tool() -def get_config() -> UntypedConfig: - """This returns unstructured output - no schema generated""" - return UntypedConfig("value1", "value2") - - -# Lists and other types are wrapped automatically -@mcp.tool() -def list_cities() -> list[str]: - """Get a list of cities""" - return ["London", "Paris", "Tokyo"] - # Returns: {"result": ["London", "Paris", "Tokyo"]} - - -@mcp.tool() -def get_temperature(city: str) -> float: - """Get temperature as a simple float""" - return 22.5 - # Returns: {"result": 22.5} -``` - -_Full example: [examples/snippets/servers/structured_output.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/structured_output.py)_ - - -### Prompts - -Prompts are reusable templates that help LLMs interact with your server effectively: - - -```python -from mcp.server.mcpserver import MCPServer -from mcp.server.mcpserver.prompts import base - -mcp = MCPServer(name="Prompt Example") - - -@mcp.prompt(title="Code Review") -def review_code(code: str) -> str: - return f"Please review this code:\n\n{code}" - - -@mcp.prompt(title="Debug Assistant") -def debug_error(error: str) -> list[base.Message]: - return [ - base.UserMessage("I'm seeing this error:"), - base.UserMessage(error), - base.AssistantMessage("I'll help debug that. What have you tried so far?"), - ] -``` - -_Full example: [examples/snippets/servers/basic_prompt.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/basic_prompt.py)_ - - -### Icons - -MCP servers can provide icons for UI display. Icons can be added to the server implementation, tools, resources, and prompts: - -```python -from mcp.server.mcpserver import MCPServer, Icon - -# Create an icon from a file path or URL -icon = Icon( - src="icon.png", - mime_type="image/png", - sizes=["64x64"] -) - -# Add icons to server -mcp = MCPServer( - "My Server", - website_url="https://example.com", - icons=[icon] -) - -# Add icons to tools, resources, and prompts -@mcp.tool(icons=[icon]) -def my_tool(): - """Tool with an icon.""" - return "result" - -@mcp.resource("demo://resource", icons=[icon]) -def my_resource(): - """Resource with an icon.""" - return "content" -``` - -_Full example: [examples/mcpserver/icons_demo.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/mcpserver/icons_demo.py)_ - -### Images - -MCPServer provides an `Image` class that automatically handles image data: - - -```python -"""Example showing image handling with MCPServer.""" - -from PIL import Image as PILImage - -from mcp.server.mcpserver import Image, MCPServer - -mcp = MCPServer("Image Example") - - -@mcp.tool() -def create_thumbnail(image_path: str) -> Image: - """Create a thumbnail from an image""" - img = PILImage.open(image_path) - img.thumbnail((100, 100)) - return Image(data=img.tobytes(), format="png") -``` - -_Full example: [examples/snippets/servers/images.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/images.py)_ - - -### Context - -The Context object is automatically injected into tool and resource functions that request it via type hints. It provides access to MCP capabilities like logging, progress reporting, resource reading, user interaction, and request metadata. - -#### Getting Context in Functions - -To use context in a tool or resource function, add a parameter with the `Context` type annotation: - -```python -from mcp.server.mcpserver import Context, MCPServer - -mcp = MCPServer(name="Context Example") - - -@mcp.tool() -async def my_tool(x: int, ctx: Context) -> str: - """Tool that uses context capabilities.""" - # The context parameter can have any name as long as it's type-annotated - return await process_with_context(x, ctx) +uv run mcp dev server.py ``` -#### Context Properties and Methods - -The Context object provides the following capabilities: - -- `ctx.request_id` - Unique ID for the current request -- `ctx.client_id` - Client ID if available -- `ctx.mcp_server` - Access to the MCPServer server instance (see [MCPServer Properties](#mcpserver-properties)) -- `ctx.session` - Access to the underlying session for advanced communication (see [Session Properties and Methods](#session-properties-and-methods)) -- `ctx.request_context` - Access to request-specific data and lifespan resources (see [Request Context Properties](#request-context-properties)) -- `await ctx.debug(data)` - Send debug log message -- `await ctx.info(data)` - Send info log message -- `await ctx.warning(data)` - Send warning log message -- `await ctx.error(data)` - Send error log message -- `await ctx.log(level, data, logger_name=None)` - Send log with custom level -- `await ctx.report_progress(progress, total=None, message=None)` - Report operation progress -- `await ctx.read_resource(uri)` - Read a resource by URI -- `await ctx.elicit(message, schema)` - Request additional information from user with validation - - -```python -from mcp.server.mcpserver import Context, MCPServer - -mcp = MCPServer(name="Progress Example") - - -@mcp.tool() -async def long_running_task(task_name: str, ctx: Context, steps: int = 5) -> str: - """Execute a task with progress updates.""" - await ctx.info(f"Starting: {task_name}") # pyright: ignore[reportDeprecated] - - for i in range(steps): - progress = (i + 1) / steps - await ctx.report_progress( - progress=progress, - total=1.0, - message=f"Step {i + 1}/{steps}", - ) - await ctx.debug(f"Completed step {i + 1}") # pyright: ignore[reportDeprecated] - - return f"Task '{task_name}' completed" -``` +Call `add` with `a=1`, `b=2` and you get `3` back. -_Full example: [examples/snippets/servers/tool_progress.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/tool_progress.py)_ - +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. -### Completions +[The tutorial](https://py.sdk.modelcontextprotocol.io/v2/tutorial/) takes it from here. -MCP supports providing completion suggestions for prompt arguments and resource template parameters. With the context parameter, servers can provide completions based on previously resolved values: +## A client in 10 lines -Client usage: +The same package is a full MCP **client**. `Client` connects to a URL, a stdio subprocess, a custom transport, or (for tests) straight to a server object in memory with no transport at all: - ```python -"""cd to the `examples/snippets` directory and run: -uv run completion-client -""" - import asyncio -import os - -from mcp_types import PromptReference, ResourceTemplateReference - -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client - -# Create server parameters for stdio connection -server_params = StdioServerParameters( - command="uv", # Using uv to run the server - args=["run", "server", "completion", "stdio"], # Server with completion support - env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, -) +from mcp import Client -async def run(): - """Run the completion client example.""" - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write) as session: - # Initialize the connection - await session.initialize() +from server import mcp - # List available resource templates - templates = await session.list_resource_templates() - print("Available resource templates:") - for template in templates.resource_templates: - print(f" - {template.uri_template}") - # List available prompts - prompts = await session.list_prompts() - print("\nAvailable prompts:") - for prompt in prompts.prompts: - print(f" - {prompt.name}") +async def main() -> None: + async with Client(mcp) as client: + result = await client.call_tool("add", {"a": 1, "b": 2}) + print(result.structured_content) # {'result': 3} - # Complete resource template arguments - if templates.resource_templates: - template = templates.resource_templates[0] - print(f"\nCompleting arguments for resource template: {template.uri_template}") - # Complete without context - result = await session.complete( - ref=ResourceTemplateReference(type="ref/resource", uri=template.uri_template), - argument={"name": "owner", "value": "model"}, - ) - print(f"Completions for 'owner' starting with 'model': {result.completion.values}") - - # Complete with context - repo suggestions based on owner - result = await session.complete( - ref=ResourceTemplateReference(type="ref/resource", uri=template.uri_template), - argument={"name": "repo", "value": ""}, - context_arguments={"owner": "modelcontextprotocol"}, - ) - print(f"Completions for 'repo' with owner='modelcontextprotocol': {result.completion.values}") - - # Complete prompt arguments - if prompts.prompts: - prompt_name = prompts.prompts[0].name - print(f"\nCompleting arguments for prompt: {prompt_name}") +asyncio.run(main()) +``` - result = await session.complete( - ref=PromptReference(type="ref/prompt", name=prompt_name), - argument={"name": "style", "value": ""}, - ) - print(f"Completions for 'style' argument: {result.completion.values}") +Swap `mcp` for `"http://localhost:8000/mcp"` and the exact same code talks to a remote server. +## Contributing -def main(): - """Entry point for the completion client.""" - asyncio.run(run()) +We are passionate about supporting contributors of all levels of experience and would love to see you get involved in the project. See the [contributing guide](https://github.com/modelcontextprotocol/python-sdk/blob/main/CONTRIBUTING.md) to get started. +## License -if __name__ == "__main__": - main() -``` +This project is licensed under the MIT License. See the [LICENSE](https://github.com/modelcontextprotocol/python-sdk/blob/main/LICENSE) file for details. -_Full example: [examples/snippets/clients/completion_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/completion_client.py)_ - -### Elicitation - -Request additional information from users. This example shows an Elicitation during a Tool Call: - - -```python -"""Elicitation examples demonstrating form and URL mode elicitation. - -Form mode elicitation collects structured, non-sensitive data through a schema. -URL mode elicitation directs users to external URLs for sensitive operations -like OAuth flows, credential collection, or payment processing. -""" - -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 - -mcp = MCPServer(name="Elicitation Example") - - -class BookingPreferences(BaseModel): - """Schema for collecting user preferences.""" - - checkAlternative: bool = Field(description="Would you like to check another date?") - alternativeDate: str = Field( - default="2024-12-26", - description="Alternative date (YYYY-MM-DD)", - ) - - -@mcp.tool() -async def book_table(date: str, time: str, party_size: int, ctx: Context) -> str: - """Book a table with date availability check. - - This demonstrates form mode elicitation for collecting non-sensitive user input. - """ - # Check if date is available - if date == "2024-12-25": - # Date unavailable - ask user for alternative - result = await ctx.elicit( - message=(f"No tables available for {party_size} on {date}. Would you like to try another date?"), - schema=BookingPreferences, - ) - - if result.action == "accept" and result.data: - if result.data.checkAlternative: - return f"[SUCCESS] Booked for {result.data.alternativeDate}" - return "[CANCELLED] No booking made" - return "[CANCELLED] Booking cancelled" - - # Date available - return f"[SUCCESS] Booked for {date} at {time}" - - -@mcp.tool() -async def secure_payment(amount: float, ctx: Context) -> str: - """Process a secure payment requiring URL confirmation. - - This demonstrates URL mode elicitation using ctx.elicit_url() for - operations that require out-of-band user interaction. - """ - elicitation_id = str(uuid.uuid4()) - - result = await ctx.elicit_url( - message=f"Please confirm payment of ${amount:.2f}", - url=f"https://payments.example.com/confirm?amount={amount}&id={elicitation_id}", - elicitation_id=elicitation_id, - ) - - if result.action == "accept": - # In a real app, the payment confirmation would happen out-of-band - # and you'd verify the payment status from your backend - return f"Payment of ${amount:.2f} initiated - check your browser to complete" - elif result.action == "decline": - return "Payment declined by user" - return "Payment cancelled" - - -@mcp.tool() -async def connect_service(service_name: str, ctx: Context) -> str: - """Connect to a third-party service requiring OAuth authorization. - - This demonstrates the "throw error" pattern using UrlElicitationRequiredError. - Use this pattern when the tool cannot proceed without user authorization. - """ - elicitation_id = str(uuid.uuid4()) - - # Raise UrlElicitationRequiredError to signal that the client must complete - # a URL elicitation before this request can be processed. - # The MCP framework will convert this to a -32042 error response. - raise UrlElicitationRequiredError( - [ - ElicitRequestURLParams( - mode="url", - message=f"Authorization required to connect to {service_name}", - url=f"https://{service_name}.example.com/oauth/authorize?elicit={elicitation_id}", - elicitation_id=elicitation_id, - ) - ] - ) -``` - -_Full example: [examples/snippets/servers/elicitation.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/elicitation.py)_ - - -Elicitation schemas support default values for all field types. Default values are automatically included in the JSON schema sent to clients, allowing them to pre-populate forms. - -The `elicit()` method returns an `ElicitationResult` with: - -- `action`: "accept", "decline", or "cancel" -- `data`: The validated response (only when accepted) - -If the client returns data that doesn't match the schema, `elicit()` raises a `pydantic.ValidationError`. - -### Sampling - -Tools can interact with LLMs through sampling (generating text): - - -```python -from mcp_types import SamplingMessage, TextContent - -from mcp.server.mcpserver import Context, MCPServer - -mcp = MCPServer(name="Sampling Example") - - -@mcp.tool() -async def generate_poem(topic: str, ctx: Context) -> str: - """Generate a poem using LLM sampling.""" - prompt = f"Write a short poem about {topic}" - - result = await ctx.session.create_message( # pyright: ignore[reportDeprecated] - messages=[ - SamplingMessage( - role="user", - content=TextContent(type="text", text=prompt), - ) - ], - max_tokens=100, - ) - - # Since we're not passing tools param, result.content is single content - if result.content.type == "text": - return result.content.text - return str(result.content) -``` - -_Full example: [examples/snippets/servers/sampling.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/sampling.py)_ - - -### Logging and Notifications - -Tools can send logs and notifications through the context: - - -```python -from mcp.server.mcpserver import Context, MCPServer - -mcp = MCPServer(name="Notifications Example") - - -@mcp.tool() -async def process_data(data: str, ctx: Context) -> str: - """Process data with logging.""" - # Different log levels - await ctx.debug(f"Debug: Processing '{data}'") # pyright: ignore[reportDeprecated] - await ctx.info("Info: Starting processing") # pyright: ignore[reportDeprecated] - await ctx.warning("Warning: This is experimental") # pyright: ignore[reportDeprecated] - await ctx.error("Error: (This is just a demo)") # pyright: ignore[reportDeprecated] - - # Notify about resource changes - await ctx.session.send_resource_list_changed() - - return f"Processed: {data}" -``` - -_Full example: [examples/snippets/servers/notifications.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/notifications.py)_ - - -### Authentication - -Authentication can be used by servers that want to expose tools accessing protected resources. - -`mcp.server.auth` implements OAuth 2.1 resource server functionality, where MCP servers act as Resource Servers (RS) that validate tokens issued by separate Authorization Servers (AS). This follows the [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization) and implements RFC 9728 (Protected Resource Metadata) for AS discovery. - -MCP servers can use authentication by providing an implementation of the `TokenVerifier` protocol: - - -```python -"""Run from the repository root: -uv run examples/snippets/servers/oauth_server.py -""" - -from pydantic import AnyHttpUrl - -from mcp.server.auth.provider import AccessToken, TokenVerifier -from mcp.server.auth.settings import AuthSettings -from mcp.server.mcpserver import MCPServer - - -class SimpleTokenVerifier(TokenVerifier): - """Simple token verifier for demonstration.""" - - async def verify_token(self, token: str) -> AccessToken | None: - pass # This is where you would implement actual token validation - - -# Create MCPServer instance as a Resource Server -mcp = MCPServer( - "Weather Service", - # Token verifier for authentication - token_verifier=SimpleTokenVerifier(), - # Auth settings for RFC 9728 Protected Resource Metadata - auth=AuthSettings( - issuer_url=AnyHttpUrl("https://auth.example.com"), # Authorization Server URL - resource_server_url=AnyHttpUrl("http://localhost:3001"), # This server's URL - required_scopes=["user"], - ), -) - - -@mcp.tool() -async def get_weather(city: str = "London") -> dict[str, str]: - """Get weather data for a city""" - return { - "city": city, - "temperature": "22", - "condition": "Partly cloudy", - "humidity": "65%", - } - - -if __name__ == "__main__": - mcp.run(transport="streamable-http", json_response=True) -``` - -_Full example: [examples/snippets/servers/oauth_server.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/oauth_server.py)_ - - -For a complete example with separate Authorization Server and Resource Server implementations, see [`examples/servers/simple-auth/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/servers/simple-auth/). - -**Architecture:** - -- **Authorization Server (AS)**: Handles OAuth flows, user authentication, and token issuance -- **Resource Server (RS)**: Your MCP server that validates tokens and serves protected resources -- **Client**: Discovers AS through RFC 9728, obtains tokens, and uses them with the MCP server - -See [TokenVerifier](https://github.com/modelcontextprotocol/python-sdk/blob/main/src/mcp/server/auth/provider.py) for more details on implementing token validation. - -### MCPServer Properties - -The MCPServer server instance accessible via `ctx.mcp_server` provides access to server configuration and metadata: - -- `ctx.mcp_server.name` - The server's name as defined during initialization -- `ctx.mcp_server.instructions` - Server instructions/description provided to clients -- `ctx.mcp_server.website_url` - Optional website URL for the server -- `ctx.mcp_server.icons` - Optional list of icons for UI display -- `ctx.mcp_server.settings` - Complete server configuration object containing: - - `debug` - Debug mode flag - - `log_level` - Current logging level - - `host` and `port` - Server network configuration - - `sse_path`, `streamable_http_path` - Transport paths - - `stateless_http` - Whether the server operates in stateless mode - - And other configuration options - -```python -@mcp.tool() -def server_info(ctx: Context) -> dict: - """Get information about the current server.""" - return { - "name": ctx.mcp_server.name, - "instructions": ctx.mcp_server.instructions, - "debug_mode": ctx.mcp_server.settings.debug, - "log_level": ctx.mcp_server.settings.log_level, - "host": ctx.mcp_server.settings.host, - "port": ctx.mcp_server.settings.port, - } -``` - -### Session Properties and Methods - -The session object accessible via `ctx.session` provides advanced control over client communication: - -- `ctx.session.client_params` - Client initialization parameters and declared capabilities -- `await ctx.session.send_log_message(level, data, logger)` - Send log messages with full control -- `await ctx.session.create_message(messages, max_tokens=...)` - Request LLM sampling/completion (`max_tokens` is keyword-only) -- `await ctx.session.send_progress_notification(token, progress, total, message)` - Direct progress updates -- `await ctx.session.send_resource_updated(uri)` - Notify clients that a specific resource changed -- `await ctx.session.send_resource_list_changed()` - Notify clients that the resource list changed -- `await ctx.session.send_tool_list_changed()` - Notify clients that the tool list changed -- `await ctx.session.send_prompt_list_changed()` - Notify clients that the prompt list changed - -```python -@mcp.tool() -async def notify_data_update(resource_uri: str, ctx: Context) -> str: - """Update data and notify clients of the change.""" - # Perform data update logic here - - # Notify clients that this specific resource changed - await ctx.session.send_resource_updated(AnyUrl(resource_uri)) - - # If this affects the overall resource list, notify about that too - await ctx.session.send_resource_list_changed() - - return f"Updated {resource_uri} and notified clients" -``` - -### Request Context Properties - -The request context accessible via `ctx.request_context` contains request-specific information and resources: - -- `ctx.request_context.lifespan_context` - Access to resources initialized during server startup - - Database connections, configuration objects, shared services - - Type-safe access to resources defined in your server's lifespan function -- `ctx.request_context.meta` - Request metadata from the client including: - - `progress_token` - Token for progress notifications - - Other client-provided metadata -- `ctx.request_context.request` - Data the transport attached to this message (for example the HTTP request object on HTTP transports; `None` on stdio) -- `ctx.request_context.request_id` - Unique identifier for this request - -```python -# Example with typed lifespan context -@dataclass -class AppContext: - db: Database - config: AppConfig - -@mcp.tool() -def query_with_config(query: str, ctx: Context) -> str: - """Execute a query using shared database and configuration.""" - # Access typed lifespan context - app_ctx: AppContext = ctx.request_context.lifespan_context - - # Use shared resources - connection = app_ctx.db - settings = app_ctx.config - - # Execute query with configuration - result = connection.execute(query, timeout=settings.query_timeout) - return str(result) -``` - -_Full lifespan example: [examples/snippets/servers/lifespan_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lifespan_example.py)_ - -## Running Your Server - -### Development Mode - -The fastest way to test and debug your server is with the MCP Inspector: - -```bash -uv run mcp dev server.py - -# Add dependencies -uv run mcp dev server.py --with pandas --with numpy - -# Mount local code -uv run mcp dev server.py --with-editable . -``` - -### Claude Desktop Integration - -Once your server is ready, install it in Claude Desktop: - -```bash -uv run mcp install server.py - -# Custom name -uv run mcp install server.py --name "My Analytics Server" - -# Environment variables -uv run mcp install server.py -v API_KEY=abc123 -v DB_URL=postgres://... -uv run mcp install server.py -f .env -``` - -### Direct Execution - -For advanced scenarios like custom deployments: - - -```python -"""Example showing direct execution of an MCP server. - -This is the simplest way to run an MCP server directly. -cd to the `examples/snippets` directory and run: - uv run direct-execution-server - or - python servers/direct_execution.py -""" - -from mcp.server.mcpserver import MCPServer - -mcp = MCPServer("My App") - - -@mcp.tool() -def hello(name: str = "World") -> str: - """Say hello to someone.""" - return f"Hello, {name}!" - - -def main(): - """Entry point for the direct execution server.""" - mcp.run() - - -if __name__ == "__main__": - main() -``` - -_Full example: [examples/snippets/servers/direct_execution.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/direct_execution.py)_ - - -Run it with: - -```bash -python servers/direct_execution.py -# or -uv run mcp run servers/direct_execution.py -``` - -Note that `uv run mcp run` or `uv run mcp dev` only supports server using MCPServer and not the low-level server variant. - -### Streamable HTTP Transport - -> **Note**: Streamable HTTP transport is the recommended transport for production deployments. Use `stateless_http=True` and `json_response=True` for optimal scalability. - - -```python -"""Run from the repository root: -uv run examples/snippets/servers/streamable_config.py -""" - -from mcp.server.mcpserver import MCPServer - -mcp = MCPServer("StatelessServer") - - -# Add a simple tool to demonstrate the server -@mcp.tool() -def greet(name: str = "World") -> str: - """Greet someone by name.""" - return f"Hello, {name}!" - - -# Run server with streamable_http transport -# Transport-specific options (stateless_http, json_response) are passed to run() -if __name__ == "__main__": - # Stateless server with JSON responses (recommended) - mcp.run(transport="streamable-http", stateless_http=True, json_response=True) - - # Other configuration options: - # Stateless server with SSE streaming responses - # mcp.run(transport="streamable-http", stateless_http=True) - - # Stateful server with session persistence - # mcp.run(transport="streamable-http") -``` - -_Full example: [examples/snippets/servers/streamable_config.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_config.py)_ - - -You can mount multiple MCPServer servers in a Starlette application: - - -```python -"""Run from the repository root: -uvicorn examples.snippets.servers.streamable_starlette_mount:app --reload -""" - -import contextlib - -from starlette.applications import Starlette -from starlette.routing import Mount - -from mcp.server.mcpserver import MCPServer - -# Create the Echo server -echo_mcp = MCPServer(name="EchoServer") - - -@echo_mcp.tool() -def echo(message: str) -> str: - """A simple echo tool""" - return f"Echo: {message}" - - -# Create the Math server -math_mcp = MCPServer(name="MathServer") - - -@math_mcp.tool() -def add_two(n: int) -> int: - """Tool to add two to the input""" - return n + 2 - - -# Create a combined lifespan to manage both session managers -@contextlib.asynccontextmanager -async def lifespan(app: Starlette): - async with contextlib.AsyncExitStack() as stack: - await stack.enter_async_context(echo_mcp.session_manager.run()) - await stack.enter_async_context(math_mcp.session_manager.run()) - yield - - -# Create the Starlette app and mount the MCP servers -app = Starlette( - routes=[ - Mount("/echo", echo_mcp.streamable_http_app(stateless_http=True, json_response=True)), - Mount("/math", math_mcp.streamable_http_app(stateless_http=True, json_response=True)), - ], - lifespan=lifespan, -) - -# Note: Clients connect to http://localhost:8000/echo/mcp and http://localhost:8000/math/mcp -# To mount at the root of each path (e.g., /echo instead of /echo/mcp): -# echo_mcp.streamable_http_app(streamable_http_path="/", stateless_http=True, json_response=True) -# math_mcp.streamable_http_app(streamable_http_path="/", stateless_http=True, json_response=True) -``` - -_Full example: [examples/snippets/servers/streamable_starlette_mount.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_starlette_mount.py)_ - - -For low level server with Streamable HTTP implementations, see: - -- Stateful server: [`examples/servers/simple-streamablehttp/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/servers/simple-streamablehttp/) -- Stateless server: [`examples/servers/simple-streamablehttp-stateless/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/servers/simple-streamablehttp-stateless/) - -The streamable HTTP transport supports: - -- Stateful and stateless operation modes -- Resumability with event stores -- JSON or SSE response formats -- Better scalability for multi-node deployments - -#### CORS Configuration for Browser-Based Clients - -If you'd like your server to be accessible by browser-based MCP clients, you'll need to configure CORS headers. The `Mcp-Session-Id` header must be exposed for browser clients to access it: - -```python -from starlette.applications import Starlette -from starlette.middleware.cors import CORSMiddleware - -# Create your Starlette app first -starlette_app = Starlette(routes=[...]) - -# Then wrap it with CORS middleware -starlette_app = CORSMiddleware( - starlette_app, - allow_origins=["*"], # Configure appropriately for production - allow_methods=["GET", "POST", "DELETE"], # MCP streamable HTTP methods - expose_headers=["Mcp-Session-Id"], -) -``` - -This configuration is necessary because: - -- The MCP streamable HTTP transport uses the `Mcp-Session-Id` header for session management -- Browsers restrict access to response headers unless explicitly exposed via CORS -- Without this configuration, browser-based clients won't be able to read the session ID from initialization responses - -### Mounting to an Existing ASGI Server - -By default, SSE servers are mounted at `/sse` and Streamable HTTP servers are mounted at `/mcp`. You can customize these paths using the methods described below. - -For more information on mounting applications in Starlette, see the [Starlette documentation](https://www.starlette.io/routing/#submounting-routes). - -#### StreamableHTTP servers - -You can mount the StreamableHTTP server to an existing ASGI server using the `streamable_http_app` method. This allows you to integrate the StreamableHTTP server with other ASGI applications. - -##### Basic mounting - - -```python -"""Basic example showing how to mount StreamableHTTP server in Starlette. - -Run from the repository root: - uvicorn examples.snippets.servers.streamable_http_basic_mounting:app --reload -""" - -import contextlib - -from starlette.applications import Starlette -from starlette.routing import Mount - -from mcp.server.mcpserver import MCPServer - -# Create MCP server -mcp = MCPServer("My App") - - -@mcp.tool() -def hello() -> str: - """A simple hello tool""" - return "Hello from MCP!" - - -# Create a lifespan context manager to run the session manager -@contextlib.asynccontextmanager -async def lifespan(app: Starlette): - async with mcp.session_manager.run(): - yield - - -# Mount the StreamableHTTP server to the existing ASGI server -# Transport-specific options are passed to streamable_http_app() -app = Starlette( - routes=[ - Mount("/", app=mcp.streamable_http_app(json_response=True)), - ], - lifespan=lifespan, -) -``` - -_Full example: [examples/snippets/servers/streamable_http_basic_mounting.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_basic_mounting.py)_ - - -##### Host-based routing - - -```python -"""Example showing how to mount StreamableHTTP server using Host-based routing. - -Run from the repository root: - uvicorn examples.snippets.servers.streamable_http_host_mounting:app --reload -""" - -import contextlib - -from starlette.applications import Starlette -from starlette.routing import Host - -from mcp.server.mcpserver import MCPServer - -# Create MCP server -mcp = MCPServer("MCP Host App") - - -@mcp.tool() -def domain_info() -> str: - """Get domain-specific information""" - return "This is served from mcp.acme.corp" - - -# Create a lifespan context manager to run the session manager -@contextlib.asynccontextmanager -async def lifespan(app: Starlette): - async with mcp.session_manager.run(): - yield - - -# Mount using Host-based routing -# Transport-specific options are passed to streamable_http_app() -app = Starlette( - routes=[ - Host("mcp.acme.corp", app=mcp.streamable_http_app(json_response=True)), - ], - lifespan=lifespan, -) -``` - -_Full example: [examples/snippets/servers/streamable_http_host_mounting.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_host_mounting.py)_ - - -##### Multiple servers with path configuration - - -```python -"""Example showing how to mount multiple StreamableHTTP servers with path configuration. - -Run from the repository root: - uvicorn examples.snippets.servers.streamable_http_multiple_servers:app --reload -""" - -import contextlib - -from starlette.applications import Starlette -from starlette.routing import Mount - -from mcp.server.mcpserver import MCPServer - -# Create multiple MCP servers -api_mcp = MCPServer("API Server") -chat_mcp = MCPServer("Chat Server") - - -@api_mcp.tool() -def api_status() -> str: - """Get API status""" - return "API is running" - - -@chat_mcp.tool() -def send_message(message: str) -> str: - """Send a chat message""" - return f"Message sent: {message}" - - -# Create a combined lifespan to manage both session managers -@contextlib.asynccontextmanager -async def lifespan(app: Starlette): - async with contextlib.AsyncExitStack() as stack: - await stack.enter_async_context(api_mcp.session_manager.run()) - await stack.enter_async_context(chat_mcp.session_manager.run()) - yield - - -# Mount the servers with transport-specific options passed to streamable_http_app() -# streamable_http_path="/" means endpoints will be at /api and /chat instead of /api/mcp and /chat/mcp -app = Starlette( - routes=[ - Mount("/api", app=api_mcp.streamable_http_app(json_response=True, streamable_http_path="/")), - Mount("/chat", app=chat_mcp.streamable_http_app(json_response=True, streamable_http_path="/")), - ], - lifespan=lifespan, -) -``` - -_Full example: [examples/snippets/servers/streamable_http_multiple_servers.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_multiple_servers.py)_ - - -##### Path configuration at initialization - - -```python -"""Example showing path configuration when mounting MCPServer. - -Run from the repository root: - uvicorn examples.snippets.servers.streamable_http_path_config:app --reload -""" - -from starlette.applications import Starlette -from starlette.routing import Mount - -from mcp.server.mcpserver import MCPServer - -# Create a simple MCPServer server -mcp_at_root = MCPServer("My Server") - - -@mcp_at_root.tool() -def process_data(data: str) -> str: - """Process some data""" - return f"Processed: {data}" - - -# Mount at /process with streamable_http_path="/" so the endpoint is /process (not /process/mcp) -# Transport-specific options like json_response are passed to streamable_http_app() -app = Starlette( - routes=[ - Mount( - "/process", - app=mcp_at_root.streamable_http_app(json_response=True, streamable_http_path="/"), - ), - ] -) -``` - -_Full example: [examples/snippets/servers/streamable_http_path_config.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_path_config.py)_ - - -#### SSE servers - -> **Note**: SSE transport is being superseded by [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http). - -You can mount the SSE server to an existing ASGI server using the `sse_app` method. This allows you to integrate the SSE server with other ASGI applications. - -```python -from starlette.applications import Starlette -from starlette.routing import Mount, Host -from mcp.server.mcpserver import MCPServer - - -mcp = MCPServer("My App") - -# Mount the SSE server to the existing ASGI server -app = Starlette( - routes=[ - Mount('/', app=mcp.sse_app()), - ] -) - -# or dynamically mount as host -app.router.routes.append(Host('mcp.acme.corp', app=mcp.sse_app())) -``` - -You can also mount multiple MCP servers at different sub-paths. The SSE transport automatically detects the mount path via ASGI's `root_path` mechanism, so message endpoints are correctly routed: - -```python -from starlette.applications import Starlette -from starlette.routing import Mount -from mcp.server.mcpserver import MCPServer - -# Create multiple MCP servers -github_mcp = MCPServer("GitHub API") -browser_mcp = MCPServer("Browser") -search_mcp = MCPServer("Search") - -# Mount each server at its own sub-path -# The SSE transport automatically uses ASGI's root_path to construct -# the correct message endpoint (e.g., /github/messages/, /browser/messages/) -app = Starlette( - routes=[ - Mount("/github", app=github_mcp.sse_app()), - Mount("/browser", app=browser_mcp.sse_app()), - Mount("/search", app=search_mcp.sse_app()), - ] -) -``` - -For more information on mounting applications in Starlette, see the [Starlette documentation](https://www.starlette.io/routing/#submounting-routes). - -## Advanced Usage - -### Low-Level Server - -For more control, you can use the low-level server implementation directly. This gives you full access to the protocol and allows you to customize every aspect of your server, including lifecycle management through the lifespan API: - - -```python -"""Run from the repository root: -uv run examples/snippets/servers/lowlevel/lifespan.py -""" - -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from typing import TypedDict - -import mcp_types as types - -import mcp.server.stdio -from mcp.server import Server, ServerRequestContext - - -# Mock database class for example -class Database: - """Mock database class for example.""" - - @classmethod - async def connect(cls) -> "Database": - """Connect to database.""" - print("Database connected") - return cls() - - async def disconnect(self) -> None: - """Disconnect from database.""" - print("Database disconnected") - - async def query(self, query_str: str) -> list[dict[str, str]]: - """Execute a query.""" - # Simulate database query - return [{"id": "1", "name": "Example", "query": query_str}] - - -class AppContext(TypedDict): - db: Database - - -@asynccontextmanager -async def server_lifespan(_server: Server[AppContext]) -> AsyncIterator[AppContext]: - """Manage server startup and shutdown lifecycle.""" - db = await Database.connect() - try: - yield {"db": db} - finally: - await db.disconnect() - - -async def handle_list_tools( - ctx: ServerRequestContext[AppContext], params: types.PaginatedRequestParams | None -) -> types.ListToolsResult: - """List available tools.""" - return types.ListToolsResult( - tools=[ - types.Tool( - name="query_db", - description="Query the database", - input_schema={ - "type": "object", - "properties": {"query": {"type": "string", "description": "SQL query to execute"}}, - "required": ["query"], - }, - ) - ] - ) - - -async def handle_call_tool( - ctx: ServerRequestContext[AppContext], params: types.CallToolRequestParams -) -> types.CallToolResult: - """Handle database query tool call.""" - if params.name != "query_db": - raise ValueError(f"Unknown tool: {params.name}") - - db = ctx.lifespan_context["db"] - results = await db.query((params.arguments or {})["query"]) - - return types.CallToolResult(content=[types.TextContent(type="text", text=f"Query results: {results}")]) - - -server = Server( - "example-server", - lifespan=server_lifespan, - on_list_tools=handle_list_tools, - on_call_tool=handle_call_tool, -) - - -async def run(): - """Run the server with lifespan management.""" - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - server.create_initialization_options(), - ) - - -if __name__ == "__main__": - import asyncio - - asyncio.run(run()) -``` - -_Full example: [examples/snippets/servers/lowlevel/lifespan.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/lifespan.py)_ - - -The lifespan API provides: - -- A way to initialize resources when the server starts and clean them up when it stops -- Access to initialized resources through the request context in handlers -- Type-safe context passing between lifespan and request handlers - - -```python -"""Run from the repository root: -uv run examples/snippets/servers/lowlevel/basic.py -""" - -import asyncio - -import mcp_types as types - -import mcp.server.stdio -from mcp.server import Server, ServerRequestContext - - -async def handle_list_prompts( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListPromptsResult: - """List available prompts.""" - return types.ListPromptsResult( - prompts=[ - types.Prompt( - name="example-prompt", - description="An example prompt template", - arguments=[types.PromptArgument(name="arg1", description="Example argument", required=True)], - ) - ] - ) - - -async def handle_get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> types.GetPromptResult: - """Get a specific prompt by name.""" - if params.name != "example-prompt": - raise ValueError(f"Unknown prompt: {params.name}") - - arg1_value = (params.arguments or {}).get("arg1", "default") - - return types.GetPromptResult( - description="Example prompt", - messages=[ - types.PromptMessage( - role="user", - content=types.TextContent(type="text", text=f"Example prompt text with argument: {arg1_value}"), - ) - ], - ) - - -server = Server( - "example-server", - on_list_prompts=handle_list_prompts, - on_get_prompt=handle_get_prompt, -) - - -async def run(): - """Run the basic low-level server.""" - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - server.create_initialization_options(), - ) - - -if __name__ == "__main__": - asyncio.run(run()) -``` - -_Full example: [examples/snippets/servers/lowlevel/basic.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/basic.py)_ - - -Caution: The `uv run mcp run` and `uv run mcp dev` tool doesn't support low-level server. - -#### Structured Output Support - -The low-level server supports structured output for tools, allowing you to return both human-readable content and machine-readable structured data. Tools can define an `outputSchema` to validate their structured output: - - -```python -"""Run from the repository root: -uv run examples/snippets/servers/lowlevel/structured_output.py -""" - -import asyncio -import json - -import mcp_types as types - -import mcp.server.stdio -from mcp.server import Server, ServerRequestContext - - -async def handle_list_tools( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListToolsResult: - """List available tools with structured output schemas.""" - return types.ListToolsResult( - tools=[ - types.Tool( - name="get_weather", - description="Get current weather for a city", - input_schema={ - "type": "object", - "properties": {"city": {"type": "string", "description": "City name"}}, - "required": ["city"], - }, - output_schema={ - "type": "object", - "properties": { - "temperature": {"type": "number", "description": "Temperature in Celsius"}, - "condition": {"type": "string", "description": "Weather condition"}, - "humidity": {"type": "number", "description": "Humidity percentage"}, - "city": {"type": "string", "description": "City name"}, - }, - "required": ["temperature", "condition", "humidity", "city"], - }, - ) - ] - ) - - -async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: - """Handle tool calls with structured output.""" - if params.name == "get_weather": - city = (params.arguments or {})["city"] - - weather_data = { - "temperature": 22.5, - "condition": "partly cloudy", - "humidity": 65, - "city": city, - } - - return types.CallToolResult( - content=[types.TextContent(type="text", text=json.dumps(weather_data, indent=2))], - structured_content=weather_data, - ) - - raise ValueError(f"Unknown tool: {params.name}") - - -server = Server( - "example-server", - on_list_tools=handle_list_tools, - on_call_tool=handle_call_tool, -) - - -async def run(): - """Run the structured output server.""" - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - server.create_initialization_options(), - ) - - -if __name__ == "__main__": - asyncio.run(run()) -``` - -_Full example: [examples/snippets/servers/lowlevel/structured_output.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/structured_output.py)_ - - -With the low-level server, handlers always return `CallToolResult` directly. You construct both the human-readable `content` and the machine-readable `structured_content` yourself, giving you full control over the response. - -##### Returning CallToolResult with `_meta` - -For passing data to client applications without exposing it to the model, use the `_meta` field on `CallToolResult`: - - -```python -"""Run from the repository root: -uv run examples/snippets/servers/lowlevel/direct_call_tool_result.py -""" - -import asyncio - -import mcp_types as types - -import mcp.server.stdio -from mcp.server import Server, ServerRequestContext - - -async def handle_list_tools( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListToolsResult: - """List available tools.""" - return types.ListToolsResult( - tools=[ - types.Tool( - name="advanced_tool", - description="Tool with full control including _meta field", - input_schema={ - "type": "object", - "properties": {"message": {"type": "string"}}, - "required": ["message"], - }, - ) - ] - ) - - -async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: - """Handle tool calls by returning CallToolResult directly.""" - if params.name == "advanced_tool": - message = (params.arguments or {}).get("message", "") - return types.CallToolResult( - content=[types.TextContent(type="text", text=f"Processed: {message}")], - structured_content={"result": "success", "message": message}, - _meta={"hidden": "data for client applications only"}, - ) - - raise ValueError(f"Unknown tool: {params.name}") - - -server = Server( - "example-server", - on_list_tools=handle_list_tools, - on_call_tool=handle_call_tool, -) - - -async def run(): - """Run the server.""" - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - server.create_initialization_options(), - ) - - -if __name__ == "__main__": - asyncio.run(run()) -``` - -_Full example: [examples/snippets/servers/lowlevel/direct_call_tool_result.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/direct_call_tool_result.py)_ - - -### Pagination (Advanced) - -For servers that need to handle large datasets, the low-level server provides paginated versions of list operations. This is an optional optimization - most servers won't need pagination unless they're dealing with hundreds or thousands of items. - -#### Server-side Implementation - - -```python -"""Example of implementing pagination with the low-level MCP server.""" - -import mcp_types as types - -from mcp.server import Server, ServerRequestContext - -# Sample data to paginate -ITEMS = [f"Item {i}" for i in range(1, 101)] # 100 items - - -async def handle_list_resources( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListResourcesResult: - """List resources with pagination support.""" - page_size = 10 - - # Extract cursor from request params - cursor = params.cursor if params is not None else None - - # Parse cursor to get offset - start = 0 if cursor is None else int(cursor) - end = start + page_size - - # Get page of resources - page_items = [ - types.Resource(uri=f"resource://items/{item}", name=item, description=f"Description for {item}") - for item in ITEMS[start:end] - ] - - # Determine next cursor - next_cursor = str(end) if end < len(ITEMS) else None - - return types.ListResourcesResult(resources=page_items, next_cursor=next_cursor) - - -server = Server("paginated-server", on_list_resources=handle_list_resources) -``` - -_Full example: [examples/snippets/servers/pagination_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/pagination_example.py)_ - - -#### Client-side Consumption - - -```python -"""Example of consuming paginated MCP endpoints from a client.""" - -import asyncio - -from mcp_types import PaginatedRequestParams, Resource - -from mcp.client.session import ClientSession -from mcp.client.stdio import StdioServerParameters, stdio_client - - -async def list_all_resources() -> None: - """Fetch all resources using pagination.""" - async with stdio_client(StdioServerParameters(command="uv", args=["run", "mcp-simple-pagination"])) as ( - read, - write, - ): - async with ClientSession(read, write) as session: - await session.initialize() - - all_resources: list[Resource] = [] - cursor = None - - while True: - # Fetch a page of resources - result = await session.list_resources(params=PaginatedRequestParams(cursor=cursor)) - all_resources.extend(result.resources) - - print(f"Fetched {len(result.resources)} resources") - - # Check if there are more pages - if result.next_cursor: - cursor = result.next_cursor - else: - break - - print(f"Total resources: {len(all_resources)}") - - -if __name__ == "__main__": - asyncio.run(list_all_resources()) -``` - -_Full example: [examples/snippets/clients/pagination_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/pagination_client.py)_ - - -#### Key Points - -- **Cursors are opaque strings** - the server defines the format (numeric offsets, timestamps, etc.) -- **Return `nextCursor=None`** when there are no more pages -- **Backward compatible** - clients that don't support pagination will still work (they'll just get the first page) -- **Flexible page sizes** - Each endpoint can define its own page size based on data characteristics - -See the [simple-pagination example](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/servers/simple-pagination) for a complete implementation. - -### Writing MCP Clients - -The SDK provides a high-level client interface for connecting to MCP servers using various [transports](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports): - - -```python -"""cd to the `examples/snippets/clients` directory and run: -uv run client -""" - -import asyncio -import os - -import mcp_types as types - -from mcp import ClientSession, StdioServerParameters -from mcp.client.context import ClientRequestContext -from mcp.client.stdio import stdio_client - -# Create server parameters for stdio connection -server_params = StdioServerParameters( - command="uv", # Using uv to run the server - args=["run", "server", "mcpserver_quickstart", "stdio"], # We're already in snippets dir - env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, -) - - -# Optional: create a sampling callback -async def handle_sampling_message( - context: ClientRequestContext, params: types.CreateMessageRequestParams -) -> types.CreateMessageResult: - print(f"Sampling request: {params.messages}") - return types.CreateMessageResult( - role="assistant", - content=types.TextContent( - type="text", - text="Hello, world! from model", - ), - model="gpt-3.5-turbo", - stop_reason="endTurn", - ) - - -async def run(): - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write, sampling_callback=handle_sampling_message) as session: - # Initialize the connection - await session.initialize() - - # List available prompts - prompts = await session.list_prompts() - print(f"Available prompts: {[p.name for p in prompts.prompts]}") - - # Get a prompt (greet_user prompt from mcpserver_quickstart) - if prompts.prompts: - prompt = await session.get_prompt("greet_user", arguments={"name": "Alice", "style": "friendly"}) - print(f"Prompt result: {prompt.messages[0].content}") - - # List available resources - resources = await session.list_resources() - print(f"Available resources: {[r.uri for r in resources.resources]}") - - # List available tools - tools = await session.list_tools() - print(f"Available tools: {[t.name for t in tools.tools]}") - - # Read a resource (greeting resource from mcpserver_quickstart) - resource_content = await session.read_resource("greeting://World") - content_block = resource_content.contents[0] - if isinstance(content_block, types.TextResourceContents): - print(f"Resource content: {content_block.text}") - - # Call a tool (add tool from mcpserver_quickstart) - result = await session.call_tool("add", arguments={"a": 5, "b": 3}) - result_unstructured = result.content[0] - if isinstance(result_unstructured, types.TextContent): - print(f"Tool result: {result_unstructured.text}") - result_structured = result.structured_content - print(f"Structured tool result: {result_structured}") - - -def main(): - """Entry point for the client script.""" - asyncio.run(run()) - - -if __name__ == "__main__": - main() -``` - -_Full example: [examples/snippets/clients/stdio_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/stdio_client.py)_ - - -Clients can also connect using [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http): - - -```python -"""Run from the repository root: -uv run examples/snippets/clients/streamable_basic.py -""" - -import asyncio - -from mcp import ClientSession -from mcp.client.streamable_http import streamable_http_client - - -async def main(): - # Connect to a streamable HTTP server - async with streamable_http_client("http://localhost:8000/mcp") as (read_stream, write_stream): - # Create a session using the client streams - async with ClientSession(read_stream, write_stream) as session: - # Initialize the connection - await session.initialize() - # List available tools - tools = await session.list_tools() - print(f"Available tools: {[tool.name for tool in tools.tools]}") - - -if __name__ == "__main__": - asyncio.run(main()) -``` - -_Full example: [examples/snippets/clients/streamable_basic.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/streamable_basic.py)_ - - -### Client Display Utilities - -When building MCP clients, the SDK provides utilities to help display human-readable names for tools, resources, and prompts: - - -```python -"""cd to the `examples/snippets` directory and run: -uv run display-utilities-client -""" - -import asyncio -import os - -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client -from mcp.shared.metadata_utils import get_display_name - -# Create server parameters for stdio connection -server_params = StdioServerParameters( - command="uv", # Using uv to run the server - args=["run", "server", "mcpserver_quickstart", "stdio"], - env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, -) - - -async def display_tools(session: ClientSession): - """Display available tools with human-readable names""" - tools_response = await session.list_tools() - - for tool in tools_response.tools: - # get_display_name() returns the title if available, otherwise the name - display_name = get_display_name(tool) - print(f"Tool: {display_name}") - if tool.description: - print(f" {tool.description}") - - -async def display_resources(session: ClientSession): - """Display available resources with human-readable names""" - resources_response = await session.list_resources() - - for resource in resources_response.resources: - display_name = get_display_name(resource) - print(f"Resource: {display_name} ({resource.uri})") - - templates_response = await session.list_resource_templates() - for template in templates_response.resource_templates: - display_name = get_display_name(template) - print(f"Resource Template: {display_name}") - - -async def run(): - """Run the display utilities example.""" - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write) as session: - # Initialize the connection - await session.initialize() - - print("=== Available Tools ===") - await display_tools(session) - - print("\n=== Available Resources ===") - await display_resources(session) - - -def main(): - """Entry point for the display utilities client.""" - asyncio.run(run()) - - -if __name__ == "__main__": - main() -``` - -_Full example: [examples/snippets/clients/display_utilities.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/display_utilities.py)_ - - -The `get_display_name()` function implements the proper precedence rules for displaying names: - -- For tools: `title` > `annotations.title` > `name` -- For other objects: `title` > `name` - -This ensures your client UI shows the most user-friendly names that servers provide. - -### OAuth Authentication for Clients - -The SDK includes [authorization support](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization) for connecting to protected MCP servers: - - -```python -"""Before running, specify running MCP RS server URL. -To spin up RS server locally, see - examples/servers/simple-auth/README.md - -cd to the `examples/snippets` directory and run: - uv run oauth-client -""" - -import asyncio -from urllib.parse import parse_qs, urlparse - -import httpx -from pydantic import AnyUrl - -from mcp import ClientSession -from mcp.client.auth import AuthorizationCodeResult, OAuthClientProvider, TokenStorage -from mcp.client.streamable_http import streamable_http_client -from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken - - -class InMemoryTokenStorage(TokenStorage): - """Demo In-memory token storage implementation.""" - - def __init__(self): - self.tokens: OAuthToken | None = None - self.client_info: OAuthClientInformationFull | None = None - - async def get_tokens(self) -> OAuthToken | None: - """Get stored tokens.""" - return self.tokens - - async def set_tokens(self, tokens: OAuthToken) -> None: - """Store tokens.""" - self.tokens = tokens - - async def get_client_info(self) -> OAuthClientInformationFull | None: - """Get stored client information.""" - return self.client_info - - async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: - """Store client information.""" - self.client_info = client_info - - -async def handle_redirect(auth_url: str) -> None: - print(f"Visit: {auth_url}") - - -async def handle_callback() -> AuthorizationCodeResult: - callback_url = input("Paste callback URL: ") - params = parse_qs(urlparse(callback_url).query) - return AuthorizationCodeResult( - code=params["code"][0], - state=params.get("state", [None])[0], - iss=params.get("iss", [None])[0], - ) - - -async def main(): - """Run the OAuth client example.""" - oauth_auth = OAuthClientProvider( - server_url="http://localhost:8001", - client_metadata=OAuthClientMetadata( - client_name="Example MCP Client", - redirect_uris=[AnyUrl("http://localhost:3000/callback")], - grant_types=["authorization_code", "refresh_token"], - response_types=["code"], - scope="user", - ), - storage=InMemoryTokenStorage(), - redirect_handler=handle_redirect, - callback_handler=handle_callback, - ) - - async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client: - async with streamable_http_client("http://localhost:8001/mcp", http_client=custom_client) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - - tools = await session.list_tools() - print(f"Available tools: {[tool.name for tool in tools.tools]}") - - resources = await session.list_resources() - print(f"Available resources: {[r.uri for r in resources.resources]}") - - -def run(): - asyncio.run(main()) - - -if __name__ == "__main__": - run() -``` - -_Full example: [examples/snippets/clients/oauth_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/oauth_client.py)_ - - -For a complete working example, see [`examples/clients/simple-auth-client/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/clients/simple-auth-client/). - -### Parsing Tool Results - -When calling tools through MCP, the `CallToolResult` object contains the tool's response in a structured format. Understanding how to parse this result is essential for properly handling tool outputs. - - -```python -"""examples/snippets/clients/parsing_tool_results.py""" - -import asyncio - -import mcp_types as types - -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client - - -async def parse_tool_results(): - """Demonstrates how to parse different types of content in CallToolResult.""" - server_params = StdioServerParameters(command="python", args=["path/to/mcp_server.py"]) - - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - - # Example 1: Parsing text content - result = await session.call_tool("get_data", {"format": "text"}) - for content in result.content: - if isinstance(content, types.TextContent): - print(f"Text: {content.text}") - - # Example 2: Parsing structured content from JSON tools - result = await session.call_tool("get_user", {"id": "123"}) - if hasattr(result, "structured_content") and result.structured_content: - # Access structured data directly - user_data = result.structured_content - print(f"User: {user_data.get('name')}, Age: {user_data.get('age')}") - - # Example 3: Parsing embedded resources - result = await session.call_tool("read_config", {}) - for content in result.content: - if isinstance(content, types.EmbeddedResource): - resource = content.resource - if isinstance(resource, types.TextResourceContents): - print(f"Config from {resource.uri}: {resource.text}") - else: - print(f"Binary data from {resource.uri}") - - # Example 4: Parsing image content - result = await session.call_tool("generate_chart", {"data": [1, 2, 3]}) - for content in result.content: - if isinstance(content, types.ImageContent): - print(f"Image ({content.mime_type}): {len(content.data)} bytes") - - # Example 5: Handling errors - result = await session.call_tool("failing_tool", {}) - if result.is_error: - print("Tool execution failed!") - for content in result.content: - if isinstance(content, types.TextContent): - print(f"Error: {content.text}") - - -async def main(): - await parse_tool_results() - - -if __name__ == "__main__": - asyncio.run(main()) -``` - -_Full example: [examples/snippets/clients/parsing_tool_results.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/parsing_tool_results.py)_ - - -### MCP Primitives - -The MCP protocol defines three core primitives that servers can implement: - -| Primitive | Control | Description | Example Use | -|-----------|-----------------------|-----------------------------------------------------|------------------------------| -| Prompts | User-controlled | Interactive templates invoked by user choice | Slash commands, menu options | -| Resources | Application-controlled| Contextual data managed by the client application | File contents, API responses | -| Tools | Model-controlled | Functions exposed to the LLM to take actions | API calls, data updates | - -### Server Capabilities - -MCP servers declare capabilities during initialization: - -| Capability | Feature Flag | Description | -|--------------|------------------------------|------------------------------------| -| `prompts` | `listChanged` | Prompt template management | -| `resources` | `subscribe`
`listChanged`| Resource exposure and updates | -| `tools` | `listChanged` | Tool discovery and execution | -| `logging` | - | Server logging configuration | -| `completions`| - | Argument completion suggestions | - -## Documentation - -- [API Reference](https://py.sdk.modelcontextprotocol.io/v2/api/mcp/) -- [Model Context Protocol documentation](https://modelcontextprotocol.io) -- [Model Context Protocol specification](https://modelcontextprotocol.io/specification/latest) -- [Officially supported servers](https://github.com/modelcontextprotocol/servers) - -## Contributing - -We are passionate about supporting contributors of all levels of experience and would love to see you get involved in the project. See the [contributing guide](https://github.com/modelcontextprotocol/python-sdk/blob/main/CONTRIBUTING.md) to get started. - -## License - -This project is licensed under the MIT License - see the LICENSE file for details. +[pypi-badge]: https://img.shields.io/pypi/v/mcp.svg +[pypi-url]: https://pypi.org/project/mcp/ +[mit-badge]: https://img.shields.io/pypi/l/mcp.svg +[mit-url]: https://github.com/modelcontextprotocol/python-sdk/blob/main/LICENSE +[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/ +[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 +[spec-url]: https://modelcontextprotocol.io/specification/latest diff --git a/docs/advanced/authorization.md b/docs/advanced/authorization.md new file mode 100644 index 0000000000..5f96571f4b --- /dev/null +++ b/docs/advanced/authorization.md @@ -0,0 +1,121 @@ +# Authorization + +Over Streamable HTTP your MCP server is an ordinary web service, and you protect it the way you protect any web service: with OAuth 2.1 bearer tokens. + +In OAuth terms, your server is a **resource server**. It never signs anyone in and it never issues a token. It does one thing: look at the `Authorization` header on each request and decide whether the token in it is good. + +## The three parties + +* The **authorization server** signs people in and issues access tokens. You don't write this. It's your identity provider (Auth0, Keycloak, Entra, your own). +* The **resource server** is your MCP server. It verifies the token on every request. +* The **client** discovers which authorization server you trust, gets a token from it, and sends it back to you as `Authorization: Bearer `. + +That's the whole triangle. Everything on this page is the middle bullet. + +## A token verifier + +The SDK has no opinion about what a valid token looks like. You tell it, by implementing **`TokenVerifier`**: + +```python title="server.py" hl_lines="12-14 19-24" +--8<-- "docs_src/authorization/tutorial001.py" +``` + +* `TokenVerifier` is a protocol with one async method. `verify_token` gets the raw token from the `Authorization` header and returns an **`AccessToken`** if it's valid, `None` if it isn't. There is nothing else to implement. +* This one looks the token up in a table. A real one verifies a JWT signature or calls the authorization server's token-introspection endpoint. That code is yours; the SDK only calls it. +* `token_verifier=` and `auth=` always travel together. Pass one without the other and `MCPServer(...)` raises a `ValueError` before it ever serves a request. + +`AuthSettings` is the public face of your resource server: + +* `issuer_url`: the authorization server that issues your tokens. +* `resource_server_url`: the public URL of this MCP endpoint. It names *which* resource a token is for, and it's where the discovery document lives. +* `required_scopes`: every token must carry all of them. + +!!! tip + `examples/servers/simple-auth/` in the SDK repository has an `IntrospectionTokenVerifier` that calls + a real authorization server's RFC 7662 endpoint. It's the shape most production verifiers take. + +## What you get over HTTP + +Authorization lives in HTTP headers, so it exists only on the HTTP transports. Run it on the one you deploy: `mcp.run(transport="streamable-http")` puts it on `http://127.0.0.1:8000/mcp`, and **Running your server** has the rest. The app now has two routes: + +```text +/mcp +/.well-known/oauth-protected-resource/mcp +``` + +You registered one tool. The second route is the SDK's. + +### Discovery + +`GET` that well-known path and you get **RFC 9728 Protected Resource Metadata**, built straight from your `AuthSettings`: + +```json +{ + "resource": "http://127.0.0.1:8000/mcp", + "authorization_servers": ["https://auth.example.com/"], + "scopes_supported": ["notes:read"], + "bearer_methods_supported": ["header"] +} +``` + +This document is how a client that has never heard of your server finds its way in: it reads `authorization_servers` and goes there for a token. You wrote none of it. + +!!! check + Call `/mcp` with no token (or with one your verifier returned `None` for) and the request is + stopped at the door: + + ```text + HTTP/1.1 401 Unauthorized + WWW-Authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp" + + {"error": "invalid_token", "error_description": "Authentication required"} + ``` + + Nothing was parsed and no tool ran. And that `resource_metadata` pointer in `WWW-Authenticate` is + what makes discovery automatic: 401 -> metadata document -> authorization server -> token -> retry. + +!!! warning + None of this protects `stdio`. A pipe has no `Authorization` header, so `token_verifier` is never + consulted there. A `stdio` server's security boundary is the process that launched it. The same + goes for the in-memory `Client(mcp)` you use in tests: it connects straight to the server object + and skips the HTTP layer, authorization included. + +## The caller's identity + +Inside any handler, **`get_access_token()`** is the `AccessToken` your verifier returned for the current request: + +```python title="server.py" hl_lines="4 32-35" +--8<-- "docs_src/authorization/tutorial002.py" +``` + +* It works in tools, resources, and prompts, and there is nothing to pass around: the auth middleware stores it in a context variable per request. +* You get back the **same object your verifier built**: `client_id`, `scopes`, `subject`, `expires_at`, and any extra `claims` you attached. That's the hook for per-tool rules: read the scopes and refuse. +* Outside an authenticated HTTP request it returns `None`. In-memory and over `stdio` it is always `None`. + +Call `whoami` with `Authorization: Bearer alice-token` and the model reads: + +```text +alice (scopes: notes:read) +``` + +## The half the SDK doesn't do + +The SDK gives you the resource-server half: verify, advertise, refuse. It does not give you a login page, a consent screen, or a token. + +To watch all three parties move, run `examples/servers/simple-auth/` from the SDK repository (a small authorization server and a resource server set up exactly like this page) and then point `examples/clients/simple-auth-client/` at it for the full discovery-and-token dance. + +!!! info + There is a second constructor argument, `auth_server_provider=`, that embeds a full authorization + server inside your MCP server. It predates the AS/RS separation that the MCP authorization spec + is built around. New servers should not reach for it. + +## Recap + +* Over Streamable HTTP your server is an OAuth 2.1 **resource server**: it verifies tokens, it never issues them. +* `TokenVerifier` is the whole integration surface: one async method, token in, `AccessToken | None` out. +* `token_verifier=` and `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` always travel together. +* The SDK publishes RFC 9728 Protected Resource Metadata at `/.well-known/oauth-protected-resource/...` and answers unauthenticated requests with a 401 whose `WWW-Authenticate` header points at it. That is the entire discovery story. +* `get_access_token()` in any handler is who's calling. +* Authorization is an HTTP concern. `stdio` and the in-memory client never see it. + +The other side of the handshake, a client that discovers your authorization server and fetches the token for you, is **OAuth clients**. diff --git a/docs/advanced/deprecated.md b/docs/advanced/deprecated.md new file mode 100644 index 0000000000..4a3d4f831a --- /dev/null +++ b/docs/advanced/deprecated.md @@ -0,0 +1,91 @@ +# Deprecated features + +The 2026-07-28 spec retires five things. The SDK still implements every one of them, and every one of them now carries a **deprecation warning**. + +The table below names each deprecated feature, why it is going away, and the replacement to build on. + +## What is deprecated + +| Deprecated | Why | What you do instead | +|---|---|---| +| **Roots**: `ctx.session.list_roots()`, `client.send_roots_list_changed()`, the `list_roots_callback=` you pass to `Client(...)` | [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) retires the capability. | Take the paths that matter as ordinary tool arguments or resource URIs. | +| **Server-initiated sampling**: `ctx.session.create_message()`, the `sampling_callback=` you pass to `Client(...)` | SEP-2577 retires the capability. | Return `InputRequiredResult` and let the client retry the call (see **Multi-round-trip requests**). | +| **Protocol logging**: `ctx.log()`, `ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()`, `ctx.session.send_log_message()`, `client.set_logging_level()` | SEP-2577 retires the capability. Nothing in-protocol replaces it. | Ordinary `import logging` to stderr (see **Logging**). | +| **`ping`**: `client.send_ping()` | **Removed** from the protocol, not merely deprecated. There is no `ping` method in 2026-07-28. | Nothing. It only works against a `mode="legacy"` connection. | +| **Client->server progress**: `client.send_progress_notification()` | 2026-07-28 makes progress server->client only. | Nothing to send. Your *server* reports progress with `ctx.report_progress()` (see **Progress**). | + +Three things fall out of that table: + +* Roots, sampling, and logging go together. One proposal, **SEP-2577**, deprecates all three capabilities at once. +* Sampling and roots share a deeper problem: they are the two places a **server** sends a **request** to the **client**. That whole direction is what 2026-07-28 replaces with **Multi-round-trip requests**. +* `ping` is the odd one out. The protocol does not deprecate it, it removes it. The SDK method still warns (its message says *removed*, not *deprecated*) and calling it on a modern connection answers with *"Method not found"*. + +## Deprecated is advisory + +Nothing breaks today. + +Every method above keeps working against any session that negotiated **2025-11-25 or earlier**. Pin `mode="legacy"` on the client and you get exactly the pre-2026 behaviour. There are no wire changes and capability negotiation is unchanged. + +What changes is that you get a visible warning the first time each one runs: + +```text +MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SEP-2577). +``` + +`MCPDeprecationWarning` subclasses `UserWarning`, **not** `DeprecationWarning`. That is deliberate: Python's default filter only shows `DeprecationWarning` in code run directly as `__main__`, which is how libraries deprecate things and nobody notices for two years. This one shows up everywhere, with no `-W` flag. + +!!! warning + "Advisory" stops at the wire. Sampling and roots are server-to-client *requests*, and a + 2026-07-28 session has no channel to carry one. Call `ctx.session.create_message()` + inside a tool on a modern connection and the warning still fires, and then the send + fails with an error: + + ```text + Cannot send 'sampling/createMessage': this transport context has no back-channel + for server-initiated requests. + ``` + + Two signals, in that order. The `MCPDeprecationWarning` fires the moment you call the + method, on any connection. The error is what comes back when the SDK then tries to + send. These two only work end-to-end on a `mode="legacy"` connection whose client + registered the matching callback. + +## Silencing the warning + +Don't, in new code. + +But a server you maintain that genuinely serves pre-2026 clients has every right to a quiet log. Filter the category before the first deprecated call runs: + +```python +import warnings + +from mcp import MCPDeprecationWarning + +warnings.filterwarnings("ignore", category=MCPDeprecationWarning) +``` + +That is the whole API. There is no per-method switch, and you don't want one: the point of one category is that one line silences it and one line brings it back. + +!!! check + Run the filter the other way and you get a free regression test. Add + `"error::mcp.MCPDeprecationWarning"` to the `filterwarnings` setting in your pytest + configuration and the deprecated call **raises** instead of warning. A tool named + `old_log` that still calls `ctx.info()` stops passing and starts reporting: + + ```text + Error executing tool old_log: The logging capability is deprecated as of 2026-07-28 (SEP-2577). + ``` + + One line of pytest configuration, and a deprecated call can never sneak back into your + codebase without failing a test. + +## Recap + +* The 2026-07-28 spec deprecates **roots**, server-initiated **sampling**, and protocol **logging** (all SEP-2577), restricts **progress** to server-to-client, and removes **`ping`**. +* The replacement column points you onward: **Multi-round-trip requests** for sampling, **Logging** for logging, **Progress** for progress. Roots needs no chapter (pass the paths as arguments) and `ping` needs nothing at all. +* Deprecated is advisory: no wire changes, everything keeps working against pre-2026 sessions, and you get a visible `MCPDeprecationWarning` (a `UserWarning`, so it is on by default). +* Sampling and roots additionally need a back-channel that a 2026-07-28 session does not have. On a modern connection they warn and then they raise. +* `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` silences the whole category; `"error::mcp.MCPDeprecationWarning"` in pytest turns it into a test failure. +* New code should not be built on any of these. + +Every other page in these docs teaches the current API. diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md new file mode 100644 index 0000000000..8473495ce0 --- /dev/null +++ b/docs/advanced/low-level-server.md @@ -0,0 +1,198 @@ +# The low-level Server + +`@mcp.tool()` is a layer. Underneath it is a second server class, `Server`, that speaks raw MCP: you hand it the protocol objects and it puts them on the wire, unchanged. + +`MCPServer` is built on top of it. You drop down when the convenience layer is in the way: + +* You need to emit an **exact** schema (loaded from a file, generated from a database), not one derived from a Python signature. +* You need full control of the result: `_meta`, `is_error`, every key of `structured_content`. +* You need to handle a method MCP doesn't define. + +For everything else, stay on `MCPServer`. + +## The same tool, by hand + +This is `search_books` from **Tools** (the nine-line `@mcp.tool()` file) with the sugar removed: + +```python title="server.py" hl_lines="23 27 33" +--8<-- "docs_src/lowlevel/tutorial001.py" +``` + +Three things changed, and they are the whole low-level API: + +* **Handlers are constructor parameters.** `on_list_tools=` and `on_call_tool=` go into `Server(...)`. There are no decorators down here, and every handler has the same shape: `async (ctx, params) -> result`. +* **You write the input schema.** `Tool.input_schema` is a plain JSON Schema `dict`. Nobody derives it from type hints, because there are no type hints to derive it from. +* **You build the result.** `CallToolResult(content=[TextContent(...)])`, by hand. Nothing is wrapped, converted, or inferred from a return annotation. + +`params` is the parsed request: `CallToolRequestParams` gives you `.name` and `.arguments`. `ctx` is a `ServerRequestContext`: `ctx.session` for talking back to the client, `ctx.lifespan_context`, `ctx.request_id`, and `ctx.meta`, the request's inbound `_meta`. + +!!! info + If you've used FastAPI, you already know this relationship. `MCPServer` is the decorators-and-type-hints layer; `Server` is the Starlette underneath. They are not rivals: `MCPServer` constructs a `Server` and registers handlers exactly like these on it. + +### Try it + +There is no Inspector for this one: `mcp dev` and `mcp run` only accept an `MCPServer`. The in-memory `Client` doesn't care; it takes a low-level `Server` exactly like it takes an `MCPServer`: + +```python title="main.py" +import asyncio + +from mcp import Client + +from server import server + + +async def main() -> None: + async with Client(server) as client: + result = await client.call_tool("search_books", {"query": "dune", "limit": 5}) + print(result.content) + + +asyncio.run(main()) +``` + +```text +[TextContent(type='text', text="Found 3 books matching 'dune' (showing up to 5).", annotations=None, meta=None)] +``` + +The same text the `@mcp.tool()` version produced. Two honest differences: + +* `result.structured_content` is `None`. The high-level server wrapped your `-> str` into `{"result": ...}`; here nobody builds what you didn't build. +* `list_tools` returns the schema **you** typed, character for character. The high-level version had `"title": "Query"` on every property and a `"title": "search_booksArguments"` at the root: Pydantic artifacts. Down here, if it's on the wire, you put it there. + +## Nothing is checked for you + +In **Tools** you saw a bad argument get rejected before your function ran. That was `MCPServer` validating the call against the schema it generated. + +`Server` does not do that. Your `input_schema` is *advertised* to the client; it is never *applied* to `params.arguments`. + +!!! check + Call `search_books` without `limit` and your `args["limit"]` raises `KeyError`. The client sees: + + ```text + MCPError: Internal server error + ``` + + A JSON-RPC error, code `-32603`, with a deliberately generic message: the SDK won't leak your traceback to a remote caller. The model never finds out what it did wrong, so it can't retry. (In a test, `raise_exceptions=True` surfaces the real exception instead; see **Testing**.) + +That generalises. An exception raised from a low-level handler is **always** a protocol error, never an `is_error=True` tool result. If you want the model to read the failure and recover, validate `params.arguments` yourself and return `CallToolResult(content=[TextContent(...)], is_error=True)`. The two kinds of failure are the subject of **Handling errors**. + +## Two tools, one handler + +`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" +--8<-- "docs_src/lowlevel/tutorial002.py" +``` + +* `list_tools` advertises both. `call_tool` dispatches on the name. +* The `else` branch matters: `Server` will happily forward a `tools/call` for a name you never listed straight into your handler. Raising there turns the call into the same `-32603` as above. + +## Structured output, by hand + +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" +--8<-- "docs_src/lowlevel/tutorial003.py" +``` + +Call it and the result carries both representations: + +```json +{ + "content": [{"type": "text", "text": "Found 3 books matching 'dune'."}], + "structuredContent": {"matches": 3, "query": "dune"}, + "isError": false, + "resultType": "complete" +} +``` + +The server never compares the two fields. This SDK's `Client` does: return `structured_content` that doesn't satisfy the `output_schema` you declared and `call_tool` raises a `RuntimeError` that starts with `Invalid structured content returned by tool search_books` and goes on to quote the `jsonschema` failure. Promising a schema is cheap; keeping it is on you. The whole ladder of return types and schemas is in **Structured Output**. + +## `_meta`: for the application, not the model + +`content` is the part of the answer the model reads. `structured_content` is the same answer as typed data. `_meta` is the third channel: data that rides along with the result for the **client application**, without being part of the answer at all. + +Use it for record IDs, trace IDs, anything your UI needs and your prompt doesn't: + +```python title="server.py" hl_lines="38" +--8<-- "docs_src/lowlevel/tutorial004.py" +``` + +* You construct it as `_meta=`, the wire name. The client reads it back as `result.meta`. +* Namespace your keys (`bookshop/record_ids`). The `io.modelcontextprotocol/*` keys are reserved by the protocol. + +!!! warning + `_meta` is a convention between you and the client application, not a guarantee about what reaches + the model. The host decides what it renders. Never put a secret in any part of a tool result. + +## Capabilities follow your handlers + +A `Server` advertises exactly the method families you gave it handlers for. The `Bookshop` above passes `on_list_tools` and `on_call_tool` and nothing else, so a client connecting to it sees: + +```json +{"tools": {"listChanged": false}} +``` + +No `resources`, no `prompts`: there is nothing to back them. Pass `on_list_prompts` and `prompts` appears; pass `on_completion` and `completions` appears. + +`MCPServer` always advertises tools, resources and prompts, whether you registered any or not, because its managers always exist. Down here the declaration *is* the constructor call. + +## The lifespan generic + +`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" +--8<-- "docs_src/lowlevel/tutorial005.py" +``` + +* The lifespan is a `Callable[[Server[Catalog]], AbstractAsyncContextManager[Catalog]]`; `@asynccontextmanager` on an `async` generator gives you exactly that. +* Whatever it `yield`s becomes `ctx.lifespan_context`, and because the handlers are annotated `ServerRequestContext[Catalog]`, `.search(...)` autocompletes and type-checks. +* It is entered once when the server starts and exited once when it stops. Startup, teardown, and `MCPServer`'s version of the same idea are in **Lifespan**. + +Without a `lifespan=`, `ctx.lifespan_context` is an empty `dict`. + +## A method of your own + +The constructor covers the methods MCP defines. `add_request_handler` covers everything else: + +```python title="server.py" hl_lines="35-36 39-40 43-44 48" +--8<-- "docs_src/lowlevel/tutorial006.py" +``` + +* The first argument is the method string. Notifications have a twin, `add_notification_handler`. +* `params_type` is the model the incoming `params` are validated against **before** your handler runs, so custom methods *do* get the validation tools don't. Subclass `RequestParams` so the `_meta` field parses like every other method's. +* The handler returns a `BaseModel`, a `dict`, or `None`. The SDK serialises it into the JSON-RPC result. + +One honest caveat: the high-level `Client` only has verbs for the methods MCP defines, so there is no `client.reindex()`. A vendor method is for a peer that already knows it exists: a client you also ship, or another service of yours speaking JSON-RPC. + +One method you cannot claim: + +```text +ValueError: 'initialize' is handled by the server runner and cannot be overridden; +use Server.middleware to observe or wrap initialization +``` + +The handshake belongs to the runner. `server/discover`, `ping`, and every other built-in are yours to replace. + +!!! tip + `Server.middleware`, mentioned in that error, wraps **every** inbound message, including `initialize`. If what you want is to observe or rewrite traffic rather than answer a new method, start at **Middleware**. + +## The other handlers + +Each of these is one idea you now have the vocabulary for; each has its own chapter. + +* `on_call_tool` may return an `InputRequiredResult` instead of a `CallToolResult` to pause the call and ask the client for input; see **Multi-round-trip requests**. +* `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion` are the same `(ctx, params) -> result` shape for the other primitives. +* `server.streamable_http_app()` returns the same Starlette app `MCPServer`'s does; deploy it the way **Running your server** deploys any other ASGI app. There is no `server.run(transport=...)` down here: `server.run(read_stream, write_stream, server.create_initialization_options())` drives one connection over a pair of streams, and that one line is the whole story. + +## Recap + +* The low-level `Server` takes its handlers as `on_*` **constructor parameters**; every handler is `async (ctx, params) -> result`. +* You write the `input_schema` dict and you build the `CallToolResult`. Nothing is derived, wrapped, or validated for you. +* An exception in a handler is a `-32603` protocol error. A tool error the model can read is a `CallToolResult` with `is_error=True` that **you** return. +* `_meta` on the result is addressed to the client application, not the model. +* `Server[T]` is generic in what its lifespan yields; `ctx.lifespan_context` is a typed `T`. +* `add_request_handler(method, params_type, handler)` serves any method. `initialize` is reserved. +* The capabilities a `Server` advertises are derived from which handlers you registered. + +`Client(server)` treated both servers identically because they *are* the same protocol, which is the whole point. The next layer down isn't a class at all: it's **Middleware**. diff --git a/docs/advanced/middleware.md b/docs/advanced/middleware.md new file mode 100644 index 0000000000..6fdf9d4a19 --- /dev/null +++ b/docs/advanced/middleware.md @@ -0,0 +1,130 @@ +# Middleware + +A **middleware** is one async function that wraps every message your server receives. + +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. + +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** 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" +--8<-- "docs_src/middleware/tutorial001.py" +``` + +* `ctx` is the same `ServerRequestContext` your handlers receive. `ctx.method` is the raw + method string; `ctx.params` are the raw params, **before** any validation. +* `call_next(ctx)` runs the rest of the chain: validation, the handler lookup, your handler. + Return what it returned and the response is untouched. +* The `try`/`finally` is deliberate: a handler that raises is still timed, because the failure + reaches your middleware as the exception out of `call_next`. +* `server.middleware.append(...)` registers it. The list runs outermost-first, so + `middleware[0]` is the one closest to the wire. + +### Try it + +Connect a client, list the tools, call one. Your log has **three** lines: + +```text +server/discover took 18.3 ms +tools/list took 0.1 ms +tools/call took 0.1 ms +``` + +You made two calls and got three lines. The first is `server/discover`: the request the +client sent to set the connection up, before you asked for anything. + +That is the point. Middleware wraps **every** inbound message: + +* The connection setup: `server/discover`, or `initialize` and `notifications/initialized` + on a legacy session. +* Every request and every notification. For a notification, `ctx.request_id is None`, + `call_next(ctx)` returns `None`, and whatever you return is discarded. +* Even a method the server has no handler for: `call_next` raises the + `MCPError(-32601, "Method not found")` *through* your middleware on its way to the client. + +## What you can do inside one + +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. +* **Rewrite.** `ctx` is a dataclass: `await call_next(dataclasses.replace(ctx, params=...))` + hands the rest of the chain different params than the client sent. Never do this to + `initialize`: the result the client gets back is built from your rewritten params, but the + server commits its connection state from the original wire params. The two sides can finish + the handshake disagreeing about what they negotiated. + +!!! check + `initialize` is one of the things middleware wraps, and it is the *only* hook you get + for it. Try to take it over with `add_request_handler` and the SDK refuses: + + ```text + ValueError: 'initialize' is handled by the server runner and cannot be overridden; + use Server.middleware to observe or wrap initialization + ``` + +!!! warning + `initialize` is handled inline: the server reads no further inbound messages until your + middleware chain returns. Awaiting a server-to-client request (`ctx.session.send_request(...)`, + an elicitation) while handling `initialize` therefore **deadlocks the connection**: the + response you are waiting for can never be read. Fire-and-forget notifications are fine. + +## `OpenTelemetryMiddleware` + +The SDK ships one middleware: `OpenTelemetryMiddleware`. Construct it and append it +(`server.middleware.append(OpenTelemetryMiddleware())`), exactly the line you already wrote +for `log_timing`. + +Every inbound message becomes a `SERVER` span named after the method and its target, so a +`tools/call` for `search_books` is the span `tools/call search_books`. + +* Every span carries `mcp.method.name` and `mcp.protocol.version`; a request's span also + carries its JSON-RPC request id (a notification has none). +* A `tools/call` span gets OpenTelemetry's GenAI semantic conventions, + `gen_ai.operation.name` (`"execute_tool"`) and `gen_ai.tool.name`, so a tracing UI groups + your tool calls the way it groups any other agent's. A `prompts/get` span gets + `gen_ai.prompt.name`. The list methods carry no `gen_ai.*` keys. +* A handler that raises sets the span's status to error. So does a tool result with + `is_error=True`. + +!!! tip + The SDK depends only on `opentelemetry-api`. With no exporter installed those spans are + no-ops, so appending this middleware costs you nothing. Install `opentelemetry-sdk` plus an + exporter and everything lights up, with no server change. + +The import is the catch. The class lives at `from mcp.server._otel import OpenTelemetryMiddleware` +today, and the leading underscore is not an accident: it is the same provisional flag this whole +page opened with. The SDK has not given it a public spelling yet, so the import path is the one +line here you should expect to change. + +!!! info + If you have written ASGI middleware, you already know this shape. Starlette's + `(scope, receive, send)` became `(ctx, call_next)`, and it runs *after* the transport, on + the decoded message instead of the raw HTTP request. The two compose: Starlette middleware + on `streamable_http_app()` sees HTTP; this sees MCP. + +## Recap + +* A middleware is `async (ctx, call_next) -> result`, 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. +* Raise instead of calling `call_next` to refuse one message; the connection survives. +* `OpenTelemetryMiddleware` turns each message into a span (with GenAI attributes on tool + calls and prompt gets) for the price of one `append`, and costs nothing until you install + an exporter. +* The whole surface is provisional. Observe with it; don't build on it. + +That is everything that wraps a request. **Authorization** is what decides whether the request +gets to run at all. diff --git a/docs/advanced/multi-round-trip.md b/docs/advanced/multi-round-trip.md new file mode 100644 index 0000000000..2b02cabffc --- /dev/null +++ b/docs/advanced/multi-round-trip.md @@ -0,0 +1,96 @@ +# Multi-round-trip requests + +Sometimes a tool can't finish in one round trip. It needs something only the user has: a choice, a confirmation, a credential. + +Before 2026-07-28 the server got it by calling **back**: opening its own request to the client (an elicitation, a sampling call) in the middle of handling the original one. The 2026-07-28 spec retires that back-channel. + +Instead, the server **returns**. + +## Return, don't call back + +The server answers `tools/call` with an **`InputRequiredResult`** instead of a `CallToolResult`. Two of its fields do the work: + +* **`input_requests`**: what the server still needs, as a dict keyed by names the server chose. Each value is an `ElicitRequest`, a `CreateMessageRequest`, or a `ListRootsRequest`. +* **`request_state`**: an opaque token. The client echoes it back verbatim on the retry. Your server is the only thing that reads it. + +The client fulfils each request, then calls the **same tool again**, carrying its answers in `input_responses` and the token in `request_state`. The server now has what it was missing and returns a normal `CallToolResult`. + +That's the whole protocol. Every leg is an ordinary request from the client to the server. Nothing ever flows the other way. + +## The server side + +The high-level `@mcp.tool()` decorator has no sugar for this yet. Today you write it on the **low-level** `Server`, whose `on_call_tool` handler is allowed to return either result type: + +```python title="server.py" hl_lines="44-47" +--8<-- "docs_src/mrtr/tutorial001.py" +``` + +* `on_call_tool` is typed `-> CallToolResult | InputRequiredResult`. Returning the second one is the entire server-side API. +* On the first call `params.input_responses` is `None`, so the guard fires and the handler asks instead of answering. +* On the retry, the `ElicitResult` the client sent is sitting under the **same key** (`"region"`) that the server used in `input_requests`. + +Everything else in that file (the explicit `input_schema`, the hand-built `CallToolResult`) is the ordinary low-level `Server`, covered in **The low-level Server**. This page only adds the second return type. + +## The client side + +`call_tool` will not hand you an `InputRequiredResult` unless you opt in. + +!!! check + Call a tool that needs input without opting in and `call_tool` raises: + + ```text + Server returned InputRequiredResult; pass allow_input_required=True to receive it and retry call_tool(..., input_responses=..., request_state=result.request_state). + ``` + + That is deliberate. Most call sites expect a result or an exception, not a third thing in the + middle of the happy path, and pyright agrees: without the flag, `call_tool` is typed to return + a plain `CallToolResult`. + +Pass `allow_input_required=True` and the result reaches you intact: + +```python +result.result_type # 'input_required' +result.request_state # 'provision-v1' +result.input_requests # {'region': ElicitRequest(method='elicitation/create', params=ElicitRequestFormParams(...))} +``` + +### The retry loop + +Now you own the loop. There is no automatic driver yet; `while isinstance(result, InputRequiredResult)` **is** the API: + +```python title="client.py" hl_lines="13-15 17-20" +--8<-- "docs_src/mrtr/tutorial002.py" +``` + +* `allow_input_required=True` widens the return type to `CallToolResult | InputRequiredResult`. That union is exactly what the `isinstance` is narrowing. +* For every entry in `input_requests` you put an `InputResponse` under the **same key** in `input_responses`. `fulfil` is where your UI goes; this one hard-codes the answer. +* Same tool name, same `arguments`, every leg. The retry is the original call carried out again, not a new method. +* `request_state=result.request_state`: copy it across. Never inspect it, never invent it. +* When the server has everything it needs it returns a `CallToolResult` and the loop exits. + +## A 2026-07-28 result + +`InputRequiredResult` only exists at protocol version **2026-07-28**. The in-memory `Client(server)` negotiates it for you; over the wire, `mode="auto"` discovers it. After connecting, `client.protocol_version` tells you what you got. + +!!! warning + A pre-2026 session has nowhere to put an `InputRequiredResult`. Return one from your handler on a + `mode="legacy"` connection and the runner cannot serialize it into the negotiated version; the + client gets back a `-32603` *"Handler returned an invalid result"* error. A server that serves + both eras must check `ctx.protocol_version` before reaching for it. + +!!! info + **URL-mode elicitation** rides this exact mechanism on a 2026 connection. The entry in + `input_requests` is an `ElicitRequest` whose params are `ElicitRequestURLParams`; the user + finishes the out-of-band flow and your client retries the call. Same loop, no new API. The + high-level server half is in **Elicitation**. + +## Recap + +* At 2026-07-28 a server that needs input mid-call **returns** an `InputRequiredResult`. It never opens a request to the client. +* `input_requests` is what it needs. `request_state` is an opaque resume token only the server reads. +* The client answers by calling the **same tool again** with `input_responses=` and `request_state=`. +* By default `call_tool` raises on an `InputRequiredResult`; `allow_input_required=True` opts in and widens the return type. +* The manual `while isinstance(result, InputRequiredResult)` loop is the whole client API; there is no auto-retry driver yet. +* The server side is the **low-level** `Server` only; `@mcp.tool()` has no sugar for this yet. + +This is the mechanism that replaces server-initiated sampling and the rest of the push-style back-channel; see **Deprecated features**. diff --git a/docs/advanced/oauth-clients.md b/docs/advanced/oauth-clients.md new file mode 100644 index 0000000000..5acbd92a7b --- /dev/null +++ b/docs/advanced/oauth-clients.md @@ -0,0 +1,137 @@ +# OAuth clients + +Some MCP servers are protected. Send them a request without a token and they answer `401 Unauthorized`. + +**`OAuthClientProvider`** is how you get the token. It is not an MCP object at all. It is an `httpx.Auth`, the standard httpx hook for "do something to every request". You attach it to an `httpx.AsyncClient`, hand that client to the Streamable HTTP transport, and stop thinking about it. + +This chapter is the client side. Making your own server demand a token is **Authorization**. + +## The provider + +```python title="client.py" hl_lines="44-54" +--8<-- "docs_src/oauth_clients/tutorial001.py" +``` + +You give it four things: + +* `server_url`: the MCP endpoint you are connecting to. The provider discovers everything else from it. +* `client_metadata`: what you would type into an authorization server's "register an application" form. +* `storage`: where tokens live between runs. +* `redirect_handler` and `callback_handler`: the two moments a human is involved. + +Nothing else in the file mentions OAuth. `main()` never sees a token. + +### Client metadata + +`OAuthClientMetadata` is the real RFC 7591 registration document, as a Pydantic model. + +You set three fields. The defaults fill in the rest: `grant_types` is already `["authorization_code", "refresh_token"]` and `response_types` is already `["code"]`, which is exactly the flow this provider runs. + +!!! check + Because it is a Pydantic model, it validates **before a single byte goes over the network**. + Leave out `redirect_uris` and construction fails on the spot with a `ValidationError` that + names the field: + + ```text + redirect_uris + Field required [type=missing, input_value={'client_name': 'Bookshop Agent'}, input_type=dict] + ``` + + No browser opened, no half-finished registration left behind on the authorization server. + +### Token storage + +**`TokenStorage`** is a `Protocol` with four async methods. You don't inherit from anything; write the methods and any class is a token store: + +* `get_tokens` / `set_tokens` hold the `OAuthToken`: access token, refresh token, expiry, scope. +* `get_client_info` / `set_client_info` hold the `OAuthClientInformationFull` the authorization server issued when the provider registered you, including your `client_id`. + +The in-memory version above works. It also forgets everything when the process exits, so the next run does the whole dance again. Persist it to a file or your platform's keyring and the next run is silent. + +!!! tip + Store `client_info`, not only the tokens. The provider registers dynamically the first time it + finds no stored `client_info`. Throw it away and you mint a fresh registration on every run. + +### The two handlers + +The authorization code flow needs a human exactly once: someone has to sign in and click "allow". + +* **`redirect_handler`** is awaited with the fully-built authorization URL. The `client_id`, the `redirect_uri`, the `state` and the PKCE challenge are already in it. Your only job is to get a browser there. A desktop app calls `webbrowser.open`; this file prints it. +* **`callback_handler`** is awaited next. It waits until the user lands back on your `redirect_uri` and returns that redirect's query parameters as an `AuthorizationCodeResult`. + +A real client runs a small local HTTP server on the redirect URI instead of calling `input()`. The shape is identical: get redirected, hand back `code`, `state`, and `iss`. + +!!! warning + Pass `state` and `iss` through exactly as they arrived. The provider compares `state` to the one + it generated and `iss` to the issuer it discovered, and refuses a mismatch. They are the CSRF + and server-mix-up defences. + +### Into the `Client` + +Look at `main()`. The provider goes on the **httpx client**, the httpx client goes into `streamable_http_client(url, http_client=...)`, and that transport goes into `Client`. + +`streamable_http_client` has no `auth=` keyword. Anything HTTP-level (auth, headers, timeouts, proxies) belongs on the `httpx.AsyncClient` you bring. That layering is **Client transports**. + +## What the provider does for you + +The first time `Client` sends a request, the server answers `401`. The provider takes over: + +1. **Discovery.** It reads the `WWW-Authenticate` header, fetches the server's Protected Resource Metadata from `/.well-known/oauth-protected-resource`, learns which authorization server protects this resource, and fetches *that* server's metadata. +2. **Registration.** Nothing in storage? It registers you dynamically with your `OAuthClientMetadata` and stores the result. +3. **Authorization.** It generates the PKCE pair and a `state`, builds the authorization URL, awaits your `redirect_handler`, then awaits your `callback_handler` for the code. +4. **Exchange.** It trades the code for an `OAuthToken`, stores it, and replays your original request with `Authorization: Bearer ...`. + +After that it is quiet. Tokens come out of storage, an expired access token is refreshed with the refresh token, and only when none of that works does it run the flow again. + +You wrote none of it. Three keyword arguments remain (`timeout`, `client_metadata_url` and `validate_resource_url`), and this file needs none of them. + +### Try it + +Everything else in these docs you have checked with an in-memory `Client(server)`. Not this: the whole point of the flow is an HTTP `401`, and there is no HTTP between an in-memory client and its server. + +The repository ships the live version. `examples/servers/simple-auth/` runs a standalone authorization server and a protected MCP server; `examples/clients/simple-auth-client/` is this chapter's client grown into a small CLI. Its README has the two commands: start the servers, run the client against them, and you watch the four steps go by. + +## Machine to machine + +A nightly job, a CI step, another service. There is no browser and nobody to click "allow". That is the **client credentials** grant: you already hold a `client_id` and a `client_secret`, and the token endpoint is the whole flow. + +`ClientCredentialsOAuthProvider` is the same `httpx.Auth`, minus the human: + +```python title="client.py" hl_lines="4 27-33" +--8<-- "docs_src/oauth_clients/tutorial002.py" +``` + +What changed: + +* No `OAuthClientMetadata`, no handlers. You pass `client_id` and `client_secret`; the provider builds a minimal `client_credentials` registration around them and skips dynamic registration entirely. +* `scopes` is a space-separated string, the OAuth wire format. +* Everything downstream is identical: the same `TokenStorage`, the same `httpx.AsyncClient(auth=...)`, the same `streamable_http_client`. + +By default the secret travels as HTTP Basic auth on the token request (`client_secret_basic`). Pass `token_endpoint_auth_method="client_secret_post"` to put it in the form body instead. Some authorization servers only accept one of the two. + +!!! tip + Read `client_secret` from the environment or a secret manager, never from source control. + +!!! info + One more provider lives in `mcp.client.auth.extensions.client_credentials`: + **`PrivateKeyJWTOAuthProvider`**, for clients that authenticate with a JWT instead of a + shared secret (`private_key_jwt`, the key-pair and workload-identity flavour). It follows + the same pattern: construct one, put it on `auth=`. The same module ships + `SignedJWTParameters` and `static_assertion_provider`, two helpers that build its assertion. + +## 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. + +Not everything is a flow error. The network can still fail; those are ordinary `httpx` exceptions and pass through untouched. + +## Recap + +* `OAuthClientProvider` is an `httpx.Auth`. Put it on an `httpx.AsyncClient`, pass that to `streamable_http_client(url, http_client=...)`, and `Client` never knows OAuth happened. +* You supply four things: the server URL, an `OAuthClientMetadata`, a `TokenStorage`, and the redirect/callback handler pair. +* `TokenStorage` is a `Protocol`: four async methods, no base class. Persist `client_info` as well as the tokens. +* Discovery, dynamic registration, PKCE, the `state` and `iss` checks, and token refresh are the provider's job, not yours. +* `ClientCredentialsOAuthProvider` is the no-human version: `client_id` + `client_secret`, no handlers, no browser. +* Every OAuth failure is an `OAuthFlowError`; `OAuthRegistrationError` and `OAuthTokenError` are its subclasses. + +The other half of this handshake, making your *server* demand the token, is **Authorization**. diff --git a/docs/advanced/pagination.md b/docs/advanced/pagination.md new file mode 100644 index 0000000000..ef33fa0c32 --- /dev/null +++ b/docs/advanced/pagination.md @@ -0,0 +1,80 @@ +# Pagination + +Most servers never need this. + +`MCPServer` answers every `list_*` request with everything it has, in one page, `next_cursor=None`. For a few dozen tools, resources or prompts that is the right answer and there is nothing to configure. + +Pagination is for the server whose resource list is really a database: thousands of rows it refuses to serialize in one response. The protocol's answer is a **cursor**: the server returns a page plus an opaque token, and the client sends that token back to get the next page. + +`@mcp.resource()` has no hook for any of that. To page, you write the list handler yourself, on the **low-level Server**. + +## A server that pages + +```python title="server.py" hl_lines="13 16-17" +--8<-- "docs_src/pagination/tutorial001.py" +``` + +* On a low-level `Server`, handlers are constructor arguments, not decorators. `on_list_resources` answers every `resources/list` request; that's the whole hookup. +* Every paged handler is typed `params: PaginatedRequestParams | None`, and the example accepts both. Over a connection, though, the SDK never hands you `None` (a request with no `params` member reaches the handler as the model with its defaults), so the signal that matters is `params.cursor is None`: **start from the top**. +* You decide what a cursor *is*. Here it's an offset rendered as a string. A timestamp, a primary key, a base64 blob: anything you can mint on the way out and recognise on the way back in. +* `next_cursor=None` is how you say "that was the last page". There is no count, no total, no `has_more`. `None` is the entire signal. + +!!! tip + A `PAGE_SIZE` of 10 makes the example readable. Pick yours per endpoint: a list of + one-line resources can afford a page of 500; a list of fat prompt templates cannot. + The client has no say in it, and that is by design. + +### Try it + +`Client(server)` connects to a low-level `Server` in memory exactly as it connects to an `MCPServer`. + +Call `list_resources()` with no arguments. You get ten resources, `book-1` through `book-10`, and `next_cursor` is the string `"10"`. + +Hand it back with `list_resources(cursor="10")` and the first resource is `book-11`, the new `next_cursor` is `"20"`. + +The tenth page comes back with `next_cursor` set to `None`. Done. + +## The client loop + +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" +--8<-- "docs_src/pagination/tutorial002.py" +``` + +* `cursor` starts as `None`, so the first request carries no cursor. +* Extend **before** you look at `next_cursor`: the last page has resources too. +* `next_cursor is None` is the exit. Anything else goes straight back into `cursor=`, untouched. + +Run its `main()` and it prints `100 resources`: ten pages of ten, stitched together by a loop that never knew there were ten pages. + +This is the same loop **The Client** chapter showed you, and it costs nothing against a server that doesn't page: `next_cursor` is `None` on the first response and the loop runs once. + +## The three rules + +**Cursors are opaque.** A client must never parse, build, or guess one. The only legal source of a cursor is the previous page's `next_cursor`, verbatim. + +**The server picks the page size.** There is no `limit=` in the protocol. If you need a different page size, you change the server. + +**A client that ignores paging still works.** It calls `list_resources()` once, gets the first ten, and never notices the `next_cursor` it threw away. Nothing breaks; it sees less. + +!!! check + Opaque means opaque. Invent a cursor (`list_resources(cursor="page-2")`) and there is + nothing the protocol can do for you. This server tries `int("page-2")`, the handler raises, + and what comes back to the client is: + + ```text + MCPError(-32603, 'Internal server error', None) + ``` + + A cursor you didn't get from the server is a bug, not a feature request. + +## Recap + +* `MCPServer` returns everything in one page. Pagination is opt-in, and you opt in on the low-level `Server`. +* `on_list_resources` (and `on_list_tools`, `on_list_prompts`, `on_list_resource_templates`) receives `PaginatedRequestParams | None`; `params.cursor` is `None` for the first page. +* You return a page plus `next_cursor`: any string you'll recognise later, or `None` when there is nothing left. +* The client loop: pass `cursor=`, accumulate, repeat until `next_cursor is None`. +* Cursors are opaque, the server owns the page size, and a non-paging client still gets page one. + +The rest of the hand-written `Server` API (`on_call_tool`, `input_schema` dicts, `_meta`) is **The low-level Server**. diff --git a/docs/advanced/session-groups.md b/docs/advanced/session-groups.md new file mode 100644 index 0000000000..e33004c47d --- /dev/null +++ b/docs/advanced/session-groups.md @@ -0,0 +1,82 @@ +# Session groups + +A `Client` connects to one server. Real applications often want several (a search server, a database server, an internal API) and end up juggling a connection and a tool list for each. + +**`ClientSessionGroup`** is one object that holds many connections and merges everything they expose into a single view. + +## Two servers + +Start with two ordinary servers. They have nothing to do with each other, so both naturally called their tool `search`: + +```python title="library_server.py" hl_lines="7" +--8<-- "docs_src/session_groups/tutorial001.py" +``` + +```python title="web_server.py" hl_lines="7" +--8<-- "docs_src/session_groups/tutorial002.py" +``` + +## One group + +Create a `ClientSessionGroup` and call **`connect_to_server`** once per server: + +```python title="client.py" hl_lines="10-12" +--8<-- "docs_src/session_groups/tutorial003.py" +``` + +* `connect_to_server` takes transport parameters, not a server object: `StdioServerParameters` (from `mcp`) to launch a subprocess, or `StreamableHttpParameters` / `SseServerParameters` (from `mcp.client.session_group`) for a server already listening on a URL. +* `group.tools` is a `dict[str, Tool]` of every connected server's tools. `group.resources` and `group.prompts` are the same shape. +* `group.call_tool(name, arguments)` looks the name up, finds the session that owns it, and forwards the call. You never say which server. + +!!! check + Put `client.py` next to the two servers and run it. The second `connect_to_server` refuses: + + ```text + mcp.shared.exceptions.MCPError: {'search'} already exist in group tools. + ``` + + That is an `MCPError`, raised before anything from the second server is registered. A name must + be unique across the **whole** group, and two servers you don't control will collide eventually. + +## `component_name_hook` + +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" +--8<-- "docs_src/session_groups/tutorial004.py" +``` + +Run it again. `print(sorted(group.tools))` now shows both: + +```text +['Library.search', 'Web.search'] +``` + +* The **key** is yours. `by_server` built it from `server_info.name`, the name each `MCPServer(...)` was constructed with. +* The `Tool` inside is untouched: `group.tools["Web.search"].name` is still `"search"`, and that is the name `call_tool` puts on the wire. The prefix never leaves your process. +* It is not only tools. The library's `hours` resource is registered as `Library.hours`. + +!!! tip + The hook runs on **every** name from **every** server, not only on conflicts: there is no + prefix-on-collision mode. Pick one scheme and let it apply everywhere. + +## Adding and removing servers + +`connect_to_server` returns the `ClientSession` it opened. Keep it if you ever want that server gone: `await group.disconnect_from_server(session)` removes its tools, resources, and prompts from the group. + +If you already hold a connected `ClientSession` (`Client.session` is one), hand it to `await group.connect_with_session(server_info, session)` instead of opening a new transport. It aggregates the same way. The group never closes a session it didn't open. + +## The classic handshake + +`ClientSessionGroup` is built on `ClientSession`, not on `Client`. Each `connect_to_server` runs the classic `initialize` handshake. It never sends the `server/discover` probe described in **Protocol versions**. Every MCP server understands that handshake, so this costs you compatibility with nothing; it only means a group takes the older, slower path to a server that could do better. + +## Recap + +* `ClientSessionGroup` holds many server connections and merges their tools, resources, and prompts into one `dict` each. +* `connect_to_server(params)` per server. It takes transport parameters, never the server object or URL a `Client` takes. +* `group.call_tool(name, arguments)` routes to the owning server for you. +* Names must be unique across the whole group; two servers with a `search` tool cannot coexist on their own. +* `component_name_hook=` rewrites every registered name. The dict key changes, the wire name does not. +* `connect_with_session` adds a session you already hold; `disconnect_from_server` removes one. + +The handshake a group speaks (and the faster one a `Client` prefers) is the subject of **Protocol versions**. diff --git a/docs/authorization.md b/docs/authorization.md deleted file mode 100644 index 4b6208bdfc..0000000000 --- a/docs/authorization.md +++ /dev/null @@ -1,5 +0,0 @@ -# Authorization - -!!! warning "Under Construction" - - This page is currently being written. Check back soon for complete documentation. diff --git a/docs/client/callbacks.md b/docs/client/callbacks.md new file mode 100644 index 0000000000..3c2ca5f096 --- /dev/null +++ b/docs/client/callbacks.md @@ -0,0 +1,143 @@ +# Client callbacks + +So far every request has gone one way: client to server. + +A server can also ask the **client** for things: to put a question to the user, to sample the user's model, to list the user's workspace folders. You answer those requests by passing **callbacks** to `Client(...)`. + +## A server that asks + +Here is a server whose tool can't finish on its own: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/client_callbacks/tutorial001.py" +``` + +* `ctx.elicit(...)` sends an `elicitation/create` request **to the client** and waits. +* The tool doesn't return until somebody (a person in a form, or your code) supplies a `name`. + +That is the server half, and the **Elicitation** chapter owns it. This chapter is the other end of the wire. + +## The elicitation callback + +```python title="client.py" hl_lines="7-11 17-18" +--8<-- "docs_src/client_callbacks/tutorial002.py" +``` + +* An elicitation callback is `async (context, params) -> ElicitResult`. +* `params.message` is the question. `params.requested_schema` is the JSON Schema of the answer the server wants. A real client renders a form from it; this one auto-fills. +* You return `ElicitResult(action="accept", content={...})`, or `action="decline"`, or `action="cancel"`. The only other option is `ErrorData(...)`, which refuses the request and fails the whole call. +* `context` is a `ClientRequestContext`: the live `session`, the server's `request_id`, and any `meta` it attached. + +!!! tip + `params` is a union of the two elicitation modes. Here `params.mode` is `"form"`; a `"url"` request + carries `params.url` instead of a schema. One callback handles both; branch on `params.mode`. + **Elicitation** shows the full pattern. + +### Try it + +Call `issue_card` and watch both ends. + +Your callback receives the server's question, already parsed: + +```python +params.mode # 'form' +params.message # 'What name should go on the card?' +params.requested_schema # {'properties': {'name': {'title': 'Name', 'type': 'string'}}, + # 'required': ['name'], 'title': 'CardHolder', 'type': 'object'} +``` + +It answers, `ctx.elicit(...)` resumes inside the tool, and the tool finishes: + +```python +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 + 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 + to answer one; every test behind this page does. **Protocol versions** has the whole story. + +## A callback is a capability + +You never told the server that your client can answer elicitation requests. The SDK did. + +When a client connects it declares its `capabilities`, the mirror image of the server's. You don't write that object. **Registering a callback is the declaration.** + +| you pass | the client declares | +| --- | --- | +| `elicitation_callback=` | `"elicitation": {"form": {}, "url": {}}` | +| `sampling_callback=` | `"sampling": {}` | +| `list_roots_callback=` | `"roots": {"listChanged": true}` | +| none of them | `{}` | + +`logging_callback` and `message_handler` are not in the table. They handle notifications, and notifications need no capability. + +The server reads the declaration back with `ctx.session.check_client_capability(...)`. Add a tool that does: + +```python title="server.py" hl_lines="23-31" +--8<-- "docs_src/client_callbacks/tutorial003.py" +``` + +Connect with only `elicitation_callback` and call it: + +```python +result.structured_content # {'result': ['elicitation']} +``` + +Pass all three callbacks and you get `['elicitation', 'sampling', 'roots']`. Pass none and you get `[]`. + +!!! check + Now do the wrong thing: connect **without** `elicitation_callback` and call `issue_card` anyway. + + The server's `elicitation/create` request still reaches your client, and the SDK answers it for + you, with an error, because you never said you could handle it. That error sinks the whole call. + `call_tool` doesn't return an `is_error` result; it raises: + + ```text + MCPError: Elicitation not supported + ``` + + That is a protocol error (`-32600`, *invalid request*), not a tool error: there is nothing for + the model to read and retry. It's why `client_features` is worth having: a well-behaved server + checks before it asks. + +## The deprecated pair + +`sampling_callback` answers `sampling/createMessage`: the server asking *your* model to complete something. `list_roots_callback` answers `roots/list`: the server asking which directories it may work in. + +Both work. Both follow the rule above. And both serve features the **2026-07-28 spec deprecates**: a modern server doesn't call back into your model mid-request, it hands the request back to you as part of the tool result (**Multi-round-trip requests**), and roots give way to plain tool arguments and resource URIs. The whole list is in **Deprecated features**. + +You still need the callbacks to talk to servers that haven't moved. The signatures: + +```python title="client.py" +--8<-- "docs_src/client_callbacks/tutorial004.py" +``` + +* A sampling callback receives the full `CreateMessageRequestParams` (`messages`, `model_preferences`, `max_tokens`) and returns a `CreateMessageResult`. *You* run the model, however you like; the SDK only carries the request. +* A roots callback takes no params at all and returns a `ListRootsResult`. +* Either one may return `ErrorData(...)` instead, to refuse. + +Pass them to `Client(...)` exactly like `elicitation_callback`. + +## The notification callbacks + +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** has what to do instead), so this callback exists for the servers that still emit it. + +`message_handler` is the catch-all: every server notification reaches it (as well as its specific callback), and on a stream-backed transport so does every transport-level `Exception`. The one pattern worth knowing is `if isinstance(message, Exception): raise message`, so a broken connection fails loudly instead of vanishing. + +## Recap + +* A server can send requests to the client. You answer them with callbacks passed to `Client(...)`. +* The elicitation callback is the current one: `async (context, params) -> ElicitResult`, one function for both form and URL mode. +* **Registering a callback is declaring the capability.** Without it, the SDK refuses the server's request on your behalf and the whole call fails with `MCPError`. +* A server finds out before asking with `ctx.session.check_client_capability(...)`. +* `sampling_callback` and `list_roots_callback` work the same way but serve deprecated features; modern servers use multi-round-trip requests instead. +* `logging_callback` and `message_handler` receive notifications. They declare nothing. + +Next: the first argument you've been passing to `Client(...)` all along, **Client transports**. diff --git a/docs/client/index.md b/docs/client/index.md new file mode 100644 index 0000000000..38efa72b69 --- /dev/null +++ b/docs/client/index.md @@ -0,0 +1,212 @@ +# The Client + +A **`Client`** is how a Python program talks to an MCP server. + +It is one object with one lifecycle: construct it, enter `async with`, call methods. Every protocol verb (list the tools, call one, read a resource, render a prompt) is an `async` method on it that returns a typed result. + +## Your first client + +```python title="client.py" hl_lines="14-18" +--8<-- "docs_src/client/tutorial001.py" +``` + +The server at the top is only there so you have something to connect to. The client is the five highlighted lines. + +* `Client(mcp)` is given the **server object itself**. That is the in-memory transport: no subprocess, no port, no HTTP. It is how every example in this chapter, and every test you write, connects. +* `async with` is the **lifecycle**. Entering it connects and negotiates; leaving it disconnects. There is no `connect()` / `close()` pair, and a `Client` cannot be reused after the block ends. +* Inside the block the connection facts are already there as plain properties. + +### What you can pass to `Client` + +`Client` takes one positional argument and resolves the transport from its type: + +* An `MCPServer` (or low-level `Server`) instance: connected **in-process**. +* A URL string (`Client("http://localhost:8000/mcp")`): Streamable HTTP, the production path. +* A **transport**: anything you can `async with ... as (read, write)`, such as `stdio_client(...)` wrapping a subprocess. + +Everything else on this page is identical across all three. Headers, subprocesses, timeouts, and the `Transport` protocol get their own chapter: **Client transports**. + +### What's on a connected client + +Four read-only properties, populated the moment you enter the block: + +* `client.server_info`: the server's identity. `server_info.name` here is `"Bookshop"`, `server_info.version` is whatever the server reports. +* `client.server_capabilities`: what the server can do (`tools`, `resources`, `prompts`, `completions`, ...). A capability the server doesn't have is `None`. +* `client.protocol_version`: the protocol version the two sides agreed on. Here it is `"2026-07-28"`. +* `client.instructions`: the server's `instructions=` string, or `None` if it didn't set one. + +You never picked a protocol version. By default the `Client` probes the server and falls back to the classic handshake on older ones, so one client works against any era of server. When you need to control that, **Protocol versions** has the whole story. + +!!! tip + `client.session` is the underlying `ClientSession`, the low-level escape hatch. + You won't need it for anything on this page. + +## Listing tools + +```python title="client.py" hl_lines="15-20" +--8<-- "docs_src/client/tutorial002.py" +``` + +`list_tools()` returns a `ListToolsResult`; the tools are in `.tools`. Each one is the complete definition a host would hand to a model: + +```python +tool.name # 'search_books' +tool.title # 'Search the catalog' +tool.description # 'Search the catalog by title or author.' +``` + +and `tool.input_schema` is the JSON Schema the server derived from the function's type hints: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"default": 10, "title": "Limit", "type": "integer"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +That schema is everything a UI needs to render an argument form, and everything a model needs to produce valid arguments. + +!!! tip + `title` is optional, so a UI showing tools to a human has to pick: the `title` if there is one, + the `name` if not. `from mcp.shared.metadata_utils import get_display_name` does exactly that, + for tools, resources, resource templates and prompts. + +## Calling a tool + +`call_tool(name, arguments)` runs the tool and gives you back a `CallToolResult`. + +```python title="client.py" hl_lines="26-33" +--8<-- "docs_src/client/tutorial003.py" +``` + +The server's `lookup_book` returns a Pydantic `Book`. Here is what the client sees: + +```python +result.content # [TextContent(type='text', text='{\n "title": "Dune",\n "author": "Frank Herbert",\n "year": 1965\n}')] +result.structured_content # {'title': 'Dune', 'author': 'Frank Herbert', 'year': 1965} +result.is_error # False +``` + +One return value, three things to read. Each has a different consumer. + +### `content`: what the model reads + +`content` is a `list` of **content blocks**, and a content block is a union: `TextContent`, `ImageContent`, `AudioContent`, `ResourceLink`, or `EmbeddedResource`. A tool can return several, of different kinds. + +That is why `main` narrows with `isinstance(block, TextContent)` before touching `block.text`. Notice there is no `.text` outside the `isinstance`: the type checker won't allow it, because `ImageContent` has `.data`, not `.text`. The union is honest about what a tool is allowed to send you; your code should be too. + +### `structured_content`: what your application reads + +`structured_content` is the tool's return value as JSON, matching the tool's declared `output_schema`. No string parsing, no guessing. + +When both are present they say the same thing twice on purpose: `content` is for a model, `structured_content` is for code. Where the structured half comes from, and how to control it, is the **Structured Output** chapter. + +### `is_error`: whether the tool failed + +A tool that raises does **not** raise in your client. It comes back as an ordinary result with `is_error=True`. + +!!! check + Ask `lookup_book` for `"Solaris"` (a title that isn't in the catalog) and the function raises + `ValueError`. The call still returns normally: + + ```python + result.is_error # True + result.content # [TextContent(type='text', text="Error executing tool lookup_book: No book titled 'Solaris' in the catalog.")] + result.structured_content # None + ``` + + The exception's message landed in `content`, where the **model** can read it and try again. That + is deliberate: a tool error is part of the conversation, not a crash. Always look at `is_error` + before you trust `structured_content`. + +!!! warning + `is_error=True` covers more than your own `raise`. Ask for a tool the server doesn't even have + (`call_tool("does_not_exist", {})`) and nothing raises. You get the same shape back, + `is_error=True` with `Unknown tool: does_not_exist` in `content`. A `Client` method raises + `MCPError` only when the server answers with a JSON-RPC **error** instead of a result, and + **Handling errors** covers when a server produces which. + +## Resources + +The resource verbs come in pairs: two ways to list, one way to read. + +```python title="client.py" hl_lines="23-32" +--8<-- "docs_src/client/tutorial004.py" +``` + +* `list_resources()` returns the **concrete** resources, the ones with a fixed URI. Here: `['catalog://genres']`. +* `list_resource_templates()` returns the **parameterised** ones. Here: `['catalog://genres/{genre}']`. They are two different lists because a template isn't readable until you fill it in. +* `read_resource(uri)` takes a plain `str` URI and works on both: pass `"catalog://genres/poetry"` and the server matches it to the template. + +`read_resource` returns `contents`, a list of `TextResourceContents` or `BlobResourceContents`. Same idea as tool content: narrow with `isinstance`, then read `.text` (or `.blob`). + +A client can also **subscribe** to a resource and be told when it changes: `subscribe_resource(uri)` and `unsubscribe_resource(uri)`, same shape as everything else here. `MCPServer` doesn't implement that half. It says so up front (`server_capabilities.resources.subscribe` is `False`) and answers the request with an `MCPError`: `-32601`, *Method not found*. A server that does support subscriptions is built on the low-level `Server` (**The low-level Server**). + +## Prompts + +```python title="client.py" hl_lines="15-20" +--8<-- "docs_src/client/tutorial005.py" +``` + +`list_prompts()` tells you what the server offers and what each prompt needs: + +```python +prompt.name # 'recommend' +prompt.title # 'Recommend a book' +prompt.arguments # [PromptArgument(name='genre', required=True)] +``` + +`get_prompt(name, arguments)` renders it. The arguments dict is `str -> str`: prompt arguments are always strings. The result is `messages`, a list of `PromptMessage`, each with a `role` and a `content` block: + +```python +message.role # 'user' +message.content # TextContent(type='text', text='Recommend one poetry book from the catalog and say why.') +``` + +A host hands those messages straight to the model. That is the whole feature. + +## Completions + +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" +--8<-- "docs_src/client/tutorial006.py" +``` + +* `ref` says *which* prompt or template you're filling in: a `PromptReference` or a `ResourceTemplateReference`. +* `argument` is `{"name": ..., "value": ...}`: the argument and what the user has typed so far. + +The answer is in `result.completion.values`. Type `"p"` and the server comes back with `['poetry']`. The server side, and how a handler uses the *other* already-filled arguments to narrow its suggestions, is the **Completions** chapter. + +## Pagination + +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" +--8<-- "docs_src/client/tutorial007.py" +``` + +This loop is correct against every server. `MCPServer` returns everything in one page, so `next_cursor` is `None` and the loop runs once, which is why most code never writes it. Servers that genuinely page, and the rules cursors obey, are in **Pagination**. + +## In tests + +`Client(mcp)` with no process and no port is already a test harness for your server. + +There is one constructor flag built for that: `Client(mcp, raise_exceptions=True)`. It only has an effect on in-memory connections, and **Testing** is the chapter that explains it and builds the whole pattern around it. + +## Recap + +* `Client(x)` connects in-memory to a server object, over Streamable HTTP to a URL string, and over anything else via a transport. +* `async with` is the whole lifecycle. Inside it, `server_info`, `server_capabilities`, `protocol_version` and `instructions` are already populated. +* `list_tools()` gives you each tool's `name`, `title`, `description` and `input_schema`. +* `call_tool()` returns `content` for the model, `structured_content` for your code, and `is_error`. A raising tool is a result, not an exception. +* `content` is a union of block types; narrow with `isinstance` before reading. +* `list_resources` / `list_resource_templates` / `read_resource`, `list_prompts` / `get_prompt`, and `complete` round out the verbs. +* Every `list_*` takes `cursor=`; loop until `next_cursor` is `None`. + +Next: the things a server can ask the *client* for, and how you answer, in **Client callbacks**. diff --git a/docs/client/protocol-versions.md b/docs/client/protocol-versions.md new file mode 100644 index 0000000000..323cc9cd48 --- /dev/null +++ b/docs/client/protocol-versions.md @@ -0,0 +1,127 @@ +# Protocol versions + +MCP has two eras. + +Servers released before 2026-07-28 open every connection with the **`initialize` handshake**: the client proposes a version, the server counters, the client acknowledges, all before the first useful request. Servers at **2026-07-28** drop the handshake. The client sends one **`server/discover`** probe and the server answers it with everything in a single result. + +You haven't had to care, because `Client` negotiates for you. This chapter is about the one constructor argument that controls it, `mode=`, and the three times you change it. + +## `mode="auto"` + +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/protocol_versions/tutorial001.py" +``` + +You didn't pass `mode`, so you got the default: `"auto"`. Entering `async with` sends a single `server/discover` probe at the newest version this SDK speaks. Then: + +* A **modern server** answers it. The client adopts the result. One round trip, done. +* An **older server** has never heard of `server/discover` and returns an error. The client falls back to the classic `initialize` handshake and takes whatever that negotiates. + +Either way you come out connected, and `client.protocol_version` tells you which it was: + +```text +2026-07-28 +``` + +That is the whole feature. One `Client`, any era of server, no branching in your code. + +!!! info + `MCPServer` answers `server/discover`, so against your own in-memory server `auto` always lands + on `2026-07-28`. The fallback only ever fires against a real pre-2026 server, which is exactly + when you want it to. + +## `mode="legacy"` + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/protocol_versions/tutorial002.py" +``` + +`mode="legacy"` never probes. It runs the `initialize` handshake, the same connection a pre-2026 client opens. + +```text +2025-11-25 +``` + +Same server. It speaks `2026-07-28` perfectly well; you told the client not to ask. + +You want this for the **push-style** features. + +A server-initiated request is the server calling *you*: `ctx.elicit(...)` putting a form in front of your user, sampling asking your model for a completion mid-tool-call. That channel only exists on a handshake-era session. + +At 2026-07-28 it is gone. The server *returns* its questions and you retry the call with the answers (**Multi-round-trip requests**). + +`mode="auto"` only gives you a handshake when the server is too old for anything else. `mode="legacy"` guarantees one. Reach for it whenever you hand `Client(...)` a `sampling_callback`, an `elicitation_callback` you want driven as a request, or a `message_handler`. **Client callbacks** goes through each. + +## Pinning a version + +`mode` also accepts a modern protocol version string. Today that set is exactly `["2026-07-28"]`. + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/protocol_versions/tutorial003.py" +``` + +A pin sends **nothing**. No probe, no handshake. The client adopts `2026-07-28` locally and the connection is live the instant `async with` returns. + +A pin is a promise *you* make: you already know the server speaks that version. The client doesn't check. + +!!! check + A pin is not a discovery. Print `client.server_info` and the price is right there: + + ```text + name='' title=None version='' description=None website_url=None icons=None + ``` + + The client never asked the server who it is, so `server_info` is a blank. `client.server_capabilities` + is the same story: every capability is `None`. Tool calls still work (the protocol needs none of it); + code that reads `server_capabilities` to decide what to offer does not. + + The next section is the fix. + +Only modern versions are pinnable. A handshake-era string is rejected at construction, before any I/O, and the error tells you what to write instead: + +```text +ValueError: mode must be 'legacy', 'auto', or one of ['2026-07-28']; got '2025-06-18' ('2025-06-18' is a handshake-era version; use mode='legacy') +``` + +## Reconnecting with `prior_discover` + +The probe is cheap, but it is still a round trip you pay on every reconnect, and the answer almost never changes. + +So keep it. After an `auto` connection, `client.session.discover_result` holds the exact `DiscoverResult` the server sent: its `supported_versions`, its `capabilities`, its `server_info`, its `instructions`. Hand it back as `prior_discover=` the next time: + +```python title="client.py" hl_lines="15 17" +--8<-- "docs_src/protocol_versions/tutorial004.py" +``` + +```text +2026-07-28 +Bookshop +``` + +The second connection made **zero** negotiation round trips and still knows exactly who it is talking to. That is the pinned mode done properly: `mode=` names the version, `prior_discover=` supplies the identity. ✨ + +`DiscoverResult` is a Pydantic model. `saved.model_dump_json()` goes into a file or a cache; `DiscoverResult.model_validate_json(...)` brings it back in the next process. + +!!! tip + `prior_discover=` only does anything when `mode` is a version pin. Under `"auto"` the client + probes the server anyway, and under `"legacy"` it is ignored. + +## The four modes + +| You write | Negotiation traffic | You get | +| --- | --- | --- | +| `Client(target)` | one `server/discover` probe; the `initialize` handshake if it fails | the newest version both sides speak, whichever era | +| `Client(target, mode="legacy")` | the `initialize` handshake | a handshake-era version; server-initiated requests work | +| `Client(target, mode="2026-07-28")` | none | that version, pinned, with a blank `server_info` | +| `Client(target, mode="2026-07-28", prior_discover=saved)` | none | that version, pinned, *and* the identity you saved last time | + +## Recap + +* MCP has a handshake era (up to `2025-11-25`, the `initialize` handshake) and a modern era (`2026-07-28`, `server/discover`). `Client` bridges them. +* `mode="auto"` is the default: probe, fall back. Leave it alone unless one of the other three rows describes you. +* `client.protocol_version` is always the answer to "what did I get?". +* `mode="legacy"` forces the handshake. It is what you need for server-initiated requests: sampling, push elicitation, `message_handler`. +* A version pin (`mode="2026-07-28"`) sends no negotiation traffic at all, at the cost of a blank `server_info`. +* `prior_discover=` pays that cost back: save `client.session.discover_result`, reconnect with it, get both. + +A modern connection has no push channel, so how does a 2026 server ask you a question mid-call? It returns it: **Multi-round-trip requests**. diff --git a/docs/client/transports.md b/docs/client/transports.md new file mode 100644 index 0000000000..c47669267a --- /dev/null +++ b/docs/client/transports.md @@ -0,0 +1,115 @@ +# Client transports + +Every `Client` talks to its server over a **transport**: the thing that actually carries the messages. + +You never configure one separately. `Client` takes a single positional argument and works the transport out from its type. + +The *server* side of each (what `mcp.run()` does and what you deploy) is **Running your server**. + +## In memory + +Pass the server object itself: + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/client_transports/tutorial001.py" +``` + +No subprocess, no port, no bytes on a wire. The client and the server are two objects in the same process, and the call still goes through the real protocol layer: `search_books` is listed, validated and invoked exactly as it would be over HTTP. + +That makes it two things at once: + +* **A test harness.** Every example in this documentation is exercised this way, and the **Testing** chapter builds the whole pattern around it. +* **An embedding API.** An application that constructs the server doesn't need a network hop to call its tools. + +## Streamable HTTP + +Pass a URL string and you get **Streamable HTTP**, the transport you deploy behind: + +```python title="client.py" hl_lines="5" +--8<-- "docs_src/client_transports/tutorial002.py" +``` + +That is the whole production client. `Client` wraps the URL in `streamable_http_client(...)` for you, on top of an `httpx.AsyncClient` configured the way MCP needs: `follow_redirects=True`, a 30-second timeout for connect/write/pool, and a 300-second read timeout because the server may hold a response stream open. + +!!! check + A `Client` you have constructed is **not** connected. Construction only picks the transport; + `async with` is what opens it. Reach for the connection before entering and the SDK tells you so: + + ```text + RuntimeError: Client must be used within an async context manager + ``` + + Nothing was resolved, fetched or spawned when you wrote `Client("http://...")`. That line is free. + +### Bring your own `httpx.AsyncClient` + +The moment you need an `Authorization` header, a cookie, a proxy, mTLS, or a different timeout, build the `httpx.AsyncClient` yourself and hand it to `streamable_http_client`: + +```python title="client.py" hl_lines="8-14" +--8<-- "docs_src/client_transports/tutorial003.py" +``` + +Two things to notice: + +* You own the `httpx.AsyncClient`, so **you** enter and exit it. The SDK never closes a client it didn't create. +* `streamable_http_client(url, http_client=...)` returns a transport, and `Client(transport)` accepts it like anything else. + +!!! warning + `streamable_http_client` used to take `headers=` and `timeout=` directly. It does not any more: + its only parameters are `url`, `http_client` and `terminate_on_close`. Reach for `headers=` out + of habit and you get: + + ```text + TypeError: streamable_http_client() got an unexpected keyword argument 'headers' + ``` + + Everything HTTP-shaped now lives on the one `httpx.AsyncClient` you pass in. + +!!! info + If you know `httpx`, you already know how to do auth, proxies, event hooks, retries and connection + limits here. The SDK adds nothing on top and takes nothing away. It is also where OAuth plugs in: + `httpx.AsyncClient(auth=OAuthClientProvider(...))`. That whole flow is **OAuth clients**. + +## stdio + +A **stdio** server is a subprocess. The client launches it, writes JSON-RPC to its stdin and reads JSON-RPC from its stdout. It is how a desktop host runs a server on your machine. + +Describe the process with `StdioServerParameters`, turn it into a transport with `stdio_client`, and hand *that* to `Client`: + +```python title="client.py" hl_lines="4-8 12" +--8<-- "docs_src/client_transports/tutorial004.py" +``` + +`Client` does not accept the parameters object on its own. `StdioServerParameters` is configuration; `stdio_client(server)` is the transport that knows how to spawn a process from it. Always wrap. + +Leaving the `async with` block also shuts the subprocess down: close stdin, wait, kill if it lingers. You never clean it up yourself. + +!!! warning + The child does **not** inherit your environment. It gets a minimal allow-list (`HOME`, `LOGNAME`, + `PATH`, `SHELL`, `TERM` and `USER` on POSIX) so nothing sensitive leaks into a process you may + not have written. + + A server that needs an API key won't find it there. Pass it explicitly with `env=`; those + variables are merged on top of the allow-list. That is what `BOOKSHOP_API_KEY` is doing above. + +## SSE + +`sse_client(url)`, from `mcp.client.sse`, is the HTTP transport that Streamable HTTP superseded. Wrap it the same way, `Client(sse_client("http://localhost:8000/sse"))`, to talk to a server that still speaks it, and don't build anything new on it. + +## The `Transport` protocol + +To `Client`, all of the above are the same thing. + +A **transport** is any async context manager that yields a `(read, write)` pair of message streams: formally, the `Transport` protocol in `mcp.client`. `Client` resolves its argument by type: a server object connects in-process, a `str` becomes `streamable_http_client(url)`, and anything else is entered as a transport directly. That last rule is why `stdio_client(...)`, `streamable_http_client(...)` and `sse_client(...)` all drop into the same slot, and why you can write your own. + +## Recap + +* `Client(mcp)` (the server object) connects in memory. Use it for tests and for embedding. +* `Client("http://.../mcp")` (a URL) connects over Streamable HTTP, the production transport. +* Headers, auth, proxies and timeouts belong on an `httpx.AsyncClient` you pass to `streamable_http_client(url, http_client=...)`. There is no `headers=` keyword. +* stdio is `Client(stdio_client(StdioServerParameters(...)))`, never the parameters object alone. +* The subprocess gets an allow-listed environment, not yours; `env=` adds to it. +* A transport is anything you can `async with x as (read, write)`. `Client` hands anything that isn't a server object or a URL straight to that protocol. +* Constructing a `Client` picks the transport. `async with` opens it. + +Once the transport is open the two sides have to agree on a protocol version. You normally never think about it; when you do, **Protocol versions** is the page. diff --git a/docs/concepts.md b/docs/concepts.md deleted file mode 100644 index a2d6eb8d3a..0000000000 --- a/docs/concepts.md +++ /dev/null @@ -1,13 +0,0 @@ -# Concepts - -!!! warning "Under Construction" - - This page is currently being written. Check back soon for complete documentation. - - diff --git a/docs/index.md b/docs/index.md index 6a937da67f..48c22e03f5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,68 +3,91 @@ !!! info "You are viewing the in-development v2 documentation" For the current stable release, see the [v1.x documentation](https://py.sdk.modelcontextprotocol.io/). -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. +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. -This Python SDK implements the full MCP specification, making it easy to: +This is the official Python SDK for it. With it you can: -- **Build MCP servers** that expose resources, prompts, and tools -- **Create MCP clients** that can connect to any MCP server -- **Use standard transports** like stdio, SSE, and Streamable HTTP +* **Build MCP servers** that expose tools, resources, and prompts to any MCP host. +* **Build MCP clients** that connect to any MCP server. +* Speak every standard transport: stdio, Streamable HTTP, and SSE. -If you want to read more about the specification, please visit the [MCP documentation](https://modelcontextprotocol.io). +## Requirements -## Quick Example +Python 3.10+. -Here's a simple MCP server that exposes a tool, resource, and prompt: +## Installation -```python title="server.py" -from mcp.server.mcpserver import MCPServer +=== "uv" -mcp = MCPServer("Test Server", json_response=True) + ```bash + uv add "mcp[cli]==2.0.0a3" + ``` +=== "pip" -@mcp.tool() -def add(a: int, b: int) -> int: - """Add two numbers""" - return a + b + ```bash + pip install "mcp[cli]==2.0.0a3" + ``` +The `[cli]` extra gives you the `mcp` command; you'll want it for development. -@mcp.resource("greeting://{name}") -def get_greeting(name: str) -> str: - """Get a personalized greeting""" - return f"Hello, {name}!" +!!! warning "Pin the version while v2 is in alpha" + 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 alpha before you copy the line + above. See [Installation](installation.md) for the details. +## Example -@mcp.prompt() -def greet_user(name: str, style: str = "friendly") -> str: - """Generate a greeting prompt""" - return f"Write a {style} greeting for someone named {name}." +### Create it +Create a file `server.py`: -if __name__ == "__main__": - mcp.run(transport="streamable-http") +```python title="server.py" +--8<-- "docs_src/index/tutorial001.py" ``` -Run the server: +That's a complete MCP server. + +It exposes one **tool**, `add`, and one templated **resource**, `greeting://{name}`. -```bash -uv run --with mcp server.py +### Run it + +```console +uv run mcp dev server.py ``` -Then open the [MCP Inspector](https://github.com/modelcontextprotocol/inspector) and connect to `http://localhost:8000/mcp`: +This starts your server and opens the [MCP Inspector](https://github.com/modelcontextprotocol/inspector), an interactive UI for poking at it. Open the URL it prints. + +!!! note + The Inspector is a Node.js app, so `mcp dev` needs `npx` on your `PATH`. + +### Try it -```bash -npx -y @modelcontextprotocol/inspector +In the Inspector, go to **Tools** and call `add` with `a=1`, `b=2`. + +You get `3` back. ✨ + +The Inspector built that form (a required integer field for `a`, another for `b`) from your type hints. So will Claude, and every other MCP host. + +Now go to **Resources** and read `greeting://World`: + +```text +Hello, World! ``` -## Getting Started +### Recap + +Look again at what you did **not** write: + +* No JSON Schema. `a: int, b: int` *is* the schema. +* No request parsing, no serialization, no validation code. +* No protocol handling at all. - -1. **[Install](installation.md)** the MCP SDK -2. **[Learn concepts](concepts.md)** - understand the three primitives and architecture -3. **[Explore authorization](authorization.md)** - add security to your servers -4. **[Use low-level APIs](low-level-server.md)** - for advanced customization +You wrote two Python functions with type hints and a docstring. The SDK does the rest. -## API Reference +## Where to go next -Full API documentation is available in the [API Reference](api/mcp/index.md). +* The **[Tutorial](tutorial/index.md)** walks through everything a server can do, one small step at a time. +* 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. diff --git a/docs/installation.md b/docs/installation.md index f398462353..13f56feecb 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,31 +1,49 @@ # 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 on PyPI as [`mcp`](https://pypi.org/project/mcp/). It requires **Python 3.10+**. -=== "pip" +These docs describe **v2**, which is in alpha, so the version pin is not optional yet: + +=== "uv" ```bash - pip install mcp + uv add "mcp[cli]==2.0.0a3" ``` -=== "uv" + +=== "pip" ```bash - uv add mcp + pip install "mcp[cli]==2.0.0a3" ``` -The following dependencies are automatically installed: +!!! 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 alpha before you copy + the line above. + + The same applies to one-off commands: `uv run --with "mcp==2.0.0a3" ...`, 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. + +## 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: -- [`httpx`](https://pypi.org/project/httpx/): HTTP client to handle HTTP Streamable and SSE transports. -- [`httpx-sse`](https://pypi.org/project/httpx-sse/): HTTP client to handle SSE transport. -- [`pydantic`](https://pypi.org/project/pydantic/): Types, JSON schema generation, data validation, and [more](https://docs.pydantic.dev/latest/). -- [`starlette`](https://pypi.org/project/starlette/): Web framework used to build the HTTP transport endpoints. -- [`python-multipart`](https://pypi.org/project/python-multipart/): Handle HTTP body parsing. -- [`sse-starlette`](https://pypi.org/project/sse-starlette/): Server-Sent Events for Starlette, used to build the SSE transport endpoint. -- [`pydantic-settings`](https://pypi.org/project/pydantic-settings/): Settings management used in MCPServer. -- [`uvicorn`](https://pypi.org/project/uvicorn/): ASGI server used to run the HTTP transport endpoints. -- [`jsonschema`](https://pypi.org/project/jsonschema/): JSON schema validation. -- [`pywin32`](https://pypi.org/project/pywin32/): Windows specific dependencies for the CLI tools. +* `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. +* [`anyio`](https://anyio.readthedocs.io/): the async runtime. The whole SDK is written against anyio, so it runs on either `asyncio` or `trio`. +* [`pydantic`](https://docs.pydantic.dev/): what every `mcp_types` model is built on, plus all schema generation and validation. +* [`pydantic-settings`](https://docs.pydantic.dev/latest/concepts/pydantic_settings/): server configuration via `MCP_*` environment variables and `.env` files. +* [`httpx`](https://www.python-httpx.org/) and [`httpx-sse`](https://pypi.org/project/httpx-sse/): the HTTP client behind the Streamable HTTP and SSE *client* transports. +* [`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. +* [`pyjwt[crypto]`](https://pyjwt.readthedocs.io/): OAuth token handling for authorization. +* [`opentelemetry-api`](https://opentelemetry-python.readthedocs.io/): just the lightweight API, so the SDK's tracing middleware costs nothing unless you install an OpenTelemetry SDK and exporter yourself. +* [`typing-extensions`](https://typing-extensions.readthedocs.io/) and `typing-inspection`: modern typing features on Python 3.10. +* `pywin32`: Windows only, used for `stdio` subprocess management. -This package has the following optional groups: +## Optional extras -- `cli`: Installs `typer` and `python-dotenv` for the MCP CLI tools. +* `mcp[cli]` adds [`typer`](https://typer.tiangolo.com/) and `python-dotenv` for the `mcp` command-line tool (`mcp dev`, `mcp run`, `mcp install`). You'll want this during development; you may not need it in a deployed server. +* `mcp[rich]` adds [`rich`](https://rich.readthedocs.io/) for nicer server logs. diff --git a/docs/low-level-server.md b/docs/low-level-server.md deleted file mode 100644 index a5b4f3df33..0000000000 --- a/docs/low-level-server.md +++ /dev/null @@ -1,5 +0,0 @@ -# Low-Level Server - -!!! warning "Under Construction" - - This page is currently being written. Check back soon for complete documentation. diff --git a/docs/run/asgi.md b/docs/run/asgi.md new file mode 100644 index 0000000000..2a21489a16 --- /dev/null +++ b/docs/run/asgi.md @@ -0,0 +1,171 @@ +# ASGI + +`mcp.run("streamable-http")` starts a web server for you. Sometimes you don't want that: your MCP server is one piece of a larger web application, or you already have an ASGI deployment. + +For that, `mcp.streamable_http_app()` returns a **Starlette application**. + +A Starlette app is an ASGI app, so anything that hosts ASGI (uvicorn, Hypercorn, another Starlette, FastAPI) can host your MCP server. + +## The app + +```python title="server.py" hl_lines="12" +--8<-- "docs_src/asgi/tutorial001.py" +``` + +`app` is an ordinary ASGI application. Hand it to any ASGI server: + +```console +uvicorn server:app +``` + +The MCP endpoint is at `/mcp`, so a client connects to `http://127.0.0.1:8000/mcp`. + +The app already carries two things: + +* One route, `/mcp`: the Streamable HTTP endpoint. +* A **lifespan** that starts `mcp.session_manager`, the object that owns every live session's background work. + +Run the app on its own (`uvicorn server:app`) and you never think about either. + +!!! tip + `streamable_http_app()` takes the same keyword arguments as `mcp.run("streamable-http", ...)`, + minus `port`: the port belongs to whatever serves the app. `host` is still accepted but binds + nothing here; the next section is what it actually controls. **Running your server** covers the + options themselves. + +`mcp.sse_app()` does the same for the superseded SSE transport. + +## Localhost only, until you say otherwise + +`streamable_http_app()` cannot know which hostname it will be served behind, so it assumes the +safest answer: localhost. With no `transport_security=`, the app switches on **DNS-rebinding +protection** and accepts a request only if its `Host` header is `127.0.0.1:`, +`localhost:`, or `[::1]:`, and only if its `Origin` header, when there is one, is the +`http://` form of the same. For `uvicorn server:app` on your machine that is exactly what you want: +it stops a malicious web page from driving your local server through a DNS name it rebound to +`127.0.0.1`. + +It also means that **deployed behind a real hostname, the app rejects every request until you +configure it**. The check runs before MCP does, the client sees only a generic transport error, and +the reason is a single warning in the *server's* log: + +```text +421 Misdirected Request Invalid Host header the Host is not in the allowlist +403 Forbidden Invalid Origin header the Origin is not in the allowlist +``` + +`transport_security=` is how you configure it. Allowlist what you actually serve: + +```python +from mcp.server.transport_security import TransportSecuritySettings + +security = TransportSecuritySettings( + allowed_hosts=["mcp.example.com", "mcp.example.com:*"], + allowed_origins=["https://app.example.com"], +) +app = mcp.streamable_http_app(transport_security=security) +``` + +* `allowed_hosts` entries are exact strings: `"mcp.example.com"` matches a bare `Host` header and + `"mcp.example.com:*"` matches any port. List both. +* `allowed_origins` only matters for browsers (nothing else sends `Origin`). It is the server-side + twin of the CORS configuration below. +* Behind a reverse proxy that already controls the `Host` header, switching the check off is the + honest configuration: `TransportSecuritySettings(enable_dns_rebinding_protection=False)`. +* Passing a non-localhost `host=` (for example `host="mcp.example.com"`) does **not** allowlist that + hostname. It only stops the localhost default from arming the protection, which leaves every Host + and Origin accepted. Say what you mean with `transport_security=` instead. + +## Mounting it + +The moment the MCP server is *part* of a bigger application, you put the app inside a `Mount`. And the moment you do that, the lifespan becomes your problem: + +```python title="server.py" hl_lines="18-21 25-26" +--8<-- "docs_src/asgi/tutorial002.py" +``` + +* `Mount("/", ...)` plus the default `/mcp` path keeps the endpoint at `/mcp`. Starlette tries routes in order and `Mount("/")` matches **every** path, so your own routes go *before* it in the list. Anything after it is unreachable. +* The `lifespan` function enters `mcp.session_manager.run()` for the lifetime of the **host** app. This is the line everyone forgets. +* `mcp.session_manager` only exists *after* `streamable_http_app()` has been called. That is why the routes are built at module level and the manager is only touched inside the lifespan. + +Starlette's `Host` route works the same way: swap `Mount("/", ...)` for `Host("mcp.example.com", ...)` to route by hostname instead of by path. The lifespan rule does not change, and neither does the transport-security one. A `Host("mcp.example.com", ...)` route only ever receives requests addressed to that hostname, so without `allowed_hosts=["mcp.example.com", "mcp.example.com:*"]` it answers every one of them with a `421`. + +!!! warning "The host app owns the lifespan" + `streamable_http_app()` wires `session_manager.run()` into the lifespan of the Starlette it + returns, but **a mounted sub-application's lifespan never runs**. Mount the app and that + built-in lifespan is dead code. Whichever app sits at the top of your ASGI stack must enter + `mcp.session_manager.run()` in its own lifespan. + +!!! check + Delete the `lifespan=lifespan` line and start the server. It starts. The route resolves. + Then the first request to `/mcp` fails with: + + ```text + RuntimeError: Task group is not initialized. Make sure to use run(). + ``` + + Nothing starts the session manager except its `run()`. + +## Two servers, one app + +Each `MCPServer` is its own app with its own session manager. Mount as many as you like; enter every manager from the one host lifespan: + +```python title="server.py" hl_lines="27-30 35-36" +--8<-- "docs_src/asgi/tutorial003.py" +``` + +* `AsyncExitStack` enters both managers; they start together and shut down in reverse order. +* The endpoints are `/notes/mcp` and `/tasks/mcp`: the mount prefix plus the default path. + +## Changing the path + +That trailing `/mcp` is `streamable_http_path`. Set it to `"/"` and the mount prefix becomes the whole public path: + +```python title="server.py" hl_lines="25" +--8<-- "docs_src/asgi/tutorial004.py" +``` + +Now clients connect to `/notes`, not `/notes/mcp`. + +## CORS for browser clients + +A browser-based client needs two permissions from you: to **send** its MCP request headers, and to **read** the one MCP sends back. Both are CORS configuration on the host app, and the transport-security allowlist above has to agree with it: + +```python title="server.py" hl_lines="27-30 33 35-49" +--8<-- "docs_src/asgi/tutorial005.py" +``` + +* `allow_headers` is the half everyone forgets. A browser **preflights** every MCP request, because `Content-Type: application/json` and the `Mcp-*` request headers are not on the CORS safelist, and a header the preflight doesn't grant is a request the browser never sends. (`allow_headers=["*"]` also works: Starlette answers a preflight with whatever it asked for.) +* `expose_headers=["Mcp-Session-Id"]` is the read half. Streamable HTTP returns the session ID in that response header, and browsers hide response headers from JavaScript unless CORS exposes them by name. Without it the client can never make its second request. +* `allow_origins` is your decision, not MCP's. Be precise, and mirror it in `allowed_origins=` above: the browser enforces CORS, but the server checks `Origin` itself, and an origin the transport doesn't trust gets a `403` even after a clean preflight. +* `allow_methods` lists the three methods Streamable HTTP uses: `POST` to send messages, `GET` to open the server-to-client stream, `DELETE` to end the session. + +## Custom routes + +`@mcp.custom_route()` registers a plain HTTP endpoint on the same app, for the things every deployed service needs that have nothing to do with MCP: a health check, an OAuth callback. + +```python title="server.py" hl_lines="15-17" +--8<-- "docs_src/asgi/tutorial006.py" +``` + +* The handler is plain Starlette: an `async` function from `Request` to `Response`. +* `streamable_http_app()` picks up every custom route. `app.routes` is now `/mcp` and `/health`. +* `GET /health` answers `{"status": "ok"}` with no MCP in sight: no session, no handshake. + +!!! warning + Custom routes are **never authenticated**, even when the rest of the server is. That is + deliberate: health checks and OAuth callbacks have to be reachable before any token exists. + Don't put anything private behind one. + +## Recap + +* `mcp.streamable_http_app()` returns a Starlette app with one route, `/mcp`. Any ASGI server can run it. +* Out of the box the app answers only requests addressed to localhost. Deploying behind a real hostname means passing `transport_security=TransportSecuritySettings(...)`. +* `Mount` (or `Host`) puts it inside a bigger Starlette or FastAPI app. +* **Mounting disables the built-in lifespan.** The host app's lifespan must enter `mcp.session_manager.run()`, or the first request fails. +* Several servers in one app means several mounts and one lifespan that enters every session manager. +* `streamable_http_path="/"` moves the endpoint to the mount prefix itself. +* Browser clients need CORS: `allow_headers` for the `Mcp-*` request headers, `expose_headers=["Mcp-Session-Id"]` for the response. +* `@mcp.custom_route()` adds plain, unauthenticated HTTP endpoints next to `/mcp`. + +Once the server is reachable at a real URL, **The Client** connects to it with that URL instead of a server object. diff --git a/docs/run/index.md b/docs/run/index.md new file mode 100644 index 0000000000..da6bb2bfd1 --- /dev/null +++ b/docs/run/index.md @@ -0,0 +1,146 @@ +# Running your server + +`mcp.run()` starts the server. + +The only decision you make is the **transport**: how the bytes between your server and its client actually move. + +## Pick a transport + +| Transport | What it is | When | +|---|---|---| +| `stdio` | The host launches your file as a subprocess and speaks over its stdin and stdout. | Local servers. The default. | +| `streamable-http` | A real HTTP server listening on a port. | Anything you deploy. | +| `sse` | The older HTTP transport. | You don't. | + +!!! warning + SSE was superseded by Streamable HTTP in the 2025-03-26 protocol revision. + `mcp.run(transport="sse")` still works, with its own `sse_path=` and `message_path=` + options, but it exists for clients that haven't moved. Don't build anything new on it. + +## `mcp.run()` + +```python title="server.py" hl_lines="12-13" +--8<-- "docs_src/run/tutorial001.py" +``` + +* `run()` is synchronous. It blocks for the life of the server. +* With no argument, the transport is `stdio`. +* It sits under `if __name__ == "__main__":` because everything that loads your server (`mcp dev`, `mcp run`, `mcp install`, your tests) **imports** this file. The guard keeps an import from turning into a running server. + +### stdio + +There is nothing to configure. The host starts your file as a child process, writes requests to its stdin, and reads responses from its stdout. + +Run it yourself and you see the consequence: + +```console +python server.py +``` + +Nothing prints, and it doesn't return. It is waiting on stdin for a host to speak first. + +That also means stdout **is the wire**. A stray `print()` corrupts the stream; the `logging` module writes to stderr and is the right tool. That story is in **Logging**. + +### Try it + +```console +uv run mcp dev server.py +``` + +The Inspector does exactly what a real host does: it launches `server.py` as a subprocess and connects to it over stdio. + +You never gave it a port. There isn't one. + +## Streamable HTTP + +To put the same server on a port instead, name the transport (and its options) in `run()`: + +```python title="server.py" hl_lines="13" +--8<-- "docs_src/run/tutorial002.py" +``` + +That one line builds a Starlette app and serves it with uvicorn. Clients connect to `http://127.0.0.1:3001/mcp`. + +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. +* `stateless_http=True`: a fresh transport per request, no session tracking. +* `event_store`, `retry_interval`, `transport_security`: resumability and DNS-rebinding protection. They can wait, until you deploy somewhere other than localhost; **ASGI** covers `transport_security`. + +!!! warning + Transport options go to `run()`, **not** to `MCPServer(...)`. The constructor describes what + your server *is*: name, version, instructions. `run()` describes how it is served. Get it + backwards and Python answers before MCP is even involved: + + ```text + TypeError: MCPServer.__init__() got an unexpected keyword argument 'port' + ``` + +`run()` is the short road. The moment you need more (your server mounted inside an existing app, two servers in one process, CORS for browser clients), you build the ASGI app yourself and hand it to any ASGI host. That is **ASGI**. + +## Server settings + +A couple of things about running are not about the transport. They are constructor arguments: + +```python title="server.py" hl_lines="3" +--8<-- "docs_src/run/tutorial003.py" +``` + +* `log_level`: handed to `logging.basicConfig()` the moment `MCPServer(...)` is constructed. That configures the **root** logger, so it sets the level for your own loggers too, not just the SDK's. Default `"INFO"`. +* `debug`: forwarded to the Starlette app that the HTTP transports build. Default `False`. + +Both land on `mcp.settings`, which you can read back at runtime. + +## The `mcp` command + +The `[cli]` extra installs a small command-line tool around all of this. + +`mcp dev` runs your server under the **MCP Inspector**: + +```console +uv run mcp dev server.py +uv run mcp dev server.py --with pandas --with numpy +uv run mcp dev server.py --with-editable . +``` + +`--with` adds packages to the environment it builds; `--with-editable` installs your own package into it. It needs `npx` on your `PATH`: the Inspector is a Node.js app. + +`mcp run` imports the file, finds the server object (a module-level `mcp`, `server`, or `app`), and calls `run()` on it: + +```console +uv run mcp run server.py +uv run mcp run server.py:bookshop +``` + +The `:` suffix names the object when it isn't called `mcp`, `server`, or `app`. + +Your `if __name__ == "__main__":` block never executes here: `mcp run` calls `run()` itself, and the only option it forwards is `--transport`. + +`mcp install` registers the server with **Claude Desktop**, so the app launches it for you: + +```console +uv run mcp install server.py --name "Bookshop" +uv run mcp install server.py -v API_KEY=abc123 -f .env +``` + +`-v KEY=VALUE` and `-f .env` record environment variables in that entry. Claude Desktop starts your server in its own process. Your shell's environment is not there. + +`mcp version` prints the installed SDK version. + +!!! tip + `mcp dev` and `mcp run` only understand `MCPServer`. If you build with the low-level `Server`, + you run it yourself. See **The low-level Server**. + +## Recap + +* A **transport** is how bytes reach your server: `stdio` for a local subprocess, `streamable-http` for a port. SSE is superseded. +* `mcp.run()` picks the transport. With no argument it is `stdio`, and it blocks. +* Every transport option (`host`, `port`, `streamable_http_path`, ...) is an argument to `run()`, never to `MCPServer(...)`. +* Keep `run()` under `if __name__ == "__main__":`. Everything that loads your server imports the file first. +* `log_level=` and `debug=` are constructor arguments; they land on `mcp.settings`. +* `mcp dev` for the Inspector, `mcp run` to execute a file, `mcp install` for Claude Desktop, `mcp version` for the version. +* The transport never changes what your server *is*: all three files on this page expose the identical tool. + +When `run()` itself is the limit (your server inside an app that already exists), the next step is **ASGI**. diff --git a/docs/testing.md b/docs/testing.md deleted file mode 100644 index fcbc3a8553..0000000000 --- a/docs/testing.md +++ /dev/null @@ -1,82 +0,0 @@ -# Testing MCP Servers - -The Python SDK provides a `Client` class for testing MCP servers with an in-memory transport. -This makes it easy to write tests without network overhead. - -## Basic Usage - -Let's assume you have a simple server with a single tool: - -```python title="server.py" -from mcp.server import MCPServer - -app = MCPServer("Calculator") - -@app.tool() -def add(a: int, b: int) -> int: - """Add two numbers.""" # (1)! - return a + b -``` - -1. The docstring is automatically added as the description of the tool. - -To run the below test, you'll need to install the following dependencies: - -=== "pip" - ```bash - pip install inline-snapshot pytest - ``` - -=== "uv" - ```bash - uv add inline-snapshot pytest - ``` - -!!! info - I think [`pytest`](https://docs.pytest.org/en/stable/) is a pretty standard testing framework, - so I won't go into details here. - - The [`inline-snapshot`](https://15r10nk.github.io/inline-snapshot/latest/) is a library that allows - you to take snapshots of the output of your tests. Which makes it easier to create tests for your - server - you don't need to use it, but we are spreading the word for best practices. - -```python title="test_server.py" -import pytest -from inline_snapshot import snapshot -from mcp import Client -from mcp_types import CallToolResult, TextContent - -from server import app - - -@pytest.fixture -def anyio_backend(): # (1)! - return "asyncio" - - -@pytest.fixture -async def client(): # (2)! - async with Client(app, raise_exceptions=True) as c: - yield c - - -@pytest.mark.anyio -async def test_call_add_tool(client: Client): - result = await client.call_tool("add", {"a": 1, "b": 2}) - assert result == snapshot( - CallToolResult( - content=[TextContent(type="text", text="3")], - structuredContent={"result": 3}, - ) - ) -``` - -1. If you are using `trio`, you should set `"trio"` as the `anyio_backend`. Check more information in the [anyio documentation](https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on). -2. The `client` fixture creates a connected client that can be reused across multiple tests. - -!!! note - `Client(app)` connects in-process and is era-neutral by default — it probes the server and picks the - appropriate protocol path. Pin `mode='legacy'` if your test exercises legacy-specific semantics - (sampling/elicitation push, `message_handler`). - -There you go! You can now extend your tests to cover more scenarios. diff --git a/docs/tutorial/completions.md b/docs/tutorial/completions.md new file mode 100644 index 0000000000..e1d1815a13 --- /dev/null +++ b/docs/tutorial/completions.md @@ -0,0 +1,125 @@ +# Completions + +A client building a UI on top of your server wants to autocomplete argument values as the user types: language names, repository names, file paths. + +**Completions** are how your server supplies those suggestions. + +## Something worth completing + +Completions apply to exactly two things: the arguments of a **prompt** and the parameters of a **resource template**. So start with a server that has one of each: + +```python title="server.py" hl_lines="6 12" +--8<-- "docs_src/completions/tutorial001.py" +``` + +Nothing here is about completions yet. + +* `review_code` takes a `language`. A user shouldn't have to guess which spellings you accept. +* `github_repo` takes an `owner` and a `repo`. Free-text boxes for both make a bad form. + +## The completion handler + +Add **one** function decorated with `@mcp.completion()`: + +```python title="server.py" hl_lines="22-30" +--8<-- "docs_src/completions/tutorial002.py" +``` + +* There is one handler per server. Every completion request lands here, and you branch on what's being completed. +* It must be `async def`: the SDK awaits it. +* It receives three arguments: + * `ref`: *which* prompt or resource template, as a `PromptReference` or a `ResourceTemplateReference`. `isinstance` is how you tell them apart. + * `argument`: `argument.name` is the argument being completed, `argument.value` is what the user has typed so far. + * `context`: the arguments already resolved. Ignore it for now. +* You return a `Completion(values=[...])`, or `None` when you have nothing to offer. + +!!! tip + `argument.value` is the prefix the user has typed. The SDK does **not** filter for you: whatever + you put in `values` is what the UI shows. The `startswith` is yours to write. + +### Try it + +Drive it with the in-memory `Client`, the same one you use in **Testing**. Call +`client.complete()` with `ref=PromptReference(name="review_code")` and +`argument={"name": "language", "value": "py"}`: + +```python +result.completion.values # ['python'] +``` + +* `ref` is the same reference type your handler receives. +* `argument` is a plain dict with exactly two keys, `name` and `value`. + +Send an empty `value` and you get the whole list back. `lang.startswith("")` is true for every language: + +```python +result.completion.values # ['go', 'javascript', 'python', 'rust', 'typescript'] +``` + +Ask about `code` (an argument your handler doesn't recognise) and it returns `None`, which the SDK turns into an empty list: + +```python +result.completion.values # [] +``` + +`None` means *"no suggestions"*, never an error. A UI falls back to a plain text box. + +## A capability you never declared + +Registering the handler is the declaration. Connect a client and look: + +```python +client.server_capabilities.completions # CompletionsCapability() +``` + +You didn't list `completions` anywhere. The SDK saw the handler and advertised it during the handshake. Every *optional* capability works this way: the handler is the declaration. (The three primitives are not optional: `MCPServer` always declares those, handlers or not.) + +!!! check + Go back to the first `server.py` (the one with no handler) and ask it anyway. The call fails + with a JSON-RPC error: + + ```text + Method not found + ``` + + And `client.server_capabilities.completions` is `None`. That's the point of the capability: a + well-behaved client checks it and never sends the request you can't answer. + +## Dependent arguments + +`github://repos/{owner}/{repo}` has two parameters, and the useful values for `repo` depend on which `owner` was picked first. + +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" +--8<-- "docs_src/completions/tutorial003.py" +``` + +* The new branch fires for the template's `repo` parameter. +* `context.arguments` is a `dict[str, str] | None` of the values picked so far (here, `owner`). +* No `owner` yet means no sensible suggestions, so the handler returns `None`. + +The client sends those resolved values with `context_arguments=`. This time `ref` is a +`ResourceTemplateReference(uri="github://repos/{owner}/{repo}")`. Ask for `repo` with an +empty `value` and pass `context_arguments={"owner": "modelcontextprotocol"}`: + +```python +result.completion.values # ['python-sdk', 'typescript-sdk', 'inspector'] +``` + +Drop `context_arguments=` and the same call returns `[]`. The handler can't know which repos to offer until it knows the owner. + +!!! info + `Completion` also takes `total=` and `has_more=`. Set them when `values` is a slice of a longer + list, so a UI can show *"and 200 more"*. Most handlers never need them. + +## Recap + +* Completions are suggestions for **prompt arguments** and **resource template parameters**. Nothing else. +* `@mcp.completion()` registers the one handler. It's `async def (ref, argument, context) -> Completion | None`. +* Branch on `isinstance(ref, ...)` and on `argument.name`. Filter by `argument.value` yourself. +* `None` becomes an empty list. It is never an error. +* `context.arguments` holds the already-resolved values; the client supplies them as `context_arguments=`. +* The `completions` capability appears the moment you register the handler. Without it, the request is `Method not found`. + +Suggestions help *before* a tool runs. To ask the user a question in the *middle* of one, you want **Elicitation**. diff --git a/docs/tutorial/context.md b/docs/tutorial/context.md new file mode 100644 index 0000000000..3a15e8fc82 --- /dev/null +++ b/docs/tutorial/context.md @@ -0,0 +1,126 @@ +# The Context + +A tool's arguments come from the model. Everything else (the request you are serving, the server you live in, a way to talk back to the client) comes from one object: the **`Context`**. + +You don't construct it and you don't configure it. You ask for it. + +## Ask for it + +Add a parameter annotated with `Context` to any tool: + +```python title="server.py" hl_lines="2 8" +--8<-- "docs_src/context/tutorial001.py" +``` + +* The SDK builds a fresh `Context` for every request and passes it in. +* The parameter **name doesn't matter**. `ctx`, `context`, `c`: the SDK finds it by its annotation. +* Resources and prompts can declare one too, the same way. +* `ctx.request_id` is the id of the request your function is serving right now. + +!!! info + If you've used FastAPI, you've seen this move: declare a parameter with the framework's own type + (`Request` there, `Context` here) and the framework supplies it. Nothing to register, nothing to + configure: the type annotation is the whole mechanism. + +### Invisible to the model + +This is the part to internalise. Here is the input schema `tools/list` reports for `search_books`: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +One property. `ctx` is not an argument: it never appears in the schema, the model is never told about it, and no client can fill it in. It's a contract between you and the SDK, invisible on the wire. + +### Try it + +Run the server with the MCP Inspector: + +```console +uv run mcp dev server.py +``` + +The form for `search_books` has a single `query` field. Call it with `dune`: + +```text +[request 3] Found 3 books matching 'dune'. +``` + +The number is whichever request this happened to be. Call the tool again and it changes: every request gets its own `Context`. + +## What it gives you + +The injected object is small. Besides `request_id`: + +* `await ctx.read_resource(uri)`: read one of the server's **own** resources from inside a tool. The next section. +* `await ctx.report_progress(progress, total, message)`: stream progress back to the caller during a long call. The whole story is in **Progress**. +* `await ctx.elicit(message, schema)` and `await ctx.elicit_url(...)`: pause the tool and ask the user a question. That's **Elicitation**. +* `ctx.session`: the server's side of the conversation with this client. Notifications you send to the client live here; the last section uses it. +* `ctx.request_context`: the raw per-request record. The field you'll reach for is `lifespan_context`, the object your startup code yielded (see **Lifespan**). + +Logging is deliberately not on that list. A server logs with Python's `logging` module, like any other Python program. **Logging** is the short chapter on why. + +!!! tip + Injection only happens for the function you registered. A helper that your tool calls doesn't get + its own `Context`; pass `ctx` down as an ordinary argument. There is no ambient + "current context" to fetch from somewhere else. + +## Read your own resources + +A server's resources aren't only for clients. A tool can read them too: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/context/tutorial002.py" +``` + +`ctx.read_resource` resolves the URI through the same registry that serves `resources/read`, so a tool gets what a client would get: an iterable of `ReadResourceContents`, one per content block. For this URI there is one: + +```python +contents.content # 'fiction, non-fiction, poetry' +contents.mime_type # 'text/plain' +``` + +* `content` is exactly what `genres()` returned. One source of truth: the client browses the resource, your tools consume it, nobody copies the string. +* `describe_catalog`'s only parameter is the `Context`, so its input schema has **no properties at all**. The model calls it with `{}`. + +## Tell the client the list changed + +What a server offers is not fixed at import time. Register a tool at runtime, then tell the client: + +```python title="server.py" hl_lines="15-16" +--8<-- "docs_src/context/tutorial003.py" +``` + +* `mcp.add_tool(recommend_book)` registers a plain function as a tool: name, description and schema derived exactly as `@mcp.tool()` would have. +* `await ctx.session.send_tool_list_changed()` sends `notifications/tools/list_changed`. A client that receives it calls `tools/list` again and sees `recommend_book`. + +The siblings are `send_resource_list_changed()`, `send_prompt_list_changed()`, and `send_resource_updated(uri)` for a change to one specific resource. + +!!! check + Before anyone runs `enable_recommendations`, the tool you are promising does not exist. Call it + anyway and the result is an error the model can read: + + ```text + Unknown tool: recommend_book + ``` + + Run `enable_recommendations`, and the very same call succeeds. The tool list is genuinely + dynamic: `tools/list` reflects whatever is registered *right now*. + +## Recap + +* Annotate a parameter with `Context` (in a tool, a resource, or a prompt) and the SDK injects it. The name is yours. +* It is invisible to the model: the input schema only ever contains your real arguments. +* `ctx.request_id` identifies the request; `ctx.request_context.lifespan_context` is what your startup yielded. +* `await ctx.read_resource(uri)` lets a tool read the server's own resources. +* `ctx.session` is the channel back to the client: `send_tool_list_changed()` and its siblings tell it to re-fetch a list you changed. +* Progress reporting and elicitation also start at `Context`; each has its own chapter. + +Next: what happens when your tool fails, and how to choose who finds out, in **Handling errors**. diff --git a/docs/tutorial/elicitation.md b/docs/tutorial/elicitation.md new file mode 100644 index 0000000000..ef8d5911b5 --- /dev/null +++ b/docs/tutorial/elicitation.md @@ -0,0 +1,153 @@ +# Elicitation + +A tool that is halfway through its job and missing one answer doesn't have to fail. + +**Elicitation** lets it ask. In the middle of a tool call the server sends the client a question, the client puts it to the user, and the answer comes back into the same function call. + +There are two modes: + +* **Form mode**: you need a value (a confirmation, a date, a quantity). You describe the fields, the client renders the form. +* **URL mode**: you need the user to go somewhere else (an OAuth consent screen, a payment page). Nothing they do there passes through the protocol. + +## Ask with a form + +`ctx.elicit()` takes a message and a Pydantic model: + +```python title="server.py" hl_lines="9-11 20-23 25" +--8<-- "docs_src/elicitation/tutorial001.py" +``` + +* The **`Context`** parameter is what gives you `ctx.elicit`; any tool can take one. That object has its own chapter: **The Context**. +* `AlternativeDate` is the **schema** of the answer you want. +* The tool is `async def`. It has to be: it stops in the middle and waits for a person. +* On any other date the tool returns straight away. It only asks when it has to. +* The date the user accepts goes back through `book_table` itself. An answer is input like any other: an alternative that is also fully booked gets asked about again, not confirmed blind. + +### What the client receives + +The client gets your message and, next to it, a JSON Schema generated from the model: + +```json +{ + "properties": { + "accept_alternative": { + "description": "Try another date?", + "title": "Accept Alternative", + "type": "boolean" + }, + "date": { + "default": "2025-12-26", + "description": "Alternative date (YYYY-MM-DD)", + "title": "Date", + "type": "string" + } + }, + "required": ["accept_alternative"], + "title": "AlternativeDate", + "type": "object" +} +``` + +That schema is the form. `Field(description=...)` is the label; a default pre-fills the input and makes the field optional. It's the same Pydantic-to-JSON-Schema machinery you already used for a tool's arguments in **Tools**. + +!!! warning + An elicitation schema is not as expressive as a tool's input schema. Flat, primitive fields + only: `str`, `int`, `float`, `bool`, or a `Literal` of strings (it becomes an `enum`). + Put a model inside the model and `ctx.elicit` raises before anything is sent to the client: + + ```text + TypeError: Elicitation schema field 'address' rendered as {'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition + ``` + + You are interrupting a person mid-task. If the answer needs nesting, it should have been an + argument to the tool. + +### The three answers + +`result.action` tells you what the user did, and there are exactly three possibilities: + +* `"accept"`: they submitted the form. `result.data` is an `AlternativeDate` instance, already validated. +* `"decline"`: they said no. +* `"cancel"`: they dismissed the question without choosing. + +`result.data` only exists on `"accept"`, which is why the example checks `result.action` first. Your type checker enforces the order: after `result.action == "accept"`, `result.data` is an `AlternativeDate`; before it, there is no `.data` at all. + +A refusal is not an error. The tool decides what declining means (here, no booking) and answers the model normally. + +!!! tip + The answer is validated against your model before your code sees it. A client that sends + `"maybe"` for a `bool` doesn't corrupt your booking: the call fails with the + `ValidationError`, your `if` never runs. + +## Send the user to a URL + +Some things must not go through the model or the client: credentials, card numbers, OAuth consent. For those you don't ask for data; you ask the user to go somewhere: + +```python title="server.py" hl_lines="10-14 23" +--8<-- "docs_src/elicitation/tutorial002.py" +``` + +* `ctx.elicit_url()` takes the message, the **URL** to visit, and an `elicitation_id` you choose: any string that identifies this elicitation within your server. +* The result has an action and nothing else. `"accept"` means the user agreed to open the URL, **not** that they finished what's on the other side. +* The payment happens out of band, between the user's browser and your payment provider. No content ever comes back through MCP. + +Look at the second tool. When your server learns the out-of-band flow finished (a webhook, a poll; here it's modelled as a second tool), `ctx.session.send_elicit_complete(...)` sends `notifications/elicitation/complete` with the same `elicitation_id`. That is how the client knows it can stop showing *"waiting for payment..."*. Without it, the client can only guess. + +## The client side + +Servers ask. Clients answer by passing an **`elicitation_callback`** to `Client(...)`: + +```python title="client.py" hl_lines="7-8 19" +--8<-- "docs_src/elicitation/tutorial003.py" +``` + +* One callback handles both modes. `params` is a union of `ElicitRequestFormParams` and `ElicitRequestURLParams`; `isinstance` is the branch. +* For a URL, you show `params.url` to the user and return the action they chose. Never any `content`. +* For a form, a real application renders `params.requested_schema` and returns the user's input as `content`. This one always says yes with a canned answer, which is exactly the callback you want in a test. +* Passing the callback is also the **capability declaration**: it's how the server learns this client can be asked. The other things a client can answer for a server live in **Client callbacks**. + +!!! info + Elicitation is a request from the *server* to the *client*, and those only exist on a + classic-handshake session, which is why this client passes `mode="legacy"`. + On a **2026-07-28** connection a tool asks by *returning* the question from the call + instead; that flow is **Multi-round-trip requests**. + +### Try it + +Start the form-mode `server.py` (the first one on this page) on Streamable HTTP (**Running your server** has the one-liner), then run the client's `main()` and ask `book_table` for Christmas day. + +The callback prints the question it was sent: + +```text +No tables for 2 on 2025-12-25. Would you like to try another date? +``` + +It answers with `{"accept_alternative": True, "date": "2025-12-27"}`, and the tool, which has been waiting inside `await ctx.elicit(...)` this whole time, finishes the booking: + +```text +Booked a table for 2 on 2025-12-27. +``` + +Now swap in the URL-mode `server.py` and point the same `main()` at `pay_deposit`: the same callback takes the other branch, prints the payment link, and the tool comes back with *"Complete the payment in your browser."* One round trip, mid-call, in both directions. + +!!! check + Now remove `elicitation_callback=` from the `Client` and call `book_table` for Christmas day + again. The whole call fails with a protocol error: + + ```text + Elicitation not supported + ``` + + A client that registered no callback never declared the `elicitation` capability, so there is + nobody to ask. Your tool didn't get a `"decline"`; it got an exception. Design for it: every + elicitation needs a sensible answer to "what if I can't ask?". + +## Recap + +* `await ctx.elicit(message, schema=Model)` asks mid-call; your tool resumes with the answer. +* The schema is a flat Pydantic model: primitive fields only, validated on the way back. +* `result.action` is `"accept"`, `"decline"` or `"cancel"`; `result.data` exists only on accept. +* `await ctx.elicit_url(message, url, elicitation_id)` is for everything that must not pass through the model; `ctx.session.send_elicit_complete(elicitation_id)` says the out-of-band part is done. +* The client answers with one `elicitation_callback`, branching on the params type; registering it is what declares the capability. + +A tool that can ask is good. A tool that says how far along it is (**Progress**) is next. diff --git a/docs/tutorial/first-steps.md b/docs/tutorial/first-steps.md new file mode 100644 index 0000000000..ccf1a32b50 --- /dev/null +++ b/docs/tutorial/first-steps.md @@ -0,0 +1,139 @@ +# First steps + +On the landing page you wrote a server, ran it, and called a tool. + +Now do it again, slowly, with all three things a server can expose, and the names for everything you just saw. + +## Host, client, and server + +Three words you'll see on every page from here on: + +* A **host** is the LLM application: Claude, an IDE, an agent runtime. It's the thing the user is talking to. +* A **client** lives inside the host and speaks MCP. The host runs one client per server it's connected to. +* A **server** is what you build with this SDK. It exposes things to clients. It never talks to the model directly. + +You write the server. Hosts are someone else's product. The SDK also gives you a `Client`. You'll use it to test your servers, and it shows up later in this chapter. + +## The three primitives + +A server exposes exactly three kinds of thing. What separates them is **who decides to use them**: + +| Primitive | Controlled by | What it is | Example | +|---------------|-----------------|-----------------------------------------------------|------------------------------------| +| **Tools** | The model | A function the model calls to take an action | An API call, a database write | +| **Resources** | The application | Data the host loads into the model's context | A file's contents, an API response | +| **Prompts** | The user | A reusable message template the user invokes by name | A slash command, a menu entry | + +"Controlled by" is the whole point of the split. A tool runs because the **model** decided to call it. A resource is attached because the **application** decided the model needed it. A prompt runs because the **user** picked it. + +!!! info + If you've built a web API you already have most of the intuition: a **resource** is a `GET` + (it loads data and changes nothing) and a **tool** is a `POST` (it does work and may have + side effects). A **prompt** has no HTTP analogue; it's closer to a saved query the user runs + by name. + +## One server, all three + +```python title="server.py" hl_lines="6 12 18" +--8<-- "docs_src/first_steps/tutorial001.py" +``` + +Three plain functions, three decorators. Each decorator is the entire registration: + +* `@mcp.tool()` makes `add` a **tool**. +* `@mcp.resource("greeting://{name}")` makes `greeting` a **resource template**: the `{name}` in the URI is the function's parameter. +* `@mcp.prompt()` makes `summarize` a **prompt**. The string it returns becomes a user message. + +Everything else (the name, the description, the argument schema) the SDK reads from the function itself: its name, its docstring, its type hints. You never declared any of it separately. + +!!! tip + The two halves of the SDK have two import paths: `from mcp import Client` and + `from mcp.server import MCPServer`. There is no `from mcp import MCPServer`. + +### Try it + +Run it with the MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Open the URL it prints. The Inspector has one tab per primitive; walk through them in order. + +**Tools.** One entry: `add`, described as *Add two numbers.* The form has a required integer field for `a` and another for `b`. Fill them in, call it, and the result is `3`. The Inspector built that form from `a: int, b: int`. So does every other client. + +**Resources.** The *Resources* list is empty. `greeting` is under **Resource Templates**, because `greeting://{name}` has a parameter: there is no single resource to list until someone supplies a `name`. Give it `World` and read it: + +```text +Hello, World! +``` + +**Prompts.** One entry: `summarize`, with a single required `text` argument. Get it with some text and you receive one message with `role: user` and your rendered string as the content. That's all a prompt is: a function that builds messages. + +The Inspector ran your server over **stdio**, one of the transports an MCP server can speak. You don't pick one yet; **Running your server** is the chapter for that. + +## Capabilities + +You saw three tabs in the Inspector. How did it know there were three? + +When a client connects, the server declares its **capabilities**: which families of requests it will answer. The client uses that declaration to decide what to even ask for. You never wrote it; `MCPServer` declares it for you. + +Look at it yourself. The SDK's `Client` accepts the server object directly and connects to it **in memory** (no subprocess, no port): + +```python +import asyncio + +from mcp import Client + +from server import mcp + + +async def main() -> None: + async with Client(mcp) as client: + print(client.server_capabilities.model_dump(exclude_none=True)) + + +asyncio.run(main()) +``` + +```text +{'prompts': {'list_changed': False}, 'resources': {'subscribe': False, 'list_changed': False}, 'tools': {'list_changed': False}} +``` + +That dictionary is the server's half of the handshake: + +| Capability | The client may now call | +|-------------|------------------------------------------------------------| +| `tools` | `tools/list`, `tools/call` | +| `resources` | `resources/list`, `resources/templates/list`, `resources/read` | +| `prompts` | `prompts/list`, `prompts/get` | + +`MCPServer` serves all three primitives, so all three are always declared. + +Notice what isn't there. `completions` (argument autocomplete for resource templates and prompts) needs a handler you write, this server doesn't have one, so the capability is absent and a well-behaved client won't ask. That's the rule for everything optional: register the thing and the capability appears; **Completions** proves it. + +!!! info + `Client(mcp)` is the same in-memory client every example in this tutorial is tested with, and + it's how you'll test yours. It gets a whole chapter: **Testing**. + +## What you did not write + +Look back over this page. You wrote three small Python functions. You did **not** write: + +* A JSON Schema. `a: int, b: int` *is* the schema for `add`. +* A request handler. `tools/list`, `resources/read`, `prompts/get`: all served for you. +* A capability declaration. `MCPServer` made it for you. +* A line of protocol. The handshake, the version negotiation, the JSON-RPC framing: all of it happened inside `mcp dev` and `Client(mcp)`, and you never saw it. + +That ratio is the whole point of the SDK. + +## Recap + +* A **host** is the LLM app, a **client** is its MCP-speaking half, a **server** is what you build. +* Tools are **model**-controlled, resources are **application**-controlled, prompts are **user**-controlled. +* One decorator per primitive: `@mcp.tool()`, `@mcp.resource(uri)`, `@mcp.prompt()`. Name, description, and schema come from the function. +* A URI with a `{param}` makes a resource **template**, listed separately from concrete resources. +* The server's **capabilities** are declared for you, and a client only asks for what a server declares. +* `Client(mcp)` connects to the server object in memory: your test harness from day one. + +Each primitive now gets its own chapter, starting with the one the model drives: **Tools**. diff --git a/docs/tutorial/handling-errors.md b/docs/tutorial/handling-errors.md new file mode 100644 index 0000000000..9ee6dd9817 --- /dev/null +++ b/docs/tutorial/handling-errors.md @@ -0,0 +1,132 @@ +# Handling errors + +A tool can fail in two ways, and the SDK treats them very differently. + +Raise an ordinary exception and the **model** sees it. Raise `MCPError` and the **protocol** sees it. + +This chapter is about choosing. + +## An error the model can fix + +Take a tool that looks something up, and let the lookup miss: + +```python title="server.py" hl_lines="11-12" +--8<-- "docs_src/handling_errors/tutorial001.py" +``` + +There is nothing MCP about those two lines. `get_author` raises a plain `ValueError`, the way any Python function would. + +Call it with a title that isn't in the catalog and look at the result: + +```python +result.is_error # True +result.content # [TextContent(text="Error executing tool get_author: No book titled 'Nothing' in the catalog.")] +result.structured_content # None +``` + +* The request **succeeded**. There is a result; nothing was raised at the caller. +* `is_error` is `True`, and your exception's message (prefixed with the tool name) is in `content`, exactly where the model reads. +* `structured_content` is `None`. A failed call has no return value to structure. + +This is a **tool error**, and it is the default for *any* exception your tool raises. It is also almost always what you want. + +The model is the one calling your tool. It picked the arguments. So a tool error is a turn in the conversation: the model reads *"No book titled 'Nothing' in the catalog."*, realises it guessed the title wrong, and calls again with a better one. You wrote one `raise` and got a self-correcting agent. + +!!! tip + Never `return` an error message from a tool. A returned string has `is_error=False`, so to the + model (and to every client UI) it looks like the tool worked and that string was the answer. + `raise`. The flag is the signal. + +## An error the model cannot fix + +Now swap `ValueError` for `MCPError`. + +```python title="server.py" hl_lines="1 3 15" +--8<-- "docs_src/handling_errors/tutorial002.py" +``` + +`MCPError` is the SDK's **protocol error**. It is the one exception the tool wrapper does *not* catch: it propagates, and the whole `tools/call` request fails with a JSON-RPC error instead of a result. + +```json +{ + "code": -32602, + "message": "No book titled 'Nothing' in the catalog." +} +``` + +* 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. + +!!! check + Same lookup, same miss, but now the call *raises* on the client side instead of returning: + + ```text + mcp.shared.exceptions.MCPError: No book titled 'Nothing' in the catalog. + ``` + + The first version handed the model a sentence it could react to. This one hands it nothing. + For `get_author` that is strictly worse, which is the point of the next section. + +## Which one to raise + +The two paths answer two different questions. + +* **Raise any exception** for a failure of *execution*: the thing your tool tried to do didn't work. The model chose the call, so the model should see the consequence and get a chance to recover. A misspelled title, an upstream API that timed out, a row that doesn't exist: all tool errors. +* **Raise `MCPError`** when the *request itself* should be rejected: the client is missing a capability your tool depends on, the server isn't in a state to serve anyone, the caller skipped a required step. No retry from the model fixes any of those, so there is nothing to gain from handing it the message. + +One question decides it: **could a smarter model have avoided this?** Yes -> ordinary exception. No -> `MCPError`. + +By that test, the second version of `get_author` made the wrong choice: a better title fixes it, so the model deserved to see the message. It's there to show you the mechanism, not to recommend it. + +!!! info + `MCPError` lives at `from mcp import MCPError` and takes `code`, `message`, and an optional + `data` payload. Whatever you put in them is what the client receives: the SDK forwards a raised + `MCPError` verbatim instead of sanitising it. + +## A resource that doesn't exist + +Resources draw the same line, and ship one named exception for the common case. + +```python title="server.py" hl_lines="2 13" +--8<-- "docs_src/handling_errors/tutorial003.py" +``` + +`books://{title}` is a **template**. It matches *any* title, so "the URI is well-formed" and "the book exists" are two different questions, and only your function can answer the second one. + +When it can't, raise `ResourceNotFoundError`. The SDK turns it into the protocol error the spec assigns to a missing resource: `-32602` with the requested URI in `data`, so the client knows *which* read failed. + +```json +{ + "code": -32602, + "message": "No book titled 'Nothing' in the catalog.", + "data": {"uri": "books://Nothing"} +} +``` + +Notice there is no `is_error=True` half-result here. A resource read either returns contents or fails: resources have only the protocol path. Templates and everything else about resources live in **Resources**. + +## Errors you never raise + +A bad argument never reaches your function. + +Send `get_author` a `title` that isn't a string and the SDK rejects it against the input schema **before** calling you, as the same kind of `is_error=True` tool error the model can read and correct. You saw this in **Tools** with `Field(le=50)`. + +It means a whole class of `raise` statements you don't write: don't re-validate your own type hints. + +!!! info + Everything on this page is what a **client** sees, and the in-memory `Client` you'll write + tests with sees exactly the same thing. Even `raise_exceptions=True` doesn't turn a tool error + back into a traceback: by the time that flag could act, your exception is already the + `is_error=True` result. Assert on the result. **Testing** covers the pattern. + +## Recap + +* Raise **any exception** in a tool -> the call returns `is_error=True` with your message in `content`. The model reads it and can retry. This is the default. +* Raise **`MCPError`** -> the call itself fails with a JSON-RPC error. The model sees nothing; the host deals with it. `code`, `message`, and `data` survive intact. +* 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`. + +Errors handled. Next: the things your server sets up once, before the first call ever arrives, the **Lifespan**. diff --git a/docs/tutorial/index.md b/docs/tutorial/index.md new file mode 100644 index 0000000000..e7c7ba799e --- /dev/null +++ b/docs/tutorial/index.md @@ -0,0 +1,51 @@ +# Tutorial - User Guide + +This tutorial shows you how to use the MCP Python SDK, step by step. + +Each section gradually builds on the previous ones, but it's written so you can go straight to any specific section to solve a specific problem. It also works as a future reference: you can come back to exactly the part you need. + +## Run the code + +All the code blocks can be copied and used directly: they are complete, working files. + +To follow along, paste a block into a `server.py` and open it in the MCP Inspector: + +```console +uv run mcp dev server.py +``` + +It is **HIGHLY encouraged** that you write (or copy) the code, edit it, and run it locally. Using it in your own editor is what really shows you the point: how little you write, the autocompletion, the type checks catching mistakes before you run anything. + +## You will not be guessing + +Every example in this tutorial is a complete file under [`docs_src/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/docs_src) in the SDK's own repository, and every one of them is exercised by the SDK's test suite through an **in-memory client**: + +```python +import pytest +from mcp import Client + +from server import mcp + + +@pytest.mark.anyio +async def test_add() -> None: + async with Client(mcp) as client: + result = await client.call_tool("add", {"a": 1, "b": 2}) + assert result.structured_content == {"result": 3} +``` + +No subprocess, no port, no transport. `Client(mcp)` connects to the server object directly. + +If a change to the SDK breaks an example on one of these pages, CI goes red before the page does. The code you read here is the code that runs. + +You'll use this yourself in the [Testing](testing.md) chapter; it's how you test your own servers, too. + +## Install the SDK + +If you haven't yet, [install the SDK](../installation.md) first. + +## Advanced User Guide + +There is also an **Advanced User Guide** you can read after this one. + +It builds on this tutorial, uses the same concepts, and teaches you the extra things: the low-level `Server`, middleware, authorization, the 2026-07-28 protocol negotiation. But you should read this first: everything in the Advanced guide assumes you know the basics. diff --git a/docs/tutorial/lifespan.md b/docs/tutorial/lifespan.md new file mode 100644 index 0000000000..97ea2d0964 --- /dev/null +++ b/docs/tutorial/lifespan.md @@ -0,0 +1,102 @@ +# Lifespan + +Most real servers hold something for their whole life: a database pool, an HTTP client, a loaded model. + +You don't want to build it on every call, and you do want to close it cleanly. That's what the **lifespan** is for. + +## A typed lifespan + +A lifespan is an `@asynccontextmanager` that receives the server and `yield`s **one object**. Whatever you yield is available to every handler for as long as the server runs. + +```python title="server.py" hl_lines="25-31 34 38 40" +--8<-- "docs_src/lifespan/tutorial001.py" +``` + +Read it bottom-up: + +* `app_lifespan` connects the `Database` **before** the `yield` and disconnects it **after**, in a `finally`. That's startup and shutdown. +* It yields an `AppContext`, a plain dataclass holding the things you set up. One field today, ten tomorrow. +* `MCPServer("Bookshop", lifespan=app_lifespan)` is the whole wiring. +* Inside the tool, the yielded object is `ctx.request_context.lifespan_context`. + +The lifespan runs **once**. It is entered when the server starts (before the first request) and exited when the server stops. Every request in between shares the same `AppContext`. + +!!! info + If you've written a FastAPI `lifespan`, you already know this. Same decorator, same `yield`, same `finally`. + +### What the model sees + +Nothing new. `ctx` is a **Context** parameter, so the SDK injects it and it never reaches the input schema: + +```json +{ + "type": "object", + "properties": { + "genre": {"title": "Genre", "type": "string"} + }, + "required": ["genre"], + "title": "count_booksArguments" +} +``` + +`genre` is the only argument the model can pass. The lifespan is your server's business. + +`@mcp.resource()` and `@mcp.prompt()` functions can take a `ctx` parameter too, written as a bare `Context` for a reason the next section gets to. Everything `ctx` carries is in **The Context**. + +### It really is typed + +Look at the annotation again: `ctx: Context[AppContext]`. + +That one type parameter is why `ctx.request_context.lifespan_context` **is** an `AppContext` to your type checker. `.db` autocompletes; `.dbb` is an error before you ever run the server. + +Write a bare `Context` instead and `lifespan_context` is typed as `dict[str, Any]`: the type checker has no way to know what your lifespan yielded. The object is still there at runtime; you've lost the help. + +!!! warning + `Context[AppContext]` is a **tool-only** spelling. Put it on an `@mcp.resource()` or + `@mcp.prompt()` function and every call to that handler fails. The client gets an error back, + and the server log shows why: + + ```text + Context is not available outside of a request + ``` + + In resources and prompts, write the bare `ctx: Context`. The object your lifespan yielded is + still `ctx.request_context.lifespan_context` at runtime; you give up the type parameter, not + the object. + +!!! tip + There is always a lifespan. If you don't pass one, the SDK's default yields an empty `dict`, + so `ctx.request_context.lifespan_context` is `{}`, never `None`. That default is also why a + bare `Context` types it as `dict[str, Any]`. + +## Watch it happen + +"Startup runs before the first request" is the kind of sentence you should not have to take on faith. + +Strip the server down to the lifecycle: give `Database` a `connected` flag, flip it in `connect()` and `disconnect()`, and add a tool that reports it. + +```python title="server.py" hl_lines="11 14 17 25 44" +--8<-- "docs_src/lifespan/tutorial002.py" +``` + +`database` lives at module level for one reason: so you can look at it from *outside* the server. + +!!! check + Three moments, three values: + + * Before the server starts, `database.connected` is `False`. Importing the module connected nothing. + * While it's running, call `database_status` and the result is `"connected"`. + * Stop the server and the `finally` block runs: `database.connected` is `False` again. + + The work happened exactly where you put it: around the `yield`, not at import time and not per request. + +## Recap + +* `lifespan=` takes an `@asynccontextmanager` that receives the server and `yield`s one object. +* Code before the `yield` is startup. The `finally` after it is shutdown. +* It runs once, around the whole life of the server, not per request. +* Whatever you `yield` is `ctx.request_context.lifespan_context` in every tool, resource, and prompt. +* `ctx: Context[AppContext]` makes that access fully typed in tools. Resources and prompts take the bare `Context`. +* No `lifespan=` means an empty `dict`, never `None`. + +Next: tools that return more than text, **Media**. diff --git a/docs/tutorial/logging.md b/docs/tutorial/logging.md new file mode 100644 index 0000000000..f4a58b70f2 --- /dev/null +++ b/docs/tutorial/logging.md @@ -0,0 +1,78 @@ +# Logging + +Log from a tool the way you log from any other Python function: with the standard library. + +MCP has a protocol-level **logging capability**: a server could push its log messages to the client as notifications, through methods on the `Context` object. The 2026-07-28 revision of the spec **deprecates that capability and does not replace it**, so this tutorial doesn't teach it. The full list of what's deprecated and what to do instead is in **Deprecated features**. + +What you do instead is what you do in every other Python program: the standard library. + +## A tool that logs + +```python title="server.py" hl_lines="1 5 13" +--8<-- "docs_src/logging/tutorial001.py" +``` + +* `logging.getLogger(__name__)` gives you a logger named after your module. Create it once, at the top. +* Inside the tool you call `logger.info(...)` like in any other function. Nothing to inject, nothing to `await`, nothing MCP-specific. + +!!! check + Call the tool and look at the whole result: + + ```python + result.content # [TextContent(text="Found 3 books matching 'dune'.")] + result.structured_content # {'result': "Found 3 books matching 'dune'."} + ``` + + The log line is nowhere in it. Logging is for **you**, the person operating the server. The model + never sees it. If the model should read something, `return` it. + +## Where it goes + +For a **stdio** server this question matters more than usual. The host launched your server as a subprocess and is reading MCP messages from its **stdout**. Standard error is yours. + +The standard library already does the right thing: log output goes to `sys.stderr` by default. Your `logger.info(...)` lines land in the terminal (or wherever the host collects the subprocess's stderr), and the protocol stream stays clean. + +!!! tip + Never `print()` in a stdio server. `print` writes to **stdout**, and stdout *is* the wire: one stray + line and the client is trying to parse it as JSON-RPC. + + `logger.debug("got here")` is the same one line of effort and goes to the right place. + +## The level + +You don't have to call `logging.basicConfig()` yourself. Constructing an `MCPServer` already did, with a handler pointed at standard error, at the level you pass as `log_level=`, so `MCPServer("Bookshop", log_level="DEBUG")` is all it takes to see your `logger.debug(...)` lines. + +The default is `"INFO"`. + +`logging.basicConfig()` never replaces handlers that already exist. If you configure logging yourself before creating the server, your configuration wins. + +## Try it + +Run the server with the MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Call `search_books` from the **Tools** tab. The Inspector shows you the result: only the return value. The line + +```text +Searching for 'dune' +``` + +went to standard error: the terminal, not the wire. + +!!! info + If what you actually want is *tracing* (every request, how long it took, whether it failed), you + don't want log lines, you want spans. The SDK ships an `OpenTelemetryMiddleware` for exactly that. + See **Middleware**. + +## Recap + +* The MCP protocol's logging capability is deprecated by the 2026-07-28 spec and not replaced. Don't build on it. +* `logger = logging.getLogger(__name__)` at module level, `logger.info(...)` in the tool. That's the whole pattern. +* Log output never reaches the model. Only the value you `return` does. +* Standard error is yours; stdout belongs to the protocol. Never `print()` in a stdio server. +* `MCPServer(..., log_level="DEBUG")` sets the level, and a logging configuration you made first is left alone. + +Next: the in-memory client that has been running every example on these pages, and how to point it at your own server, in **Testing**. diff --git a/docs/tutorial/media.md b/docs/tutorial/media.md new file mode 100644 index 0000000000..a473c0bba2 --- /dev/null +++ b/docs/tutorial/media.md @@ -0,0 +1,108 @@ +# Media + +Text is not the only thing a tool can return. + +The SDK ships two helpers for binary results (**`Image`** and **`Audio`**) and an **`Icon`** type for giving your server, tools, resources, and prompts a face in the client's UI. + +## Returning an image + +Annotate the return type as `Image` and return one: + +```python title="server.py" hl_lines="14 16" +--8<-- "docs_src/media/tutorial001.py" +``` + +* `Image` takes exactly one of `data` (raw bytes) or `path` (a file to read). +* `format="png"` becomes the MIME type the client sees: `image/png`. +* The bytes here are a one-pixel placeholder so the file runs on its own. In a real server they come from Pillow, matplotlib, a headless browser, or anything else that hands you `bytes`. + +`Image` is an SDK convenience, not a protocol type. On the wire your return value becomes an **`ImageContent`** block (your bytes base64-encoded, plus the MIME type): + +```python +result.content # [ImageContent(type="image", data="iVBORw0KGgoAAAANSUhEUg...", mime_type="image/png")] +result.structured_content # None +``` + +Two things to notice: + +* `data` is base64. You returned raw `bytes`; the SDK did the encoding. +* `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**, where the return annotation *is* the schema.) + +!!! info + `ImageContent` and `AudioContent` live in `mcp_types`, right next to the `TextContent` + you met in **Tools**. A tool result is a list of content blocks; `Image` and `Audio` are + the shortest way to produce the two binary kinds. + +### Try it + +```console +uv run mcp dev server.py +``` + +Open the **Tools** tab and call `logo`. The result is not a string: it is an `image` content block, and the Inspector renders it as a picture. You returned `bytes`; everything between that and the pixels on screen was the SDK. + +## Returning audio + +`Audio` is the same shape: + +```python title="server.py" hl_lines="21-24" +--8<-- "docs_src/media/tutorial002.py" +``` + +The result is an **`AudioContent`** block: + +```python +result.content # [AudioContent(type="audio", data="UklGRjQAAABXQVZFZm1...", mime_type="audio/wav")] +result.structured_content # None +``` + +Same deal: raw bytes in, base64 and a MIME type out, no output schema. + +## Bytes or a file + +Both helpers also accept `path=` instead of `data=`. The file is read when the result is built, and the MIME type is guessed from the suffix: + +* `Image`: `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`. +* `Audio`: `.wav`, `.mp3`, `.ogg`, `.flac`, `.aac`, `.m4a`. + +A suffix it doesn't recognise falls back to `application/octet-stream`. + +!!! check + With `data=` there is no filename, so there is nothing to guess from. Forget `format=` and + the SDK falls back to a default: `image/png` for images, `audio/wav` for audio. Build an + `Audio` from MP3 bytes that way and the client is told `mime_type="audio/wav"`, then + faithfully fails to decode it. When you pass `data=`, pass `format=`. + +## Icons + +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" +--8<-- "docs_src/media/tutorial003.py" +``` + +* `src` is a URI the client can resolve: `https:`, or a `data:` URI if you want the icon embedded with no extra fetch. +* `mime_type` and `sizes` (`"48x48"`, or `"any"` for a scalable format) let the client pick the right one when you offer several. +* `theme="light"` or `theme="dark"` marks an icon for one colour scheme. + +The same `icons=[...]` keyword is accepted by `MCPServer(...)`, `@mcp.tool()`, `@mcp.resource()`, and `@mcp.prompt()`. + +### Where a client sees them + +Icons travel with whatever they decorate. The server's arrive during the handshake, on `client.server_info`: + +```python +client.server_info.icons # [Icon(src="https://example.com/brand-kit.png", mime_type="image/png", sizes=["48x48"])] +``` + +A tool's icons are on the `Tool` object from `tools/list`, a resource's on the `Resource` from `resources/list`, a prompt's on the `Prompt` from `prompts/list`. The field is always called `icons`. + +## Recap + +* Return an `Image` or `Audio` from a tool and the client receives an `ImageContent` / `AudioContent` block: your bytes base64-encoded, with a MIME type. +* Build one from in-memory `data=` plus an explicit `format=`, or from a `path=` and let the suffix decide. +* Media results carry no `structured_content` and no output schema. +* An `Icon` is a pointer: a `src` URI plus optional `mime_type`, `sizes`, and `theme`. +* `icons=[...]` works on the server, on tools, on resources, and on prompts, and clients find them on the matching objects. + +That is everything a tool can put *into* a result. Helping the user fill in a prompt's or a resource template's arguments *before* anything runs is **Completions**. diff --git a/docs/tutorial/progress.md b/docs/tutorial/progress.md new file mode 100644 index 0000000000..3267e89193 --- /dev/null +++ b/docs/tutorial/progress.md @@ -0,0 +1,117 @@ +# Progress + +A tool that takes thirty seconds and says nothing for thirty seconds looks broken. + +**Progress notifications** fix that. The tool reports how far along it is; the client decides what to draw with it: a bar, a spinner, a log line. + +## Report it from the tool + +Take a **`Context`** parameter and call `report_progress`: + +```python title="server.py" hl_lines="8 11" +--8<-- "docs_src/progress/tutorial001.py" +``` + +Three arguments, and you decide what they mean: + +* `progress`: how far you are. The spec requires it to **increase** with every report; never repeat a value or go backwards. +* `total`: how much there is in total, if you know. Optional. +* `message`: one human-readable line about *this* step. Optional. + +`ctx` is injected because of its type hint and the model never sees it: `import_catalog`'s input schema has a single property, `urls`. **The Context** chapter is all about that object; progress is one of the things it gives you. + +## Listen for it from the client + +The client opts in **per call**, by passing `progress_callback=` to `call_tool`: + +```python title="client.py" hl_lines="7 16" +import anyio +from mcp import Client + +from server import mcp + + +async def show(progress: float, total: float | None, message: str | None) -> None: + print(f"{message} ({progress}/{total})") + + +async def main() -> None: + async with Client(mcp) as client: + result = await client.call_tool( + "import_catalog", + {"urls": ["https://example.com/a.json", "https://example.com/b.json"]}, + progress_callback=show, + ) + print(result.structured_content) + + +anyio.run(main) +``` + +The callback is an `async` function taking exactly what the server reported: `progress`, `total`, `message`. + +!!! info + `Client(mcp)` connects straight to the server object, in memory, the same client the **Testing** + chapter is built on. `progress_callback` is the same parameter whatever transport the `Client` + uses; the *timing* you are about to see is the in-memory connection's. It runs your callback + inline, so every report lands before `call_tool` returns. Over a real transport the + notifications race the result, and a slow callback can still be running after `call_tool` has + returned. + +### Try it + +Put `client.py` next to `server.py` and run it: + +```console +python client.py +``` + +```text +Imported https://example.com/a.json (1/2) +Imported https://example.com/b.json (2/2) +{'result': 'Imported 2 records.'} +``` + +Every `await ctx.report_progress(...)` on the server became one call to `show` on the client, in order, and both lines printed **before** `call_tool` returned. Progress is not bundled into the result; it streams while the tool is still working. + +!!! warning + `progress_callback` belongs to the **call**, not the `Client`. There is no constructor argument + for it, because different calls want different callbacks: one drives a download bar, the next + one a log line. + +!!! check + Now delete `progress_callback=show` and run it again: + + ```text + {'result': 'Imported 2 records.'} + ``` + + No error, no warning, same result. `report_progress` is a **no-op when the caller didn't ask + for progress**, so you report unconditionally and never have to wonder whether anyone is + listening. + +## When you don't know the total + +`total` is for when you know the denominator. Often you don't: you're draining a feed, walking a cursor, downloading something with no length header. + +Leave it out: + +```python title="server.py" hl_lines="20" +--8<-- "docs_src/progress/tutorial002.py" +``` + +The callback receives `total=None`. A client can still show *activity* ("3 imported so far...") but it can't show a percentage. Don't invent a total to get a prettier bar. + +!!! tip + `progress` doesn't have to count anything in particular. Bytes, rows, pages: pick the unit the + user would recognise, and only promise a `total` you can keep. + +## Recap + +* `await ctx.report_progress(progress, total=None, message=None)` from any tool that takes a `Context`. +* The client passes `progress_callback=` to `call_tool`: per call, never on the `Client`. +* The callback is `async (progress, total, message) -> None` and fires while the tool is still running. +* No callback on the call means `report_progress` does nothing. Report unconditionally. +* Omit `total` when you don't know it; the callback gets `None`. + +Progress is what a running tool shows the *user*. The lines it logs for *you*, the person operating the server, are a different channel: **Logging** is next. diff --git a/docs/tutorial/prompts.md b/docs/tutorial/prompts.md new file mode 100644 index 0000000000..44c23fa2e2 --- /dev/null +++ b/docs/tutorial/prompts.md @@ -0,0 +1,150 @@ +# Prompts + +A **prompt** is a message template the user picks. + +Tools are for the model. A prompt is the opposite: the user chooses one from a menu in their client (a slash command, a button), fills in its arguments, and the rendered messages go into the conversation as if they had typed them. + +You declare one by putting `@mcp.prompt()` on a function that returns the text. + +## Your first prompt + +```python title="server.py" hl_lines="6-9" +--8<-- "docs_src/prompts/tutorial001.py" +``` + +The SDK reads the same three things it read from your tools: + +* The **name** is the function name: `review_code`. +* The **description** the client shows is the docstring: `Review a piece of code.` +* The **arguments** come from the parameters. `code` has no default, so it's required. + +That is what a client gets back from `prompts/list`: + +```json +{ + "name": "review_code", + "description": "Review a piece of code.", + "arguments": [ + {"name": "code", "required": true} + ] +} +``` + +There is no JSON Schema here. Prompt arguments are a flat list of **named string values**: a form a person fills in, not a payload a model constructs. + +### Rendering it + +The client renders the template with `prompts/get`, passing the arguments. Your function runs and the `str` you return becomes **one user message**: + +```json +{ + "description": "Review a piece of code.", + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "Please review this code:\n\ndef add(a, b): return a + b" + } + } + ], + "resultType": "complete" +} +``` + +That is the entire life of a prompt: listed by name, rendered on demand, dropped into the chat. + +!!! check + `required` is enforced before your function runs. Render `review_code` without `code` and the + request itself fails with a JSON-RPC error (code `-32603`): + + ```text + mcp.shared.exceptions.MCPError: Internal server error + ``` + + There is no tool-style error result to hand back to a model, because no model is in the loop: + the call raises. The reason (`Missing required arguments: {'code'}`) lands in your server's log. + +### Try it + +Run the server with the MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Open the **Prompts** tab and select `review_code`. The Inspector draws a form with one required `code` field. Fill it in, render it, and you get back exactly the user message above. + +## More than one message + +A code review is one message. A debugging session is a conversation, and a prompt can seed the whole thing. + +Return a list of messages instead of a `str`: + +```python title="server.py" hl_lines="2 13-20" +--8<-- "docs_src/prompts/tutorial002.py" +``` + +* `UserMessage` and `AssistantMessage` come from `mcp.server.mcpserver.prompts.base`. Hand them a `str` and they wrap it in `TextContent` for you. The role is the class name. +* `Message` is their common base. Use it as the return annotation. + +Rendering `debug_error` now produces three messages, in order: + +```json +{ + "description": "Start a debugging conversation.", + "messages": [ + {"role": "user", "content": {"type": "text", "text": "I'm seeing this error:"}}, + {"role": "user", "content": {"type": "text", "text": "TypeError: 'int' object is not iterable"}}, + { + "role": "assistant", + "content": {"type": "text", "text": "I'll help debug that. What have you tried so far?"} + } + ], + "resultType": "complete" +} +``` + +Notice the last one. Pre-filling an `assistant` turn is how you steer the model's *next* reply without making the user type the steering themselves. + +## Titles and argument descriptions + +`review_code` is a function name, not a label. Give the client something better to put on the button, and describe each argument so the form explains itself: + +```python title="server.py" hl_lines="10-13" +--8<-- "docs_src/prompts/tutorial003.py" +``` + +* `title="Code review"` is the human-readable name, exactly like a tool's `title`. +* `Annotated[str, Field(description=...)]` is the same pattern you used in **Tools**. Here the description lands on the argument instead of in a schema. +* `language` has a default, so it stops being required. + +The `prompts/list` entry now carries everything a client needs to draw a good form: + +```json +{ + "name": "review_code", + "title": "Code review", + "description": "Review a piece of code.", + "arguments": [ + {"name": "code", "description": "The code to review.", "required": true}, + {"name": "language", "description": "The language the code is written in.", "required": false} + ] +} +``` + +!!! info + If you have read **Tools**, you already know everything on this page. Same decorator, same + docstring-as-description, same `Annotated`/`Field`. The only things that change are who + triggers it (the user) and where the result goes (into the conversation). + +## Recap + +* `@mcp.prompt()` on a function makes it a prompt. Name from the function, description from the docstring. +* Prompts are **user-controlled**: the client lists them, the user picks one and fills in the arguments. +* Arguments are a flat list of named strings (no schema). A parameter with a default is optional. +* Return a `str` and it becomes one user message. Return a list of `UserMessage` / `AssistantMessage` to seed a multi-turn conversation. +* `title=` and `Field(description=...)` are what a client puts in its UI. +* A missing required argument fails the whole request. There is no per-prompt error result. + +Next up: the one extra parameter a tool, resource or prompt can ask the SDK for, **The Context**. diff --git a/docs/tutorial/resources.md b/docs/tutorial/resources.md new file mode 100644 index 0000000000..5cf35503f9 --- /dev/null +++ b/docs/tutorial/resources.md @@ -0,0 +1,139 @@ +# Resources + +A **resource** is data you expose for the application to read. + +That's the split. A tool is something the **model** decides to call. A resource is something the **application** decides to load (a config file, a record, a document) and put in front of the model as context. + +You declare one by putting `@mcp.resource(uri)` on a plain Python function. + +## Your first resource + +```python title="server.py" hl_lines="6-8" +--8<-- "docs_src/resources/tutorial001.py" +``` + +It's the same shape as a tool, plus one thing: the **URI**. Resources are addressed, not named. A client asks for `config://app`, never for `get_config`. + +The SDK still reads the rest from the function: + +* The **name** is the function name: `get_config`. +* The **description** the client sees is the docstring. +* The **content** is whatever you return. + +During `resources/list` the client gets this: + +```json +{ + "name": "get_config", + "uri": "config://app", + "description": "The active shop configuration.", + "mimeType": "text/plain" +} +``` + +And when it reads `config://app`, your function runs and the return value comes back as text: + +```python +result.contents # [TextResourceContents(uri="config://app", mime_type="text/plain", text="theme=dark\nlanguage=en")] +``` + +!!! tip + Listing is cheap. Your function is **not** called during `resources/list`, only during + `resources/read`, and only for the URI that was asked for. Expose a thousand resources + and you pay for the ones somebody opens. + +### Try it + +Run the server with the MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Open the URL it prints and go to the **Resources** tab. `config://app` is in the list with its description. Click it and the Inspector reads it: there are your two lines of config. + +## Resource templates + +One URI per record doesn't scale. Put a **placeholder** in the URI and a matching parameter on the function: + +```python title="server.py" hl_lines="12-13" +--8<-- "docs_src/resources/tutorial002.py" +``` + +`{user_id}` in the URI, `user_id: str` on the function. That is the entire contract. + +This is now a **resource template**, and it moves house: it leaves `resources/list` and shows up in `resources/templates/list` instead, as a pattern rather than an address: + +```json +{ + "name": "get_user_profile", + "uriTemplate": "users://{user_id}/profile", + "description": "A customer's profile.", + "mimeType": "text/plain" +} +``` + +The client fills in the placeholder and reads a concrete URI: `users://42/profile`, `users://ada/profile`. One function answers all of them, with the matched value passed in as `user_id`: + +```python +result.contents # [TextResourceContents(uri="users://42/profile", text="User 42: 12 orders since 2021.")] +``` + +Notice the `uri` in the result. It is the **concrete** URI the client asked for, not the template. + +!!! check + The placeholders and the parameters have to agree. Rename the function parameter to + `user` while the URI still says `{user_id}` and the decorator refuses **at import time**, + before any client gets near it: + + ```text + ValueError: Mismatch between URI parameters {'user_id'} and function parameters {'user'} + ``` + + A mismatch can only ever be a bug, so the SDK makes it impossible to start the server with one. + +`get_user_profile` can also take a parameter annotated `Context`. The SDK injects it without ever treating it as a URI parameter, and **The Context** chapter covers what it gives you. + +## What you return + +You're not limited to `str`. Give each resource a `mime_type` and return whatever fits: + +```python title="server.py" hl_lines="8-9 14-15 20-21" +--8<-- "docs_src/resources/tutorial003.py" +``` + +* `readme` returns a `str`, so it's sent as-is. This is the common case. +* `catalog_stats` returns a `dict`, so the SDK serialises it to **JSON text** for you: + + ```json + { + "books": 1204, + "authors": 391 + } + ``` + +* `placeholder_cover` returns `bytes`, so the client gets a `BlobResourceContents` instead of a `TextResourceContents`, with your bytes base64-encoded in its `blob` field. + +The same rule applies to anything else JSON-serialisable: a list, a Pydantic model, a dataclass. If it isn't a `str` and isn't `bytes`, it becomes JSON. + +`mime_type` is yours to declare, and it defaults to `text/plain`. The SDK never inspects what you return to guess it, so a `dict` resource you don't label is still advertised as plain text. + +!!! tip + `name=`, `title=` and `description=` are also accepted by `@mcp.resource()` when you don't + want to derive them from the function. And when there's no function to write at all, + `mcp.server.mcpserver.resources` has ready-made `Resource` classes (`TextResource`, + `BinaryResource`, `FileResource`, `HttpResource`, `DirectoryResource`) that you register + with `mcp.add_resource(...)`. + +A client can also **subscribe** to a resource and be notified when it changes; that's the client's half of the story and it lives in **The Client**. + +## Recap + +* `@mcp.resource(uri)` on a function makes it a resource. The URI is the address, the return value is the content, the docstring is the description. +* A `{placeholder}` in the URI makes it a **template**: it's listed under `resources/templates/list` and one function serves every URI that matches. +* Placeholder names must equal the function's parameter names. Get it wrong and you find out at import time, not in production. +* Your function runs when the resource is **read**, not when it's listed. +* `str` becomes text, `bytes` becomes a base64 blob, anything else becomes JSON text. `mime_type=` is how you label it. +* Tools are for the model to act. Resources are for the application to read. + +Next: the third primitive, the one a person picks from a menu, **Prompts**. diff --git a/docs/tutorial/structured-output.md b/docs/tutorial/structured-output.md new file mode 100644 index 0000000000..7f20b670ad --- /dev/null +++ b/docs/tutorial/structured-output.md @@ -0,0 +1,245 @@ +# Structured Output + +In **Tools** you returned a `str` and the result came back twice: as text in `content`, and as `{"result": "..."}` in `structured_content`. + +This chapter is about that second channel: where it comes from, every shape it can take, and how the SDK keeps it honest. + +The short version: **the return type annotation is the output schema**. You already wrote it. + +## The output schema + +```python title="server.py" hl_lines="9" +--8<-- "docs_src/structured_output/tutorial001.py" +``` + +The line that matters is the signature: `-> int`. + +Because of it, the tool the SDK sends during `tools/list` carries an `output_schema` next to the input schema you met in **Tools**: + +```json +{ + "properties": { + "result": {"title": "Result", "type": "integer"} + }, + "required": ["result"], + "title": "get_temperatureOutput", + "type": "object" +} +``` + +A bare `int` isn't a JSON object, so the SDK **wraps** it in `{"result": ...}`. Call the tool and both channels are filled: + +```python +result.content # [TextContent(text="17")] +result.structured_content # {"result": 17} +``` + +Every scalar gets the same wrapper: `str`, `int`, `float`, `bool`, `bytes`, `None`. + +## Two channels + +Why send the same value twice? + +* `content` is for the **model**. A language model reads text; this is the only part of the result it sees. +* `structured_content` is for the **application** the model runs inside: code that wants `17`, not a sentence containing "17". +* `output_schema` is the contract between them, published before the tool is ever called. + +You return one Python value. The SDK fills in all three. + +## Return a model + +Declare the shape as a Pydantic `BaseModel` and return an instance: + +```python title="server.py" hl_lines="8-11 15" +--8<-- "docs_src/structured_output/tutorial002.py" +``` + +`WeatherData` **is** the schema now. No wrapper, no `result` key: + +```json +{ + "properties": { + "temperature": {"description": "Degrees Celsius.", "title": "Temperature", "type": "number"}, + "humidity": {"description": "Relative humidity, 0 to 1.", "title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"} + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object" +} +``` + +`structured_content` is the object, field for field: + +```python +result.structured_content # {"temperature": 16.2, "humidity": 0.83, "conditions": "Overcast"} +``` + +And the model is not left out. The SDK serializes the same object to JSON text for `content`: + +```json +{ + "temperature": 16.2, + "humidity": 0.83, + "conditions": "Overcast" +} +``` + +Notice the `Field(description=...)` on `temperature` and `humidity` landed in the schema. The same `Field` that described your **inputs** describes your outputs. + +!!! info + If you've used FastAPI's `response_model`, you already know this: a Pydantic model as the declared + response, serialized and documented for you. The only difference is that here the return annotation + is the whole declaration. + +## A `TypedDict` + +Not every shape deserves a class. A `TypedDict` produces the same schema: + +```python title="server.py" hl_lines="8" +--8<-- "docs_src/structured_output/tutorial003.py" +``` + +A `TypedDict` is a plain `dict` at runtime, so that is what you build and return. The schema, the validation, and `structured_content` are identical to the `BaseModel` version (minus the descriptions, which `TypedDict` has no place for). + +## A dataclass + +Dataclasses work too, and so does any ordinary class whose attributes have type hints. The SDK builds a Pydantic model out of the annotations behind the scenes. + +```python title="server.py" hl_lines="8-9" +--8<-- "docs_src/structured_output/tutorial004.py" +``` + +Three spellings, one schema. Use whichever your codebase already has. + +## Lists + +A `list[...]` isn't a JSON object either, so it gets the `{"result": ...}` wrapper, with your item type as a `$defs` reference inside it: + +```python title="server.py" hl_lines="15" +--8<-- "docs_src/structured_output/tutorial005.py" +``` + +```json +{ + "$defs": { + "WeatherData": { + "properties": { + "temperature": {"title": "Temperature", "type": "number"}, + "humidity": {"title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"} + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object" + } + }, + "properties": { + "result": {"items": {"$ref": "#/$defs/WeatherData"}, "title": "Result", "type": "array"} + }, + "required": ["result"], + "title": "get_forecastOutput", + "type": "object" +} +``` + +Ask for a two-day forecast and `structured_content` is `{"result": [{...}, {...}]}`. `content` becomes **two** `TextContent` blocks, one per item: a list is flattened for the model rather than dumped as one string. + +`tuple[...]`, unions, and `Optional[...]` are wrapped the same way. + +## Dictionaries + +`dict[str, ...]` is the one generic that already *is* a JSON object, so it isn't wrapped: + +```python title="server.py" hl_lines="9" +--8<-- "docs_src/structured_output/tutorial006.py" +``` + +```json +{ + "additionalProperties": {"type": "number"}, + "title": "get_temperaturesDictOutput", + "type": "object" +} +``` + +```python +result.structured_content # {"London": 16.2, "Reykjavik": 4.4} +``` + +The keys must be `str`. A `dict[int, float]` can't be a JSON object, so it falls back to the `{"result": ...}` wrapper. + +## Validation + +`output_schema` is not documentation. Whatever your function returns is **validated against it** before it leaves the server. + +You don't notice while you build the value by hand: Pydantic already made sure your `WeatherData` was a `WeatherData`. You notice the day the data comes from somewhere you don't control: + +```python title="server.py" hl_lines="9 21" +--8<-- "docs_src/structured_output/tutorial007.py" +``` + +The annotation promises `WeatherData`. The upstream response stopped sending `humidity`. + +!!! check + Call `get_weather` and it does not quietly hand the client a half-empty object. The call fails, + and the first lines of the error name the field: + + ```text + Error executing tool get_weather: 1 validation error for WeatherData + humidity + Field required [type=missing, input_value={'temperature': 16.2, 'conditions': 'Overcast'}, input_type=dict] + ``` + + That text comes back as the tool result with `is_error=True`, so the model knows the call failed + instead of confidently reading weather that isn't there. + +Returning a plain `dict` from a `-> WeatherData` tool is fine, by the way. That's exactly what `json.loads` produced. Validation is on the value, not on the Python type. + +## Opting out + +Sometimes the return annotation is for your type checker, not for the protocol. Pass `structured_output=False` and the tool is text-only: + +```python title="server.py" hl_lines="6" +--8<-- "docs_src/structured_output/tutorial008.py" +``` + +No `output_schema`, no wrapping, no validation. `structured_content` is `None` and `content` is the string you returned. + +The opposite, `structured_output=True`, turns the automatic detection into a requirement: a tool whose return type can't produce a schema raises at import time instead of falling back to text. + +## A class without type hints + +There is one way to end up unstructured without asking for it: return a class that has **no annotations on its body**. + +```python title="server.py" hl_lines="6-9" +--8<-- "docs_src/structured_output/tutorial009.py" +``` + +`Station` sets `name` and `online` inside `__init__`, but the *class* declares nothing. The SDK reads class annotations, finds none, and gives up. + +!!! warning + It gives up **silently**. `output_schema` is `None`, `structured_content` is `None`, and the text + the model reads is the object's `repr`: + + ```text + "" + ``` + + No error, no warning, a useless tool. Move the annotations onto the class body, or pass + `structured_output=True`, which turns this into a hard error the moment the module imports: + `Function get_station: return type is not serializable for structured output`. + +!!! tip + Need full control (building the `CallToolResult` yourself, or attaching `_meta` that the + application can see but the model can't)? That's **The low-level Server**. + +## Recap + +* The **return type annotation** is the output schema. It's published in `tools/list` as `output_schema`. +* Scalars, lists, tuples and unions are wrapped in `{"result": ...}`. Models, `TypedDict`s, dataclasses, annotated classes and `dict[str, ...]` are objects already and stay as they are. +* Every result carries `content` (text, for the model) **and** `structured_content` (data, for the application). +* What you return is validated against the schema. A mismatch is a tool error, not a corrupt result. +* `structured_output=False` opts a tool out. A class without type hints opts out silently; watch for it. + +You now own everything a tool can say back. Next, the second primitive: **Resources**. diff --git a/docs/tutorial/testing.md b/docs/tutorial/testing.md new file mode 100644 index 0000000000..9e31aa095f --- /dev/null +++ b/docs/tutorial/testing.md @@ -0,0 +1,106 @@ +# Testing + +The Python SDK ships a `Client` class with an **in-memory transport**: pass it your server object and it connects to it directly. + +No subprocess. No port. No transport at all. It's the same idea as FastAPI's `TestClient`. + +## Basic usage + +Let's assume you have a simple server with a single tool: + +```python title="server.py" +--8<-- "docs_src/testing/tutorial001.py" +``` + +To run the test below you'll need two extra (development) dependencies: + +=== "uv" + + ```bash + uv add --dev pytest inline-snapshot + ``` + +=== "pip" + + ```bash + pip install pytest inline-snapshot + ``` + +!!! info + These docs assume you already know [`pytest`](https://docs.pytest.org/en/stable/). + + [`inline-snapshot`](https://15r10nk.github.io/inline-snapshot/latest/) is what the test below + uses to assert on the whole result object in one line. It records the output of a test as the + `snapshot(...)` literal you see. If you'd rather not use it, drop the import and assert on the + fields you care about (`result.content[0].text == "3"`) like in any other test. + +Now the test: + +```python title="test_server.py" +import pytest +from inline_snapshot import snapshot +from mcp import Client +from mcp_types import CallToolResult, TextContent + +from server import mcp + + +@pytest.fixture +def anyio_backend(): # (1)! + return "asyncio" + + +@pytest.fixture +async def client(): # (2)! + async with Client(mcp, raise_exceptions=True) as c: + yield c + + +@pytest.mark.anyio +async def test_call_add_tool(client: Client): + result = await client.call_tool("add", {"a": 1, "b": 2}) + assert result == snapshot( + CallToolResult( + content=[TextContent(type="text", text="3")], + structured_content={"result": 3}, + ) + ) +``` + +1. If you are using `trio`, return `"trio"` instead. See the [anyio documentation](https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on) for the details. +2. The fixture yields a connected client. Every test that takes `client` gets a fresh in-memory connection to the same server. + +There you go! You can now extend your tests to cover more scenarios. + +## Why `raise_exceptions=True`? + +Two different things can go wrong, and this flag only touches one of them. + +An exception inside one of **your tools** is not a protocol failure. It becomes a normal result with +`is_error=True`, and the model reads the message. `raise_exceptions` doesn't change that: with or +without it, `call_tool` returns the same `is_error=True` result. There's a whole chapter on it: +**Handling errors**. + +A failure **outside** a tool body is different. On the connection `Client(mcp)` gives you, the +server sanitises it into a generic `"Internal server error"` before the client sees it. You should +never leak the details of an unexpected crash to a remote caller. In a test that is exactly what +you *don't* want, and it is what `raise_exceptions=True` changes: your test sees the real message +instead of the sanitised one. + +Leave it on in tests. It has no meaning in production code. + +## In-process by default + +!!! note + `Client(mcp)` connects in-process and is **era-neutral** by default: it probes the server and + picks the appropriate protocol path. Pin `mode="legacy"` if your test exercises legacy-specific + semantics (sampling or elicitation push, `message_handler`), and drop `raise_exceptions=True` + there: a legacy connection never sanitises in the first place, and the flag re-raises the + failure inside the server task instead of in your test. + +That one line is also why the rest of this tutorial can promise you that its examples work: every +example file is exercised by the SDK's own test suite through exactly this client. You're using the +same tool the SDK uses on itself. + +The tutorial ends here. Putting your tested server in front of a real client, over a real +transport, is **Running your server**. diff --git a/docs/tutorial/tools.md b/docs/tutorial/tools.md new file mode 100644 index 0000000000..774638856d --- /dev/null +++ b/docs/tutorial/tools.md @@ -0,0 +1,172 @@ +# Tools + +A **tool** is a function the model can call. + +You declare one by putting `@mcp.tool()` on a plain Python function. That's the whole API. + +## Your first tool + +```python title="server.py" hl_lines="6-8" +--8<-- "docs_src/tools/tutorial001.py" +``` + +Look at what you wrote. There are no schemas, no JSON, no protocol, just a function. The SDK reads three things from it: + +* The **name** of the tool is the name of the function: `search_books`. +* The **description** the model sees is the docstring: `Search the catalog by title or author.` +* The **arguments** the model is allowed to pass come from the type hints: `query: str` and `limit: int`. + +### The input schema + +From those type hints the SDK generates a JSON Schema and sends it to the client during `tools/list`: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"title": "Limit", "type": "integer"} + }, + "required": ["query", "limit"], + "title": "search_booksArguments" +} +``` + +Both arguments are in `required` because neither has a default. You'll fix that in a moment. (The `title` keys are Pydantic artifacts; the properties, their types, and `required` are the contract.) + +!!! tip + Type hints aren't documentation here. They are **the contract**. If a client sends `"limit": "ten"`, + the SDK rejects it before your function ever runs. + +### What the model gets back + +Call the tool with `{"query": "dune", "limit": 5}` and the result has two parts: + +```python +result.content # [TextContent(text="Found 3 books matching 'dune' (showing up to 5).")] +result.structured_content # {'result': "Found 3 books matching 'dune' (showing up to 5)."} +``` + +`content` is the text the **model** reads. `structured_content` is typed data for the **client application**. It's there because you declared the return type as `-> str`. + +Don't worry about `structured_content` yet. Return real Python objects from your tools and the right thing happens; the **Structured Output** chapter is all about it. + +### Try it + +Run the server with the MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Open the URL it prints, go to the **Tools** tab, and call `search_books`. + +The Inspector renders a form with a required `query` text field and a required `limit` number field. It built that form from your type hints. So will every other MCP client. + +## Optional arguments + +Give a parameter a default value and it stops being required. That's it. It's just Python. + +```python title="server.py" hl_lines="7" +--8<-- "docs_src/tools/tutorial002.py" +``` + +The schema follows: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"default": 10, "title": "Limit", "type": "integer"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +`limit` left `required` and gained `"default": 10`. A client that omits it gets `10`, exactly as Python would. + +## Richer schemas with `Field` + +Type hints get you a long way, but sometimes you want to *describe* an argument, or constrain it. + +Wrap the type in `Annotated` and add a Pydantic `Field`: + +```python title="server.py" hl_lines="12-14" +--8<-- "docs_src/tools/tutorial003.py" +``` + +Three new things, all on the parameters: + +* `Field(description=...)`: a per-argument description the model reads alongside the docstring. +* `Field(ge=1, le=50)`: numeric bounds. They land in the schema as `"minimum": 1, "maximum": 50`. +* `Literal["fiction", "non-fiction", "poetry"]`: an enum. The model can only pick one of those. + +!!! check + Constraints are not decoration. Call the tool with `limit=999` and the SDK answers with a + tool error **before your function runs**: + + ```text + Input should be less than or equal to 50 + ``` + + That error goes back to the model as the tool result, and the model reads it and retries with + a valid value. You wrote `le=50` once and got self-correcting agents for free. + +!!! info + If you've used FastAPI or Pydantic, you already know all of this. It's the same `Field`, + the same `Annotated`, the same validation. There is nothing MCP-specific to learn here. + +## A model as a parameter + +When a tool takes more than a couple of arguments, group them into a Pydantic model: + +```python title="server.py" hl_lines="8-11 15" +--8<-- "docs_src/tools/tutorial004.py" +``` + +The `Book` schema is nested inside the tool's input schema (as a `$defs` reference), the model fills it in as a JSON object, and your function receives a **real `Book` instance**, already validated, with `.title`, `.author` and `.year` attributes. + +You can mix and match: plain parameters next to model parameters, nested models, lists of models. It's Pydantic all the way down. + +## `async def` + +If a tool does I/O (calls an API, reads a file, queries a database), declare it `async def` and `await` inside it. The SDK awaits it. + +A plain `def` tool works too: the SDK runs it in a thread so it never blocks the server. + +There is nothing else to configure. + +## Names, titles, and annotations + +Everything the SDK infers, you can override in the decorator: + +```python title="server.py" hl_lines="8-11" +--8<-- "docs_src/tools/tutorial005.py" +``` + +* `title` is a human-readable name for UIs. Clients show *"Search the catalog"* instead of `search_books`. +* `annotations` are behavioural **hints** for the client: + * `read_only_hint=True`: this tool doesn't change anything. + * `open_world_hint=False`: it works on a closed set of things (this catalog), not the open web. + * The other two, `destructive_hint` and `idempotent_hint`, describe a tool that *writes*: may it + delete something, and is calling it twice the same as calling it once? The spec defines both + only for non-read-only tools, so they would say nothing on `search_books`. + +A well-behaved client uses them to decide things like *"do I need to ask the user before running this?"*. They are hints, not security. Never rely on a client honouring them. + +!!! tip + `name=` and `description=` are also accepted by `@mcp.tool()` if you don't want to derive them + from the function name and docstring. Most of the time you do. + +## Recap + +* `@mcp.tool()` on a function makes it a tool. Name from the function, description from the docstring. +* Type hints **are** the input schema. Defaults make arguments optional. +* `Annotated[..., Field(...)]` adds descriptions and constraints; `Literal` adds enums. +* A Pydantic model parameter is how you take a structured "body". +* Bad arguments are rejected for you, with an error the model can read and recover from. +* `async def` for I/O, plain `def` for everything else. + +Next up, **Structured Output**: what happens to the value you `return`. diff --git a/docs_src/__init__.py b/docs_src/__init__.py new file mode 100644 index 0000000000..d19acbc088 --- /dev/null +++ b/docs_src/__init__.py @@ -0,0 +1,7 @@ +"""Complete, runnable source for every code example in `docs/`. + +Each `docs/.md` includes its examples from `docs_src//tutorialNNN.py` +via `--8<--`, and `tests/docs_src/test_.py` imports the same module and +exercises it through the in-memory `mcp.Client`. The file you read in the docs is +the file CI runs. +""" diff --git a/docs_src/asgi/__init__.py b/docs_src/asgi/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/asgi/tutorial001.py b/docs_src/asgi/tutorial001.py new file mode 100644 index 0000000000..800f19b5f2 --- /dev/null +++ b/docs_src/asgi/tutorial001.py @@ -0,0 +1,12 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Notes") + + +@mcp.tool() +def add_note(text: str) -> str: + """Save a note.""" + return f"Saved: {text}" + + +app = mcp.streamable_http_app() diff --git a/docs_src/asgi/tutorial002.py b/docs_src/asgi/tutorial002.py new file mode 100644 index 0000000000..15e5388301 --- /dev/null +++ b/docs_src/asgi/tutorial002.py @@ -0,0 +1,27 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server import MCPServer + +mcp = MCPServer("Notes") + + +@mcp.tool() +def add_note(text: str) -> str: + """Save a note.""" + return f"Saved: {text}" + + +@asynccontextmanager +async def lifespan(app: Starlette) -> AsyncIterator[None]: + async with mcp.session_manager.run(): + yield + + +app = Starlette( + routes=[Mount("/", app=mcp.streamable_http_app())], + lifespan=lifespan, +) diff --git a/docs_src/asgi/tutorial003.py b/docs_src/asgi/tutorial003.py new file mode 100644 index 0000000000..cea736ec2d --- /dev/null +++ b/docs_src/asgi/tutorial003.py @@ -0,0 +1,39 @@ +from collections.abc import AsyncIterator +from contextlib import AsyncExitStack, asynccontextmanager + +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server import MCPServer + +notes = MCPServer("Notes") +tasks = MCPServer("Tasks") + + +@notes.tool() +def add_note(text: str) -> str: + """Save a note.""" + return f"Saved: {text}" + + +@tasks.tool() +def add_task(title: str) -> str: + """Create a task.""" + return f"Created: {title}" + + +@asynccontextmanager +async def lifespan(app: Starlette) -> AsyncIterator[None]: + async with AsyncExitStack() as stack: + await stack.enter_async_context(notes.session_manager.run()) + await stack.enter_async_context(tasks.session_manager.run()) + yield + + +app = Starlette( + routes=[ + Mount("/notes", app=notes.streamable_http_app()), + Mount("/tasks", app=tasks.streamable_http_app()), + ], + lifespan=lifespan, +) diff --git a/docs_src/asgi/tutorial004.py b/docs_src/asgi/tutorial004.py new file mode 100644 index 0000000000..785a808b28 --- /dev/null +++ b/docs_src/asgi/tutorial004.py @@ -0,0 +1,27 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server import MCPServer + +mcp = MCPServer("Notes") + + +@mcp.tool() +def add_note(text: str) -> str: + """Save a note.""" + return f"Saved: {text}" + + +@asynccontextmanager +async def lifespan(app: Starlette) -> AsyncIterator[None]: + async with mcp.session_manager.run(): + yield + + +app = Starlette( + routes=[Mount("/notes", app=mcp.streamable_http_app(streamable_http_path="/"))], + lifespan=lifespan, +) diff --git a/docs_src/asgi/tutorial005.py b/docs_src/asgi/tutorial005.py new file mode 100644 index 0000000000..abd627512e --- /dev/null +++ b/docs_src/asgi/tutorial005.py @@ -0,0 +1,52 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.cors import CORSMiddleware +from starlette.routing import Mount + +from mcp.server import MCPServer +from mcp.server.transport_security import TransportSecuritySettings + +mcp = MCPServer("Notes") + + +@mcp.tool() +def add_note(text: str) -> str: + """Save a note.""" + return f"Saved: {text}" + + +@asynccontextmanager +async def lifespan(app: Starlette) -> AsyncIterator[None]: + async with mcp.session_manager.run(): + yield + + +security = TransportSecuritySettings( + allowed_hosts=["mcp.example.com", "mcp.example.com:*"], + allowed_origins=["https://app.example.com"], +) + +app = Starlette( + routes=[Mount("/", app=mcp.streamable_http_app(transport_security=security))], + middleware=[ + Middleware( + CORSMiddleware, + allow_origins=["https://app.example.com"], + allow_methods=["GET", "POST", "DELETE"], + allow_headers=[ + "Authorization", + "Content-Type", + "Last-Event-ID", + "Mcp-Method", + "Mcp-Name", + "Mcp-Protocol-Version", + "Mcp-Session-Id", + ], + expose_headers=["Mcp-Session-Id"], + ) + ], + lifespan=lifespan, +) diff --git a/docs_src/asgi/tutorial006.py b/docs_src/asgi/tutorial006.py new file mode 100644 index 0000000000..a3554ec443 --- /dev/null +++ b/docs_src/asgi/tutorial006.py @@ -0,0 +1,20 @@ +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +from mcp.server import MCPServer + +mcp = MCPServer("Notes") + + +@mcp.tool() +def add_note(text: str) -> str: + """Save a note.""" + return f"Saved: {text}" + + +@mcp.custom_route("/health", methods=["GET"]) +async def health(request: Request) -> Response: + return JSONResponse({"status": "ok"}) + + +app = mcp.streamable_http_app() diff --git a/docs_src/authorization/__init__.py b/docs_src/authorization/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/authorization/tutorial001.py b/docs_src/authorization/tutorial001.py new file mode 100644 index 0000000000..f15f54fd79 --- /dev/null +++ b/docs_src/authorization/tutorial001.py @@ -0,0 +1,31 @@ +from pydantic import AnyHttpUrl + +from mcp.server import MCPServer +from mcp.server.auth.provider import AccessToken, TokenVerifier +from mcp.server.auth.settings import AuthSettings + +KNOWN_TOKENS = { + "alice-token": AccessToken(token="alice-token", client_id="alice", scopes=["notes:read"]), +} + + +class StaticTokenVerifier(TokenVerifier): + async def verify_token(self, token: str) -> AccessToken | None: + return KNOWN_TOKENS.get(token) + + +mcp = MCPServer( + "Notes", + token_verifier=StaticTokenVerifier(), + auth=AuthSettings( + issuer_url=AnyHttpUrl("https://auth.example.com"), + resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"), + required_scopes=["notes:read"], + ), +) + + +@mcp.tool() +def list_notes() -> list[str]: + """List every note in the notebook.""" + return ["Buy milk", "Ship the release"] diff --git a/docs_src/authorization/tutorial002.py b/docs_src/authorization/tutorial002.py new file mode 100644 index 0000000000..55b024f2cc --- /dev/null +++ b/docs_src/authorization/tutorial002.py @@ -0,0 +1,35 @@ +from pydantic import AnyHttpUrl + +from mcp.server import MCPServer +from mcp.server.auth.middleware.auth_context import get_access_token +from mcp.server.auth.provider import AccessToken, TokenVerifier +from mcp.server.auth.settings import AuthSettings + +KNOWN_TOKENS = { + "alice-token": AccessToken(token="alice-token", client_id="alice", scopes=["notes:read"]), +} + + +class StaticTokenVerifier(TokenVerifier): + async def verify_token(self, token: str) -> AccessToken | None: + return KNOWN_TOKENS.get(token) + + +mcp = MCPServer( + "Notes", + token_verifier=StaticTokenVerifier(), + auth=AuthSettings( + issuer_url=AnyHttpUrl("https://auth.example.com"), + resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"), + required_scopes=["notes:read"], + ), +) + + +@mcp.tool() +def whoami() -> str: + """Report which OAuth client is calling.""" + token = get_access_token() + if token is None: + return "anonymous" + return f"{token.client_id} (scopes: {', '.join(token.scopes)})" diff --git a/docs_src/client/__init__.py b/docs_src/client/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/client/tutorial001.py b/docs_src/client/tutorial001.py new file mode 100644 index 0000000000..b020926dda --- /dev/null +++ b/docs_src/client/tutorial001.py @@ -0,0 +1,18 @@ +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop", instructions="Search the catalog before recommending a book.") + + +@mcp.tool() +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." + + +async def main() -> None: + async with Client(mcp) as client: + print(client.server_info) + print(client.server_capabilities) + print(client.protocol_version) + print(client.instructions) diff --git a/docs_src/client/tutorial002.py b/docs_src/client/tutorial002.py new file mode 100644 index 0000000000..a3e379ab44 --- /dev/null +++ b/docs_src/client/tutorial002.py @@ -0,0 +1,20 @@ +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool(title="Search the catalog") +def search_books(query: str, limit: int = 10) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r} (showing up to {limit})." + + +async def main() -> None: + async with Client(mcp) as client: + result = await client.list_tools() + for tool in result.tools: + print(tool.name) + print(tool.title) + print(tool.description) + print(tool.input_schema) diff --git a/docs_src/client/tutorial003.py b/docs_src/client/tutorial003.py new file mode 100644 index 0000000000..1aeab63a49 --- /dev/null +++ b/docs_src/client/tutorial003.py @@ -0,0 +1,33 @@ +from mcp_types import TextContent +from pydantic import BaseModel + +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +class Book(BaseModel): + title: str + author: str + year: int + + +@mcp.tool() +def lookup_book(title: str) -> Book: + """Look up a book by its exact title.""" + if title != "Dune": + raise ValueError(f"No book titled {title!r} in the catalog.") + return Book(title="Dune", author="Frank Herbert", year=1965) + + +async def main() -> None: + async with Client(mcp) as client: + result = await client.call_tool("lookup_book", {"title": "Dune"}) + + for block in result.content: + if isinstance(block, TextContent): + print(block.text) + + print(result.structured_content) + print(result.is_error) diff --git a/docs_src/client/tutorial004.py b/docs_src/client/tutorial004.py new file mode 100644 index 0000000000..fddcde90a5 --- /dev/null +++ b/docs_src/client/tutorial004.py @@ -0,0 +1,32 @@ +from mcp_types import TextResourceContents + +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.resource("catalog://genres") +def genres() -> list[str]: + """The genres the catalog is organised by.""" + return ["fiction", "non-fiction", "poetry"] + + +@mcp.resource("catalog://genres/{genre}") +def books_in_genre(genre: str) -> str: + """Every title we stock in one genre.""" + return f"3 books filed under {genre}." + + +async def main() -> None: + async with Client(mcp) as client: + listed = await client.list_resources() + print([resource.uri for resource in listed.resources]) + + templates = await client.list_resource_templates() + print([template.uri_template for template in templates.resource_templates]) + + result = await client.read_resource("catalog://genres/poetry") + for contents in result.contents: + if isinstance(contents, TextResourceContents): + print(contents.text) diff --git a/docs_src/client/tutorial005.py b/docs_src/client/tutorial005.py new file mode 100644 index 0000000000..ce4d164775 --- /dev/null +++ b/docs_src/client/tutorial005.py @@ -0,0 +1,20 @@ +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.prompt(title="Recommend a book") +def recommend(genre: str) -> str: + """Ask for a recommendation in a genre.""" + return f"Recommend one {genre} book from the catalog and say why." + + +async def main() -> None: + async with Client(mcp) as client: + listed = await client.list_prompts() + print(listed.prompts) + + result = await client.get_prompt("recommend", {"genre": "poetry"}) + for message in result.messages: + print(message.role, message.content) diff --git a/docs_src/client/tutorial006.py b/docs_src/client/tutorial006.py new file mode 100644 index 0000000000..b76b6a0f11 --- /dev/null +++ b/docs_src/client/tutorial006.py @@ -0,0 +1,32 @@ +from mcp_types import Completion, CompletionArgument, CompletionContext, PromptReference, ResourceTemplateReference + +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + +GENRES = ["fiction", "non-fiction", "poetry"] + + +@mcp.prompt() +def recommend(genre: str) -> str: + """Ask for a recommendation in a genre.""" + return f"Recommend one {genre} book from the catalog and say why." + + +@mcp.completion() +async def complete_genre( + ref: PromptReference | ResourceTemplateReference, + argument: CompletionArgument, + context: CompletionContext | None, +) -> Completion | None: + return Completion(values=[genre for genre in GENRES if genre.startswith(argument.value)]) + + +async def main() -> None: + async with Client(mcp) as client: + result = await client.complete( + ref=PromptReference(type="ref/prompt", name="recommend"), + argument={"name": "genre", "value": "p"}, + ) + print(result.completion.values) diff --git a/docs_src/client/tutorial007.py b/docs_src/client/tutorial007.py new file mode 100644 index 0000000000..594b052020 --- /dev/null +++ b/docs_src/client/tutorial007.py @@ -0,0 +1,31 @@ +from mcp_types import Tool + +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." + + +@mcp.tool() +def reserve_book(title: str) -> str: + """Put a book on hold.""" + return f"Reserved {title!r}." + + +async def main() -> None: + async with Client(mcp) as client: + tools: list[Tool] = [] + cursor: str | None = None + while True: + page = await client.list_tools(cursor=cursor) + tools.extend(page.tools) + if page.next_cursor is None: + break + cursor = page.next_cursor + print([tool.name for tool in tools]) diff --git a/docs_src/client_callbacks/__init__.py b/docs_src/client_callbacks/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/client_callbacks/tutorial001.py b/docs_src/client_callbacks/tutorial001.py new file mode 100644 index 0000000000..154bc4dd81 --- /dev/null +++ b/docs_src/client_callbacks/tutorial001.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel + +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +mcp = MCPServer("Library") + + +class CardHolder(BaseModel): + name: str + + +@mcp.tool() +async def issue_card(ctx: Context) -> str: + """Issue a new library card.""" + answer = await ctx.elicit("What name should go on the card?", schema=CardHolder) + if answer.action == "accept": + return f"Card issued to {answer.data.name}." + return "No card issued." diff --git a/docs_src/client_callbacks/tutorial002.py b/docs_src/client_callbacks/tutorial002.py new file mode 100644 index 0000000000..2bae985d60 --- /dev/null +++ b/docs_src/client_callbacks/tutorial002.py @@ -0,0 +1,21 @@ +from mcp_types import ElicitRequestParams, ElicitResult + +from mcp import Client +from mcp.client import ClientRequestContext + + +async def handle_elicitation( + context: ClientRequestContext, + params: ElicitRequestParams, +) -> ElicitResult: + return ElicitResult(action="accept", content={"name": "Ada Lovelace"}) + + +async def main() -> None: + async with Client( + "http://127.0.0.1:8000/mcp", + mode="legacy", + elicitation_callback=handle_elicitation, + ) as client: + result = await client.call_tool("issue_card") + print(result.content) diff --git a/docs_src/client_callbacks/tutorial003.py b/docs_src/client_callbacks/tutorial003.py new file mode 100644 index 0000000000..c7a269a36d --- /dev/null +++ b/docs_src/client_callbacks/tutorial003.py @@ -0,0 +1,31 @@ +from mcp_types import ClientCapabilities, ElicitationCapability, RootsCapability, SamplingCapability +from pydantic import BaseModel + +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +mcp = MCPServer("Library") + + +class CardHolder(BaseModel): + name: str + + +@mcp.tool() +async def issue_card(ctx: Context) -> str: + """Issue a new library card.""" + answer = await ctx.elicit("What name should go on the card?", schema=CardHolder) + if answer.action == "accept": + return f"Card issued to {answer.data.name}." + return "No card issued." + + +@mcp.tool() +def client_features(ctx: Context) -> list[str]: + """Which optional features the connected client declared.""" + declared = { + "elicitation": ClientCapabilities(elicitation=ElicitationCapability()), + "sampling": ClientCapabilities(sampling=SamplingCapability()), + "roots": ClientCapabilities(roots=RootsCapability()), + } + return [name for name, capability in declared.items() if ctx.session.check_client_capability(capability)] diff --git a/docs_src/client_callbacks/tutorial004.py b/docs_src/client_callbacks/tutorial004.py new file mode 100644 index 0000000000..20c9b81870 --- /dev/null +++ b/docs_src/client_callbacks/tutorial004.py @@ -0,0 +1,19 @@ +from mcp_types import CreateMessageRequestParams, CreateMessageResult, ListRootsResult, Root, TextContent +from pydantic import FileUrl + +from mcp.client import ClientRequestContext + + +async def handle_sampling( + context: ClientRequestContext, + params: CreateMessageRequestParams, +) -> CreateMessageResult: + return CreateMessageResult( + role="assistant", + content=TextContent(type="text", text="The answer is 42."), + model="my-llm", + ) + + +async def handle_list_roots(context: ClientRequestContext) -> ListRootsResult: + return ListRootsResult(roots=[Root(uri=FileUrl("file:///home/ada/notebooks"), name="notebooks")]) diff --git a/docs_src/client_transports/__init__.py b/docs_src/client_transports/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/client_transports/tutorial001.py b/docs_src/client_transports/tutorial001.py new file mode 100644 index 0000000000..5920f3d92b --- /dev/null +++ b/docs_src/client_transports/tutorial001.py @@ -0,0 +1,16 @@ +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." + + +async def main() -> None: + async with Client(mcp) as client: + result = await client.call_tool("search_books", {"query": "dune"}) + print(result.structured_content) diff --git a/docs_src/client_transports/tutorial002.py b/docs_src/client_transports/tutorial002.py new file mode 100644 index 0000000000..8350a90556 --- /dev/null +++ b/docs_src/client_transports/tutorial002.py @@ -0,0 +1,7 @@ +from mcp import Client + + +async def main() -> None: + async with Client("http://localhost:8000/mcp") as client: + result = await client.list_tools() + print([tool.name for tool in result.tools]) diff --git a/docs_src/client_transports/tutorial003.py b/docs_src/client_transports/tutorial003.py new file mode 100644 index 0000000000..0134a72561 --- /dev/null +++ b/docs_src/client_transports/tutorial003.py @@ -0,0 +1,16 @@ +import httpx + +from mcp import Client +from mcp.client.streamable_http import streamable_http_client + + +async def main() -> None: + async with httpx.AsyncClient( + headers={"Authorization": "Bearer ..."}, + timeout=httpx.Timeout(30.0, read=300.0), + follow_redirects=True, + ) as http_client: + transport = streamable_http_client("http://localhost:8000/mcp", http_client=http_client) + async with Client(transport) as client: + result = await client.list_tools() + print([tool.name for tool in result.tools]) diff --git a/docs_src/client_transports/tutorial004.py b/docs_src/client_transports/tutorial004.py new file mode 100644 index 0000000000..8e07e09741 --- /dev/null +++ b/docs_src/client_transports/tutorial004.py @@ -0,0 +1,14 @@ +from mcp import Client, StdioServerParameters +from mcp.client.stdio import stdio_client + +server = StdioServerParameters( + command="uv", + args=["run", "server.py"], + env={"BOOKSHOP_API_KEY": "secret"}, +) + + +async def main() -> None: + async with Client(stdio_client(server)) as client: + result = await client.list_tools() + print([tool.name for tool in result.tools]) diff --git a/docs_src/completions/__init__.py b/docs_src/completions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/completions/tutorial001.py b/docs_src/completions/tutorial001.py new file mode 100644 index 0000000000..8326a65e84 --- /dev/null +++ b/docs_src/completions/tutorial001.py @@ -0,0 +1,15 @@ +from mcp.server import MCPServer + +mcp = MCPServer("GitHub Explorer") + + +@mcp.resource("github://repos/{owner}/{repo}") +def github_repo(owner: str, repo: str) -> str: + """A GitHub repository.""" + return f"Repository: {owner}/{repo}" + + +@mcp.prompt() +def review_code(language: str, code: str) -> str: + """Review a snippet of code.""" + return f"Review this {language} code:\n{code}" diff --git a/docs_src/completions/tutorial002.py b/docs_src/completions/tutorial002.py new file mode 100644 index 0000000000..471527792b --- /dev/null +++ b/docs_src/completions/tutorial002.py @@ -0,0 +1,30 @@ +from mcp_types import Completion, CompletionArgument, CompletionContext, PromptReference, ResourceTemplateReference + +from mcp.server import MCPServer + +mcp = MCPServer("GitHub Explorer") + +LANGUAGES = ["go", "javascript", "python", "rust", "typescript"] + + +@mcp.resource("github://repos/{owner}/{repo}") +def github_repo(owner: str, repo: str) -> str: + """A GitHub repository.""" + return f"Repository: {owner}/{repo}" + + +@mcp.prompt() +def review_code(language: str, code: str) -> str: + """Review a snippet of code.""" + return f"Review this {language} code:\n{code}" + + +@mcp.completion() +async def handle_completion( + ref: PromptReference | ResourceTemplateReference, + argument: CompletionArgument, + context: CompletionContext | None, +) -> Completion | None: + if isinstance(ref, PromptReference) and argument.name == "language": + return Completion(values=[lang for lang in LANGUAGES if lang.startswith(argument.value)]) + return None diff --git a/docs_src/completions/tutorial003.py b/docs_src/completions/tutorial003.py new file mode 100644 index 0000000000..3cbe21bcd6 --- /dev/null +++ b/docs_src/completions/tutorial003.py @@ -0,0 +1,40 @@ +from mcp_types import Completion, CompletionArgument, CompletionContext, PromptReference, ResourceTemplateReference + +from mcp.server import MCPServer + +mcp = MCPServer("GitHub Explorer") + +LANGUAGES = ["go", "javascript", "python", "rust", "typescript"] + +REPOS_BY_OWNER = { + "modelcontextprotocol": ["python-sdk", "typescript-sdk", "inspector"], + "pydantic": ["pydantic", "pydantic-ai", "logfire"], +} + + +@mcp.resource("github://repos/{owner}/{repo}") +def github_repo(owner: str, repo: str) -> str: + """A GitHub repository.""" + return f"Repository: {owner}/{repo}" + + +@mcp.prompt() +def review_code(language: str, code: str) -> str: + """Review a snippet of code.""" + return f"Review this {language} code:\n{code}" + + +@mcp.completion() +async def handle_completion( + ref: PromptReference | ResourceTemplateReference, + argument: CompletionArgument, + context: CompletionContext | None, +) -> Completion | None: + if isinstance(ref, PromptReference) and argument.name == "language": + return Completion(values=[lang for lang in LANGUAGES if lang.startswith(argument.value)]) + if isinstance(ref, ResourceTemplateReference) and argument.name == "repo": + if context is None or context.arguments is None: + return None + repos = REPOS_BY_OWNER.get(context.arguments.get("owner", ""), []) + return Completion(values=[repo for repo in repos if repo.startswith(argument.value)]) + return None diff --git a/docs_src/context/__init__.py b/docs_src/context/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/context/tutorial001.py b/docs_src/context/tutorial001.py new file mode 100644 index 0000000000..1666ca5d22 --- /dev/null +++ b/docs_src/context/tutorial001.py @@ -0,0 +1,10 @@ +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str, ctx: Context) -> str: + """Search the catalog by title or author.""" + return f"[request {ctx.request_id}] Found 3 books matching {query!r}." diff --git a/docs_src/context/tutorial002.py b/docs_src/context/tutorial002.py new file mode 100644 index 0000000000..f85caf00f2 --- /dev/null +++ b/docs_src/context/tutorial002.py @@ -0,0 +1,17 @@ +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +mcp = MCPServer("Bookshop") + + +@mcp.resource("catalog://genres") +def genres() -> str: + """The genres the catalog is organised into.""" + return "fiction, non-fiction, poetry" + + +@mcp.tool() +async def describe_catalog(ctx: Context) -> str: + """Describe how the catalog is organised.""" + [contents] = await ctx.read_resource("catalog://genres") + return f"The catalog is organised into: {contents.content}" diff --git a/docs_src/context/tutorial003.py b/docs_src/context/tutorial003.py new file mode 100644 index 0000000000..d3a741d3c2 --- /dev/null +++ b/docs_src/context/tutorial003.py @@ -0,0 +1,17 @@ +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +mcp = MCPServer("Bookshop") + + +def recommend_book(genre: str) -> str: + """Recommend a book in the given genre.""" + return f"In {genre}, try 'Dune'." + + +@mcp.tool() +async def enable_recommendations(ctx: Context) -> str: + """Switch on the recommendation tool.""" + mcp.add_tool(recommend_book) + await ctx.session.send_tool_list_changed() + return "Recommendations are now available." diff --git a/docs_src/elicitation/__init__.py b/docs_src/elicitation/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/elicitation/tutorial001.py b/docs_src/elicitation/tutorial001.py new file mode 100644 index 0000000000..083194833c --- /dev/null +++ b/docs_src/elicitation/tutorial001.py @@ -0,0 +1,26 @@ +from pydantic import BaseModel, Field + +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +mcp = MCPServer("Bistro") + + +class AlternativeDate(BaseModel): + accept_alternative: bool = Field(description="Try another date?") + date: str = Field(default="2025-12-26", description="Alternative date (YYYY-MM-DD)") + + +@mcp.tool() +async def book_table(date: str, party_size: int, ctx: Context) -> str: + """Book a table at the bistro.""" + if date != "2025-12-25": + return f"Booked a table for {party_size} on {date}." + + result = await ctx.elicit( + message=f"No tables for {party_size} on {date}. Would you like to try another date?", + schema=AlternativeDate, + ) + if result.action == "accept" and result.data.accept_alternative: + return await book_table(result.data.date, party_size, ctx) + return "No booking made." diff --git a/docs_src/elicitation/tutorial002.py b/docs_src/elicitation/tutorial002.py new file mode 100644 index 0000000000..b8e3456b0c --- /dev/null +++ b/docs_src/elicitation/tutorial002.py @@ -0,0 +1,24 @@ +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +mcp = MCPServer("Bistro") + + +@mcp.tool() +async def pay_deposit(booking_id: str, ctx: Context) -> str: + """Take the deposit that confirms a booking.""" + result = await ctx.elicit_url( + message="A 20 EUR deposit confirms your booking.", + url=f"https://pay.example.com/deposit/{booking_id}", + elicitation_id=f"deposit-{booking_id}", + ) + if result.action == "accept": + return "Complete the payment in your browser." + return "No deposit taken. The booking expires in one hour." + + +@mcp.tool() +async def confirm_deposit(booking_id: str, ctx: Context) -> str: + """Record a payment reported by the payment provider.""" + await ctx.session.send_elicit_complete(f"deposit-{booking_id}") + return f"Deposit received for booking {booking_id}." diff --git a/docs_src/elicitation/tutorial003.py b/docs_src/elicitation/tutorial003.py new file mode 100644 index 0000000000..f6bb4020b6 --- /dev/null +++ b/docs_src/elicitation/tutorial003.py @@ -0,0 +1,22 @@ +from mcp_types import ElicitRequestParams, ElicitRequestURLParams, ElicitResult + +from mcp import Client +from mcp.client import ClientRequestContext + + +async def handle_elicitation(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + if isinstance(params, ElicitRequestURLParams): + print(f"Open this link to continue: {params.url}") + return ElicitResult(action="accept") + print(params.message) + return ElicitResult(action="accept", content={"accept_alternative": True, "date": "2025-12-27"}) + + +async def main() -> None: + async with Client( + "http://127.0.0.1:8000/mcp", + mode="legacy", + elicitation_callback=handle_elicitation, + ) as client: + result = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2}) + print(result.content) diff --git a/docs_src/first_steps/__init__.py b/docs_src/first_steps/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/first_steps/tutorial001.py b/docs_src/first_steps/tutorial001.py new file mode 100644 index 0000000000..7c3e7a5a1e --- /dev/null +++ b/docs_src/first_steps/tutorial001.py @@ -0,0 +1,21 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Demo") + + +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + +@mcp.resource("greeting://{name}") +def greeting(name: str) -> str: + """Greet someone by name.""" + return f"Hello, {name}!" + + +@mcp.prompt() +def summarize(text: str) -> str: + """Summarize a piece of text in one sentence.""" + return f"Summarize the following text in one sentence:\n\n{text}" diff --git a/docs_src/handling_errors/__init__.py b/docs_src/handling_errors/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/handling_errors/tutorial001.py b/docs_src/handling_errors/tutorial001.py new file mode 100644 index 0000000000..003ea94669 --- /dev/null +++ b/docs_src/handling_errors/tutorial001.py @@ -0,0 +1,13 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + +CATALOG = {"Dune": "Frank Herbert", "Neuromancer": "William Gibson"} + + +@mcp.tool() +def get_author(title: str) -> str: + """Look up the author of a book in the catalog.""" + if title not in CATALOG: + raise ValueError(f"No book titled {title!r} in the catalog.") + return CATALOG[title] diff --git a/docs_src/handling_errors/tutorial002.py b/docs_src/handling_errors/tutorial002.py new file mode 100644 index 0000000000..b45c67e967 --- /dev/null +++ b/docs_src/handling_errors/tutorial002.py @@ -0,0 +1,16 @@ +from mcp_types import INVALID_PARAMS + +from mcp import MCPError +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + +CATALOG = {"Dune": "Frank Herbert", "Neuromancer": "William Gibson"} + + +@mcp.tool() +def get_author(title: str) -> str: + """Look up the author of a book in the catalog.""" + if title not in CATALOG: + raise MCPError(code=INVALID_PARAMS, message=f"No book titled {title!r} in the catalog.") + return CATALOG[title] diff --git a/docs_src/handling_errors/tutorial003.py b/docs_src/handling_errors/tutorial003.py new file mode 100644 index 0000000000..55f2c4f07f --- /dev/null +++ b/docs_src/handling_errors/tutorial003.py @@ -0,0 +1,14 @@ +from mcp.server import MCPServer +from mcp.server.mcpserver.exceptions import ResourceNotFoundError + +mcp = MCPServer("Bookshop") + +CATALOG = {"Dune": "Frank Herbert", "Neuromancer": "William Gibson"} + + +@mcp.resource("books://{title}") +def book(title: str) -> str: + """The catalog entry for one book.""" + if title not in CATALOG: + raise ResourceNotFoundError(f"No book titled {title!r} in the catalog.") + return f"{title} by {CATALOG[title]}" diff --git a/docs_src/index/__init__.py b/docs_src/index/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/index/tutorial001.py b/docs_src/index/tutorial001.py new file mode 100644 index 0000000000..d975940fef --- /dev/null +++ b/docs_src/index/tutorial001.py @@ -0,0 +1,15 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Demo") + + +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + +@mcp.resource("greeting://{name}") +def greeting(name: str) -> str: + """Greet someone by name.""" + return f"Hello, {name}!" diff --git a/docs_src/lifespan/__init__.py b/docs_src/lifespan/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/lifespan/tutorial001.py b/docs_src/lifespan/tutorial001.py new file mode 100644 index 0000000000..9b7ba3db54 --- /dev/null +++ b/docs_src/lifespan/tutorial001.py @@ -0,0 +1,41 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass + +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + + +class Database: + @classmethod + async def connect(cls) -> "Database": + return cls() + + async def disconnect(self) -> None: ... + + def query(self) -> int: + return 3 + + +@dataclass +class AppContext: + db: Database + + +@asynccontextmanager +async def app_lifespan(server: MCPServer) -> AsyncIterator[AppContext]: + db = await Database.connect() + try: + yield AppContext(db=db) + finally: + await db.disconnect() + + +mcp = MCPServer("Bookshop", lifespan=app_lifespan) + + +@mcp.tool() +def count_books(genre: str, ctx: Context[AppContext]) -> str: + """Count the books in a genre.""" + db = ctx.request_context.lifespan_context.db + return f"{db.query()} books in {genre!r}." diff --git a/docs_src/lifespan/tutorial002.py b/docs_src/lifespan/tutorial002.py new file mode 100644 index 0000000000..6ed3c6a374 --- /dev/null +++ b/docs_src/lifespan/tutorial002.py @@ -0,0 +1,44 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass + +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + + +class Database: + def __init__(self) -> None: + self.connected = False + + async def connect(self) -> None: + self.connected = True + + async def disconnect(self) -> None: + self.connected = False + + +@dataclass +class AppContext: + db: Database + + +database = Database() + + +@asynccontextmanager +async def app_lifespan(server: MCPServer) -> AsyncIterator[AppContext]: + await database.connect() + try: + yield AppContext(db=database) + finally: + await database.disconnect() + + +mcp = MCPServer("Bookshop", lifespan=app_lifespan) + + +@mcp.tool() +def database_status(ctx: Context[AppContext]) -> str: + """Report whether the database connection is up.""" + db = ctx.request_context.lifespan_context.db + return "connected" if db.connected else "disconnected" diff --git a/docs_src/logging/__init__.py b/docs_src/logging/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/logging/tutorial001.py b/docs_src/logging/tutorial001.py new file mode 100644 index 0000000000..ee90d220c1 --- /dev/null +++ b/docs_src/logging/tutorial001.py @@ -0,0 +1,14 @@ +import logging + +from mcp.server import MCPServer + +logger = logging.getLogger(__name__) + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + logger.info("Searching for %r", query) + return f"Found 3 books matching {query!r}." diff --git a/docs_src/lowlevel/__init__.py b/docs_src/lowlevel/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/lowlevel/tutorial001.py b/docs_src/lowlevel/tutorial001.py new file mode 100644 index 0000000000..999c707f25 --- /dev/null +++ b/docs_src/lowlevel/tutorial001.py @@ -0,0 +1,33 @@ +from mcp_types import ( + CallToolRequestParams, + CallToolResult, + ListToolsResult, + PaginatedRequestParams, + TextContent, + Tool, +) + +from mcp.server import Server, ServerRequestContext + +SEARCH_BOOKS = Tool( + name="search_books", + description="Search the catalog by title or author.", + input_schema={ + "type": "object", + "properties": {"query": {"type": "string"}, "limit": {"type": "integer"}}, + "required": ["query", "limit"], + }, +) + + +async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[SEARCH_BOOKS]) + + +async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + args = params.arguments or {} + text = f"Found 3 books matching {args['query']!r} (showing up to {args['limit']})." + return CallToolResult(content=[TextContent(type="text", text=text)]) + + +server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool) diff --git a/docs_src/lowlevel/tutorial002.py b/docs_src/lowlevel/tutorial002.py new file mode 100644 index 0000000000..d3033f6013 --- /dev/null +++ b/docs_src/lowlevel/tutorial002.py @@ -0,0 +1,48 @@ +from mcp_types import ( + CallToolRequestParams, + CallToolResult, + ListToolsResult, + PaginatedRequestParams, + TextContent, + Tool, +) + +from mcp.server import Server, ServerRequestContext + +SEARCH_BOOKS = Tool( + name="search_books", + description="Search the catalog by title or author.", + input_schema={ + "type": "object", + "properties": {"query": {"type": "string"}, "limit": {"type": "integer"}}, + "required": ["query", "limit"], + }, +) + +ADD_BOOK = Tool( + name="add_book", + description="Add a book to the catalog.", + input_schema={ + "type": "object", + "properties": {"title": {"type": "string"}, "author": {"type": "string"}, "year": {"type": "integer"}}, + "required": ["title", "author", "year"], + }, +) + + +async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[SEARCH_BOOKS, ADD_BOOK]) + + +async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + args = params.arguments or {} + if params.name == "search_books": + text = f"Found 3 books matching {args['query']!r} (showing up to {args['limit']})." + elif params.name == "add_book": + text = f"Added {args['title']!r} by {args['author']} ({args['year']})." + else: + raise ValueError(f"Unknown tool: {params.name}") + return CallToolResult(content=[TextContent(type="text", text=text)]) + + +server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool) diff --git a/docs_src/lowlevel/tutorial003.py b/docs_src/lowlevel/tutorial003.py new file mode 100644 index 0000000000..f350397006 --- /dev/null +++ b/docs_src/lowlevel/tutorial003.py @@ -0,0 +1,41 @@ +from mcp_types import ( + CallToolRequestParams, + CallToolResult, + ListToolsResult, + PaginatedRequestParams, + TextContent, + Tool, +) + +from mcp.server import Server, ServerRequestContext + +SEARCH_BOOKS = Tool( + name="search_books", + description="Search the catalog by title or author.", + input_schema={ + "type": "object", + "properties": {"query": {"type": "string"}, "limit": {"type": "integer"}}, + "required": ["query", "limit"], + }, + output_schema={ + "type": "object", + "properties": {"matches": {"type": "integer"}, "query": {"type": "string"}}, + "required": ["matches", "query"], + }, +) + + +async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[SEARCH_BOOKS]) + + +async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + args = params.arguments or {} + data = {"matches": 3, "query": args["query"]} + return CallToolResult( + content=[TextContent(type="text", text=f"Found 3 books matching {args['query']!r}.")], + structured_content=data, + ) + + +server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool) diff --git a/docs_src/lowlevel/tutorial004.py b/docs_src/lowlevel/tutorial004.py new file mode 100644 index 0000000000..18b0bef8f6 --- /dev/null +++ b/docs_src/lowlevel/tutorial004.py @@ -0,0 +1,42 @@ +from mcp_types import ( + CallToolRequestParams, + CallToolResult, + ListToolsResult, + PaginatedRequestParams, + TextContent, + Tool, +) + +from mcp.server import Server, ServerRequestContext + +SEARCH_BOOKS = Tool( + name="search_books", + description="Search the catalog by title or author.", + input_schema={ + "type": "object", + "properties": {"query": {"type": "string"}, "limit": {"type": "integer"}}, + "required": ["query", "limit"], + }, + output_schema={ + "type": "object", + "properties": {"matches": {"type": "integer"}, "query": {"type": "string"}}, + "required": ["matches", "query"], + }, +) + + +async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[SEARCH_BOOKS]) + + +async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + args = params.arguments or {} + data = {"matches": 3, "query": args["query"]} + return CallToolResult( + content=[TextContent(type="text", text=f"Found 3 books matching {args['query']!r}.")], + structured_content=data, + _meta={"bookshop/record_ids": ["bk_17", "bk_42", "bk_99"]}, + ) + + +server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool) diff --git a/docs_src/lowlevel/tutorial005.py b/docs_src/lowlevel/tutorial005.py new file mode 100644 index 0000000000..e33077ecec --- /dev/null +++ b/docs_src/lowlevel/tutorial005.py @@ -0,0 +1,51 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass + +from mcp_types import ( + CallToolRequestParams, + CallToolResult, + ListToolsResult, + PaginatedRequestParams, + TextContent, + Tool, +) + +from mcp.server import Server, ServerRequestContext + + +@dataclass +class Catalog: + books: list[str] + + def search(self, query: str) -> list[str]: + return [title for title in self.books if query.lower() in title.lower()] + + +@asynccontextmanager +async def lifespan(server: Server[Catalog]) -> AsyncIterator[Catalog]: + yield Catalog(books=["Dune", "Dune Messiah", "Children of Dune"]) + + +SEARCH_BOOKS = Tool( + name="search_books", + description="Search the catalog by title or author.", + input_schema={ + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, +) + + +async def list_tools(ctx: ServerRequestContext[Catalog], params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[SEARCH_BOOKS]) + + +async def call_tool(ctx: ServerRequestContext[Catalog], params: CallToolRequestParams) -> CallToolResult: + matches = ctx.lifespan_context.search((params.arguments or {})["query"]) + text = f"Found {len(matches)} books: {', '.join(matches)}." + return CallToolResult(content=[TextContent(type="text", text=text)]) + + +server = Server("Bookshop", lifespan=lifespan, on_list_tools=list_tools, on_call_tool=call_tool) diff --git a/docs_src/lowlevel/tutorial006.py b/docs_src/lowlevel/tutorial006.py new file mode 100644 index 0000000000..601fe5c576 --- /dev/null +++ b/docs_src/lowlevel/tutorial006.py @@ -0,0 +1,48 @@ +from mcp_types import ( + CallToolRequestParams, + CallToolResult, + ListToolsResult, + PaginatedRequestParams, + RequestParams, + TextContent, + Tool, +) +from pydantic import BaseModel + +from mcp.server import Server, ServerRequestContext + +SEARCH_BOOKS = Tool( + name="search_books", + description="Search the catalog by title or author.", + input_schema={ + "type": "object", + "properties": {"query": {"type": "string"}, "limit": {"type": "integer"}}, + "required": ["query", "limit"], + }, +) + + +async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[SEARCH_BOOKS]) + + +async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + args = params.arguments or {} + text = f"Found 3 books matching {args['query']!r} (showing up to {args['limit']})." + return CallToolResult(content=[TextContent(type="text", text=text)]) + + +class ReindexParams(RequestParams): + full: bool = False + + +class ReindexResult(BaseModel): + indexed: int + + +async def reindex(ctx: ServerRequestContext, params: ReindexParams) -> ReindexResult: + return ReindexResult(indexed=3) + + +server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool) +server.add_request_handler("bookshop/reindex", ReindexParams, reindex) diff --git a/docs_src/media/__init__.py b/docs_src/media/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/media/tutorial001.py b/docs_src/media/tutorial001.py new file mode 100644 index 0000000000..646817fbb0 --- /dev/null +++ b/docs_src/media/tutorial001.py @@ -0,0 +1,16 @@ +import base64 + +from mcp.server import MCPServer +from mcp.server.mcpserver import Image + +mcp = MCPServer("Brand kit") + +LOGO_PNG = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGOQ9bsBAAHPAURf8l/aAAAAAElFTkSuQmCC" +) + + +@mcp.tool() +def logo() -> Image: + """The brand logo as a PNG.""" + return Image(data=LOGO_PNG, format="png") diff --git a/docs_src/media/tutorial002.py b/docs_src/media/tutorial002.py new file mode 100644 index 0000000000..c98bddcd03 --- /dev/null +++ b/docs_src/media/tutorial002.py @@ -0,0 +1,24 @@ +import base64 + +from mcp.server import MCPServer +from mcp.server.mcpserver import Audio, Image + +mcp = MCPServer("Brand kit") + +LOGO_PNG = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGOQ9bsBAAHPAURf8l/aAAAAAElFTkSuQmCC" +) + +CHIME_WAV = base64.b64decode("UklGRjQAAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YRAAAAAAAAAAAAAAAAAAAAAAAAAA") + + +@mcp.tool() +def logo() -> Image: + """The brand logo as a PNG.""" + return Image(data=LOGO_PNG, format="png") + + +@mcp.tool() +def chime() -> Audio: + """The notification chime as a WAV.""" + return Audio(data=CHIME_WAV, format="wav") diff --git a/docs_src/media/tutorial003.py b/docs_src/media/tutorial003.py new file mode 100644 index 0000000000..a06e6dfcd1 --- /dev/null +++ b/docs_src/media/tutorial003.py @@ -0,0 +1,20 @@ +from mcp_types import Icon + +from mcp.server import MCPServer + +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"]) + +mcp = MCPServer("Brand kit", icons=[LOGO]) + + +@mcp.tool(icons=[PALETTE]) +def palette() -> list[str]: + """The brand colour palette as hex codes.""" + return ["#1d4ed8", "#f59e0b", "#10b981"] + + +@mcp.resource("brand://guidelines", icons=[LOGO]) +def guidelines() -> str: + """How to use the brand assets.""" + return "Use the primary colour for calls to action." diff --git a/docs_src/middleware/__init__.py b/docs_src/middleware/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/middleware/tutorial001.py b/docs_src/middleware/tutorial001.py new file mode 100644 index 0000000000..71be62db8f --- /dev/null +++ b/docs_src/middleware/tutorial001.py @@ -0,0 +1,50 @@ +import logging +import time + +from mcp_types import ( + CallToolRequestParams, + CallToolResult, + ListToolsResult, + PaginatedRequestParams, + TextContent, + Tool, +) + +from mcp.server import Server, ServerRequestContext +from mcp.server.context import CallNext, HandlerResult + +logger = logging.getLogger(__name__) + + +async def on_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult( + tools=[ + Tool( + name="search_books", + description="Search the catalog by title or author.", + input_schema={ + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, + ) + ] + ) + + +async def on_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + query = (params.arguments or {})["query"] + return CallToolResult(content=[TextContent(type="text", text=f"Found 3 books matching {query!r}.")]) + + +async def log_timing(ctx: ServerRequestContext, call_next: CallNext) -> HandlerResult: + start = time.perf_counter() + try: + return await call_next(ctx) + finally: + elapsed_ms = (time.perf_counter() - start) * 1000 + logger.info("%s took %.1f ms", ctx.method, elapsed_ms) + + +server = Server("Bookshop", on_list_tools=on_list_tools, on_call_tool=on_call_tool) +server.middleware.append(log_timing) diff --git a/docs_src/mrtr/__init__.py b/docs_src/mrtr/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/mrtr/tutorial001.py b/docs_src/mrtr/tutorial001.py new file mode 100644 index 0000000000..c0f4153cab --- /dev/null +++ b/docs_src/mrtr/tutorial001.py @@ -0,0 +1,53 @@ +from mcp_types import ( + CallToolRequestParams, + CallToolResult, + ElicitRequest, + ElicitRequestFormParams, + ElicitResult, + InputRequiredResult, + ListToolsResult, + PaginatedRequestParams, + TextContent, + Tool, +) + +from mcp.server import Server, ServerRequestContext + +ASK_REGION = ElicitRequest( + params=ElicitRequestFormParams( + message="Which region should the database live in?", + requested_schema={ + "type": "object", + "properties": {"region": {"type": "string"}}, + "required": ["region"], + }, + ) +) + + +async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult( + tools=[ + Tool( + name="provision", + description="Provision a database. Asks which region to put it in.", + input_schema={ + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + }, + ) + ] + ) + + +async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult | InputRequiredResult: + answer = (params.input_responses or {}).get("region") + if not isinstance(answer, ElicitResult) or answer.content is None: + return InputRequiredResult(input_requests={"region": ASK_REGION}, request_state="provision-v1") + name = (params.arguments or {})["name"] + text = f"Provisioned {name!r} in {answer.content['region']}." + return CallToolResult(content=[TextContent(type="text", text=text)]) + + +server = Server("Provisioner", on_list_tools=list_tools, on_call_tool=call_tool) diff --git a/docs_src/mrtr/tutorial002.py b/docs_src/mrtr/tutorial002.py new file mode 100644 index 0000000000..a6556fe365 --- /dev/null +++ b/docs_src/mrtr/tutorial002.py @@ -0,0 +1,23 @@ +from mcp_types import CallToolResult, ElicitRequest, ElicitResult, InputRequest, InputRequiredResult, InputResponse + +from mcp import Client + + +def fulfil(request: InputRequest) -> InputResponse: + if not isinstance(request, ElicitRequest): + raise NotImplementedError(f"this client cannot answer a {request.method!r} request") + return ElicitResult(action="accept", content={"region": "eu-west-1"}) + + +async def provision(client: Client, name: str) -> CallToolResult: + result = await client.call_tool("provision", {"name": name}, allow_input_required=True) + while isinstance(result, InputRequiredResult): + responses = {key: fulfil(request) for key, request in (result.input_requests or {}).items()} + result = await client.call_tool( + "provision", + {"name": name}, + input_responses=responses, + request_state=result.request_state, + allow_input_required=True, + ) + return result diff --git a/docs_src/oauth_clients/__init__.py b/docs_src/oauth_clients/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/oauth_clients/tutorial001.py b/docs_src/oauth_clients/tutorial001.py new file mode 100644 index 0000000000..4360379a29 --- /dev/null +++ b/docs_src/oauth_clients/tutorial001.py @@ -0,0 +1,62 @@ +from urllib.parse import parse_qs, urlparse + +import httpx +from pydantic import AnyUrl + +from mcp import Client +from mcp.client.auth import AuthorizationCodeResult, OAuthClientProvider +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken + + +class InMemoryTokenStorage: + def __init__(self) -> None: + self.tokens: OAuthToken | None = None + self.client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> OAuthToken | None: + return self.tokens + + async def set_tokens(self, tokens: OAuthToken) -> None: + self.tokens = tokens + + async def get_client_info(self) -> OAuthClientInformationFull | None: + return self.client_info + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + self.client_info = client_info + + +async def open_browser(authorization_url: str) -> None: + print(f"Visit: {authorization_url}") + + +async def wait_for_callback() -> AuthorizationCodeResult: + redirect_url = input("Paste the URL you were redirected to: ") + params = parse_qs(urlparse(redirect_url).query) + return AuthorizationCodeResult( + code=params["code"][0], + state=params["state"][0], + iss=params["iss"][0] if "iss" in params else None, + ) + + +oauth = OAuthClientProvider( + server_url="http://localhost:8001/mcp", + client_metadata=OAuthClientMetadata( + client_name="Bookshop Agent", + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + scope="user", + ), + storage=InMemoryTokenStorage(), + redirect_handler=open_browser, + callback_handler=wait_for_callback, +) + + +async def main() -> None: + async with httpx.AsyncClient(auth=oauth, follow_redirects=True) as http_client: + transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client) + async with Client(transport) as client: + result = await client.list_tools() + print([tool.name for tool in result.tools]) diff --git a/docs_src/oauth_clients/tutorial002.py b/docs_src/oauth_clients/tutorial002.py new file mode 100644 index 0000000000..b5b052c962 --- /dev/null +++ b/docs_src/oauth_clients/tutorial002.py @@ -0,0 +1,41 @@ +import httpx + +from mcp import Client +from mcp.client.auth.extensions.client_credentials import ClientCredentialsOAuthProvider +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.auth import OAuthClientInformationFull, OAuthToken + + +class InMemoryTokenStorage: + def __init__(self) -> None: + self.tokens: OAuthToken | None = None + self.client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> OAuthToken | None: + return self.tokens + + async def set_tokens(self, tokens: OAuthToken) -> None: + self.tokens = tokens + + async def get_client_info(self) -> OAuthClientInformationFull | None: + return self.client_info + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + self.client_info = client_info + + +oauth = ClientCredentialsOAuthProvider( + server_url="http://localhost:8001/mcp", + storage=InMemoryTokenStorage(), + client_id="reporting-agent", + client_secret="...", + scopes="user", +) + + +async def main() -> None: + async with httpx.AsyncClient(auth=oauth, follow_redirects=True) as http_client: + transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client) + async with Client(transport) as client: + result = await client.list_tools() + print([tool.name for tool in result.tools]) diff --git a/docs_src/pagination/__init__.py b/docs_src/pagination/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/pagination/tutorial001.py b/docs_src/pagination/tutorial001.py new file mode 100644 index 0000000000..2ad4b9453f --- /dev/null +++ b/docs_src/pagination/tutorial001.py @@ -0,0 +1,20 @@ +from typing import Any + +from mcp_types import ListResourcesResult, PaginatedRequestParams, Resource + +from mcp.server import Server, ServerRequestContext + +BOOKS = [f"book-{n}" for n in range(1, 101)] + +PAGE_SIZE = 10 + + +async def list_books(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListResourcesResult: + start = 0 if params is None or params.cursor is None else int(params.cursor) + end = start + PAGE_SIZE + page = [Resource(uri=f"books://catalog/{name}", name=name) for name in BOOKS[start:end]] + next_cursor = str(end) if end < len(BOOKS) else None + return ListResourcesResult(resources=page, next_cursor=next_cursor) + + +server = Server("Bookshop", on_list_resources=list_books) diff --git a/docs_src/pagination/tutorial002.py b/docs_src/pagination/tutorial002.py new file mode 100644 index 0000000000..cacb796e8b --- /dev/null +++ b/docs_src/pagination/tutorial002.py @@ -0,0 +1,34 @@ +from typing import Any + +from mcp_types import ListResourcesResult, PaginatedRequestParams, Resource + +from mcp import Client +from mcp.server import Server, ServerRequestContext + +BOOKS = [f"book-{n}" for n in range(1, 101)] + +PAGE_SIZE = 10 + + +async def list_books(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListResourcesResult: + start = 0 if params is None or params.cursor is None else int(params.cursor) + end = start + PAGE_SIZE + page = [Resource(uri=f"books://catalog/{name}", name=name) for name in BOOKS[start:end]] + next_cursor = str(end) if end < len(BOOKS) else None + return ListResourcesResult(resources=page, next_cursor=next_cursor) + + +server = Server("Bookshop", on_list_resources=list_books) + + +async def main() -> None: + async with Client(server) as client: + resources: list[Resource] = [] + cursor: str | None = None + while True: + page = await client.list_resources(cursor=cursor) + resources.extend(page.resources) + if page.next_cursor is None: + break + cursor = page.next_cursor + print(f"{len(resources)} resources") diff --git a/docs_src/progress/__init__.py b/docs_src/progress/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/progress/tutorial001.py b/docs_src/progress/tutorial001.py new file mode 100644 index 0000000000..afa9d496cf --- /dev/null +++ b/docs_src/progress/tutorial001.py @@ -0,0 +1,12 @@ +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +async def import_catalog(urls: list[str], ctx: Context) -> str: + """Import book records from a list of catalog URLs.""" + for done, url in enumerate(urls, start=1): + await ctx.report_progress(done, total=len(urls), message=f"Imported {url}") + return f"Imported {len(urls)} records." diff --git a/docs_src/progress/tutorial002.py b/docs_src/progress/tutorial002.py new file mode 100644 index 0000000000..270d9fc97a --- /dev/null +++ b/docs_src/progress/tutorial002.py @@ -0,0 +1,21 @@ +from collections.abc import AsyncIterator + +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +mcp = MCPServer("Bookshop") + + +async def fetch_records(feed_url: str) -> AsyncIterator[str]: + for title in ("Dune", "Neuromancer", "Hyperion"): + yield f"{feed_url}#{title}" + + +@mcp.tool() +async def import_feed(feed_url: str, ctx: Context) -> str: + """Import every record a catalog feed yields.""" + imported = 0 + async for record in fetch_records(feed_url): + imported += 1 + await ctx.report_progress(imported, message=f"Imported {record}") + return f"Imported {imported} records." diff --git a/docs_src/prompts/__init__.py b/docs_src/prompts/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/prompts/tutorial001.py b/docs_src/prompts/tutorial001.py new file mode 100644 index 0000000000..8ae504225c --- /dev/null +++ b/docs_src/prompts/tutorial001.py @@ -0,0 +1,9 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Code Helper") + + +@mcp.prompt() +def review_code(code: str) -> str: + """Review a piece of code.""" + return f"Please review this code:\n\n{code}" diff --git a/docs_src/prompts/tutorial002.py b/docs_src/prompts/tutorial002.py new file mode 100644 index 0000000000..3db7862b88 --- /dev/null +++ b/docs_src/prompts/tutorial002.py @@ -0,0 +1,20 @@ +from mcp.server import MCPServer +from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, UserMessage + +mcp = MCPServer("Code Helper") + + +@mcp.prompt() +def review_code(code: str) -> str: + """Review a piece of code.""" + return f"Please review this code:\n\n{code}" + + +@mcp.prompt() +def debug_error(error: str) -> list[Message]: + """Start a debugging conversation.""" + return [ + UserMessage("I'm seeing this error:"), + UserMessage(error), + AssistantMessage("I'll help debug that. What have you tried so far?"), + ] diff --git a/docs_src/prompts/tutorial003.py b/docs_src/prompts/tutorial003.py new file mode 100644 index 0000000000..63600c6180 --- /dev/null +++ b/docs_src/prompts/tutorial003.py @@ -0,0 +1,16 @@ +from typing import Annotated + +from pydantic import Field + +from mcp.server import MCPServer + +mcp = MCPServer("Code Helper") + + +@mcp.prompt(title="Code review") +def review_code( + code: Annotated[str, Field(description="The code to review.")], + language: Annotated[str, Field(description="The language the code is written in.")] = "python", +) -> str: + """Review a piece of code.""" + return f"Please review this {language} code:\n\n{code}" diff --git a/docs_src/protocol_versions/__init__.py b/docs_src/protocol_versions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/protocol_versions/tutorial001.py b/docs_src/protocol_versions/tutorial001.py new file mode 100644 index 0000000000..23570e3bcf --- /dev/null +++ b/docs_src/protocol_versions/tutorial001.py @@ -0,0 +1,15 @@ +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." + + +async def main() -> None: + async with Client(mcp) as client: + print(client.protocol_version) diff --git a/docs_src/protocol_versions/tutorial002.py b/docs_src/protocol_versions/tutorial002.py new file mode 100644 index 0000000000..5c00c8b4ee --- /dev/null +++ b/docs_src/protocol_versions/tutorial002.py @@ -0,0 +1,15 @@ +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." + + +async def main() -> None: + async with Client(mcp, mode="legacy") as client: + print(client.protocol_version) diff --git a/docs_src/protocol_versions/tutorial003.py b/docs_src/protocol_versions/tutorial003.py new file mode 100644 index 0000000000..5fd32ac109 --- /dev/null +++ b/docs_src/protocol_versions/tutorial003.py @@ -0,0 +1,15 @@ +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." + + +async def main() -> None: + async with Client(mcp, mode="2026-07-28") as client: + print(client.protocol_version) diff --git a/docs_src/protocol_versions/tutorial004.py b/docs_src/protocol_versions/tutorial004.py new file mode 100644 index 0000000000..c1b8fc6b5b --- /dev/null +++ b/docs_src/protocol_versions/tutorial004.py @@ -0,0 +1,19 @@ +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." + + +async def main() -> None: + async with Client(mcp) as client: + saved = client.session.discover_result + + async with Client(mcp, mode="2026-07-28", prior_discover=saved) as client: + print(client.protocol_version) + print(client.server_info.name) diff --git a/docs_src/resources/__init__.py b/docs_src/resources/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/resources/tutorial001.py b/docs_src/resources/tutorial001.py new file mode 100644 index 0000000000..99c6d89050 --- /dev/null +++ b/docs_src/resources/tutorial001.py @@ -0,0 +1,9 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.resource("config://app") +def get_config() -> str: + """The active shop configuration.""" + return "theme=dark\nlanguage=en" diff --git a/docs_src/resources/tutorial002.py b/docs_src/resources/tutorial002.py new file mode 100644 index 0000000000..557fa92410 --- /dev/null +++ b/docs_src/resources/tutorial002.py @@ -0,0 +1,15 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.resource("config://app") +def get_config() -> str: + """The active shop configuration.""" + return "theme=dark\nlanguage=en" + + +@mcp.resource("users://{user_id}/profile") +def get_user_profile(user_id: str) -> str: + """A customer's profile.""" + return f"User {user_id}: 12 orders since 2021." diff --git a/docs_src/resources/tutorial003.py b/docs_src/resources/tutorial003.py new file mode 100644 index 0000000000..10881d3a4b --- /dev/null +++ b/docs_src/resources/tutorial003.py @@ -0,0 +1,23 @@ +import base64 + +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.resource("docs://readme", mime_type="text/markdown") +def readme() -> str: + """How to use this server.""" + return "# Bookshop\n\nSearch the catalog with the `search_books` tool." + + +@mcp.resource("stats://catalog", mime_type="application/json") +def catalog_stats() -> dict[str, int]: + """Live counts for the catalog.""" + return {"books": 1204, "authors": 391} + + +@mcp.resource("covers://placeholder", mime_type="image/gif") +def placeholder_cover() -> bytes: + """A 1x1 transparent GIF, shown when a book has no cover.""" + return base64.b64decode("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7") diff --git a/docs_src/run/__init__.py b/docs_src/run/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/run/tutorial001.py b/docs_src/run/tutorial001.py new file mode 100644 index 0000000000..c8cd92854e --- /dev/null +++ b/docs_src/run/tutorial001.py @@ -0,0 +1,13 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." + + +if __name__ == "__main__": + mcp.run() diff --git a/docs_src/run/tutorial002.py b/docs_src/run/tutorial002.py new file mode 100644 index 0000000000..686dc44296 --- /dev/null +++ b/docs_src/run/tutorial002.py @@ -0,0 +1,13 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." + + +if __name__ == "__main__": + mcp.run(transport="streamable-http", port=3001) diff --git a/docs_src/run/tutorial003.py b/docs_src/run/tutorial003.py new file mode 100644 index 0000000000..19a9c11a69 --- /dev/null +++ b/docs_src/run/tutorial003.py @@ -0,0 +1,13 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop", log_level="DEBUG") + + +@mcp.tool() +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." + + +if __name__ == "__main__": + mcp.run() diff --git a/docs_src/session_groups/__init__.py b/docs_src/session_groups/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/session_groups/tutorial001.py b/docs_src/session_groups/tutorial001.py new file mode 100644 index 0000000000..08bec6d66e --- /dev/null +++ b/docs_src/session_groups/tutorial001.py @@ -0,0 +1,15 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Library") + + +@mcp.tool() +def search(query: str) -> str: + """Search the library catalog.""" + return f"3 books match {query!r}." + + +@mcp.resource("library://hours") +def hours() -> str: + """When the library is open.""" + return "Mon-Fri 09:00-17:00" diff --git a/docs_src/session_groups/tutorial002.py b/docs_src/session_groups/tutorial002.py new file mode 100644 index 0000000000..154c279c05 --- /dev/null +++ b/docs_src/session_groups/tutorial002.py @@ -0,0 +1,9 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Web") + + +@mcp.tool() +def search(query: str) -> str: + """Search the web.""" + return f"12 pages match {query!r}." diff --git a/docs_src/session_groups/tutorial003.py b/docs_src/session_groups/tutorial003.py new file mode 100644 index 0000000000..f4360f5cb7 --- /dev/null +++ b/docs_src/session_groups/tutorial003.py @@ -0,0 +1,19 @@ +import asyncio + +from mcp import ClientSessionGroup, StdioServerParameters + + +async def main() -> None: + library = StdioServerParameters(command="uv", args=["run", "mcp", "run", "library_server.py"]) + web = StdioServerParameters(command="uv", args=["run", "mcp", "run", "web_server.py"]) + + async with ClientSessionGroup() as group: + await group.connect_to_server(library) + await group.connect_to_server(web) + + result = await group.call_tool("search", {"query": "model context protocol"}) + print(result.structured_content) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs_src/session_groups/tutorial004.py b/docs_src/session_groups/tutorial004.py new file mode 100644 index 0000000000..7d107669f7 --- /dev/null +++ b/docs_src/session_groups/tutorial004.py @@ -0,0 +1,26 @@ +import asyncio + +from mcp_types import Implementation + +from mcp import ClientSessionGroup, StdioServerParameters + + +def by_server(name: str, server_info: Implementation) -> str: + return f"{server_info.name}.{name}" + + +async def main() -> None: + library = StdioServerParameters(command="uv", args=["run", "mcp", "run", "library_server.py"]) + web = StdioServerParameters(command="uv", args=["run", "mcp", "run", "web_server.py"]) + + async with ClientSessionGroup(component_name_hook=by_server) as group: + await group.connect_to_server(library) + await group.connect_to_server(web) + + print(sorted(group.tools)) + result = await group.call_tool("Web.search", {"query": "model context protocol"}) + print(result.structured_content) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs_src/structured_output/__init__.py b/docs_src/structured_output/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/structured_output/tutorial001.py b/docs_src/structured_output/tutorial001.py new file mode 100644 index 0000000000..191c9bd8fd --- /dev/null +++ b/docs_src/structured_output/tutorial001.py @@ -0,0 +1,11 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Weather") + +READINGS = {"London": 17, "Cairo": 34, "Reykjavik": 4} + + +@mcp.tool() +def get_temperature(city: str) -> int: + """Current temperature in a city, in whole degrees Celsius.""" + return READINGS[city] diff --git a/docs_src/structured_output/tutorial002.py b/docs_src/structured_output/tutorial002.py new file mode 100644 index 0000000000..8ea0ef998b --- /dev/null +++ b/docs_src/structured_output/tutorial002.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel, Field + +from mcp.server import MCPServer + +mcp = MCPServer("Weather") + + +class WeatherData(BaseModel): + temperature: float = Field(description="Degrees Celsius.") + humidity: float = Field(description="Relative humidity, 0 to 1.") + conditions: str + + +@mcp.tool() +def get_weather(city: str) -> WeatherData: + """Current weather for a city.""" + return WeatherData(temperature=16.2, humidity=0.83, conditions="Overcast") diff --git a/docs_src/structured_output/tutorial003.py b/docs_src/structured_output/tutorial003.py new file mode 100644 index 0000000000..783dc40316 --- /dev/null +++ b/docs_src/structured_output/tutorial003.py @@ -0,0 +1,17 @@ +from typing import TypedDict + +from mcp.server import MCPServer + +mcp = MCPServer("Weather") + + +class WeatherData(TypedDict): + temperature: float + humidity: float + conditions: str + + +@mcp.tool() +def get_weather(city: str) -> WeatherData: + """Current weather for a city.""" + return WeatherData(temperature=16.2, humidity=0.83, conditions="Overcast") diff --git a/docs_src/structured_output/tutorial004.py b/docs_src/structured_output/tutorial004.py new file mode 100644 index 0000000000..fb4c6e29d7 --- /dev/null +++ b/docs_src/structured_output/tutorial004.py @@ -0,0 +1,18 @@ +from dataclasses import dataclass + +from mcp.server import MCPServer + +mcp = MCPServer("Weather") + + +@dataclass +class WeatherData: + temperature: float + humidity: float + conditions: str + + +@mcp.tool() +def get_weather(city: str) -> WeatherData: + """Current weather for a city.""" + return WeatherData(temperature=16.2, humidity=0.83, conditions="Overcast") diff --git a/docs_src/structured_output/tutorial005.py b/docs_src/structured_output/tutorial005.py new file mode 100644 index 0000000000..7bbaae500f --- /dev/null +++ b/docs_src/structured_output/tutorial005.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel + +from mcp.server import MCPServer + +mcp = MCPServer("Weather") + + +class WeatherData(BaseModel): + temperature: float + humidity: float + conditions: str + + +@mcp.tool() +def get_forecast(city: str, days: int) -> list[WeatherData]: + """Daily forecast for a city.""" + return [WeatherData(temperature=16.2 + day, humidity=0.83, conditions="Overcast") for day in range(days)] diff --git a/docs_src/structured_output/tutorial006.py b/docs_src/structured_output/tutorial006.py new file mode 100644 index 0000000000..205abd2c17 --- /dev/null +++ b/docs_src/structured_output/tutorial006.py @@ -0,0 +1,11 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Weather") + +READINGS = {"London": 16.2, "Cairo": 34.1, "Reykjavik": 4.4} + + +@mcp.tool() +def get_temperatures(cities: list[str]) -> dict[str, float]: + """Current temperature for each city, in degrees Celsius.""" + return {city: READINGS[city] for city in cities} diff --git a/docs_src/structured_output/tutorial007.py b/docs_src/structured_output/tutorial007.py new file mode 100644 index 0000000000..c889a302c0 --- /dev/null +++ b/docs_src/structured_output/tutorial007.py @@ -0,0 +1,21 @@ +import json + +from pydantic import BaseModel + +from mcp.server import MCPServer + +mcp = MCPServer("Weather") + +UPSTREAM = {"London": '{"temperature": 16.2, "conditions": "Overcast"}'} + + +class WeatherData(BaseModel): + temperature: float + humidity: float + conditions: str + + +@mcp.tool() +def get_weather(city: str) -> WeatherData: + """Current weather for a city.""" + return json.loads(UPSTREAM[city]) diff --git a/docs_src/structured_output/tutorial008.py b/docs_src/structured_output/tutorial008.py new file mode 100644 index 0000000000..b0e992398d --- /dev/null +++ b/docs_src/structured_output/tutorial008.py @@ -0,0 +1,9 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Weather") + + +@mcp.tool(structured_output=False) +def weather_report(city: str) -> str: + """A human-readable weather report for a city.""" + return f"{city}: 17 degrees, overcast, light rain easing by evening." diff --git a/docs_src/structured_output/tutorial009.py b/docs_src/structured_output/tutorial009.py new file mode 100644 index 0000000000..c5a2d17d0e --- /dev/null +++ b/docs_src/structured_output/tutorial009.py @@ -0,0 +1,15 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Weather") + + +class Station: + def __init__(self, name: str, online: bool): + self.name = name + self.online = online + + +@mcp.tool() +def get_station(name: str) -> Station: + """Look up a weather station by name.""" + return Station(name=name, online=True) diff --git a/docs_src/testing/__init__.py b/docs_src/testing/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/testing/tutorial001.py b/docs_src/testing/tutorial001.py new file mode 100644 index 0000000000..ab7938b890 --- /dev/null +++ b/docs_src/testing/tutorial001.py @@ -0,0 +1,9 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Calculator") + + +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b diff --git a/docs_src/tools/__init__.py b/docs_src/tools/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/tools/tutorial001.py b/docs_src/tools/tutorial001.py new file mode 100644 index 0000000000..b324ec8140 --- /dev/null +++ b/docs_src/tools/tutorial001.py @@ -0,0 +1,9 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str, limit: int) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r} (showing up to {limit})." diff --git a/docs_src/tools/tutorial002.py b/docs_src/tools/tutorial002.py new file mode 100644 index 0000000000..1ade813788 --- /dev/null +++ b/docs_src/tools/tutorial002.py @@ -0,0 +1,9 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str, limit: int = 10) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r} (showing up to {limit})." diff --git a/docs_src/tools/tutorial003.py b/docs_src/tools/tutorial003.py new file mode 100644 index 0000000000..1bc415a593 --- /dev/null +++ b/docs_src/tools/tutorial003.py @@ -0,0 +1,18 @@ +from typing import Annotated, Literal + +from pydantic import Field + +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books( + query: Annotated[str, Field(description="Title or author to search for.")], + limit: Annotated[int, Field(ge=1, le=50, description="Maximum number of results.")] = 10, + genre: Literal["fiction", "non-fiction", "poetry"] | None = None, +) -> str: + """Search the catalog by title or author.""" + where = f" in {genre}" if genre else "" + return f"Found 3 books matching {query!r}{where} (showing up to {limit})." diff --git a/docs_src/tools/tutorial004.py b/docs_src/tools/tutorial004.py new file mode 100644 index 0000000000..a52f06643f --- /dev/null +++ b/docs_src/tools/tutorial004.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel, Field + +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +class Book(BaseModel): + title: str + author: str + year: int = Field(ge=1450, description="Year of first publication.") + + +@mcp.tool() +def add_book(book: Book) -> str: + """Add a book to the catalog.""" + return f"Added {book.title!r} by {book.author} ({book.year})." diff --git a/docs_src/tools/tutorial005.py b/docs_src/tools/tutorial005.py new file mode 100644 index 0000000000..f9fcbce966 --- /dev/null +++ b/docs_src/tools/tutorial005.py @@ -0,0 +1,14 @@ +from mcp_types import ToolAnnotations + +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool( + title="Search the catalog", + annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False), +) +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." diff --git a/mkdocs.yml b/mkdocs.yml index cb89faf0f0..d3bbba2119 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 @@ -11,14 +11,42 @@ site_url: https://py.sdk.modelcontextprotocol.io/v2/ # copyright: © Model Context Protocol 2025 to present nav: - - Introduction: index.md + - MCP Python SDK: index.md - Installation: installation.md + - Tutorial - User Guide: + - tutorial/index.md + - First steps: tutorial/first-steps.md + - Tools: tutorial/tools.md + - Structured Output: tutorial/structured-output.md + - Resources: tutorial/resources.md + - Prompts: tutorial/prompts.md + - The Context: tutorial/context.md + - Handling errors: tutorial/handling-errors.md + - Lifespan: tutorial/lifespan.md + - Media: tutorial/media.md + - Completions: tutorial/completions.md + - Elicitation: tutorial/elicitation.md + - Progress: tutorial/progress.md + - Logging: tutorial/logging.md + - Testing: tutorial/testing.md + - Running your server: + - run/index.md + - ASGI: run/asgi.md + - The Client: + - client/index.md + - Client callbacks: client/callbacks.md + - Client transports: client/transports.md + - Protocol versions: client/protocol-versions.md + - Advanced: + - Multi-round-trip requests: advanced/multi-round-trip.md + - The low-level Server: advanced/low-level-server.md + - Pagination: advanced/pagination.md + - Middleware: advanced/middleware.md + - Authorization: advanced/authorization.md + - OAuth clients: advanced/oauth-clients.md + - Session groups: advanced/session-groups.md + - Deprecated features: advanced/deprecated.md - Migration Guide: migration.md - - Documentation: - - Concepts: concepts.md - - Low-Level Server: low-level-server.md - - Authorization: authorization.md - - Testing: testing.md - API Reference: api/ theme: @@ -78,7 +106,13 @@ markdown_extensions: - pymdownx.critic - pymdownx.mark - pymdownx.superfences - - pymdownx.snippets + # Code examples live as complete, importable, tested files under `docs_src/` + # and are included into pages with `--8<-- "docs_src//tutorialNNN.py"` + # (resolved against the repo root, the extension's default base_path). + # `check_paths: true` + `strict: true` turn a renamed/deleted example into a + # build failure instead of a silently empty code block. + - pymdownx.snippets: + check_paths: true - pymdownx.tilde - pymdownx.inlinehilite - pymdownx.highlight: @@ -103,6 +137,7 @@ markdown_extensions: watch: - src/mcp + - docs_src plugins: - search diff --git a/pyproject.toml b/pyproject.toml index e7ef057f3b..22ba4d4f4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -137,6 +137,7 @@ include = [ "src/mcp", "src/mcp-types/mcp_types", "tests", + "docs_src", "examples/stories", "examples/servers", "examples/snippets", @@ -166,6 +167,9 @@ executionEnvironments = [ { root = "examples/servers", extraPaths = [ "examples/servers/simple-auth", ], reportUnusedFunction = false }, + # docs_src/ holds the complete, runnable code examples included into docs/*.md. + # Decorated (@mcp.tool/...) module-level functions are never called by name. + { root = "docs_src", reportUnusedFunction = false }, ] [tool.ruff] diff --git a/scripts/update_readme_snippets.py b/scripts/update_readme_snippets.py index 8a534e5cb5..413c980175 100755 --- a/scripts/update_readme_snippets.py +++ b/scripts/update_readme_snippets.py @@ -43,11 +43,13 @@ def process_snippet_block(match: re.Match[str], check_mode: bool = False) -> str file_path = match.group(2) try: - # Read the entire file + # Read the entire file. A missing source file must be fatal: a "Warning" + # that returns the stale block lets --check pass with exit 0, so a + # renamed or deleted snippet is invisible to CI. SystemExit deliberately + # escapes the `except Exception` below. file = Path(file_path) if not file.exists(): - print(f"Warning: File not found: {file_path}") - return full_match + sys.exit(f"Error: snippet-source file not found: {file_path}") code = file.read_text().rstrip() github_url = get_github_url(file_path) @@ -69,7 +71,7 @@ def process_snippet_block(match: re.Match[str], check_mode: bool = False) -> str if existing_content is not None: existing_lines = existing_content.strip().split("\n") # Find code between ```python and ``` - code_lines = [] + code_lines: list[str] = [] in_code = False for line in existing_lines: if line.strip() == "```python": diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index 308f28d1c2..f5ca07400b 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -207,7 +207,7 @@ async def main(): def __post_init__(self) -> None: if self.mode not in ("legacy", "auto") and self.mode not in MODERN_PROTOCOL_VERSIONS: hint = ( - f" ({self.mode!r} is a handshake-era version — use mode='legacy')" + f" ({self.mode!r} is a handshake-era version; use mode='legacy')" if self.mode in HANDSHAKE_PROTOCOL_VERSIONS else "" ) diff --git a/src/mcp/server/mcpserver/context.py b/src/mcp/server/mcpserver/context.py index aeb91fdfe4..15b6fd4ad4 100644 --- a/src/mcp/server/mcpserver/context.py +++ b/src/mcp/server/mcpserver/context.py @@ -85,7 +85,7 @@ def mcp_server(self) -> MCPServer: @property def request_context(self) -> ServerRequestContext[LifespanContextT, RequestT]: """Access to the underlying request context.""" - if self._request_context is None: # pragma: no cover + if self._request_context is None: raise ValueError("Context is not available outside of a request") return self._request_context diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 60b8b8473d..028c6a4753 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -193,7 +193,7 @@ def __init__( raise ValueError("Cannot specify both auth_server_provider and token_verifier") if not auth_server_provider and not token_verifier: # pragma: no cover raise ValueError("Must specify either auth_server_provider or token_verifier when auth is enabled") - elif auth_server_provider or token_verifier: # pragma: no cover + elif auth_server_provider or token_verifier: raise ValueError("Cannot specify auth_server_provider or token_verifier without auth settings") self._auth_server_provider = auth_server_provider diff --git a/src/mcp/server/mcpserver/utilities/types.py b/src/mcp/server/mcpserver/utilities/types.py index 63f94d4cc7..937a7fa9b6 100644 --- a/src/mcp/server/mcpserver/utilities/types.py +++ b/src/mcp/server/mcpserver/utilities/types.py @@ -27,7 +27,7 @@ def __init__( def _get_mime_type(self) -> str: """Get MIME type from format or guess from file extension.""" - if self._format: # pragma: no cover + if self._format: return f"image/{self._format.lower()}" if self.path: @@ -39,14 +39,14 @@ def _get_mime_type(self) -> str: ".gif": "image/gif", ".webp": "image/webp", }.get(suffix, "application/octet-stream") - return "image/png" # pragma: no cover # default for raw binary data + return "image/png" # default for raw binary data def to_image_content(self) -> ImageContent: """Convert to MCP ImageContent.""" if self.path: with open(self.path, "rb") as f: data = base64.b64encode(f.read()).decode() - elif self.data is not None: # pragma: no cover + elif self.data is not None: data = base64.b64encode(self.data).decode() else: # pragma: no cover raise ValueError("No image data available") @@ -73,7 +73,7 @@ def __init__( def _get_mime_type(self) -> str: """Get MIME type from format or guess from file extension.""" - if self._format: # pragma: no cover + if self._format: return f"audio/{self._format.lower()}" if self.path: @@ -86,14 +86,14 @@ def _get_mime_type(self) -> str: ".aac": "audio/aac", ".m4a": "audio/mp4", }.get(suffix, "application/octet-stream") - return "audio/wav" # pragma: no cover # default for raw binary data + return "audio/wav" # default for raw binary data def to_audio_content(self) -> AudioContent: """Convert to MCP AudioContent.""" if self.path: with open(self.path, "rb") as f: data = base64.b64encode(f.read()).decode() - elif self.data is not None: # pragma: no cover + elif self.data is not None: data = base64.b64encode(self.data).decode() else: # pragma: no cover raise ValueError("No audio data available") diff --git a/tests/client/test_client.py b/tests/client/test_client.py index f869d1f1bc..e8557bcec4 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -509,7 +509,7 @@ def test_client_rejects_handshake_era_mode_at_construction() -> None: `__post_init__` with a hint to use `mode='legacy'` — the version-pin path is modern-only.""" server = MCPServer("test") - with pytest.raises(ValueError, match=r"handshake-era version — use mode='legacy'"): + with pytest.raises(ValueError, match=r"handshake-era version; use mode='legacy'"): Client(server, mode="2025-06-18") with pytest.raises(ValueError, match=r"mode must be 'legacy', 'auto', or one of"): Client(server, mode="not-a-version") diff --git a/tests/docs_src/__init__.py b/tests/docs_src/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/docs_src/test_asgi.py b/tests/docs_src/test_asgi.py new file mode 100644 index 0000000000..93aa502428 --- /dev/null +++ b/tests/docs_src/test_asgi.py @@ -0,0 +1,213 @@ +"""`docs/run/asgi.md`: every claim the page makes, proved against the real SDK.""" + +import inspect + +import httpx +import pytest +from mcp_types import TextContent +from starlette.applications import Starlette +from starlette.middleware.cors import CORSMiddleware +from starlette.requests import Request +from starlette.responses import PlainTextResponse, Response +from starlette.routing import Mount, Route + +from docs_src.asgi import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005, tutorial006 +from mcp import Client +from mcp.server import MCPServer + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_streamable_http_app_is_a_starlette_app_with_one_route() -> None: + """tutorial001: the factory returns a Starlette application with a single route at `/mcp`.""" + (route,) = tutorial001.app.routes + assert isinstance(route, Route) + assert route.path == "/mcp" + + +async def test_the_server_behind_the_app_is_unchanged() -> None: + """tutorial001: wrapping the server in an ASGI app changes nothing about its tools.""" + async with Client(tutorial001.mcp) as client: + result = await client.call_tool("add_note", {"text": "milk"}) + assert result.content == [TextContent(type="text", text="Saved: milk")] + assert result.structured_content == {"result": "Saved: milk"} + + +async def test_streamable_http_app_takes_runs_options_except_port() -> None: + """The tip: every `run("streamable-http", ...)` option is here except `port`. `host` is one of them.""" + parameters = set(inspect.signature(MCPServer.streamable_http_app).parameters) - {"self"} + assert parameters == { + "streamable_http_path", + "json_response", + "stateless_http", + "event_store", + "retry_interval", + "transport_security", + "host", + } + + +async def test_a_request_before_the_session_manager_runs_is_rejected() -> None: + """The `!!! check`: nothing starts the session manager except its lifespan.""" + transport = httpx.ASGITransport(app=tutorial001.app) + async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http: + with pytest.raises(RuntimeError, match=r"Task group is not initialized\. Make sure to use run\(\)\."): + await http.post("/mcp") + + +async def test_mounting_at_the_root_keeps_the_default_path() -> None: + """tutorial002: `Mount("/")` plus the default `streamable_http_path` leaves the endpoint at `/mcp`.""" + (mount,) = tutorial002.app.routes + assert isinstance(mount, Mount) + assert mount.path == "" + (inner,) = mount.routes + assert isinstance(inner, Route) + assert inner.path == "/mcp" + + +async def test_a_root_mount_swallows_routes_listed_after_it() -> None: + """The mounting bullet: `Mount("/")` matches every path, so your own routes go before it in the list.""" + + async def about(request: Request) -> Response: + return PlainTextResponse("about") + + mcp_app = MCPServer("Notes").streamable_http_app() + listed_after = Starlette(routes=[Mount("/", app=mcp_app), Route("/about", about)]) + listed_before = Starlette(routes=[Route("/about", about), Mount("/", app=mcp_app)]) + + transport = httpx.ASGITransport(app=listed_after) + async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http: + assert (await http.get("/about")).status_code == 404 + + transport = httpx.ASGITransport(app=listed_before) + async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http: + assert (await http.get("/about")).status_code == 200 + + +async def test_the_host_lifespan_enters_the_session_manager() -> None: + """tutorial002: the host app's lifespan owns `session_manager.run()` and starts and stops cleanly.""" + async with tutorial002.lifespan(tutorial002.app): + async with Client(tutorial002.mcp) as client: + result = await client.call_tool("add_note", {"text": "milk"}) + assert result.structured_content == {"result": "Saved: milk"} + + +async def test_two_servers_get_two_mounts() -> None: + """tutorial003: each server is mounted under its own prefix, each still ending in `/mcp`.""" + notes_mount, tasks_mount = tutorial003.app.routes + assert isinstance(notes_mount, Mount) + assert isinstance(tasks_mount, Mount) + assert notes_mount.path == "/notes" + assert tasks_mount.path == "/tasks" + + +async def test_one_lifespan_starts_both_session_managers() -> None: + """tutorial003: a single `AsyncExitStack` lifespan runs both managers; both servers answer.""" + async with tutorial003.lifespan(tutorial003.app): + async with Client(tutorial003.notes) as client: + notes_result = await client.call_tool("add_note", {"text": "milk"}) + assert notes_result.structured_content == {"result": "Saved: milk"} + async with Client(tutorial003.tasks) as client: + tasks_result = await client.call_tool("add_task", {"title": "ship"}) + assert tasks_result.structured_content == {"result": "Created: ship"} + + +async def test_streamable_http_path_moves_the_endpoint_to_the_mount_prefix() -> None: + """tutorial004: `streamable_http_path="/"` makes the `Mount` prefix the whole public path.""" + (mount,) = tutorial004.app.routes + assert isinstance(mount, Mount) + assert mount.path == "/notes" + (inner,) = mount.routes + assert isinstance(inner, Route) + assert inner.path == "/" + + +async def test_cors_exposes_the_session_id_header() -> None: + """tutorial005: the browser origin gets the three MCP methods and can read `Mcp-Session-Id`.""" + (middleware,) = tutorial005.app.user_middleware + assert middleware.cls is CORSMiddleware + transport = httpx.ASGITransport(app=tutorial005.app) + async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http: + preflight = await http.options( + "/mcp", + headers={"Origin": "https://app.example.com", "Access-Control-Request-Method": "POST"}, + ) + assert preflight.status_code == 200 + assert preflight.headers["access-control-allow-methods"] == "GET, POST, DELETE" + + response = await http.get("/not-the-endpoint", headers={"Origin": "https://app.example.com"}) + assert response.headers["access-control-allow-origin"] == "https://app.example.com" + assert response.headers["access-control-expose-headers"] == "Mcp-Session-Id" + + +async def test_custom_route_lands_next_to_the_mcp_endpoint() -> None: + """tutorial006: `@mcp.custom_route()` adds a plain Starlette route to the returned app.""" + mcp_route, health_route = tutorial006.app.routes + assert isinstance(mcp_route, Route) + assert isinstance(health_route, Route) + assert mcp_route.path == "/mcp" + assert health_route.path == "/health" + + +async def test_the_health_check_answers_outside_the_protocol() -> None: + """tutorial006: `GET /health` is ordinary HTTP, with no session manager and no MCP.""" + transport = httpx.ASGITransport(app=tutorial006.app) + async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http: + response = await http.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +INITIALIZE = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "b", "version": "1"}}, +} +MCP_HEADERS = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"} + + +async def test_the_default_app_is_localhost_only() -> None: + """The "Localhost only" section: with no `transport_security=`, the app answers a real hostname + with the page's `421 Invalid Host header` and a foreign Origin with `403 Invalid Origin header`, + before any MCP code runs.""" + bare = MCPServer("Notes") + app = bare.streamable_http_app() + transport = httpx.ASGITransport(app=app) + async with bare.session_manager.run(): + async with httpx.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http: + wrong_host = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS) + async with httpx.AsyncClient(transport=transport, base_url="http://localhost:8000") as http: + wrong_origin = await http.post( + "/mcp", json=INITIALIZE, headers={**MCP_HEADERS, "Origin": "https://app.example.com"} + ) + assert (wrong_host.status_code, wrong_host.text) == (421, "Invalid Host header") + assert (wrong_origin.status_code, wrong_origin.text) == (403, "Invalid Origin header") + + +async def test_the_documented_browser_origin_works_end_to_end() -> None: + """tutorial005: the page's scenario for real. The public hostname, the browser origin, a + realistic preflight naming the `Mcp-*` headers, then the actual request.""" + transport = httpx.ASGITransport(app=tutorial005.app) + async with tutorial005.lifespan(tutorial005.app): + async with httpx.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http: + preflight = await http.options( + "/mcp", + headers={ + "Origin": "https://app.example.com", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "content-type, mcp-protocol-version, mcp-session-id", + }, + ) + assert preflight.status_code == 200 + allowed = {h.strip().lower() for h in preflight.headers["access-control-allow-headers"].split(",")} + assert {"content-type", "mcp-protocol-version", "mcp-session-id"} <= allowed + + response = await http.post( + "/mcp", json=INITIALIZE, headers={**MCP_HEADERS, "Origin": "https://app.example.com"} + ) + assert response.status_code == 200 + assert response.headers["mcp-session-id"] + assert response.headers["access-control-allow-origin"] == "https://app.example.com" + assert response.headers["access-control-expose-headers"] == "Mcp-Session-Id" diff --git a/tests/docs_src/test_authorization.py b/tests/docs_src/test_authorization.py new file mode 100644 index 0000000000..4c7554ed75 --- /dev/null +++ b/tests/docs_src/test_authorization.py @@ -0,0 +1,98 @@ +"""`docs/advanced/authorization.md`: every claim the page makes, proved against the real SDK.""" + +import httpx +import pytest +from inline_snapshot import snapshot +from mcp_types import TextContent +from starlette.routing import Route + +from docs_src.authorization import tutorial001, tutorial002 +from mcp import Client +from mcp.client.streamable_http import streamable_http_client +from mcp.server import MCPServer + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_the_in_memory_client_never_authenticates() -> None: + """tutorial001: `Client(mcp)` connects to the server object directly, so no token is ever checked.""" + async with Client(tutorial001.mcp) as client: + result = await client.call_tool("list_notes", {}) + assert not result.is_error + assert result.structured_content == {"result": ["Buy milk", "Ship the release"]} + + +async def test_token_verifier_and_auth_settings_must_travel_together() -> None: + """tutorial001: passing `token_verifier=` without `auth=` is refused at construction time.""" + with pytest.raises(ValueError, match="Cannot specify auth_server_provider or token_verifier without auth settings"): + MCPServer("Notes", token_verifier=tutorial001.StaticTokenVerifier()) + + +async def test_the_app_grows_a_protected_resource_metadata_route() -> None: + """tutorial001: the HTTP app has the `/mcp` endpoint plus the RFC 9728 well-known route.""" + mcp_route, metadata_route = tutorial001.mcp.streamable_http_app().routes + assert isinstance(mcp_route, Route) + assert isinstance(metadata_route, Route) + assert mcp_route.path == "/mcp" + assert metadata_route.path == "/.well-known/oauth-protected-resource/mcp" + + +async def test_the_metadata_document_is_built_from_auth_settings() -> None: + """tutorial001: `GET` on the well-known route returns the Protected Resource Metadata the page shows.""" + transport = httpx.ASGITransport(app=tutorial001.mcp.streamable_http_app()) + async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client: + response = await http_client.get("/.well-known/oauth-protected-resource/mcp") + assert response.status_code == 200 + assert response.json() == snapshot( + { + "resource": "http://127.0.0.1:8000/mcp", + "authorization_servers": ["https://auth.example.com/"], + "scopes_supported": ["notes:read"], + "bearer_methods_supported": ["header"], + } + ) + + +async def test_a_request_without_a_token_never_reaches_the_protocol() -> None: + """The `!!! check`: no `Authorization` header means a 401 that points at the metadata document.""" + transport = httpx.ASGITransport(app=tutorial001.mcp.streamable_http_app()) + async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client: + response = await http_client.post("/mcp", json={}) + assert response.status_code == 401 + assert response.json() == {"error": "invalid_token", "error_description": "Authentication required"} + assert response.headers["www-authenticate"] == ( + 'Bearer error="invalid_token", error_description="Authentication required", ' + 'resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"' + ) + + +async def test_a_token_the_verifier_rejects_gets_the_same_401() -> None: + """tutorial001: `verify_token` returning `None` and a missing header are indistinguishable to the caller.""" + transport = httpx.ASGITransport(app=tutorial001.mcp.streamable_http_app()) + async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client: + response = await http_client.post("/mcp", json={}, headers={"Authorization": "Bearer not-a-real-token"}) + assert response.status_code == 401 + assert response.json() == {"error": "invalid_token", "error_description": "Authentication required"} + + +async def test_get_access_token_is_none_outside_an_authenticated_request() -> None: + """tutorial002: in-memory there is no HTTP layer, so `get_access_token()` returns `None`.""" + async with Client(tutorial002.mcp) as client: + result = await client.call_tool("whoami", {}) + assert result.structured_content == {"result": "anonymous"} + + +async def test_get_access_token_is_the_callers_access_token() -> None: + """tutorial002: over Streamable HTTP a valid bearer token reaches the tool as an `AccessToken`.""" + url = "http://127.0.0.1:8000/mcp" + transport = httpx.ASGITransport(app=tutorial002.mcp.streamable_http_app()) + headers = {"Authorization": "Bearer alice-token"} + async with tutorial002.mcp.session_manager.run(): + async with ( + httpx.AsyncClient(transport=transport, base_url=url, headers=headers) as http_client, + Client(streamable_http_client(url, http_client=http_client)) as client, + ): + result = await client.call_tool("whoami", {}) + assert result.content == [TextContent(type="text", text="alice (scopes: notes:read)")] + assert result.structured_content == {"result": "alice (scopes: notes:read)"} diff --git a/tests/docs_src/test_client.py b/tests/docs_src/test_client.py new file mode 100644 index 0000000000..97cc327dcb --- /dev/null +++ b/tests/docs_src/test_client.py @@ -0,0 +1,182 @@ +"""`docs/client/index.md`: every claim the page makes, proved against the real SDK.""" + +import pytest +from inline_snapshot import snapshot +from mcp_types import Prompt, PromptArgument, PromptReference, TextContent, TextResourceContents, Tool + +from docs_src.client import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005, tutorial006, tutorial007 +from mcp import Client, MCPError +from mcp.shared.metadata_utils import get_display_name + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_every_client_program_on_the_page_runs(capsys: pytest.CaptureFixture[str]) -> None: + """Each `main()` is the literal client program shown on the page; all seven run clean in-memory.""" + await tutorial001.main() + await tutorial002.main() + await tutorial003.main() + await tutorial004.main() + await tutorial005.main() + await tutorial006.main() + await tutorial007.main() + assert "Bookshop" in capsys.readouterr().out + + +async def test_connected_properties_are_populated_inside_the_block() -> None: + """tutorial001: server_info, server_capabilities, protocol_version and instructions are just there.""" + async with Client(tutorial001.mcp) as client: + assert client.server_info.name == "Bookshop" + assert client.protocol_version == "2026-07-28" + assert client.instructions == "Search the catalog before recommending a book." + assert client.server_capabilities.tools is not None + assert client.server_capabilities.logging is None + + +async def test_a_client_is_not_reusable_after_the_block_ends() -> None: + """tutorial001: `async with` is the whole lifecycle. Construct a new Client per connection.""" + client = Client(tutorial001.mcp) + async with client: + assert client.server_info.name == "Bookshop" + with pytest.raises(RuntimeError, match="cannot reenter"): + await client.__aenter__() + + +async def test_list_tools_returns_the_full_definition() -> None: + """tutorial002: each listed tool carries its name, title, description and the derived input schema.""" + async with Client(tutorial002.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.name == "search_books" + assert tool.title == "Search the catalog" + assert tool.description == "Search the catalog by title or author." + assert tool.input_schema == snapshot( + { + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"default": 10, "title": "Limit", "type": "integer"}, + }, + "required": ["query"], + "title": "search_booksArguments", + } + ) + + +def test_get_display_name_prefers_the_title() -> None: + """The `!!! tip`: get_display_name returns the title when there is one and the name when there isn't.""" + titled = Tool(name="search_books", title="Search the catalog", input_schema={"type": "object"}) + untitled = Tool(name="search_books", input_schema={"type": "object"}) + assert get_display_name(titled) == "Search the catalog" + assert get_display_name(untitled) == "search_books" + + +async def test_call_tool_result_has_three_things_to_read() -> None: + """tutorial003: content for the model, structured_content for code, is_error for both.""" + async with Client(tutorial003.mcp) as client: + result = await client.call_tool("lookup_book", {"title": "Dune"}) + assert not result.is_error + (block,) = result.content + assert isinstance(block, TextContent) + assert block.text == '{\n "title": "Dune",\n "author": "Frank Herbert",\n "year": 1965\n}' + assert result.structured_content == {"title": "Dune", "author": "Frank Herbert", "year": 1965} + + +async def test_a_raising_tool_is_a_result_not_an_exception() -> None: + """tutorial003 `!!! check`: the exception's message comes back in content with is_error=True.""" + async with Client(tutorial003.mcp) as client: + result = await client.call_tool("lookup_book", {"title": "Solaris"}) + assert result.is_error + (block,) = result.content + assert isinstance(block, TextContent) + assert block.text == "Error executing tool lookup_book: No book titled 'Solaris' in the catalog." + assert result.structured_content is None + + +async def test_an_unknown_tool_name_is_a_result_not_an_exception() -> None: + """The `!!! warning`: a tool the server doesn't have comes back as is_error=True, not as MCPError.""" + async with Client(tutorial003.mcp) as client: + result = await client.call_tool("does_not_exist", {}) + assert result.is_error + (block,) = result.content + assert isinstance(block, TextContent) + assert block.text == "Unknown tool: does_not_exist" + assert result.structured_content is None + + +async def test_resources_and_templates_are_two_separate_lists() -> None: + """tutorial004: concrete resources and parameterised templates come back from different verbs.""" + async with Client(tutorial004.mcp) as client: + (resource,) = (await client.list_resources()).resources + assert resource.uri == "catalog://genres" + (template,) = (await client.list_resource_templates()).resource_templates + assert template.uri_template == "catalog://genres/{genre}" + + +async def test_read_resource_fills_in_a_template() -> None: + """tutorial004: read_resource takes a plain str URI; narrow the contents with isinstance.""" + async with Client(tutorial004.mcp) as client: + (contents,) = (await client.read_resource("catalog://genres/poetry")).contents + assert isinstance(contents, TextResourceContents) + assert contents.text == "3 books filed under poetry." + + +async def test_mcpserver_does_not_implement_resource_subscriptions() -> None: + """The Resources section: MCPServer advertises subscribe=False and rejects subscribe_resource with -32601.""" + async with Client(tutorial004.mcp) as client: + assert client.server_capabilities.resources is not None + assert client.server_capabilities.resources.subscribe is False + with pytest.raises(MCPError) as exc_info: + await client.subscribe_resource("catalog://genres") + assert exc_info.value.error.code == -32601 + assert exc_info.value.error.message == "Method not found" + + +async def test_list_prompts_describes_the_arguments() -> None: + """tutorial005: a listed prompt carries its name, title and the arguments it needs.""" + async with Client(tutorial005.mcp) as client: + (prompt,) = (await client.list_prompts()).prompts + assert prompt == snapshot( + Prompt( + name="recommend", + title="Recommend a book", + description="Ask for a recommendation in a genre.", + arguments=[PromptArgument(name="genre", required=True)], + ) + ) + + +async def test_get_prompt_renders_the_messages() -> None: + """tutorial005: get_prompt returns the rendered messages a host hands to the model.""" + async with Client(tutorial005.mcp) as client: + result = await client.get_prompt("recommend", {"genre": "poetry"}) + (message,) = result.messages + assert message.role == "user" + assert message.content == TextContent( + type="text", text="Recommend one poetry book from the catalog and say why." + ) + + +async def test_complete_suggests_values_for_an_argument() -> None: + """tutorial006: complete takes a ref and a name/value pair and returns the matching values.""" + async with Client(tutorial006.mcp) as client: + result = await client.complete( + ref=PromptReference(type="ref/prompt", name="recommend"), + argument={"name": "genre", "value": "p"}, + ) + assert result.completion.values == ["poetry"] + + +async def test_a_single_page_server_ends_the_pagination_loop_immediately() -> None: + """tutorial007: every list_* takes cursor=; next_cursor is None when there is nothing left.""" + async with Client(tutorial007.mcp) as client: + page = await client.list_tools(cursor=None) + assert page.next_cursor is None + assert [tool.name for tool in page.tools] == ["search_books", "reserve_book"] + + +async def test_raise_exceptions_is_a_constructor_flag() -> None: + """The `## In tests` section: `raise_exceptions=True` is accepted by the in-memory Client.""" + async with Client(tutorial001.mcp, raise_exceptions=True) as client: + result = await client.call_tool("search_books", {"query": "dune"}) + assert result.structured_content == {"result": "Found 3 books matching 'dune'."} diff --git a/tests/docs_src/test_client_callbacks.py b/tests/docs_src/test_client_callbacks.py new file mode 100644 index 0000000000..b615c4700f --- /dev/null +++ b/tests/docs_src/test_client_callbacks.py @@ -0,0 +1,129 @@ +"""`docs/client/callbacks.md`: every claim the page makes, proved against the real SDK.""" + +import pytest +from inline_snapshot import snapshot +from mcp_types import ( + INVALID_REQUEST, + CreateMessageRequestParams, + CreateMessageResult, + ElicitRequestFormParams, + ElicitRequestParams, + ElicitResult, + ErrorData, + ListRootsResult, + Root, + SamplingMessage, + TextContent, +) +from pydantic import FileUrl + +from docs_src.client_callbacks import tutorial001, tutorial002, tutorial003, tutorial004 +from mcp import Client, MCPError +from mcp.client import ClientRequestContext + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_the_callback_answers_the_servers_question() -> None: + """tutorial001+002: the server's `ctx.elicit` is resolved by the client's `elicitation_callback`.""" + async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=tutorial002.handle_elicitation) as client: + result = await client.call_tool("issue_card") + assert not result.is_error + assert result.content == [TextContent(type="text", text="Card issued to Ada Lovelace.")] + + +async def test_the_callback_receives_the_servers_question_as_form_params() -> None: + """tutorial002: the callback gets `ElicitRequestFormParams` (the message and the requested schema).""" + received: list[ElicitRequestParams] = [] + + async def recording(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + received.append(params) + return await tutorial002.handle_elicitation(context, params) + + async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=recording) as client: + await client.call_tool("issue_card") + (params,) = received + assert isinstance(params, ElicitRequestFormParams) + assert params.mode == "form" + assert params.message == "What name should go on the card?" + assert params.requested_schema == snapshot( + { + "properties": {"name": {"title": "Name", "type": "string"}}, + "required": ["name"], + "title": "CardHolder", + "type": "object", + } + ) + + +async def test_returning_error_data_refuses_the_request_and_fails_the_call() -> None: + """The callback's only other return type: `ErrorData` refuses the request and fails the whole call.""" + + async def refuse(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult | ErrorData: + return ErrorData(code=INVALID_REQUEST, message="No forms here.") + + async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=refuse) as client: + with pytest.raises(MCPError, match="No forms here") as exc_info: + await client.call_tool("issue_card") + assert exc_info.value.error.code == INVALID_REQUEST + + +async def test_without_the_callback_the_servers_request_is_refused() -> None: + """The `!!! check`: no `elicitation_callback` means the SDK answers with an error and the call fails.""" + async with Client(tutorial001.mcp, mode="legacy") as client: + with pytest.raises(MCPError, match="Elicitation not supported") as exc_info: + await client.call_tool("issue_card") + assert exc_info.value.error.code == INVALID_REQUEST + + +async def test_registering_the_callback_declares_the_capability() -> None: + """tutorial003: `elicitation_callback` alone advertises exactly the `elicitation` capability.""" + async with Client(tutorial003.mcp, mode="legacy", elicitation_callback=tutorial002.handle_elicitation) as client: + result = await client.call_tool("client_features") + assert result.structured_content == {"result": ["elicitation"]} + + +async def test_no_callbacks_means_no_capabilities() -> None: + """tutorial003: a client constructed without callbacks declares nothing.""" + async with Client(tutorial003.mcp, mode="legacy") as client: + result = await client.call_tool("client_features") + assert result.structured_content == {"result": []} + + +async def test_each_callback_declares_its_own_capability() -> None: + """The page's table: the elicitation, sampling, and roots callbacks each declare their capability.""" + async with Client( + tutorial003.mcp, + mode="legacy", + elicitation_callback=tutorial002.handle_elicitation, + sampling_callback=tutorial004.handle_sampling, + list_roots_callback=tutorial004.handle_list_roots, + ) as client: + result = await client.call_tool("client_features") + assert result.structured_content == {"result": ["elicitation", "sampling", "roots"]} + + +async def test_the_modern_in_memory_path_has_no_back_channel() -> None: + """The `!!! info`: under the default mode the negotiated path has no back-channel for `elicitation/create`.""" + async with Client(tutorial001.mcp, elicitation_callback=tutorial002.handle_elicitation) as client: + with pytest.raises(MCPError, match="Method not found"): + await client.call_tool("issue_card") + + +async def test_the_deprecated_callbacks_return_what_the_page_says() -> None: + """tutorial004: the sampling and roots callbacks produce the result types the page names.""" + async with Client(tutorial003.mcp, mode="legacy") as client: + context = ClientRequestContext(session=client.session, request_id=1) + params = CreateMessageRequestParams( + messages=[SamplingMessage(role="user", content=TextContent(type="text", text="6 * 7?"))], + max_tokens=16, + ) + assert await tutorial004.handle_sampling(context, params) == snapshot( + CreateMessageResult( + role="assistant", content=TextContent(type="text", text="The answer is 42."), model="my-llm" + ) + ) + assert await tutorial004.handle_list_roots(context) == snapshot( + ListRootsResult(roots=[Root(uri=FileUrl("file:///home/ada/notebooks"), name="notebooks")]) + ) diff --git a/tests/docs_src/test_client_transports.py b/tests/docs_src/test_client_transports.py new file mode 100644 index 0000000000..848eddd52e --- /dev/null +++ b/tests/docs_src/test_client_transports.py @@ -0,0 +1,58 @@ +"""`docs/client/transports.md`: every claim the page makes, proved against the real SDK.""" + +import inspect + +import pytest + +from docs_src.client_transports import tutorial001, tutorial004 +from mcp import Client +from mcp.client.stdio import get_default_environment, stdio_client +from mcp.client.streamable_http import streamable_http_client + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_the_in_memory_program_on_the_page_runs(capsys: pytest.CaptureFixture[str]) -> None: + """tutorial001's `main()` is the literal client program on the page; it runs clean end to end.""" + await tutorial001.main() + assert "Found 3 books matching 'dune'." in capsys.readouterr().out + + +async def test_in_memory_client_talks_to_the_server_object() -> None: + """tutorial001: passing the server object connects in-process. No subprocess, no port.""" + async with Client(tutorial001.mcp) as client: + assert client.server_info.name == "Bookshop" + assert client.protocol_version == "2026-07-28" + result = await client.call_tool("search_books", {"query": "dune"}) + assert result.structured_content == {"result": "Found 3 books matching 'dune'."} + + +async def test_constructing_a_client_does_not_connect_it() -> None: + """tutorial002: a URL string is accepted as-is, and nothing happens until `async with`.""" + client = Client("http://localhost:8000/mcp") + with pytest.raises(RuntimeError, match="Client must be used within an async context manager"): + client.session + + +async def test_streamable_http_configuration_lives_on_the_httpx_client() -> None: + """tutorial003: `streamable_http_client` takes `http_client=`; there is no `headers=` or any other HTTP knob.""" + assert list(inspect.signature(streamable_http_client).parameters) == ["url", "http_client", "terminate_on_close"] + + +async def test_stdio_parameters_are_wrapped_by_stdio_client() -> None: + """tutorial004: `stdio_client(params)` is the transport, and `Client` takes it like any other.""" + client = Client(stdio_client(tutorial004.server)) + with pytest.raises(RuntimeError, match="Client must be used within an async context manager"): + client.session + + +async def test_the_child_environment_is_an_allowlist(monkeypatch: pytest.MonkeyPatch) -> None: + """tutorial004: a variable set in the parent process is not inherited; `env=` adds it back explicitly.""" + monkeypatch.setenv("BOOKSHOP_API_KEY", "from-the-parent") + inherited = get_default_environment() + assert "PATH" in inherited + assert "BOOKSHOP_API_KEY" not in inherited + extra = tutorial004.server.env + assert extra is not None + assert (inherited | extra)["BOOKSHOP_API_KEY"] == "secret" diff --git a/tests/docs_src/test_completions.py b/tests/docs_src/test_completions.py new file mode 100644 index 0000000000..b1f5c18164 --- /dev/null +++ b/tests/docs_src/test_completions.py @@ -0,0 +1,116 @@ +"""`docs/tutorial/completions.md`: every claim the page makes, proved against the real SDK.""" + +import pytest +from inline_snapshot import snapshot +from mcp_types import ( + Completion, + CompletionContext, + CompletionsCapability, + ErrorData, + PromptReference, + ResourceTemplateReference, +) + +from docs_src.completions import tutorial001, tutorial002, tutorial003 +from mcp import Client, MCPError + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + +TEMPLATE_REF = ResourceTemplateReference(uri="github://repos/{owner}/{repo}") +PROMPT_REF = PromptReference(name="review_code") + + +async def test_a_server_with_no_handler_has_no_completions_capability() -> None: + """tutorial001: there is something worth completing, but no handler and no advertised capability.""" + async with Client(tutorial001.mcp) as client: + (template,) = (await client.list_resource_templates()).resource_templates + assert template.uri_template == "github://repos/{owner}/{repo}" + (prompt,) = (await client.list_prompts()).prompts + assert prompt.name == "review_code" + assert client.server_capabilities.completions is None + + +async def test_completing_without_a_handler_is_method_not_found() -> None: + """tutorial001: nothing handles `completion/complete`, so the request is a JSON-RPC error.""" + async with Client(tutorial001.mcp) as client: + with pytest.raises(MCPError) as excinfo: + await client.complete(ref=PROMPT_REF, argument={"name": "language", "value": "py"}) + assert excinfo.value.error == ErrorData(code=-32601, message="Method not found", data="completion/complete") + + +async def test_registering_the_handler_advertises_the_capability() -> None: + """tutorial002: `@mcp.completion()` is the whole declaration; the capability is derived from it.""" + async with Client(tutorial002.mcp) as client: + assert client.server_capabilities.completions == CompletionsCapability() + + +async def test_prompt_argument_completion_filters_on_the_typed_prefix() -> None: + """tutorial002: the handler returns the languages that start with `argument.value`.""" + async with Client(tutorial002.mcp) as client: + result = await client.complete(ref=PROMPT_REF, argument={"name": "language", "value": "py"}) + assert result.completion == snapshot(Completion(values=["python"])) + + +async def test_empty_value_returns_every_suggestion() -> None: + """tutorial002: an empty prefix matches everything, so the client gets the whole list.""" + async with Client(tutorial002.mcp) as client: + result = await client.complete(ref=PROMPT_REF, argument={"name": "language", "value": ""}) + assert result.completion.values == ["go", "javascript", "python", "rust", "typescript"] + + +async def test_returning_none_is_an_empty_list_not_an_error() -> None: + """tutorial002: an argument the handler does not recognise produces `values=[]`, never a failure.""" + async with Client(tutorial002.mcp) as client: + result = await client.complete(ref=PROMPT_REF, argument={"name": "code", "value": "x"}) + assert result.completion == snapshot(Completion(values=[])) + result = await client.complete(ref=TEMPLATE_REF, argument={"name": "repo", "value": ""}) + assert result.completion.values == [] + + +async def test_context_arguments_resolve_a_dependent_parameter() -> None: + """tutorial003: the already-resolved `owner` arrives in `context.arguments` and picks the repo list.""" + async with Client(tutorial003.mcp) as client: + result = await client.complete( + ref=TEMPLATE_REF, + argument={"name": "repo", "value": ""}, + context_arguments={"owner": "modelcontextprotocol"}, + ) + assert result.completion == snapshot(Completion(values=["python-sdk", "typescript-sdk", "inspector"])) + + +async def test_the_typed_prefix_still_filters_a_dependent_parameter() -> None: + """tutorial003: `argument.value` narrows the owner's repos exactly as it narrows a prompt argument.""" + async with Client(tutorial003.mcp) as client: + result = await client.complete( + ref=TEMPLATE_REF, + argument={"name": "repo", "value": "py"}, + context_arguments={"owner": "modelcontextprotocol"}, + ) + assert result.completion.values == ["python-sdk"] + + +def test_context_arguments_is_optional() -> None: + """tutorial003: `context.arguments` is `dict[str, str] | None`; the handler's `None` guard is required.""" + assert CompletionContext.model_fields["arguments"].annotation == (dict[str, str] | None) + assert CompletionContext().arguments is None + + +async def test_no_context_means_no_suggestions() -> None: + """tutorial003: without a resolved `owner` (or with an unknown one) the handler has nothing to offer.""" + async with Client(tutorial003.mcp) as client: + result = await client.complete(ref=TEMPLATE_REF, argument={"name": "repo", "value": ""}) + assert result.completion.values == [] + result = await client.complete( + ref=TEMPLATE_REF, + argument={"name": "repo", "value": ""}, + context_arguments={"owner": "nobody"}, + ) + assert result.completion.values == [] + + +async def test_the_prompt_branch_is_untouched_by_the_new_one() -> None: + """tutorial003: adding the resource-template branch leaves prompt-argument completion as it was.""" + async with Client(tutorial003.mcp) as client: + result = await client.complete(ref=PROMPT_REF, argument={"name": "language", "value": "type"}) + assert result.completion.values == ["typescript"] diff --git a/tests/docs_src/test_context.py b/tests/docs_src/test_context.py new file mode 100644 index 0000000000..2948b10f57 --- /dev/null +++ b/tests/docs_src/test_context.py @@ -0,0 +1,88 @@ +"""`docs/tutorial/context.md`: every claim the page makes, proved against the real SDK.""" + +import re + +import pytest +from inline_snapshot import snapshot +from mcp_types import TextContent, TextResourceContents, ToolListChangedNotification + +from docs_src.context import tutorial001, tutorial002, tutorial003 +from mcp import Client + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_the_context_parameter_is_not_in_the_input_schema() -> None: + """tutorial001: the injected `Context` never appears in the schema the model sees.""" + async with Client(tutorial001.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.input_schema == snapshot( + { + "type": "object", + "properties": {"query": {"title": "Query", "type": "string"}}, + "required": ["query"], + "title": "search_booksArguments", + } + ) + + +async def test_every_request_gets_its_own_context() -> None: + """tutorial001: `ctx.request_id` identifies the request being served, so it changes per call.""" + async with Client(tutorial001.mcp) as client: + first = await client.call_tool("search_books", {"query": "dune"}) + second = await client.call_tool("search_books", {"query": "dune"}) + assert isinstance(first.content[0], TextContent) + assert isinstance(second.content[0], TextContent) + assert re.fullmatch(r"\[request \d+\] Found 3 books matching 'dune'\.", first.content[0].text) + assert first.content[0].text != second.content[0].text + + +async def test_a_tool_reads_the_servers_own_resource() -> None: + """tutorial002: `ctx.read_resource` resolves the URI through the same registry `resources/read` uses.""" + async with Client(tutorial002.mcp) as client: + result = await client.call_tool("describe_catalog", {}) + assert not result.is_error + assert result.content == [ + TextContent(type="text", text="The catalog is organised into: fiction, non-fiction, poetry") + ] + (contents,) = (await client.read_resource("catalog://genres")).contents + assert isinstance(contents, TextResourceContents) + assert contents.text == "fiction, non-fiction, poetry" + + +async def test_a_context_only_tool_takes_no_arguments() -> None: + """tutorial002: a tool whose only parameter is the `Context` has an empty input schema.""" + async with Client(tutorial002.mcp) as client: + tools = {tool.name: tool for tool in (await client.list_tools()).tools} + assert tools["describe_catalog"].input_schema == snapshot( + {"type": "object", "properties": {}, "title": "describe_catalogArguments"} + ) + + +async def test_register_a_tool_at_runtime_and_notify_the_client() -> None: + """tutorial003: `mcp.add_tool` takes effect immediately and `send_tool_list_changed` reaches the client.""" + messages: list[object] = [] + + async def collect(message: object) -> None: + messages.append(message) + + async with Client(tutorial003.mcp, mode="legacy", message_handler=collect) as client: + assert [tool.name for tool in (await client.list_tools()).tools] == ["enable_recommendations"] + + missing = await client.call_tool("recommend_book", {"genre": "fiction"}) + assert missing.is_error + assert missing.content == [TextContent(type="text", text="Unknown tool: recommend_book")] + + enabled = await client.call_tool("enable_recommendations", {}) + assert enabled.content == [TextContent(type="text", text="Recommendations are now available.")] + + assert [tool.name for tool in (await client.list_tools()).tools] == [ + "enable_recommendations", + "recommend_book", + ] + result = await client.call_tool("recommend_book", {"genre": "fiction"}) + assert result.content == [TextContent(type="text", text="In fiction, try 'Dune'.")] + + (notification,) = messages + assert isinstance(notification, ToolListChangedNotification) diff --git a/tests/docs_src/test_deprecated.py b/tests/docs_src/test_deprecated.py new file mode 100644 index 0000000000..892a8f3627 --- /dev/null +++ b/tests/docs_src/test_deprecated.py @@ -0,0 +1,144 @@ +"""`docs/advanced/deprecated.md`: the page's behavioural claims, executed against the live SDK. + +This chapter has no `docs_src/` example by design: it is the one page allowed to name +the deprecated methods, and a runnable example would teach exactly what the page tells +the reader not to build. So instead of importing an example, each test here runs a +claim the page states in prose (the warning category and text, the warn-*then*-raise +order on a modern connection, the `ping` removal, and both `filterwarnings` recipes) +so the prose cannot drift away from what the SDK does. +""" + +import warnings + +import pytest +from mcp_types import CreateMessageRequestParams, CreateMessageResult, SamplingMessage, TextContent + +from mcp import Client, MCPDeprecationWarning, MCPError +from mcp.client import ClientRequestContext +from mcp.server import MCPServer +from mcp.server.mcpserver import Context +from mcp.shared.exceptions import NoBackChannelError + +pytestmark = pytest.mark.anyio + +mcp = MCPServer("Deprecated") + + +@mcp.tool() +async def ask_model(prompt: str, ctx: Context) -> str: + """A tool still built on server-initiated sampling.""" + result = await ctx.session.create_message( # pyright: ignore[reportDeprecated] + messages=[SamplingMessage(role="user", content=TextContent(type="text", text=prompt))], + max_tokens=8, + ) + return str(result.content) + + +@mcp.tool() +async def old_log(ctx: Context) -> str: + """A tool still built on protocol logging.""" + await ctx.info("hello") # pyright: ignore[reportDeprecated] + return "ok" + + +async def test_create_message_warns_and_then_raises_on_a_modern_connection() -> None: + """The `!!! warning`: on a modern connection sampling warns AND THEN the send raises. + + The two signals are independent: `@deprecated` fires the moment the method is + called, and only afterwards does the channel refuse the send. The page reports + both, in that order. + """ + async with Client(mcp) as client: + with ( + pytest.warns( + MCPDeprecationWarning, + match=r"^The sampling capability is deprecated as of 2026-07-28 \(SEP-2577\)\.$", + ), + pytest.raises(NoBackChannelError) as exc, + ): + await client.call_tool("ask_model", {"prompt": "hi"}) + assert str(exc.value) == ( + "Cannot send 'sampling/createMessage': " + "this transport context has no back-channel for server-initiated requests." + ) + + +async def test_a_deprecated_feature_still_works_on_a_legacy_session() -> None: + """The page's headline: the deprecation is advisory. + + On a classic-handshake session, the same `ask_model` tool that fails on a modern + connection runs to completion: sampling round-trips through the client's callback + and the result comes back. The only difference is the visible warning. + """ + + async def canned_sampling(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult: + return CreateMessageResult( + role="assistant", + content=TextContent(type="text", text="four"), + model="canned", + stop_reason="endTurn", + ) + + async with Client(mcp, mode="legacy", sampling_callback=canned_sampling) as client: + with pytest.warns(MCPDeprecationWarning, match=r"The sampling capability is deprecated"): + result = await client.call_tool("ask_model", {"prompt": "What is 2 + 2?"}) + assert not result.is_error + [content] = result.content + assert isinstance(content, TextContent) + assert "four" in content.text + + +async def test_send_ping_still_carries_the_deprecation_warning() -> None: + """The opening sentence: every retired method carries an `MCPDeprecationWarning`. + + `ping` is removed from the 2026-07-28 protocol rather than put in a deprecation + window, but the SDK method is still decorated (its message says *removed*) and + a modern connection answers the actual request with "Method not found". + """ + async with Client(mcp) as client: + with ( + pytest.warns( + MCPDeprecationWarning, + match=r"^ping is removed as of 2026-07-28; the method only works under mode='legacy'\.$", + ), + pytest.raises(MCPError, match="^Method not found$"), + ): + await client.send_ping() # pyright: ignore[reportDeprecated] + + +def test_mcp_deprecation_warning_is_a_user_warning() -> None: + """The "Deprecated is advisory" section: the category subclasses `UserWarning`. + + Python's default filter hides `DeprecationWarning` outside `__main__`; deriving + from `UserWarning` is what makes the warning visible with no `-W` flag. + """ + assert issubclass(MCPDeprecationWarning, UserWarning) + assert not issubclass(MCPDeprecationWarning, DeprecationWarning) + + +@pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning") +async def test_error_filter_turns_the_deprecated_call_into_the_documented_tool_error() -> None: + """The `!!! check`: `"error::mcp.MCPDeprecationWarning"` makes `old_log` fail. + + Under the error filter the warning becomes the raised exception, the tool manager + wraps it, and the result is exactly the tool error the page quotes. + """ + async with Client(mcp) as client: + result = await client.call_tool("old_log", {}) + assert result.is_error + [content] = result.content + assert isinstance(content, TextContent) + assert content.text == ( + "Error executing tool old_log: The logging capability is deprecated as of 2026-07-28 (SEP-2577)." + ) + + +async def test_filterwarnings_ignore_silences_the_whole_category() -> None: + """The "Silencing the warning" snippet: one `filterwarnings` line quiets the category.""" + async with Client(mcp) as client: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + warnings.filterwarnings("ignore", category=MCPDeprecationWarning) + result = await client.call_tool("old_log", {}) + assert not result.is_error + assert not any(issubclass(w.category, MCPDeprecationWarning) for w in caught) diff --git a/tests/docs_src/test_elicitation.py b/tests/docs_src/test_elicitation.py new file mode 100644 index 0000000000..44523a141f --- /dev/null +++ b/tests/docs_src/test_elicitation.py @@ -0,0 +1,248 @@ +"""`docs/tutorial/elicitation.md`: every claim the page makes, proved against the real SDK.""" + +from typing import Literal + +import pytest +from inline_snapshot import snapshot +from mcp_types import ( + ElicitCompleteNotification, + ElicitRequestFormParams, + ElicitRequestParams, + ElicitRequestURLParams, + ElicitResult, + TextContent, +) +from pydantic import BaseModel + +from docs_src.elicitation import tutorial001, tutorial002, tutorial003 +from mcp import Client, MCPError +from mcp.client import ClientRequestContext +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_an_accepted_answer_resumes_the_tool() -> None: + """tutorial001: the user's answer comes back into the same call as a validated model.""" + + async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + return ElicitResult(action="accept", content={"accept_alternative": True, "date": "2025-12-26"}) + + async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_elicit) as client: + result = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2}) + assert not result.is_error + assert result.content == [TextContent(type="text", text="Booked a table for 2 on 2025-12-26.")] + + +async def test_an_alternative_that_is_also_full_is_asked_about_again() -> None: + """tutorial001: the accepted date goes back through `book_table`, so a full date is re-asked, not booked.""" + asked: list[str] = [] + + async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + asked.append(params.message) + date = "2025-12-25" if len(asked) == 1 else "2025-12-27" + return ElicitResult(action="accept", content={"accept_alternative": True, "date": date}) + + async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_elicit) as client: + result = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2}) + assert result.content == [TextContent(type="text", text="Booked a table for 2 on 2025-12-27.")] + assert asked == [ + "No tables for 2 on 2025-12-25. Would you like to try another date?", + "No tables for 2 on 2025-12-25. Would you like to try another date?", + ] + + +async def test_the_client_receives_the_message_and_the_generated_schema() -> None: + """tutorial001: form mode sends your message plus a JSON Schema built from the Pydantic model.""" + received: list[ElicitRequestParams] = [] + + async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + received.append(params) + return ElicitResult(action="accept", content={"accept_alternative": False}) + + async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_elicit) as client: + await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2}) + (params,) = received + assert isinstance(params, ElicitRequestFormParams) + assert params.message == "No tables for 2 on 2025-12-25. Would you like to try another date?" + assert params.requested_schema == snapshot( + { + "properties": { + "accept_alternative": { + "description": "Try another date?", + "title": "Accept Alternative", + "type": "boolean", + }, + "date": { + "default": "2025-12-26", + "description": "Alternative date (YYYY-MM-DD)", + "title": "Date", + "type": "string", + }, + }, + "required": ["accept_alternative"], + "title": "AlternativeDate", + "type": "object", + } + ) + + +async def test_decline_and_cancel_are_ordinary_return_values() -> None: + """tutorial001: a refusal is not an error; the tool sees the action and answers the model normally.""" + + async def on_decline(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + return ElicitResult(action="decline") + + async def on_cancel(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + return ElicitResult(action="cancel") + + async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_decline) as client: + declined = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2}) + async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_cancel) as client: + cancelled = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2}) + assert declined.content == [TextContent(type="text", text="No booking made.")] + assert not declined.is_error + assert cancelled.content == [TextContent(type="text", text="No booking made.")] + + +async def test_a_tool_that_does_not_ask_needs_nothing_from_the_client() -> None: + """tutorial001: the elicitation only happens on the path that needs it.""" + async with Client(tutorial001.mcp, mode="legacy") as client: + result = await client.call_tool("book_table", {"date": "2025-12-30", "party_size": 4}) + assert result.content == [TextContent(type="text", text="Booked a table for 4 on 2025-12-30.")] + + +async def test_an_answer_that_does_not_match_the_schema_never_reaches_the_tool_code() -> None: + """`!!! tip`: the client's content is validated against the model; a mismatch fails the call.""" + + async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + return ElicitResult(action="accept", content={"accept_alternative": "maybe"}) + + async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_elicit) as client: + result = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2}) + assert result.is_error + assert isinstance(result.content[0], TextContent) + assert "Input should be a valid boolean" in result.content[0].text + + +class Address(BaseModel): + city: str + + +class Applicant(BaseModel): + name: str + address: Address + + +class Seating(BaseModel): + area: Literal["inside", "terrace"] + + +schema_gate_server = MCPServer("Bistro") +"""The `!!! warning` claims: what the elicitation schema gate accepts and rejects.""" + + +@schema_gate_server.tool() +async def sign_up(ctx: Context) -> str: + """Collect the new customer's details.""" + return str(await ctx.elicit(message="Who are you?", schema=Applicant)) + + +@schema_gate_server.tool() +async def choose_seating(ctx: Context) -> str: + """Ask where the party wants to sit.""" + result = await ctx.elicit(message="Where would you like to sit?", schema=Seating) + assert result.action == "accept" + return result.data.area + + +async def test_a_nested_model_is_rejected_before_anything_is_sent() -> None: + """`!!! warning`: a non-primitive field raises `TypeError` inside `ctx.elicit`, with this exact message.""" + async with Client(schema_gate_server, mode="legacy") as client: + result = await client.call_tool("sign_up", {}) + assert result.is_error + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == ( + "Error executing tool sign_up: Elicitation schema field 'address' rendered as " + "{'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition" + ) + + +async def test_a_literal_field_passes_the_gate_as_an_enum() -> None: + """`!!! warning`: a `Literal[...]` of strings renders as a JSON Schema `enum`, which the spec allows.""" + received: list[ElicitRequestParams] = [] + + async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + received.append(params) + return ElicitResult(action="accept", content={"area": "terrace"}) + + async with Client(schema_gate_server, mode="legacy", elicitation_callback=on_elicit) as client: + result = await client.call_tool("choose_seating", {}) + assert result.content == [TextContent(type="text", text="terrace")] + (params,) = received + assert isinstance(params, ElicitRequestFormParams) + assert params.requested_schema["properties"]["area"] == snapshot( + {"enum": ["inside", "terrace"], "title": "Area", "type": "string"} + ) + + +async def test_url_mode_sends_a_url_and_gets_consent_back_not_data() -> None: + """tutorial002: the client receives the URL and the elicitation id; only the action comes back.""" + received: list[ElicitRequestParams] = [] + + async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + received.append(params) + return ElicitResult(action="accept") + + async with Client(tutorial002.mcp, mode="legacy", elicitation_callback=on_elicit) as client: + result = await client.call_tool("pay_deposit", {"booking_id": "b42"}) + assert result.content == [TextContent(type="text", text="Complete the payment in your browser.")] + (params,) = received + assert isinstance(params, ElicitRequestURLParams) + assert params.url == "https://pay.example.com/deposit/b42" + assert params.elicitation_id == "deposit-b42" + + +async def test_a_declined_url_elicitation_is_an_ordinary_return_value() -> None: + """tutorial002: the tool decides what a refusal means.""" + + async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + return ElicitResult(action="decline") + + async with Client(tutorial002.mcp, mode="legacy", elicitation_callback=on_elicit) as client: + result = await client.call_tool("pay_deposit", {"booking_id": "b42"}) + assert result.content == [TextContent(type="text", text="No deposit taken. The booking expires in one hour.")] + + +async def test_send_elicit_complete_notifies_the_client_with_the_same_id() -> None: + """tutorial002: `send_elicit_complete` emits `notifications/elicitation/complete`.""" + notifications: list[object] = [] + + async def on_message(message: object) -> None: + notifications.append(message) + + async with Client(tutorial002.mcp, mode="legacy", message_handler=on_message) as client: + result = await client.call_tool("confirm_deposit", {"booking_id": "b42"}) + assert result.content == [TextContent(type="text", text="Deposit received for booking b42.")] + (notification,) = notifications + assert isinstance(notification, ElicitCompleteNotification) + assert notification.params.elicitation_id == "deposit-b42" + + +async def test_the_docs_client_callback_handles_both_modes() -> None: + """tutorial003: one `elicitation_callback` answers the form and the URL consent.""" + async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=tutorial003.handle_elicitation) as client: + booked = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2}) + async with Client(tutorial002.mcp, mode="legacy", elicitation_callback=tutorial003.handle_elicitation) as client: + paid = await client.call_tool("pay_deposit", {"booking_id": "b42"}) + assert booked.content == [TextContent(type="text", text="Booked a table for 2 on 2025-12-27.")] + assert paid.content == [TextContent(type="text", text="Complete the payment in your browser.")] + + +async def test_a_client_without_the_callback_cannot_be_asked() -> None: + """`!!! check`: no `elicitation_callback` means no `elicitation` capability; the call is a protocol error.""" + async with Client(tutorial001.mcp, mode="legacy") as client: + with pytest.raises(MCPError, match="Elicitation not supported"): + await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2}) diff --git a/tests/docs_src/test_first_steps.py b/tests/docs_src/test_first_steps.py new file mode 100644 index 0000000000..15d6708ee2 --- /dev/null +++ b/tests/docs_src/test_first_steps.py @@ -0,0 +1,98 @@ +"""`docs/tutorial/first-steps.md`: every claim the page makes, proved against the real SDK.""" + +import pytest +from inline_snapshot import snapshot +from mcp_types import ( + PromptArgument, + PromptMessage, + TextContent, + TextResourceContents, +) + +from docs_src.first_steps import tutorial001 +from mcp import Client +from mcp.server import MCPServer + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_each_decorator_registers_one_primitive() -> None: + """tutorial001: name, description and schema all come from the decorated function.""" + async with Client(tutorial001.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.name == "add" + assert tool.description == "Add two numbers." + assert tool.input_schema == snapshot( + { + "type": "object", + "properties": { + "a": {"title": "A", "type": "integer"}, + "b": {"title": "B", "type": "integer"}, + }, + "required": ["a", "b"], + "title": "addArguments", + } + ) + + (template,) = (await client.list_resource_templates()).resource_templates + assert template.name == "greeting" + assert template.uri_template == "greeting://{name}" + assert template.description == "Greet someone by name." + + (prompt,) = (await client.list_prompts()).prompts + assert prompt.name == "summarize" + assert prompt.description == "Summarize a piece of text in one sentence." + assert prompt.arguments == [PromptArgument(name="text", required=True)] + + +async def test_call_the_tool() -> None: + """tutorial001: the Inspector walkthrough. `add` with 1 and 2 answers 3.""" + async with Client(tutorial001.mcp) as client: + result = await client.call_tool("add", {"a": 1, "b": 2}) + assert not result.is_error + assert result.content == [TextContent(type="text", text="3")] + assert result.structured_content == {"result": 3} + + +async def test_templated_resource_is_a_template_not_a_resource() -> None: + """tutorial001: a `{param}` in the URI means the concrete-resource list stays empty.""" + async with Client(tutorial001.mcp) as client: + assert (await client.list_resources()).resources == [] + + +async def test_read_the_resource_template() -> None: + """tutorial001: supplying a `name` reads the template as a concrete resource.""" + async with Client(tutorial001.mcp) as client: + result = await client.read_resource("greeting://World") + assert result.contents == [ + TextResourceContents(uri="greeting://World", mime_type="text/plain", text="Hello, World!") + ] + + +async def test_get_the_prompt() -> None: + """tutorial001: the returned string becomes a single user message.""" + async with Client(tutorial001.mcp) as client: + result = await client.get_prompt("summarize", {"text": "MCP is a protocol."}) + rendered = "Summarize the following text in one sentence:\n\nMCP is a protocol." + assert result.messages == [PromptMessage(role="user", content=TextContent(type="text", text=rendered))] + + +async def test_the_three_primitive_capabilities_are_always_declared() -> None: + """tutorial001: `MCPServer` always declares tools/resources/prompts; only `completions` follows your code. + + An `MCPServer` with nothing registered declares the same three, which is why the + page ties registration to the *optional* capabilities only. + """ + async with Client(tutorial001.mcp) as client: + declared = client.server_capabilities + # The exact dictionary the page prints from `model_dump(exclude_none=True)`. + assert declared.model_dump(exclude_none=True) == snapshot( + { + "prompts": {"list_changed": False}, + "resources": {"subscribe": False, "list_changed": False}, + "tools": {"list_changed": False}, + } + ) + async with Client(MCPServer("Empty")) as client: + assert client.server_capabilities == declared diff --git a/tests/docs_src/test_handling_errors.py b/tests/docs_src/test_handling_errors.py new file mode 100644 index 0000000000..1a76a7bb77 --- /dev/null +++ b/tests/docs_src/test_handling_errors.py @@ -0,0 +1,86 @@ +"""`docs/tutorial/handling-errors.md`: every claim the page makes, proved against the real SDK.""" + +import pytest +from mcp_types import INVALID_PARAMS, ErrorData, TextContent, TextResourceContents + +from docs_src.handling_errors import tutorial001, tutorial002, tutorial003 +from mcp import Client, MCPError + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_a_plain_exception_becomes_a_tool_error_the_model_reads() -> None: + """tutorial001: any non-`MCPError` exception comes back as `is_error=True` with the message in `content`.""" + async with Client(tutorial001.mcp) as client: + result = await client.call_tool("get_author", {"title": "Nothing"}) + assert result.is_error + assert result.content == [ + TextContent(type="text", text="Error executing tool get_author: No book titled 'Nothing' in the catalog.") + ] + assert result.structured_content is None + + +async def test_a_title_the_catalog_knows_is_an_ordinary_result() -> None: + """tutorial001: the non-raising path is a plain `is_error=False` result.""" + async with Client(tutorial001.mcp) as client: + result = await client.call_tool("get_author", {"title": "Dune"}) + assert not result.is_error + assert result.structured_content == {"result": "Frank Herbert"} + + +async def test_a_bad_argument_never_reaches_the_function() -> None: + """tutorial001: schema validation rejects the call before `get_author` runs, as the same kind of tool error.""" + async with Client(tutorial001.mcp) as client: + result = await client.call_tool("get_author", {"title": 42}) + assert result.is_error + assert isinstance(result.content[0], TextContent) + assert "Input should be a valid string" in result.content[0].text + + +async def test_mcp_error_makes_the_call_itself_fail() -> None: + """tutorial002: `MCPError` is not caught. It surfaces as a JSON-RPC error, with `code` and `message` intact.""" + async with Client(tutorial002.mcp) as client: + with pytest.raises(MCPError) as exc_info: + await client.call_tool("get_author", {"title": "Nothing"}) + assert exc_info.value.code == INVALID_PARAMS + assert exc_info.value.message == "No book titled 'Nothing' in the catalog." + + +async def test_mcp_error_only_fires_on_the_raising_path() -> None: + """tutorial002: a title the catalog knows still returns a normal result.""" + async with Client(tutorial002.mcp) as client: + result = await client.call_tool("get_author", {"title": "Dune"}) + assert not result.is_error + assert result.structured_content == {"result": "Frank Herbert"} + + +async def test_resource_not_found_error_maps_to_invalid_params() -> None: + """tutorial003: `ResourceNotFoundError` from a template handler is `-32602` with the URI in `data`.""" + async with Client(tutorial003.mcp) as client: + with pytest.raises(MCPError) as exc_info: + await client.read_resource("books://Nothing") + assert exc_info.value.error == ErrorData( + code=INVALID_PARAMS, + message="No book titled 'Nothing' in the catalog.", + data={"uri": "books://Nothing"}, + ) + + +async def test_raise_exceptions_does_not_turn_a_tool_error_into_a_traceback() -> None: + """The closing `!!! info`: even `raise_exceptions=True` leaves a failing tool as the `is_error=True` result.""" + async with Client(tutorial001.mcp, raise_exceptions=True) as client: + result = await client.call_tool("get_author", {"title": "Nothing"}) + assert result.is_error + assert result.content == [ + TextContent(type="text", text="Error executing tool get_author: No book titled 'Nothing' in the catalog.") + ] + + +async def test_a_title_the_template_knows_reads_normally() -> None: + """tutorial003: the non-raising path resolves the template and returns text contents.""" + async with Client(tutorial003.mcp) as client: + result = await client.read_resource("books://Dune") + (contents,) = result.contents + assert isinstance(contents, TextResourceContents) + assert contents.text == "Dune by Frank Herbert" diff --git a/tests/docs_src/test_index.py b/tests/docs_src/test_index.py new file mode 100644 index 0000000000..3012ae1a08 --- /dev/null +++ b/tests/docs_src/test_index.py @@ -0,0 +1,31 @@ +"""`docs/index.md`: the landing-page server does exactly what the page says it does.""" + +import pytest +from inline_snapshot import snapshot +from mcp_types import CallToolResult, TextContent, TextResourceContents + +from docs_src.index.tutorial001 import mcp +from mcp import Client + +# `pyproject.toml` globally downgrades `mcp.MCPDeprecationWarning` to *ignore* because the +# SDK still calls those methods internally. A documentation example must never lean on +# that allowance, so every test that runs one re-arms the warning as an error. This is a +# per-module mark, not a conftest hook, because `pytest_collection_modifyitems` receives +# every item in the session. A hook here would break unrelated tests across the repo. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_add_tool() -> None: + async with Client(mcp) as client: + result = await client.call_tool("add", {"a": 1, "b": 2}) + assert result == snapshot( + CallToolResult(content=[TextContent(type="text", text="3")], structured_content={"result": 3}) + ) + + +async def test_greeting_resource_template() -> None: + async with Client(mcp) as client: + result = await client.read_resource("greeting://World") + assert result.contents == snapshot( + [TextResourceContents(uri="greeting://World", mime_type="text/plain", text="Hello, World!")] + ) diff --git a/tests/docs_src/test_lifespan.py b/tests/docs_src/test_lifespan.py new file mode 100644 index 0000000000..d78764fd64 --- /dev/null +++ b/tests/docs_src/test_lifespan.py @@ -0,0 +1,113 @@ +"""`docs/tutorial/lifespan.md`: every claim the page makes, proved against the real SDK.""" + +import pytest +from inline_snapshot import snapshot +from mcp_types import TextContent, TextResourceContents + +from docs_src.lifespan import tutorial001, tutorial002 +from mcp import Client, MCPError +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_lifespan_object_reaches_the_tool() -> None: + """tutorial001: the object the lifespan yields is `ctx.request_context.lifespan_context`.""" + async with Client(tutorial001.mcp) as client: + result = await client.call_tool("count_books", {"genre": "poetry"}) + assert not result.is_error + assert result.content == [TextContent(type="text", text="3 books in 'poetry'.")] + assert result.structured_content == {"result": "3 books in 'poetry'."} + + +async def test_context_parameter_never_reaches_the_input_schema() -> None: + """tutorial001: `ctx` is injected by the SDK, so `genre` is the only argument the model sees.""" + async with Client(tutorial001.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.input_schema == snapshot( + { + "type": "object", + "properties": {"genre": {"title": "Genre", "type": "string"}}, + "required": ["genre"], + "title": "count_booksArguments", + } + ) + + +async def test_startup_runs_before_the_first_request_and_shutdown_after_the_last() -> None: + """tutorial002: `connect()` runs at startup, the `finally` runs `disconnect()` at shutdown.""" + assert not tutorial002.database.connected + async with Client(tutorial002.mcp) as client: + assert tutorial002.database.connected + result = await client.call_tool("database_status", {}) + assert result.structured_content == {"result": "connected"} + assert not tutorial002.database.connected + + +async def test_bare_context_reaches_the_lifespan_object_in_resources_and_prompts() -> None: + """A resource or prompt declaring a bare `ctx: Context` gets the same lifespan object a tool gets.""" + mcp = MCPServer("Bookshop", lifespan=tutorial001.app_lifespan) + + @mcp.resource("books://{genre}/count") + def genre_count(genre: str, ctx: Context) -> str: + """Count the books in a genre.""" + app = ctx.request_context.lifespan_context + assert isinstance(app, tutorial001.AppContext) + return f"{app.db.query()} books in {genre!r}." + + @mcp.prompt() + def stock_report(ctx: Context) -> str: + """Ask for a stock report.""" + app = ctx.request_context.lifespan_context + assert isinstance(app, tutorial001.AppContext) + return f"Summarise a shelf of {app.db.query()} books." + + async with Client(mcp) as client: + resource = await client.read_resource("books://poetry/count") + assert resource.contents == [ + TextResourceContents(uri="books://poetry/count", mime_type="text/plain", text="3 books in 'poetry'.") + ] + prompt = await client.get_prompt("stock_report") + (message,) = prompt.messages + assert message.content == TextContent(type="text", text="Summarise a shelf of 3 books.") + + +async def test_parameterized_context_is_tool_only(caplog: pytest.LogCaptureFixture) -> None: + """`Context[AppContext]` on a resource or prompt fails every call; the server logs the `ValueError`.""" + mcp = MCPServer("Bookshop", lifespan=tutorial001.app_lifespan) + + @mcp.resource("books://{genre}/count") + def genre_count(genre: str, ctx: Context[tutorial001.AppContext]) -> str: + """Count the books in a genre.""" + return f"{ctx.request_context.lifespan_context.db.query()} books in {genre!r}." + + @mcp.prompt() + def stock_report(ctx: Context[tutorial001.AppContext]) -> str: + """Ask for a stock report.""" + return f"Summarise a shelf of {ctx.request_context.lifespan_context.db.query()} books." + + async with Client(mcp) as client: + with pytest.raises(MCPError, match="Error creating resource from template"): + await client.read_resource("books://poetry/count") + assert "ValueError: Context is not available outside of a request" in caplog.text + + caplog.clear() + with pytest.raises(MCPError): + await client.get_prompt("stock_report") + assert "ValueError: Context is not available outside of a request" in caplog.text + + +async def test_default_lifespan_yields_an_empty_dict() -> None: + """No `lifespan=`: the SDK's default yields `{}`, so `lifespan_context` is never `None`.""" + bare = MCPServer("Bare") + + @bare.tool() + def show(ctx: Context) -> str: + """Show the lifespan context.""" + return repr(ctx.request_context.lifespan_context) + + async with Client(bare) as client: + result = await client.call_tool("show", {}) + assert result.structured_content == {"result": "{}"} diff --git a/tests/docs_src/test_logging.py b/tests/docs_src/test_logging.py new file mode 100644 index 0000000000..fa4b995c6e --- /dev/null +++ b/tests/docs_src/test_logging.py @@ -0,0 +1,62 @@ +"""`docs/tutorial/logging.md`: every claim the page makes, proved against the real SDK.""" + +import logging + +import pytest +from inline_snapshot import snapshot +from mcp_types import CallToolResult, TextContent + +from docs_src.logging import tutorial001 +from mcp import Client +from mcp.server import MCPServer + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_the_tool_logs_through_the_standard_library(caplog: pytest.LogCaptureFixture) -> None: + """tutorial001: `logger.info(...)` inside a tool emits an ordinary stdlib record named after the module.""" + caplog.set_level(logging.INFO) + async with Client(tutorial001.mcp) as client: + await client.call_tool("search_books", {"query": "dune"}) + (record,) = list(filter(lambda r: r.name == tutorial001.logger.name, caplog.records)) + assert record.levelname == "INFO" + assert record.getMessage() == "Searching for 'dune'" + + +async def test_the_log_line_never_reaches_the_client() -> None: + """tutorial001: the result is only the return value. Log output is invisible to the model.""" + async with Client(tutorial001.mcp) as client: + result = await client.call_tool("search_books", {"query": "dune"}) + assert result == snapshot( + CallToolResult( + content=[TextContent(type="text", text="Found 3 books matching 'dune'.")], + structured_content={"result": "Found 3 books matching 'dune'."}, + ) + ) + + +def test_log_level_configures_the_root_logger() -> None: + """`MCPServer(log_level=...)` calls `logging.basicConfig()` when nothing has configured logging yet.""" + root = logging.getLogger() + handlers, level = root.handlers[:], root.level + root.handlers = [] + try: + MCPServer("Bookshop", log_level="DEBUG") + assert root.level == logging.DEBUG + assert len(root.handlers) == 1 + finally: + root.handlers, root.level = handlers, level + + +def test_an_existing_logging_configuration_wins() -> None: + """`logging.basicConfig()` is a no-op once a handler is installed, so your own setup is not overridden.""" + root = logging.getLogger() + handlers, level = root.handlers[:], root.level + root.handlers, root.level = [logging.NullHandler()], logging.WARNING + try: + MCPServer("Bookshop", log_level="DEBUG") + assert root.level == logging.WARNING + assert len(root.handlers) == 1 + finally: + root.handlers, root.level = handlers, level diff --git a/tests/docs_src/test_lowlevel.py b/tests/docs_src/test_lowlevel.py new file mode 100644 index 0000000000..34746dd0b3 --- /dev/null +++ b/tests/docs_src/test_lowlevel.py @@ -0,0 +1,143 @@ +"""`docs/advanced/low-level-server.md`: every claim the page makes, proved against the real SDK.""" + +import pytest +from inline_snapshot import snapshot +from mcp_types import INTERNAL_ERROR, CallToolRequestParams, CallToolResult, ErrorData, RequestParams, TextContent + +from docs_src.lowlevel import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005, tutorial006 +from mcp import Client, MCPError +from mcp.server import Server, ServerRequestContext + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_the_input_schema_on_the_wire_is_the_dict_you_wrote() -> None: + """tutorial001: nothing is derived. `tools/list` returns the literal `input_schema` dict.""" + async with Client(tutorial001.server) as client: + (tool,) = (await client.list_tools()).tools + assert tool.name == "search_books" + assert tool.description == "Search the catalog by title or author." + assert tool.input_schema == snapshot( + { + "type": "object", + "properties": {"query": {"type": "string"}, "limit": {"type": "integer"}}, + "required": ["query", "limit"], + } + ) + assert tool.output_schema is None + + +async def test_the_client_does_not_care_which_server_class_it_connects_to() -> None: + """tutorial001: `Client(server)` accepts a low-level `Server` and the call answers like **Tools**.""" + async with Client(tutorial001.server) as client: + result = await client.call_tool("search_books", {"query": "dune", "limit": 5}) + assert not result.is_error + assert result.content == [TextContent(type="text", text="Found 3 books matching 'dune' (showing up to 5).")] + assert result.structured_content is None + + +async def test_only_the_handlers_you_passed_become_capabilities() -> None: + """tutorial001: two tool handlers advertise `tools` and nothing else.""" + async with Client(tutorial001.server) as client: + assert client.server_capabilities.model_dump(exclude_none=True) == snapshot({"tools": {"list_changed": False}}) + + +async def test_arguments_are_not_validated_against_your_schema() -> None: + """tutorial001: a call missing a `required` argument still reaches the handler and blows up there.""" + async with Client(tutorial001.server) as client: + with pytest.raises(MCPError) as exc_info: + await client.call_tool("search_books", {"query": "dune"}) + assert exc_info.value.error == ErrorData(code=INTERNAL_ERROR, message="Internal server error", data=None) + + +async def test_one_handler_routes_every_tool() -> None: + """tutorial002: `on_call_tool` is the single entry point; it dispatches on `params.name`.""" + async with Client(tutorial002.server) as client: + assert [tool.name for tool in (await client.list_tools()).tools] == ["search_books", "add_book"] + result = await client.call_tool("add_book", {"title": "Dune", "author": "Frank Herbert", "year": 1965}) + assert result.content == [TextContent(type="text", text="Added 'Dune' by Frank Herbert (1965).")] + + +async def test_an_unknown_tool_name_becomes_a_protocol_error_not_a_tool_error() -> None: + """tutorial002: raising from a handler is a `-32603` JSON-RPC error, never an `is_error` result.""" + async with Client(tutorial002.server) as client: + with pytest.raises(MCPError) as exc_info: + await client.call_tool("does_not_exist", {}) + assert exc_info.value.error == ErrorData(code=INTERNAL_ERROR, message="Internal server error", data=None) + + +async def test_output_schema_and_structured_content_are_both_yours_to_build() -> None: + """tutorial003: you declare the schema on the `Tool` and you build the matching payload.""" + async with Client(tutorial003.server) as client: + (tool,) = (await client.list_tools()).tools + assert tool.output_schema == snapshot( + { + "type": "object", + "properties": {"matches": {"type": "integer"}, "query": {"type": "string"}}, + "required": ["matches", "query"], + } + ) + result = await client.call_tool("search_books", {"query": "dune", "limit": 5}) + assert result.content == [TextContent(type="text", text="Found 3 books matching 'dune'.")] + assert result.structured_content == {"matches": 3, "query": "dune"} + + +async def test_the_client_checks_the_schema_you_promised() -> None: + """The page's warning: a `structured_content` that violates your `output_schema` fails in `call_tool`.""" + + async def promise_breaker(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + return CallToolResult(content=[TextContent(type="text", text="oops")], structured_content={"matches": "three"}) + + lying = Server("Bookshop", on_list_tools=tutorial003.list_tools, on_call_tool=promise_breaker) + async with Client(lying) as client: + with pytest.raises(RuntimeError, match="Invalid structured content returned by tool search_books"): + await client.call_tool("search_books", {"query": "dune", "limit": 5}) + + +async def test_meta_reaches_the_client_application() -> None: + """tutorial004: `_meta=` on the result comes back as `result.meta` and serialises under `_meta`.""" + async with Client(tutorial004.server) as client: + result = await client.call_tool("search_books", {"query": "dune", "limit": 5}) + assert result.meta == {"bookshop/record_ids": ["bk_17", "bk_42", "bk_99"]} + assert result.model_dump(by_alias=True, exclude_none=True) == snapshot( + { + "_meta": {"bookshop/record_ids": ["bk_17", "bk_42", "bk_99"]}, + "content": [{"type": "text", "text": "Found 3 books matching 'dune'."}], + "structuredContent": {"matches": 3, "query": "dune"}, + "isError": False, + "resultType": "complete", + } + ) + + +async def test_the_lifespan_object_reaches_every_handler_with_its_type() -> None: + """tutorial005: what the lifespan yields is `ctx.lifespan_context`, typed by `Server[Catalog]`.""" + async with Client(tutorial005.server) as client: + result = await client.call_tool("search_books", {"query": "dune"}) + assert result.content == [TextContent(type="text", text="Found 3 books: Dune, Dune Messiah, Children of Dune.")] + + +async def test_add_request_handler_registers_a_method_the_constructor_does_not_know() -> None: + """tutorial006: the registry holds the handler and the params model it validates against.""" + entry = tutorial006.server.get_request_handler("bookshop/reindex") + assert entry is not None + assert entry.params_type is tutorial006.ReindexParams + assert tutorial006.server.get_request_handler("bookshop/burn") is None + + +async def test_a_custom_method_never_changes_the_advertised_capabilities() -> None: + """tutorial006: only the spec's method families map to capabilities. `bookshop/reindex` is invisible.""" + async with Client(tutorial006.server) as client: + assert client.server_capabilities.model_dump(exclude_none=True) == snapshot({"tools": {"list_changed": False}}) + + +def test_initialize_is_reserved() -> None: + """The page's `ValueError`: the handshake belongs to the runner, not to `add_request_handler`.""" + server = Server("Bookshop") + + async def grab_the_handshake(ctx: ServerRequestContext, params: RequestParams) -> None: + raise NotImplementedError + + with pytest.raises(ValueError, match="'initialize' is handled by the server runner"): + server.add_request_handler("initialize", RequestParams, grab_the_handshake) diff --git a/tests/docs_src/test_media.py b/tests/docs_src/test_media.py new file mode 100644 index 0000000000..96ea42a0b1 --- /dev/null +++ b/tests/docs_src/test_media.py @@ -0,0 +1,62 @@ +"""`docs/tutorial/media.md`: every claim the page makes, proved against the real SDK.""" + +import base64 + +import pytest +from mcp_types import AudioContent, Icon, ImageContent + +from docs_src.media import tutorial001, tutorial002, tutorial003 +from mcp import Client +from mcp.server.mcpserver import Audio, Image + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_image_return_becomes_an_image_content_block() -> None: + """tutorial001: `-> Image` reaches the client as a base64 `ImageContent` block, not text.""" + async with Client(tutorial001.mcp) as client: + result = await client.call_tool("logo", {}) + assert not result.is_error + assert result.content == [ + ImageContent(type="image", data=base64.b64encode(tutorial001.LOGO_PNG).decode(), mime_type="image/png") + ] + + +async def test_image_result_has_no_structured_content_and_no_output_schema() -> None: + """tutorial001: media is content for the model, not data for the application.""" + async with Client(tutorial001.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.output_schema is None + result = await client.call_tool("logo", {}) + assert result.structured_content is None + + +async def test_audio_return_becomes_an_audio_content_block() -> None: + """tutorial002: `Audio` is the same shape as `Image`.""" + async with Client(tutorial002.mcp) as client: + result = await client.call_tool("chime", {}) + assert not result.is_error + assert result.content == [ + AudioContent(type="audio", data=base64.b64encode(tutorial002.CHIME_WAV).decode(), mime_type="audio/wav") + ] + assert result.structured_content is None + + +def test_raw_data_without_a_format_falls_back_to_a_default_mime_type() -> None: + """The `!!! check`: with `data=` there is no suffix to guess from, so `format=` decides.""" + assert Image(data=b"\x89PNG\r\n\x1a\n", format="png").to_image_content().mime_type == "image/png" + assert Image(data=b"\x89PNG\r\n\x1a\n").to_image_content().mime_type == "image/png" + assert Audio(data=b"\xff\xfb").to_audio_content().mime_type == "audio/wav" + + +async def test_icons_are_visible_where_they_were_declared() -> None: + """tutorial003: server icons land on `server_info`, tool icons on the `Tool`, resource icons on the `Resource`.""" + async with Client(tutorial003.mcp) as client: + assert client.server_info.icons == [ + Icon(src="https://example.com/brand-kit.png", mime_type="image/png", sizes=["48x48"]) + ] + (tool,) = (await client.list_tools()).tools + assert tool.icons == [Icon(src="https://example.com/palette.svg", mime_type="image/svg+xml", sizes=["any"])] + (resource,) = (await client.list_resources()).resources + assert resource.icons == [Icon(src="https://example.com/brand-kit.png", mime_type="image/png", sizes=["48x48"])] diff --git a/tests/docs_src/test_middleware.py b/tests/docs_src/test_middleware.py new file mode 100644 index 0000000000..97d9e96086 --- /dev/null +++ b/tests/docs_src/test_middleware.py @@ -0,0 +1,116 @@ +"""`docs/advanced/middleware.md`: every claim the page makes, proved against the real SDK.""" + +import logging +import re + +import pytest +from mcp_types import ( + INVALID_REQUEST, + METHOD_NOT_FOUND, + CallToolRequestParams, + ErrorData, + RequestId, + TextContent, +) + +from docs_src.middleware import tutorial001 +from mcp import Client, MCPError +from mcp.server import Server, ServerRequestContext +from mcp.server.context import CallNext, HandlerResult + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +def _is_timing_record(record: logging.LogRecord) -> bool: + """A record emitted by tutorial001's `log_timing` middleware (and nothing else caplog caught).""" + return record.name == tutorial001.logger.name + + +def test_timing_record_predicate() -> None: + """The caplog filter keeps the middleware's own records and drops everyone else's.""" + args = (logging.INFO, __file__, 1, "msg", None, None) + assert _is_timing_record(logging.LogRecord(tutorial001.logger.name, *args)) + assert not _is_timing_record(logging.LogRecord("somebody.elses.logger", *args)) + + +async def test_middleware_observes_every_inbound_message(caplog: pytest.LogCaptureFixture) -> None: + """tutorial001: two client calls produce three timed lines. `server/discover` is wrapped too.""" + with caplog.at_level(logging.INFO, logger=tutorial001.logger.name): + async with Client(tutorial001.server) as client: + await client.list_tools() + await client.call_tool("search_books", {"query": "dune"}) + messages = [record.getMessage() for record in filter(_is_timing_record, caplog.records)] + assert [message.split(" took ")[0] for message in messages] == ["server/discover", "tools/list", "tools/call"] + assert re.fullmatch(r"tools/call took \d+\.\d ms", messages[-1]) + + +async def test_the_result_passes_through_unchanged() -> None: + """tutorial001: `log_timing` returns what `call_next` returned, so the client sees the real result.""" + async with Client(tutorial001.server) as client: + result = await client.call_tool("search_books", {"query": "dune"}) + assert not result.is_error + assert result.content == [TextContent(type="text", text="Found 3 books matching 'dune'.")] + + +async def test_a_notification_has_no_request_id() -> None: + """`ctx.request_id is None` is how middleware tells a notification from a request.""" + seen: list[tuple[str, RequestId | None]] = [] + + async def spy(ctx: ServerRequestContext, call_next: CallNext) -> HandlerResult: + seen.append((ctx.method, ctx.request_id)) + return await call_next(ctx) + + server = Server("Bookshop", on_list_tools=tutorial001.on_list_tools, on_call_tool=tutorial001.on_call_tool) + server.middleware.append(spy) + async with Client(server, mode="legacy") as client: + await client.list_tools() + assert seen == [("initialize", 1), ("notifications/initialized", None), ("tools/list", 2)] + + +async def test_raising_before_call_next_refuses_the_message() -> None: + """A middleware that raises instead of calling `call_next` answers with a JSON-RPC error.""" + + async def gate(ctx: ServerRequestContext, call_next: CallNext) -> HandlerResult: + if ctx.method == "tools/call": + raise MCPError(code=INVALID_REQUEST, message="No calls on Sundays.") + return await call_next(ctx) + + server = Server("Bookshop", on_list_tools=tutorial001.on_list_tools, on_call_tool=tutorial001.on_call_tool) + server.middleware.append(gate) + async with Client(server) as client: + with pytest.raises(MCPError) as exc_info: + await client.call_tool("search_books", {"query": "dune"}) + assert exc_info.value.error.code == INVALID_REQUEST + assert exc_info.value.error.message == "No calls on Sundays." + assert len((await client.list_tools()).tools) == 1 + + +async def test_an_unhandled_method_raises_through_the_middleware() -> None: + """A method without a handler raises `METHOD_NOT_FOUND` out of `call_next`, through the middleware.""" + seen: list[tuple[str, int]] = [] + + async def spy(ctx: ServerRequestContext, call_next: CallNext) -> HandlerResult: + try: + return await call_next(ctx) + except MCPError as exc: + seen.append((ctx.method, exc.error.code)) + raise + + server = Server("Bookshop", on_list_tools=tutorial001.on_list_tools, on_call_tool=tutorial001.on_call_tool) + server.middleware.append(spy) + async with Client(server) as client: + with pytest.raises(MCPError) as exc_info: + await client.read_resource("config://settings") + assert exc_info.value.error == ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="resources/read") + assert seen == [("resources/read", METHOD_NOT_FOUND)] + + +async def test_initialize_cannot_be_replaced_only_wrapped() -> None: + """`add_request_handler("initialize", ...)` is rejected: middleware is the sanctioned hook.""" + expected = ( + "'initialize' is handled by the server runner and cannot be overridden; " + "use Server.middleware to observe or wrap initialization" + ) + with pytest.raises(ValueError, match=re.escape(expected)): + tutorial001.server.add_request_handler("initialize", CallToolRequestParams, tutorial001.on_call_tool) diff --git a/tests/docs_src/test_mrtr.py b/tests/docs_src/test_mrtr.py new file mode 100644 index 0000000000..7dc78dbdc1 --- /dev/null +++ b/tests/docs_src/test_mrtr.py @@ -0,0 +1,103 @@ +"""`docs/advanced/multi-round-trip.md`: every claim the page makes, proved against the real SDK.""" + +import pytest +from inline_snapshot import snapshot +from mcp_types import ( + INTERNAL_ERROR, + CallToolResult, + CreateMessageRequest, + CreateMessageRequestParams, + ElicitRequest, + ElicitRequestFormParams, + ElicitResult, + InputRequiredResult, + TextContent, +) + +from docs_src.mrtr import tutorial001, tutorial002 +from mcp import Client, MCPError + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_first_call_returns_an_input_required_result() -> None: + """tutorial001: a tool that is missing input returns `InputRequiredResult` instead of calling back.""" + async with Client(tutorial001.server) as client: + result = await client.call_tool("provision", {"name": "orders"}, allow_input_required=True) + assert result == snapshot( + InputRequiredResult( + result_type="input_required", + input_requests={ + "region": ElicitRequest( + method="elicitation/create", + params=ElicitRequestFormParams( + mode="form", + message="Which region should the database live in?", + requested_schema={ + "type": "object", + "properties": {"region": {"type": "string"}}, + "required": ["region"], + }, + ), + ) + }, + request_state="provision-v1", + ) + ) + + +async def test_call_tool_raises_without_the_opt_in() -> None: + """The page's `!!! check`: `allow_input_required` defaults to `False` and the result is a hard error.""" + async with Client(tutorial001.server) as client: + with pytest.raises(RuntimeError) as exc: + await client.call_tool("provision", {"name": "orders"}) + assert str(exc.value) == ( + "Server returned InputRequiredResult; pass allow_input_required=True to receive it " + "and retry call_tool(..., input_responses=..., request_state=result.request_state)." + ) + + +async def test_retry_with_input_responses_and_request_state_completes_the_call() -> None: + """tutorial001: the retry carries `input_responses` keyed like `input_requests` plus the echoed token.""" + async with Client(tutorial001.server) as client: + result = await client.call_tool( + "provision", + {"name": "orders"}, + input_responses={"region": ElicitResult(action="accept", content={"region": "eu-west-1"})}, + request_state="provision-v1", + ) + assert result == snapshot( + CallToolResult(content=[TextContent(type="text", text="Provisioned 'orders' in eu-west-1.")]) + ) + + +async def test_the_manual_loop_drives_the_call_to_completion() -> None: + """tutorial002: `while isinstance(result, InputRequiredResult)` is the whole client API, and it terminates.""" + async with Client(tutorial001.server) as client: + result = await tutorial002.provision(client, "billing") + assert result == snapshot( + CallToolResult(content=[TextContent(type="text", text="Provisioned 'billing' in eu-west-1.")]) + ) + + +async def test_the_in_memory_client_negotiates_2026_07_28() -> None: + """`InputRequiredResult` only exists at 2026-07-28; `Client(server)` lands there without being asked.""" + async with Client(tutorial001.server) as client: + assert client.protocol_version == "2026-07-28" + + +async def test_a_pre_2026_session_has_nowhere_to_put_the_result() -> None: + """The page's `!!! warning`: on a legacy session the runner cannot serialize an `InputRequiredResult`.""" + async with Client(tutorial001.server, mode="legacy") as client: + with pytest.raises(MCPError) as exc: + await client.call_tool("provision", {"name": "orders"}, allow_input_required=True) + assert exc.value.error.code == INTERNAL_ERROR + assert exc.value.error.message == "Handler returned an invalid result" + + +def test_fulfil_refuses_a_request_it_cannot_answer() -> None: + """tutorial002: `fulfil` is the dispatch point. This client only knows how to answer an `ElicitRequest`.""" + request = CreateMessageRequest(params=CreateMessageRequestParams(messages=[], max_tokens=64)) + with pytest.raises(NotImplementedError, match="sampling/createMessage"): + tutorial002.fulfil(request) diff --git a/tests/docs_src/test_oauth_clients.py b/tests/docs_src/test_oauth_clients.py new file mode 100644 index 0000000000..3cb196eb67 --- /dev/null +++ b/tests/docs_src/test_oauth_clients.py @@ -0,0 +1,131 @@ +"""`docs/advanced/oauth-clients.md`: every claim the page makes, proved against the real SDK.""" + +import inspect + +import httpx +import pytest +from pydantic import AnyUrl, ValidationError + +from docs_src.oauth_clients import tutorial001, tutorial002 +from mcp.client.auth import OAuthClientProvider, OAuthFlowError, OAuthRegistrationError, OAuthTokenError, TokenStorage +from mcp.client.auth.extensions.client_credentials import ( + PrivateKeyJWTOAuthProvider, + RFC7523OAuthClientProvider, + static_assertion_provider, +) +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_in_memory_storage_satisfies_the_token_storage_protocol() -> None: + """tutorial001: `TokenStorage` is a Protocol: four async methods, no base class.""" + storage: TokenStorage = tutorial001.InMemoryTokenStorage() + assert await storage.get_tokens() is None + assert await storage.get_client_info() is None + + +async def test_storage_round_trips_tokens_and_client_info() -> None: + """tutorial001: whatever the provider stores, it gets back: the whole persistence contract.""" + storage = tutorial001.InMemoryTokenStorage() + tokens = OAuthToken(access_token="at-123", refresh_token="rt-456", expires_in=3600, scope="user") + client_info = OAuthClientInformationFull( + client_id="generated-by-the-as", + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + ) + await storage.set_tokens(tokens) + await storage.set_client_info(client_info) + assert await storage.get_tokens() == tokens + assert await storage.get_client_info() == client_info + + +async def test_the_provider_is_an_httpx_auth() -> None: + """tutorial001: `OAuthClientProvider` plugs into httpx, not into MCP.""" + assert isinstance(tutorial001.oauth, httpx.Auth) + + +async def test_the_metadata_defaults_are_the_authorization_code_flow() -> None: + """tutorial001: `grant_types` and `response_types` default to code + refresh: nothing to set.""" + metadata = tutorial001.oauth.context.client_metadata + assert metadata.grant_types == ["authorization_code", "refresh_token"] + assert metadata.response_types == ["code"] + + +async def test_redirect_uris_is_required() -> None: + """The `!!! check`: registration metadata is validated locally, before any network.""" + with pytest.raises(ValidationError, match="redirect_uris\n Field required"): + OAuthClientMetadata.model_validate({"client_name": "Bookshop Agent"}) + + +async def test_the_redirect_handler_receives_the_authorization_url(capsys: pytest.CaptureFixture[str]) -> None: + """tutorial001: `redirect_handler` is the one place the authorization URL surfaces.""" + await tutorial001.open_browser("https://auth.example.com/authorize?client_id=abc") + assert capsys.readouterr().out == "Visit: https://auth.example.com/authorize?client_id=abc\n" + + +async def test_client_credentials_provider_has_no_human_in_the_loop() -> None: + """tutorial002: `ClientCredentialsOAuthProvider` is the same `httpx.Auth`, minus the handlers.""" + assert isinstance(tutorial002.oauth, OAuthClientProvider) + assert isinstance(tutorial002.oauth, httpx.Auth) + assert tutorial002.oauth.context.redirect_handler is None + assert tutorial002.oauth.context.callback_handler is None + + +async def test_client_credentials_provider_builds_its_own_metadata() -> None: + """tutorial002: the grant is `client_credentials`, there is nothing to redirect to.""" + metadata = tutorial002.oauth.context.client_metadata + assert metadata.grant_types == ["client_credentials"] + assert metadata.token_endpoint_auth_method == "client_secret_basic" + assert metadata.redirect_uris is None + assert metadata.scope == "user" + + +async def test_the_three_remaining_keyword_arguments_have_defaults() -> None: + """The page names `timeout`, `client_metadata_url` and `validate_resource_url` as the remainder.""" + parameters = inspect.signature(OAuthClientProvider.__init__).parameters + supplied = ["server_url", "client_metadata", "storage", "redirect_handler", "callback_handler"] + remainder = ["timeout", "client_metadata_url", "validate_resource_url"] + assert list(parameters) == ["self", *supplied, *remainder] + assert all(parameters[name].default is not inspect.Parameter.empty for name in remainder) + + +async def test_the_one_more_provider_is_private_key_jwt() -> None: + """The `!!! info`: `PrivateKeyJWTOAuthProvider` is the same `httpx.Auth`, built the same way.""" + provider = PrivateKeyJWTOAuthProvider( + server_url="http://localhost:8001/mcp", + storage=tutorial002.InMemoryTokenStorage(), + client_id="reporting-agent", + assertion_provider=static_assertion_provider("a.prebuilt.jwt"), + ) + assert isinstance(provider, OAuthClientProvider) + assert isinstance(provider, httpx.Auth) + assert provider.context.client_metadata.token_endpoint_auth_method == "private_key_jwt" + + +async def test_the_page_does_not_count_the_deprecated_provider() -> None: + """Why the `!!! info` says *one* more provider: `RFC7523OAuthClientProvider` warns on construction.""" + with pytest.warns(DeprecationWarning, match="RFC7523OAuthClientProvider is deprecated"): + RFC7523OAuthClientProvider( + server_url="http://localhost:8001/mcp", + client_metadata=tutorial001.oauth.context.client_metadata, + storage=tutorial001.InMemoryTokenStorage(), + ) + + +async def test_every_oauth_error_is_an_oauth_flow_error() -> None: + """Catch `OAuthFlowError` and you have caught registration and token failures too.""" + assert issubclass(OAuthRegistrationError, OAuthFlowError) + assert issubclass(OAuthTokenError, OAuthFlowError) + + +async def test_not_everything_is_a_flow_error() -> None: + """A bad argument is a `ValueError`, not an `OAuthFlowError`: the page says *OAuth* failures.""" + with pytest.raises(ValueError, match="client_metadata_url must be a valid HTTPS URL") as exc_info: + OAuthClientProvider( + server_url="http://localhost:8001/mcp", + client_metadata=tutorial001.oauth.context.client_metadata, + storage=tutorial001.InMemoryTokenStorage(), + client_metadata_url="http://not-https.example/client.json", + ) + assert not isinstance(exc_info.value, OAuthFlowError) diff --git a/tests/docs_src/test_pagination.py b/tests/docs_src/test_pagination.py new file mode 100644 index 0000000000..ab5949df96 --- /dev/null +++ b/tests/docs_src/test_pagination.py @@ -0,0 +1,80 @@ +"""`docs/advanced/pagination.md`: every claim the page makes, proved against the real SDK.""" + +import pytest +from mcp_types import Resource + +from docs_src.pagination import tutorial001, tutorial002 +from mcp import Client, MCPError +from mcp.server import MCPServer +from mcp.server.mcpserver.resources import TextResource + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + +mcp = MCPServer("Bookshop") +for n in range(1, 101): + mcp.add_resource(TextResource(uri=f"books://catalog/book-{n}", name=f"book-{n}", text=f"book-{n}")) + + +async def test_mcpserver_never_pages() -> None: + """The page's framing: `MCPServer` answers `resources/list` in one page with `next_cursor=None`.""" + async with Client(mcp) as client: + result = await client.list_resources() + assert len(result.resources) == 100 + assert result.next_cursor is None + + +async def test_first_page_has_ten_resources_and_a_cursor() -> None: + """tutorial001: no cursor means page one: ten resources and a `next_cursor` the client may ignore.""" + async with Client(tutorial001.server) as client: + page = await client.list_resources() + assert [resource.name for resource in page.resources] == [f"book-{n}" for n in range(1, 11)] + assert page.next_cursor == "10" + + +async def test_the_cursor_resumes_where_the_last_page_stopped() -> None: + """tutorial001: handing `next_cursor` straight back yields the next page, no overlap.""" + async with Client(tutorial001.server) as client: + page = await client.list_resources(cursor="10") + assert page.resources[0].name == "book-11" + assert page.next_cursor == "20" + + +async def test_the_last_page_carries_no_cursor() -> None: + """tutorial001: `next_cursor=None` is the only end-of-list signal.""" + async with Client(tutorial001.server) as client: + page = await client.list_resources(cursor="90") + assert len(page.resources) == 10 + assert page.next_cursor is None + + +async def test_the_loop_collects_all_one_hundred() -> None: + """tutorial001: the `cursor=` loop visits ten pages and reassembles the whole catalog.""" + async with Client(tutorial001.server) as client: + resources: list[Resource] = [] + cursor: str | None = None + pages = 0 + while True: + page = await client.list_resources(cursor=cursor) + resources.extend(page.resources) + pages += 1 + if page.next_cursor is None: + break + cursor = page.next_cursor + assert pages == 10 + assert len({resource.uri for resource in resources}) == 100 + + +async def test_the_client_program_on_the_page_runs(capsys: pytest.CaptureFixture[str]) -> None: + """tutorial002: `main()` is the literal client program on the page and prints the stitched total.""" + await tutorial002.main() + assert capsys.readouterr().out == "100 resources\n" + + +async def test_an_invented_cursor_is_an_error() -> None: + """Cursors are opaque: a string the server never minted blows up inside the handler.""" + async with Client(tutorial001.server) as client: + with pytest.raises(MCPError) as excinfo: + await client.list_resources(cursor="page-2") + assert excinfo.value.code == -32603 + assert str(excinfo.value) == "Internal server error" diff --git a/tests/docs_src/test_progress.py b/tests/docs_src/test_progress.py new file mode 100644 index 0000000000..45cc4df8eb --- /dev/null +++ b/tests/docs_src/test_progress.py @@ -0,0 +1,102 @@ +"""`docs/tutorial/progress.md`: every claim the page makes, proved against the real SDK.""" + +import inspect + +import anyio +import pytest +from mcp_types import TextContent + +from docs_src.progress import tutorial001, tutorial002 +from mcp import Client + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + +URLS = ["https://example.com/a.json", "https://example.com/b.json"] + + +async def test_context_parameter_is_invisible_to_the_model() -> None: + """tutorial001: `ctx` comes from the type hint and never reaches the input schema.""" + async with Client(tutorial001.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.input_schema["properties"] == { + "urls": {"items": {"type": "string"}, "title": "Urls", "type": "array"} + } + assert tool.input_schema["required"] == ["urls"] + + +async def test_each_report_becomes_one_callback_invocation_in_order() -> None: + """tutorial001: `progress_callback` receives every `(progress, total, message)` the tool reported.""" + updates: list[tuple[float, float | None, str | None]] = [] + + async def show(progress: float, total: float | None, message: str | None) -> None: + updates.append((progress, total, message)) + + async with Client(tutorial001.mcp) as client: + result = await client.call_tool("import_catalog", {"urls": URLS}, progress_callback=show) + assert updates == [ + (1, 2, "Imported https://example.com/a.json"), + (2, 2, "Imported https://example.com/b.json"), + ] + assert result.content == [TextContent(type="text", text="Imported 2 records.")] + assert result.structured_content == {"result": "Imported 2 records."} + + +async def test_over_a_wire_dispatcher_callbacks_race_the_result() -> None: + """The `!!! info`: only the in-memory connection runs the callback inline. + + On a wire dispatcher (`mode="legacy"` here) each progress notification starts its own task, so + `call_tool` can return while a slow callback is still running. The callbacks below block on an + event that is only set *after* `call_tool` has returned: exactly the situation the page tells + you not to rule out. + """ + release = anyio.Event() + done = anyio.Event() + finished: list[float] = [] + + async def gated(progress: float, total: float | None, message: str | None) -> None: + await release.wait() + finished.append(progress) + if len(finished) == 2: + done.set() + + async with Client(tutorial001.mcp, mode="legacy") as client: + with anyio.fail_after(5): + result = await client.call_tool("import_catalog", {"urls": URLS}, progress_callback=gated) + assert finished == [] + release.set() + with anyio.fail_after(5): + await done.wait() + assert sorted(finished) == [1, 2] + assert result.structured_content == {"result": "Imported 2 records."} + + +async def test_without_a_callback_report_progress_is_a_no_op() -> None: + """The `!!! check`: omit `progress_callback` and the tool runs to the same result, no error.""" + async with Client(tutorial001.mcp) as client: + result = await client.call_tool("import_catalog", {"urls": URLS}) + assert not result.is_error + assert result.structured_content == {"result": "Imported 2 records."} + + +def test_progress_callback_is_per_call_not_per_client() -> None: + """The `!!! warning`: `call_tool` takes `progress_callback`; the `Client` constructor does not.""" + assert "progress_callback" in inspect.signature(Client.call_tool).parameters + assert "progress_callback" not in inspect.signature(Client.__init__).parameters + + +async def test_omitting_total_reaches_the_callback_as_none() -> None: + """tutorial002: a report without `total` arrives as `total=None`: activity, not a percentage.""" + updates: list[tuple[float, float | None, str | None]] = [] + + async def show(progress: float, total: float | None, message: str | None) -> None: + updates.append((progress, total, message)) + + async with Client(tutorial002.mcp) as client: + result = await client.call_tool("import_feed", {"feed_url": "https://example.com/feed"}, progress_callback=show) + assert updates == [ + (1, None, "Imported https://example.com/feed#Dune"), + (2, None, "Imported https://example.com/feed#Neuromancer"), + (3, None, "Imported https://example.com/feed#Hyperion"), + ] + assert result.structured_content == {"result": "Imported 3 records."} diff --git a/tests/docs_src/test_prompts.py b/tests/docs_src/test_prompts.py new file mode 100644 index 0000000000..1cbab3af0a --- /dev/null +++ b/tests/docs_src/test_prompts.py @@ -0,0 +1,101 @@ +"""`docs/tutorial/prompts.md`: every claim the page makes, proved against the real SDK.""" + +import traceback + +import pytest +from inline_snapshot import snapshot +from mcp_types import PromptArgument, PromptMessage, TextContent + +from docs_src.prompts import tutorial001, tutorial002, tutorial003 +from mcp import Client, MCPError + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_function_becomes_the_prompt() -> None: + """tutorial001: the name, the docstring and the parameters are the whole `prompts/list` entry.""" + async with Client(tutorial001.mcp) as client: + (prompt,) = (await client.list_prompts()).prompts + assert prompt.model_dump(mode="json", by_alias=True, exclude_none=True) == snapshot( + { + "name": "review_code", + "description": "Review a piece of code.", + "arguments": [{"name": "code", "required": True}], + } + ) + + +async def test_returned_string_becomes_one_user_message() -> None: + """tutorial001: a `str` return value is rendered as a single `user` message.""" + async with Client(tutorial001.mcp) as client: + result = await client.get_prompt("review_code", {"code": "def add(a, b): return a + b"}) + assert result.model_dump(mode="json", by_alias=True, exclude_none=True) == snapshot( + { + "description": "Review a piece of code.", + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "Please review this code:\n\ndef add(a, b): return a + b", + }, + } + ], + "resultType": "complete", + } + ) + + +async def test_missing_required_argument_is_a_protocol_error() -> None: + """tutorial001: omitting a required argument fails the request itself. There is no error result.""" + async with Client(tutorial001.mcp) as client: + with pytest.raises(MCPError) as exc_info: + await client.get_prompt("review_code") + assert exc_info.value.code == -32603 + assert exc_info.value.message == "Internal server error" + # The line a traceback prints, exactly as the page quotes it: the code is not in the message. + assert traceback.format_exception_only(exc_info.value) == snapshot( + ["mcp.shared.exceptions.MCPError: Internal server error\n"] + ) + + +async def test_message_list_becomes_a_multi_turn_template() -> None: + """tutorial002: a list of `UserMessage` / `AssistantMessage` renders in order, roles intact.""" + async with Client(tutorial002.mcp) as client: + assert [p.name for p in (await client.list_prompts()).prompts] == ["review_code", "debug_error"] + result = await client.get_prompt("debug_error", {"error": "TypeError: 'int' object is not iterable"}) + assert result.messages == [ + PromptMessage(role="user", content=TextContent(type="text", text="I'm seeing this error:")), + PromptMessage( + role="user", + content=TextContent(type="text", text="TypeError: 'int' object is not iterable"), + ), + PromptMessage( + role="assistant", + content=TextContent(type="text", text="I'll help debug that. What have you tried so far?"), + ), + ] + + +async def test_title_and_argument_descriptions() -> None: + """tutorial003: `title=` and `Field(description=...)` land in the `prompts/list` entry.""" + async with Client(tutorial003.mcp) as client: + (prompt,) = (await client.list_prompts()).prompts + assert prompt.title == "Code review" + assert prompt.arguments == [ + PromptArgument(name="code", description="The code to review.", required=True), + PromptArgument(name="language", description="The language the code is written in.", required=False), + ] + + +async def test_default_value_makes_the_argument_optional() -> None: + """tutorial003: a parameter with a default can be omitted and the default is used in the render.""" + async with Client(tutorial003.mcp) as client: + result = await client.get_prompt("review_code", {"code": "x = 1"}) + assert result.messages == [ + PromptMessage( + role="user", + content=TextContent(type="text", text="Please review this python code:\n\nx = 1"), + ) + ] diff --git a/tests/docs_src/test_protocol_versions.py b/tests/docs_src/test_protocol_versions.py new file mode 100644 index 0000000000..f8e5b19f16 --- /dev/null +++ b/tests/docs_src/test_protocol_versions.py @@ -0,0 +1,94 @@ +"""`docs/client/protocol-versions.md`: every claim the page makes, proved against the real SDK.""" + +import re + +import pytest +from mcp_types import DiscoverResult, Implementation, ServerCapabilities + +from docs_src.protocol_versions import tutorial001, tutorial002, tutorial003, tutorial004 +from mcp import Client + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_auto_lands_on_the_modern_version() -> None: + """tutorial001: the default `mode="auto"` probes `server/discover` and adopts the result.""" + async with Client(tutorial001.mcp) as client: + assert client.protocol_version == "2026-07-28" + assert client.server_info.name == "Bookshop" + assert client.session.discover_result is not None + assert client.session.initialize_result is None + + +async def test_legacy_forces_the_initialize_handshake() -> None: + """tutorial002: `mode="legacy"` runs `initialize` against the very same server.""" + async with Client(tutorial002.mcp, mode="legacy") as client: + assert client.protocol_version == "2025-11-25" + assert client.server_info.name == "Bookshop" + assert client.session.initialize_result is not None + assert client.session.discover_result is None + + +async def test_version_pin_sends_nothing_and_knows_nothing() -> None: + """tutorial003: a pin adopts the version locally; `server_info` and capabilities are blank.""" + async with Client(tutorial003.mcp, mode="2026-07-28") as client: + assert client.protocol_version == "2026-07-28" + assert client.server_info == Implementation(name="", version="") + # The `!!! check` fence is the literal `print(client.server_info)` output. + assert str(client.server_info) == "name='' title=None version='' description=None website_url=None icons=None" + assert client.server_capabilities == ServerCapabilities() + result = await client.call_tool("search_books", {"query": "dune"}) + assert result.structured_content == {"result": "Found 3 books matching 'dune'."} + + +def test_handshake_era_version_is_not_a_valid_pin() -> None: + """A pre-2026 version string is rejected at construction with the exact error the page shows.""" + with pytest.raises( + ValueError, + match=re.escape( + "mode must be 'legacy', 'auto', or one of ['2026-07-28']; " + "got '2025-06-18' ('2025-06-18' is a handshake-era version; use mode='legacy')" + ), + ): + Client(tutorial003.mcp, mode="2025-06-18") + + +async def test_prior_discover_round_trips() -> None: + """tutorial004: save `discover_result`, reconnect with it, and the identity comes back.""" + async with Client(tutorial004.mcp) as client: + saved = client.session.discover_result + assert saved is not None + assert saved.supported_versions == ["2026-07-28"] + + async with Client(tutorial004.mcp, mode="2026-07-28", prior_discover=saved) as client: + assert client.protocol_version == "2026-07-28" + assert client.server_info.name == "Bookshop" + assert client.server_capabilities.tools is not None + + +async def test_discover_result_survives_json() -> None: + """`DiscoverResult` is a Pydantic model: dump it to JSON, validate it back, reconnect with it.""" + async with Client(tutorial004.mcp) as client: + saved = client.session.discover_result + assert saved is not None + + restored = DiscoverResult.model_validate_json(saved.model_dump_json()) + assert restored == saved + + async with Client(tutorial004.mcp, mode="2026-07-28", prior_discover=restored) as client: + assert client.server_info.name == "Bookshop" + + +async def test_prior_discover_is_ignored_unless_mode_is_a_pin() -> None: + """The `!!! tip`: under `auto` the client probes anyway; under `legacy` it never discovers.""" + stale = DiscoverResult( + supported_versions=["2026-07-28"], + capabilities=ServerCapabilities(), + server_info=Implementation(name="Stale", version="0.0.0"), + ) + async with Client(tutorial004.mcp, prior_discover=stale) as client: + assert client.server_info.name == "Bookshop" + async with Client(tutorial004.mcp, mode="legacy", prior_discover=stale) as client: + assert client.session.discover_result is None + assert client.protocol_version == "2025-11-25" diff --git a/tests/docs_src/test_resources.py b/tests/docs_src/test_resources.py new file mode 100644 index 0000000000..85e827833d --- /dev/null +++ b/tests/docs_src/test_resources.py @@ -0,0 +1,119 @@ +"""`docs/tutorial/resources.md`: every claim the page makes, proved against the real SDK.""" + +import base64 + +import pytest +from inline_snapshot import snapshot +from mcp_types import BlobResourceContents, Resource, ResourceTemplate, TextResourceContents + +from docs_src.resources import tutorial001, tutorial002, tutorial003 +from mcp import Client +from mcp.server import MCPServer + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_function_becomes_a_listed_resource() -> None: + """tutorial001: the URI, the function name and the docstring are the whole listing entry.""" + async with Client(tutorial001.mcp) as client: + (resource,) = (await client.list_resources()).resources + assert resource == snapshot( + Resource( + name="get_config", + uri="config://app", + description="The active shop configuration.", + mime_type="text/plain", + ) + ) + + +async def test_read_returns_the_return_value_as_text() -> None: + """tutorial001: reading the URI runs the function and wraps the `str` in `TextResourceContents`.""" + async with Client(tutorial001.mcp) as client: + result = await client.read_resource("config://app") + assert result.contents == [ + TextResourceContents(uri="config://app", mime_type="text/plain", text="theme=dark\nlanguage=en") + ] + + +async def test_template_is_listed_separately_from_resources() -> None: + """tutorial002: a `{placeholder}` moves the entry from `resources/list` to `resources/templates/list`.""" + async with Client(tutorial002.mcp) as client: + assert [r.uri for r in (await client.list_resources()).resources] == ["config://app"] + (template,) = (await client.list_resource_templates()).resource_templates + assert template == snapshot( + ResourceTemplate( + name="get_user_profile", + uri_template="users://{user_id}/profile", + description="A customer's profile.", + mime_type="text/plain", + ) + ) + + +async def test_reading_a_template_fills_the_placeholder() -> None: + """tutorial002: the client reads a concrete URI; the matched value arrives as the function argument.""" + async with Client(tutorial002.mcp) as client: + result = await client.read_resource("users://42/profile") + assert result.contents == [ + TextResourceContents( + uri="users://42/profile", mime_type="text/plain", text="User 42: 12 orders since 2021." + ) + ] + + +def test_uri_params_must_match_function_params() -> None: + """The `!!! check`: a placeholder/parameter mismatch is rejected at decoration time, not at read time.""" + broken = MCPServer("Bookshop") + with pytest.raises(ValueError) as exc_info: + + @broken.resource("users://{user_id}/profile") + def get_user_profile(user: str) -> None: + """A customer's profile.""" + + assert str(exc_info.value) == snapshot( + "Mismatch between URI parameters {'user_id'} and function parameters {'user'}" + ) + + +async def test_mime_type_is_what_you_declare() -> None: + """tutorial003: `mime_type=` lands in the listing verbatim; the SDK never guesses it from the value.""" + async with Client(tutorial003.mcp) as client: + resources = (await client.list_resources()).resources + assert {r.uri: r.mime_type for r in resources} == snapshot( + { + "docs://readme": "text/markdown", + "stats://catalog": "application/json", + "covers://placeholder": "image/gif", + } + ) + + +async def test_str_return_is_sent_as_is() -> None: + """tutorial003: a `str` return value is the text content, untouched.""" + async with Client(tutorial003.mcp) as client: + (content,) = (await client.read_resource("docs://readme")).contents + assert isinstance(content, TextResourceContents) + assert content.text == "# Bookshop\n\nSearch the catalog with the `search_books` tool." + + +async def test_dict_return_becomes_json_text() -> None: + """tutorial003: a non-`str`, non-`bytes` return value is serialised to JSON text.""" + async with Client(tutorial003.mcp) as client: + (content,) = (await client.read_resource("stats://catalog")).contents + assert isinstance(content, TextResourceContents) + assert content.text == snapshot('{\n "books": 1204,\n "authors": 391\n}') + + +async def test_bytes_return_becomes_a_blob() -> None: + """tutorial003: a `bytes` return value arrives as `BlobResourceContents`, base64-encoded in `blob`.""" + async with Client(tutorial003.mcp) as client: + (content,) = (await client.read_resource("covers://placeholder")).contents + assert isinstance(content, BlobResourceContents) + assert content == BlobResourceContents( + uri="covers://placeholder", + mime_type="image/gif", + blob="R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", + ) + assert base64.b64decode(content.blob) == tutorial003.placeholder_cover() diff --git a/tests/docs_src/test_run.py b/tests/docs_src/test_run.py new file mode 100644 index 0000000000..4b9a8926ad --- /dev/null +++ b/tests/docs_src/test_run.py @@ -0,0 +1,52 @@ +"""`docs/run/index.md`: every claim the page makes that is observable without a transport.""" + +from typing import Any + +import pytest +from inline_snapshot import snapshot +from mcp_types import CallToolResult, TextContent + +from docs_src.run import tutorial001, tutorial002, tutorial003 +from mcp import Client +from mcp.server import MCPServer + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_the_run_call_is_guarded_so_importing_does_not_start_a_server() -> None: + """tutorial001: `run()` sits under `__main__`, so the module imports cleanly and serves in-memory.""" + async with Client(tutorial001.mcp) as client: + result = await client.call_tool("search_books", {"query": "dune"}) + assert result == snapshot( + CallToolResult( + content=[TextContent(type="text", text="Found 3 books matching 'dune'.")], + structured_content={"result": "Found 3 books matching 'dune'."}, + ) + ) + + +async def test_the_transport_never_changes_what_the_server_is() -> None: + """tutorial001/002/003 differ only in how they run: every client sees the identical tool.""" + async with ( + Client(tutorial001.mcp) as stdio_client, + Client(tutorial002.mcp) as http_client, + Client(tutorial003.mcp) as configured_client, + ): + baseline = await stdio_client.list_tools() + assert baseline == await http_client.list_tools() + assert baseline == await configured_client.list_tools() + + +def test_transport_options_are_not_constructor_options() -> None: + """The page's warning: `port=` belongs to `run()`; the constructor rejects it.""" + options: dict[str, Any] = {"port": 3001} + with pytest.raises(TypeError, match="unexpected keyword argument 'port'"): + MCPServer("Bookshop", **options) + + +def test_settings_are_constructor_arguments_and_land_on_settings() -> None: + """tutorial003: `log_level=` ends up on `mcp.settings`; the defaults are INFO and not-debug.""" + assert tutorial001.mcp.settings.log_level == "INFO" + assert tutorial001.mcp.settings.debug is False + assert tutorial003.mcp.settings.log_level == "DEBUG" diff --git a/tests/docs_src/test_session_groups.py b/tests/docs_src/test_session_groups.py new file mode 100644 index 0000000000..e6fee8ce92 --- /dev/null +++ b/tests/docs_src/test_session_groups.py @@ -0,0 +1,98 @@ +"""`docs/advanced/session-groups.md`: every claim the page makes, proved against the real SDK. + +`connect_to_server` opens a real transport (a subprocess or a socket), so these tests drive the +exact same aggregation path through `connect_with_session` with in-memory sessions instead. +""" + +import traceback + +import pytest +from mcp_types import INVALID_PARAMS, Implementation + +from docs_src.session_groups import tutorial001, tutorial002, tutorial004 +from mcp import Client, ClientSessionGroup, MCPError + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_both_servers_call_their_tool_search() -> None: + """tutorial001 + tutorial002: two unrelated servers, one colliding tool name.""" + async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web: + (library_tool,) = (await library.list_tools()).tools + (web_tool,) = (await web.list_tools()).tools + assert library_tool.name == "search" + assert web_tool.name == "search" + + +async def test_a_connected_server_is_aggregated_into_the_group() -> None: + """tutorial003: the group exposes every component of every connected server as a dict.""" + async with Client(tutorial001.mcp) as library: + group = ClientSessionGroup() + await group.connect_with_session(library.server_info, library.session) + assert sorted(group.tools) == ["search"] + assert sorted(group.resources) == ["hours"] + assert group.prompts == {} + assert group.tools["search"].description == "Search the library catalog." + + +async def test_colliding_names_are_rejected() -> None: + """tutorial003: without a hook the second `search` raises, and nothing from `Web` is kept.""" + async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web: + group = ClientSessionGroup() + await group.connect_with_session(library.server_info, library.session) + with pytest.raises(MCPError) as exc_info: + await group.connect_with_session(web.server_info, web.session) + assert str(exc_info.value) == "{'search'} already exist in group tools." + assert exc_info.value.error.code == INVALID_PARAMS + assert sorted(group.tools) == ["search"] + # The page's `!!! check` fence is the last line of the traceback, verbatim. + assert traceback.format_exception_only(exc_info.value) == [ + "mcp.shared.exceptions.MCPError: {'search'} already exist in group tools.\n" + ] + + +async def test_component_name_hook_prefixes_every_name() -> None: + """tutorial004: the hook rewrites every registered name, so both servers coexist.""" + async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web: + group = ClientSessionGroup(component_name_hook=tutorial004.by_server) + await group.connect_with_session(library.server_info, library.session) + await group.connect_with_session(web.server_info, web.session) + assert sorted(group.tools) == ["Library.search", "Web.search"] + assert sorted(group.resources) == ["Library.hours"] + + +def test_the_hook_is_a_plain_function_of_name_and_server_info() -> None: + """tutorial004: `by_server` builds the key from `server_info.name`.""" + assert tutorial004.by_server("search", Implementation(name="Web", version="1.0.0")) == "Web.search" + + +async def test_the_key_is_prefixed_but_the_wire_name_is_not() -> None: + """tutorial004: the dict key is yours; the `Tool` inside keeps the name the server declared.""" + async with Client(tutorial002.mcp) as web: + group = ClientSessionGroup(component_name_hook=tutorial004.by_server) + await group.connect_with_session(web.server_info, web.session) + assert group.tools["Web.search"].name == "search" + + +async def test_call_tool_routes_to_the_owning_server() -> None: + """tutorial004: `group.call_tool` resolves the prefixed name to the session that owns it.""" + async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web: + group = ClientSessionGroup(component_name_hook=tutorial004.by_server) + await group.connect_with_session(library.server_info, library.session) + await group.connect_with_session(web.server_info, web.session) + web_result = await group.call_tool("Web.search", {"query": "model context protocol"}) + assert web_result.structured_content == {"result": "12 pages match 'model context protocol'."} + library_result = await group.call_tool("Library.search", {"query": "dune"}) + assert library_result.structured_content == {"result": "3 books match 'dune'."} + + +async def test_disconnect_removes_every_component_of_that_server() -> None: + """tutorial004: `disconnect_from_server` takes the session back out of all three dicts.""" + async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web: + group = ClientSessionGroup(component_name_hook=tutorial004.by_server) + await group.connect_with_session(library.server_info, library.session) + web_session = await group.connect_with_session(web.server_info, web.session) + await group.disconnect_from_server(web_session) + assert sorted(group.tools) == ["Library.search"] + assert sorted(group.resources) == ["Library.hours"] diff --git a/tests/docs_src/test_shape.py b/tests/docs_src/test_shape.py new file mode 100644 index 0000000000..375f3e4581 --- /dev/null +++ b/tests/docs_src/test_shape.py @@ -0,0 +1,193 @@ +"""Structural invariants every `docs_src/` example must satisfy. + +These are deliberately string/regex checks, not an AST analyzer: each predicate +is branch-free at the call site so the suite stays compatible with the repo's +100% branch-coverage gate, and a contributor whose doc PR goes red gets a +one-line reason, not a parser traceback. +""" + +import importlib +import re +from itertools import filterfalse +from pathlib import Path + +import pytest + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning") + +REPO_ROOT = Path(__file__).parent.parent.parent +DOCS_SRC = REPO_ROOT / "docs_src" + +EXAMPLE_FILES = sorted(p for p in DOCS_SRC.rglob("*.py") if p.name != "__init__.py") +"""Every example module under `docs_src/` (the `__init__.py` scaffolding is not an example).""" + +_PRIVATE_MCP_IMPORT = re.compile(r"^\s*(?:from|import)\s+(mcp(?:\.\w+)*\._\w+)", re.MULTILINE) +"""A `_`-private segment inside the imported MODULE path: `from mcp.client._memory import X`.""" + +_PRIVATE_MCP_NAME = re.compile(r"^\s*from\s+(mcp(?:\.\w+)*)\s+import\s+[^#\n]*?\b(_\w+)\b", re.MULTILINE) +"""A `_`-private NAME imported from a public `mcp` module: `from mcp.client import _memory`.""" + +RETIRED_NAMES = ("UrlElicitationRequiredError",) +"""Public SDK names built on protocol surfaces retired by the 2026-07-28 spec. + +`UrlElicitationRequiredError` is the `-32042` flow; the spec lists that code as +reserved-never-reused, so no documentation example may teach it even while the +symbol is still exported. +""" + +_INCLUDE_DIRECTIVE = re.compile(r"(?:--8<--\s*\"|` README marker.""" + +_TYPOGRAPHIC_NON_ASCII = re.compile("[\u2014\u2013\u2192\u2026\u2018\u2019\u201c\u201d\u00a0\u2264\u2265\u00a7]") +"""Typographic characters the documentation never uses: em/en dash, arrow, ellipsis, curly +quotes, no-break space, comparison signs, section sign. They are written as escapes so this +file satisfies the very check it implements. + +Plain ASCII punctuation is a deliberate style rule, and one of these is also a real bug: a U+2026 +inside a fenced example breaks the fence linter on Windows, where the example source is piped to +ruff in the platform encoding rather than UTF-8. Emoji are not banned; this is about typography. +""" + +BOOK_PAGES = sorted( + { + REPO_ROOT / "README.v2.md", + REPO_ROOT / "docs" / "index.md", + REPO_ROOT / "docs" / "installation.md", + *(REPO_ROOT / "docs" / "tutorial").rglob("*.md"), + *(REPO_ROOT / "docs" / "run").rglob("*.md"), + *(REPO_ROOT / "docs" / "client").rglob("*.md"), + *(REPO_ROOT / "docs" / "advanced").rglob("*.md"), + } +) +"""Every page of the tutorial book plus the README: the files this directory's tests stand behind.""" + +DOCS_TEST_FILES = sorted(Path(__file__).parent.glob("*.py")) +"""This directory itself. The prose in these modules' docstrings follows the same typography rule.""" + + +def _rel(path: Path) -> str: + """A repo-relative path, used as the parametrize id so failures name the file.""" + return path.relative_to(REPO_ROOT).as_posix() + + +def _module_name(path: Path) -> str: + """The dotted import name of an example, derived from its repo-relative path.""" + return _rel(path).removesuffix(".py").replace("/", ".") + + +def _private_mcp_imports(source: str) -> list[str]: + """Every `mcp.*` import in `source` that reaches a `_`-private module OR name. + + Two single-line spellings are covered: a private segment in the module path + (`from mcp.client._memory import X`, `import mcp.server._otel`) and a private + name pulled from a public module (`from mcp.client import _memory`). + """ + named = [f"{module}.{name}" for module, name in _PRIVATE_MCP_NAME.findall(source)] + return _PRIVATE_MCP_IMPORT.findall(source) + named + + +def _retired_names_used(source: str) -> list[str]: + """The retired SDK names that appear anywhere in `source`.""" + return [name for name in RETIRED_NAMES if name in source] + + +def _typographic_chars(text: str) -> list[str]: + """Every banned typographic character in `text`, in order of appearance.""" + return _TYPOGRAPHIC_NON_ASCII.findall(text) + + +def _referenced_examples() -> set[str]: + """Every `docs_src/...` path that some docs page or the README actually includes. + + The README is globbed rather than named so this survives the planned + `README.v2.md` -> `README.md` rename instead of crashing on a missing file. + """ + pages = [*sorted((REPO_ROOT / "docs").rglob("*.md")), *sorted(REPO_ROOT.glob("README*.md"))] + return {ref for page in pages for ref in _INCLUDE_DIRECTIVE.findall(page.read_text(encoding="utf-8"))} + + +def _is_real_file(rel: str) -> bool: + """Whether a repo-relative path exists on disk.""" + return (REPO_ROOT / rel).is_file() + + +def test_private_mcp_import_detector() -> None: + """The detector flags both single-line spellings of a private `mcp` reach-in, and only those. + + It does not parse Python: a private name hidden behind an `as` alias or inside a + parenthesised multi-line `import` would slip through. Examples are short single-line + imports, so the cheap detector is the right trade against a 100-line AST analyzer. + """ + assert _private_mcp_imports("from mcp.client._memory import InMemoryTransport") == ["mcp.client._memory"] + assert _private_mcp_imports("import mcp.server._otel") == ["mcp.server._otel"] + assert _private_mcp_imports("from mcp.client import _memory") == ["mcp.client._memory"] + assert _private_mcp_imports("from mcp.server import MCPServer\nfrom mcp.client.client import Client") == [] + # only `mcp` is policed: another library's private module is not this test's business + assert _private_mcp_imports("from pydantic._internal import _fields") == [] + + +def test_retired_name_detector() -> None: + """The detector flags a retired name and stays quiet on clean source.""" + assert _retired_names_used("raise UrlElicitationRequiredError([])") == ["UrlElicitationRequiredError"] + assert _retired_names_used("from mcp.server import MCPServer") == [] + + +def test_typographic_char_detector() -> None: + """The detector flags banned typography and allows plain ASCII and emoji.""" + assert _typographic_chars("a \u2014 b \u2192 c\u2026") == ["\u2014", "\u2192", "\u2026"] + assert _typographic_chars("plain ASCII with a \u2728 emoji is fine") == [] + + +@pytest.mark.parametrize("path", EXAMPLE_FILES, ids=_rel) +def test_example_imports(path: Path) -> None: + """The example imports cleanly against the current SDK. + + A renamed symbol, a moved import path, or a changed keyword argument breaks an + example at import time, long before anyone reads the page it appears on. + + Honest scope: an example another test in this directory already imported is a + `sys.modules` cache hit here and its real coverage is that behavioural test. + This test is the floor for the example that has a page but no test yet. + """ + importlib.import_module(_module_name(path)) + + +@pytest.mark.parametrize("path", EXAMPLE_FILES, ids=_rel) +def test_example_uses_only_public_mcp_modules(path: Path) -> None: + """An example is the public API contract: it must never import a `_`-private `mcp` module.""" + assert not _private_mcp_imports(path.read_text(encoding="utf-8")), f"{_rel(path)} reaches into private mcp" + + +@pytest.mark.parametrize("path", EXAMPLE_FILES, ids=_rel) +def test_example_avoids_retired_api(path: Path) -> None: + """An example must not teach an API the 2026-07-28 spec retired, even while it is still exported.""" + assert not _retired_names_used(path.read_text(encoding="utf-8")), f"{_rel(path)} uses a retired API" + + +@pytest.mark.parametrize("path", [*BOOK_PAGES, *EXAMPLE_FILES, *DOCS_TEST_FILES], ids=_rel) +def test_page_uses_plain_ascii_punctuation(path: Path) -> None: + """A page, example, or docs test never uses em-dashes, arrows, ellipses, or other typographic non-ASCII.""" + found = _typographic_chars(path.read_text(encoding="utf-8")) + assert not found, f"{_rel(path)} contains non-ASCII typography: {sorted(set(found))}" + + +def test_every_example_is_included_by_a_page() -> None: + """Every `docs_src/` example is shown by at least one docs page or the README. + + An orphan example is dead documentation: it gets type-checked and tested + but no reader ever sees it, so it silently stops describing anything. + """ + examples = {_rel(p) for p in EXAMPLE_FILES} + orphans = sorted(examples - _referenced_examples()) + assert not orphans, f"docs_src files no page includes: {orphans}" + + +def test_every_included_path_exists() -> None: + """Every `docs_src/` path a page includes exists on disk. + + `mkdocs build --strict` also enforces this, but only when the docs are + built; this puts the same guarantee inside the ordinary `pytest` run. + """ + missing = sorted(filterfalse(_is_real_file, _referenced_examples())) + assert not missing, f"pages include docs_src files that do not exist: {missing}" diff --git a/tests/docs_src/test_structured_output.py b/tests/docs_src/test_structured_output.py new file mode 100644 index 0000000000..795b0ccf1e --- /dev/null +++ b/tests/docs_src/test_structured_output.py @@ -0,0 +1,192 @@ +"""`docs/tutorial/structured-output.md`: every claim the page makes, proved against the real SDK.""" + +import pytest +from inline_snapshot import snapshot +from mcp_types import TextContent + +from docs_src.structured_output import ( + tutorial001, + tutorial002, + tutorial003, + tutorial004, + tutorial005, + tutorial006, + tutorial007, + tutorial008, + tutorial009, +) +from mcp import Client +from mcp.server import MCPServer +from mcp.server.mcpserver.exceptions import InvalidSignature + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_scalar_return_is_wrapped() -> None: + """tutorial001: `-> int` becomes a `{"result": ...}` output schema and fills both channels.""" + async with Client(tutorial001.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.output_schema == snapshot( + { + "properties": {"result": {"title": "Result", "type": "integer"}}, + "required": ["result"], + "title": "get_temperatureOutput", + "type": "object", + } + ) + result = await client.call_tool("get_temperature", {"city": "London"}) + assert not result.is_error + assert result.content == [TextContent(type="text", text="17")] + assert result.structured_content == {"result": 17} + + +async def test_basemodel_is_the_schema() -> None: + """tutorial002: a `BaseModel` return type is the output schema itself: no wrapper.""" + async with Client(tutorial002.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.output_schema == snapshot( + { + "properties": { + "temperature": {"description": "Degrees Celsius.", "title": "Temperature", "type": "number"}, + "humidity": {"description": "Relative humidity, 0 to 1.", "title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"}, + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object", + } + ) + result = await client.call_tool("get_weather", {"city": "London"}) + assert result.structured_content == {"temperature": 16.2, "humidity": 0.83, "conditions": "Overcast"} + serialized = '{\n "temperature": 16.2,\n "humidity": 0.83,\n "conditions": "Overcast"\n}' + assert result.content == [TextContent(type="text", text=serialized)] + + +async def test_typeddict_produces_the_same_schema() -> None: + """tutorial003: a `TypedDict` return type produces the same object schema as the `BaseModel`.""" + async with Client(tutorial003.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.output_schema == snapshot( + { + "properties": { + "temperature": {"title": "Temperature", "type": "number"}, + "humidity": {"title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"}, + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object", + } + ) + result = await client.call_tool("get_weather", {"city": "London"}) + assert result.structured_content == {"temperature": 16.2, "humidity": 0.83, "conditions": "Overcast"} + + +async def test_dataclass_produces_the_same_schema() -> None: + """tutorial004: a dataclass (an annotated class) produces the same object schema again.""" + async with Client(tutorial004.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.output_schema == snapshot( + { + "properties": { + "temperature": {"title": "Temperature", "type": "number"}, + "humidity": {"title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"}, + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object", + } + ) + result = await client.call_tool("get_weather", {"city": "London"}) + assert result.structured_content == {"temperature": 16.2, "humidity": 0.83, "conditions": "Overcast"} + + +async def test_list_return_is_wrapped() -> None: + """tutorial005: `-> list[WeatherData]` is wrapped in `{"result": ...}` and flattened into one block per item.""" + async with Client(tutorial005.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.output_schema == snapshot( + { + "$defs": { + "WeatherData": { + "properties": { + "temperature": {"title": "Temperature", "type": "number"}, + "humidity": {"title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"}, + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object", + } + }, + "properties": { + "result": {"items": {"$ref": "#/$defs/WeatherData"}, "title": "Result", "type": "array"} + }, + "required": ["result"], + "title": "get_forecastOutput", + "type": "object", + } + ) + result = await client.call_tool("get_forecast", {"city": "London", "days": 2}) + assert result.structured_content == { + "result": [ + {"temperature": 16.2, "humidity": 0.83, "conditions": "Overcast"}, + {"temperature": 17.2, "humidity": 0.83, "conditions": "Overcast"}, + ] + } + assert len(result.content) == 2 + + +async def test_dict_str_return_is_not_wrapped() -> None: + """tutorial006: `dict[str, float]` is already a JSON object, so there is no `result` wrapper.""" + async with Client(tutorial006.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.output_schema == snapshot( + {"additionalProperties": {"type": "number"}, "title": "get_temperaturesDictOutput", "type": "object"} + ) + result = await client.call_tool("get_temperatures", {"cities": ["London", "Reykjavik"]}) + assert result.structured_content == {"London": 16.2, "Reykjavik": 4.4} + + +async def test_return_value_is_validated_against_the_schema() -> None: + """tutorial007: a return value that does not match the output schema is a tool error, not a result.""" + async with Client(tutorial007.mcp) as client: + result = await client.call_tool("get_weather", {"city": "London"}) + assert result.is_error + assert result.structured_content is None + assert isinstance(result.content[0], TextContent) + assert result.content[0].text.startswith("Error executing tool get_weather: 1 validation error for WeatherData") + assert "humidity\n Field required" in result.content[0].text + + +async def test_structured_output_false_opts_out() -> None: + """tutorial008: `structured_output=False` drops the schema and the structured channel entirely.""" + async with Client(tutorial008.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.output_schema is None + result = await client.call_tool("weather_report", {"city": "London"}) + assert result.structured_content is None + assert result.content == [ + TextContent(type="text", text="London: 17 degrees, overcast, light rain easing by evening.") + ] + + +async def test_class_without_type_hints_is_silently_unstructured() -> None: + """tutorial009: a class with no annotations on its body gets no schema, and the model gets a `repr`.""" + async with Client(tutorial009.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.output_schema is None + result = await client.call_tool("get_station", {"name": "north"}) + assert not result.is_error + assert result.structured_content is None + assert isinstance(result.content[0], TextContent) + assert result.content[0].text.startswith('" None: + """tutorial009: `structured_output=True` refuses a return type it cannot build a schema for.""" + mcp = MCPServer("Weather") + with pytest.raises(InvalidSignature, match="is not serializable for structured output"): + mcp.add_tool(tutorial009.get_station, structured_output=True) diff --git a/tests/docs_src/test_testing.py b/tests/docs_src/test_testing.py new file mode 100644 index 0000000000..035f72312f --- /dev/null +++ b/tests/docs_src/test_testing.py @@ -0,0 +1,23 @@ +"""`docs/tutorial/testing.md`: the page's own test, run for real. + +The page shows this test against a `server.py` next to it; here the import path +is the only difference. +""" + +import pytest +from inline_snapshot import snapshot +from mcp_types import CallToolResult, TextContent + +from docs_src.testing.tutorial001 import mcp +from mcp import Client + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_call_add_tool() -> None: + async with Client(mcp, raise_exceptions=True) as client: + result = await client.call_tool("add", {"a": 1, "b": 2}) + assert result == snapshot( + CallToolResult(content=[TextContent(type="text", text="3")], structured_content={"result": 3}) + ) diff --git a/tests/docs_src/test_tools.py b/tests/docs_src/test_tools.py new file mode 100644 index 0000000000..08e2a5ca69 --- /dev/null +++ b/tests/docs_src/test_tools.py @@ -0,0 +1,107 @@ +"""`docs/tutorial/tools.md`: every claim the page makes, proved against the real SDK.""" + +import pytest +from inline_snapshot import snapshot +from mcp_types import TextContent, ToolAnnotations + +from docs_src.tools import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005 +from mcp import Client + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_signature_becomes_the_schema() -> None: + """tutorial001: the function name, the docstring and the type hints are the whole tool definition.""" + async with Client(tutorial001.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.name == "search_books" + assert tool.description == "Search the catalog by title or author." + assert tool.input_schema == snapshot( + { + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"title": "Limit", "type": "integer"}, + }, + "required": ["query", "limit"], + "title": "search_booksArguments", + } + ) + + +async def test_call_returns_text_and_structured_content() -> None: + """tutorial001: the return value reaches the model as text and the client as typed data.""" + async with Client(tutorial001.mcp) as client: + result = await client.call_tool("search_books", {"query": "dune", "limit": 5}) + assert not result.is_error + assert result.content == [TextContent(type="text", text="Found 3 books matching 'dune' (showing up to 5).")] + assert result.structured_content == {"result": "Found 3 books matching 'dune' (showing up to 5)."} + + +async def test_default_value_makes_the_argument_optional() -> None: + """tutorial002: a plain Python default drops the argument from `required` and lands in the schema. + + The whole schema is pinned because the page quotes it verbatim. + """ + async with Client(tutorial002.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.input_schema == snapshot( + { + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"default": 10, "title": "Limit", "type": "integer"}, + }, + "required": ["query"], + "title": "search_booksArguments", + } + ) + result = await client.call_tool("search_books", {"query": "dune"}) + assert result.structured_content == {"result": "Found 3 books matching 'dune' (showing up to 10)."} + + +async def test_field_constraints_land_in_the_schema() -> None: + """tutorial003: `Field(...)` metadata and `Literal` choices become JSON Schema the model can see.""" + async with Client(tutorial003.mcp) as client: + (tool,) = (await client.list_tools()).tools + props = tool.input_schema["properties"] + assert props["query"]["description"] == "Title or author to search for." + assert props["limit"] == snapshot( + { + "default": 10, + "description": "Maximum number of results.", + "maximum": 50, + "minimum": 1, + "title": "Limit", + "type": "integer", + } + ) + assert props["genre"]["anyOf"][0]["enum"] == ["fiction", "non-fiction", "poetry"] + + +async def test_constraint_violation_is_an_error_the_model_can_read() -> None: + """tutorial003: an out-of-range argument is rejected by the schema, not by your code.""" + async with Client(tutorial003.mcp) as client: + result = await client.call_tool("search_books", {"query": "dune", "limit": 999}) + assert result.is_error + assert isinstance(result.content[0], TextContent) + assert "less than or equal to 50" in result.content[0].text + + +async def test_pydantic_model_parameter() -> None: + """tutorial004: a `BaseModel` parameter nests its own schema and arrives as a real instance.""" + async with Client(tutorial004.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.input_schema["$defs"]["Book"]["required"] == ["title", "author", "year"] + book = {"title": "Dune", "author": "Frank Herbert", "year": 1965} + result = await client.call_tool("add_book", {"book": book}) + assert result.structured_content == {"result": "Added 'Dune' by Frank Herbert (1965)."} + + +async def test_title_and_annotations() -> None: + """tutorial005: `title` and `ToolAnnotations` are display and behaviour metadata for the client.""" + async with Client(tutorial005.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.title == "Search the catalog" + assert tool.annotations == ToolAnnotations(read_only_hint=True, open_world_hint=False) diff --git a/tests/test_examples.py b/tests/test_examples.py index ae35767f7c..f24e932bed 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -93,8 +93,25 @@ async def test_desktop(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): assert "file2.txt" in content.text -# TODO(v2): Change back to README.md when v2 is released -@pytest.mark.parametrize("example", list(find_examples("README.v2.md")), ids=str) +# TODO(v2): Change back to README.md when v2 is released. +# `--8<--` include directives lint clean as Python, so pages built from +# `docs_src/` includes cost nothing here; the real validation of those files is +# pyright + ruff + tests/docs_src/. +@pytest.mark.parametrize( + "example", + list( + find_examples( + "README.v2.md", + "docs/index.md", + "docs/installation.md", + "docs/tutorial", + "docs/run", + "docs/client", + "docs/advanced", + ) + ), + ids=str, +) def test_docs_examples(example: CodeExample, eval_example: EvalExample): ruff_ignore: list[str] = ["F841", "I001", "F821"] # F821: undefined names (snippets lack imports)