diff --git a/.github/actions/conformance/client.py b/.github/actions/conformance/client.py new file mode 100644 index 0000000000..a13742e6c3 --- /dev/null +++ b/.github/actions/conformance/client.py @@ -0,0 +1,367 @@ +"""MCP unified conformance test client. + +This client is designed to work with the @modelcontextprotocol/conformance npm package. +It handles all conformance test scenarios via environment variables and CLI arguments. + +Contract: + - MCP_CONFORMANCE_SCENARIO env var -> scenario name + - MCP_CONFORMANCE_CONTEXT env var -> optional JSON (for client-credentials scenarios) + - Server URL as last CLI argument (sys.argv[1]) + - Must exit 0 within 30 seconds + +Scenarios: + initialize - Connect, initialize, list tools, close + tools_call - Connect, call add_numbers(a=5, b=3), close + sse-retry - Connect, call test_reconnection, close + elicitation-sep1034-client-defaults - Elicitation with default accept callback + auth/client-credentials-jwt - Client credentials with private_key_jwt + auth/client-credentials-basic - Client credentials with client_secret_basic + auth/* - Authorization code flow (default for auth scenarios) +""" + +import asyncio +import json +import logging +import os +import sys +from collections.abc import Callable, Coroutine +from typing import Any, cast +from urllib.parse import parse_qs, urlparse + +import httpx +from pydantic import AnyUrl + +from mcp import ClientSession, types +from mcp.client.auth import OAuthClientProvider, TokenStorage +from mcp.client.auth.extensions.client_credentials import ( + ClientCredentialsOAuthProvider, + PrivateKeyJWTOAuthProvider, + SignedJWTParameters, +) +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken +from mcp.shared.context import RequestContext + +# Set up logging to stderr (stdout is for conformance test output) +logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + stream=sys.stderr, +) +logger = logging.getLogger(__name__) + +# Type for async scenario handler functions +ScenarioHandler = Callable[[str], Coroutine[Any, None, None]] + +# Registry of scenario handlers +HANDLERS: dict[str, ScenarioHandler] = {} + + +def register(name: str) -> Callable[[ScenarioHandler], ScenarioHandler]: + """Register a scenario handler.""" + + def decorator(fn: ScenarioHandler) -> ScenarioHandler: + HANDLERS[name] = fn + return fn + + return decorator + + +def get_conformance_context() -> dict[str, Any]: + """Load conformance test context from MCP_CONFORMANCE_CONTEXT environment variable.""" + context_json = os.environ.get("MCP_CONFORMANCE_CONTEXT") + if not context_json: + raise RuntimeError( + "MCP_CONFORMANCE_CONTEXT environment variable not set. " + "Expected JSON with client_id, client_secret, and/or private_key_pem." + ) + try: + return json.loads(context_json) + except json.JSONDecodeError as e: + raise RuntimeError(f"Failed to parse MCP_CONFORMANCE_CONTEXT as JSON: {e}") from e + + +class InMemoryTokenStorage(TokenStorage): + """Simple in-memory token storage for conformance testing.""" + + 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 + + +class ConformanceOAuthCallbackHandler: + """OAuth callback handler that automatically fetches the authorization URL + and extracts the auth code, without requiring user interaction. + """ + + def __init__(self) -> None: + self._auth_code: str | None = None + self._state: str | None = None + + async def handle_redirect(self, authorization_url: str) -> None: + """Fetch the authorization URL and extract the auth code from the redirect.""" + logger.debug(f"Fetching authorization URL: {authorization_url}") + + async with httpx.AsyncClient() as client: + response = await client.get( + authorization_url, + follow_redirects=False, + ) + + if response.status_code in (301, 302, 303, 307, 308): + location = cast(str, response.headers.get("location")) + if location: + redirect_url = urlparse(location) + query_params: dict[str, list[str]] = parse_qs(redirect_url.query) + + if "code" in query_params: + self._auth_code = query_params["code"][0] + state_values = query_params.get("state") + self._state = state_values[0] if state_values else None + logger.debug(f"Got auth code from redirect: {self._auth_code[:10]}...") + return + else: + raise RuntimeError(f"No auth code in redirect URL: {location}") + else: + raise RuntimeError(f"No redirect location received from {authorization_url}") + else: + raise RuntimeError(f"Expected redirect response, got {response.status_code} from {authorization_url}") + + async def handle_callback(self) -> tuple[str, str | None]: + """Return the captured auth code and state.""" + if self._auth_code is None: + raise RuntimeError("No authorization code available - was handle_redirect called?") + auth_code = self._auth_code + state = self._state + self._auth_code = None + self._state = None + return auth_code, state + + +# --- Scenario Handlers --- + + +@register("initialize") +async def run_initialize(server_url: str) -> None: + """Connect, initialize, list tools, close.""" + async with streamable_http_client(url=server_url) as (read_stream, write_stream, _): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + logger.debug("Initialized successfully") + await session.list_tools() + logger.debug("Listed tools successfully") + + +@register("tools_call") +async def run_tools_call(server_url: str) -> None: + """Connect, initialize, list tools, call add_numbers(a=5, b=3), close.""" + async with streamable_http_client(url=server_url) as (read_stream, write_stream, _): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + await session.list_tools() + result = await session.call_tool("add_numbers", {"a": 5, "b": 3}) + logger.debug(f"add_numbers result: {result}") + + +@register("sse-retry") +async def run_sse_retry(server_url: str) -> None: + """Connect, initialize, list tools, call test_reconnection, close.""" + async with streamable_http_client(url=server_url) as (read_stream, write_stream, _): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + await session.list_tools() + result = await session.call_tool("test_reconnection", {}) + logger.debug(f"test_reconnection result: {result}") + + +async def default_elicitation_callback( + context: RequestContext["ClientSession", Any], + params: types.ElicitRequestParams, +) -> types.ElicitResult | types.ErrorData: + """Accept elicitation and apply defaults from the schema (SEP-1034).""" + content: dict[str, str | int | float | bool | list[str] | None] = {} + + # For form mode, extract defaults from the requested_schema + if isinstance(params, types.ElicitRequestFormParams): + schema = params.requestedSchema + logger.debug(f"Elicitation schema: {schema}") + properties = schema.get("properties", {}) + for prop_name, prop_schema in properties.items(): + if "default" in prop_schema: + content[prop_name] = prop_schema["default"] + logger.debug(f"Applied defaults: {content}") + + return types.ElicitResult(action="accept", content=content) + + +@register("elicitation-sep1034-client-defaults") +async def run_elicitation_defaults(server_url: str) -> None: + """Connect with elicitation callback that applies schema defaults.""" + async with streamable_http_client(url=server_url) as (read_stream, write_stream, _): + async with ClientSession( + read_stream, write_stream, elicitation_callback=default_elicitation_callback + ) as session: + await session.initialize() + await session.list_tools() + result = await session.call_tool("test_client_elicitation_defaults", {}) + logger.debug(f"test_client_elicitation_defaults result: {result}") + + +@register("auth/client-credentials-jwt") +async def run_client_credentials_jwt(server_url: str) -> None: + """Client credentials flow with private_key_jwt authentication.""" + context = get_conformance_context() + client_id = context.get("client_id") + private_key_pem = context.get("private_key_pem") + signing_algorithm = context.get("signing_algorithm", "ES256") + + if not client_id: + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_id'") + if not private_key_pem: + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'private_key_pem'") + + jwt_params = SignedJWTParameters( + issuer=client_id, + subject=client_id, + signing_algorithm=signing_algorithm, + signing_key=private_key_pem, + ) + + oauth_auth = PrivateKeyJWTOAuthProvider( + server_url=server_url, + storage=InMemoryTokenStorage(), + client_id=client_id, + assertion_provider=jwt_params.create_assertion_provider(), + ) + + await _run_auth_session(server_url, oauth_auth) + + +@register("auth/client-credentials-basic") +async def run_client_credentials_basic(server_url: str) -> None: + """Client credentials flow with client_secret_basic authentication.""" + context = get_conformance_context() + client_id = context.get("client_id") + client_secret = context.get("client_secret") + + if not client_id: + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_id'") + if not client_secret: + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_secret'") + + oauth_auth = ClientCredentialsOAuthProvider( + server_url=server_url, + storage=InMemoryTokenStorage(), + client_id=client_id, + client_secret=client_secret, + token_endpoint_auth_method="client_secret_basic", + ) + + await _run_auth_session(server_url, oauth_auth) + + +async def run_auth_code_client(server_url: str) -> None: + """Authorization code flow (default for auth/* scenarios).""" + callback_handler = ConformanceOAuthCallbackHandler() + storage = InMemoryTokenStorage() + + # Check for pre-registered client credentials from context + context_json = os.environ.get("MCP_CONFORMANCE_CONTEXT") + if context_json: + try: + context = json.loads(context_json) + client_id = context.get("client_id") + client_secret = context.get("client_secret") + if client_id: + await storage.set_client_info( + OAuthClientInformationFull( + client_id=client_id, + client_secret=client_secret, + redirect_uris=[AnyUrl("http://localhost:3000/callback")], + token_endpoint_auth_method="client_secret_basic" if client_secret else "none", + ) + ) + logger.debug(f"Pre-loaded client credentials: client_id={client_id}") + except json.JSONDecodeError: + logger.exception("Failed to parse MCP_CONFORMANCE_CONTEXT") + + oauth_auth = OAuthClientProvider( + server_url=server_url, + client_metadata=OAuthClientMetadata( + client_name="conformance-client", + redirect_uris=[AnyUrl("http://localhost:3000/callback")], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + ), + storage=storage, + redirect_handler=callback_handler.handle_redirect, + callback_handler=callback_handler.handle_callback, + client_metadata_url="https://conformance-test.local/client-metadata.json", + ) + + await _run_auth_session(server_url, oauth_auth) + + +async def _run_auth_session(server_url: str, oauth_auth: OAuthClientProvider) -> None: + """Common session logic for all OAuth flows.""" + client = httpx.AsyncClient(auth=oauth_auth, timeout=30.0) + async with streamable_http_client(url=server_url, http_client=client) as (read_stream, write_stream, _): + async with ClientSession( + read_stream, write_stream, elicitation_callback=default_elicitation_callback + ) as session: + await session.initialize() + logger.debug("Initialized successfully") + + tools_result = await session.list_tools() + logger.debug(f"Listed tools: {[t.name for t in tools_result.tools]}") + + # Call the first available tool (different tests have different tools) + if tools_result.tools: + tool_name = tools_result.tools[0].name + try: + result = await session.call_tool(tool_name, {}) + logger.debug(f"Called {tool_name}, result: {result}") + except Exception as e: + logger.debug(f"Tool call result/error: {e}") + + logger.debug("Connection closed successfully") + + +def main() -> None: + """Main entry point for the conformance client.""" + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} ", file=sys.stderr) + sys.exit(1) + + server_url = sys.argv[1] + scenario = os.environ.get("MCP_CONFORMANCE_SCENARIO") + + if scenario: + logger.debug(f"Running explicit scenario '{scenario}' against {server_url}") + handler = HANDLERS.get(scenario) + if handler: + asyncio.run(handler(server_url)) + elif scenario.startswith("auth/"): + asyncio.run(run_auth_code_client(server_url)) + else: + print(f"Unknown scenario: {scenario}", file=sys.stderr) + sys.exit(1) + else: + logger.debug(f"Running default auth flow against {server_url}") + asyncio.run(run_auth_code_client(server_url)) + + +if __name__ == "__main__": + main() diff --git a/.github/actions/conformance/expected-failures.yml b/.github/actions/conformance/expected-failures.yml new file mode 100644 index 0000000000..b542e788b4 --- /dev/null +++ b/.github/actions/conformance/expected-failures.yml @@ -0,0 +1,4 @@ +# Known conformance test failures for v1.x +# These are tracked and should be removed as they're fixed. +server: [] +client: [] diff --git a/.github/actions/conformance/run-server.sh b/.github/actions/conformance/run-server.sh new file mode 100755 index 0000000000..b11c4fce5a --- /dev/null +++ b/.github/actions/conformance/run-server.sh @@ -0,0 +1,30 @@ +#!/bin/bash +set -e + +PORT="${PORT:-3001}" +SERVER_URL="http://localhost:${PORT}/mcp" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/../../.." + +# Start everything-server +uv run --frozen mcp-everything-server --port "$PORT" & +SERVER_PID=$! +trap "kill $SERVER_PID 2>/dev/null || true; wait $SERVER_PID 2>/dev/null || true" EXIT + +# Wait for server to be ready +MAX_RETRIES=30 +RETRY_COUNT=0 +while ! curl -s "$SERVER_URL" > /dev/null 2>&1; do + RETRY_COUNT=$((RETRY_COUNT + 1)) + if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then + echo "Server failed to start after ${MAX_RETRIES} retries" >&2 + exit 1 + fi + sleep 0.5 +done + +echo "Server ready at $SERVER_URL" + +# Run conformance tests +npx @modelcontextprotocol/conformance@0.1.13 server --url "$SERVER_URL" "$@" diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..00dc69828b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: monthly + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml new file mode 100644 index 0000000000..19c557a131 --- /dev/null +++ b/.github/workflows/conformance.yml @@ -0,0 +1,44 @@ +name: Conformance Tests + +on: + push: + branches: [v1.x] + pull_request: + branches: [v1.x] + workflow_dispatch: + +concurrency: + group: conformance-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + server-conformance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1 + with: + enable-cache: true + version: 0.9.5 + - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: 24 + - run: uv sync --frozen --all-extras --package mcp-everything-server + - run: ./.github/actions/conformance/run-server.sh + + client-conformance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1 + with: + enable-cache: true + version: 0.9.5 + - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: 24 + - run: uv sync --frozen --all-extras --package mcp + - run: npx @modelcontextprotocol/conformance@0.1.13 client --command 'uv run --frozen python .github/actions/conformance/client.py' --suite all --expected-failures .github/actions/conformance/expected-failures.yml diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml new file mode 100644 index 0000000000..d9362afd57 --- /dev/null +++ b/.github/workflows/deploy-docs.yml @@ -0,0 +1,57 @@ +name: Deploy Docs + +on: + push: + branches: + - main + - v1.x + paths: + - docs/** + - mkdocs.yml + - src/mcp/** + - scripts/build-docs.sh + - pyproject.toml + - uv.lock + - .github/workflows/deploy-docs.yml + workflow_dispatch: + +concurrency: + group: deploy-docs + cancel-in-progress: false + +jobs: + deploy-docs: + runs-on: ubuntu-latest + + permissions: + contents: read + pages: write + id-token: write + + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Install uv + uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1 + with: + enable-cache: true + version: 0.9.5 + + - name: Build combined docs (v1.x at /, main at /v2/) + run: bash scripts/build-docs.sh site + + - name: Configure Pages + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: site + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/.github/workflows/publish-docs-manually.yml b/.github/workflows/publish-docs-manually.yml deleted file mode 100644 index befe44d31c..0000000000 --- a/.github/workflows/publish-docs-manually.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Publish Docs manually - -on: - workflow_dispatch: - -jobs: - docs-publish: - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - - name: Configure Git Credentials - run: | - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - - - name: Install uv - uses: astral-sh/setup-uv@v3 - with: - enable-cache: true - version: 0.9.5 - - - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV - - uses: actions/cache@v4 - with: - key: mkdocs-material-${{ env.cache_id }} - path: .cache - restore-keys: | - mkdocs-material- - - - run: uv sync --frozen --group docs - - run: uv run --frozen --no-sync mkdocs gh-deploy --force diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 59ede84172..085f82d833 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -51,32 +51,3 @@ jobs: - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 - - docs-publish: - runs-on: ubuntu-latest - needs: ["pypi-publish"] - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - - name: Configure Git Credentials - run: | - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - - - name: Install uv - uses: astral-sh/setup-uv@v3 - with: - enable-cache: true - version: 0.9.5 - - - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV - - uses: actions/cache@v4 - with: - key: mkdocs-material-${{ env.cache_id }} - path: .cache - restore-keys: | - mkdocs-material- - - - run: uv sync --frozen --group docs - - run: uv run --frozen --no-sync mkdocs gh-deploy --force diff --git a/.github/workflows/shared.yml b/.github/workflows/shared.yml index 531487db5a..16ba250cde 100644 --- a/.github/workflows/shared.yml +++ b/.github/workflows/shared.yml @@ -35,7 +35,7 @@ jobs: continue-on-error: true strategy: matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] dep-resolution: - name: lowest-direct install-flags: "--upgrade --resolution lowest-direct" @@ -61,7 +61,7 @@ jobs: uv run --frozen --no-sync coverage combine uv run --frozen --no-sync coverage report - readme-snippets: + doc-snippets: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 @@ -74,5 +74,5 @@ jobs: - name: Install dependencies run: uv sync --frozen --all-extras --python 3.10 - - name: Check README snippets are up to date - run: uv run --frozen scripts/update_readme_snippets.py --check + - name: Check doc snippets are up to date + run: uv run --frozen scripts/update_doc_snippets.py --check diff --git a/.github/workflows/weekly-lockfile-update.yml b/.github/workflows/weekly-lockfile-update.yml new file mode 100644 index 0000000000..c44eaf5aca --- /dev/null +++ b/.github/workflows/weekly-lockfile-update.yml @@ -0,0 +1,40 @@ +name: Weekly Lockfile Update + +on: + workflow_dispatch: + schedule: + # Every Thursday at 8:00 UTC + - cron: "0 8 * * 4" + +permissions: + contents: write + pull-requests: write + +jobs: + update-lockfile: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1 + with: + version: 0.9.5 + + - name: Update lockfile + run: | + echo '## Updated Dependencies' > pr_body.md + echo '' >> pr_body.md + echo '```' >> pr_body.md + uv lock --upgrade 2>&1 | tee -a pr_body.md + echo '```' >> pr_body.md + + - name: Create pull request + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v7 + with: + commit-message: "chore: update uv.lock with latest dependencies" + title: "chore: weekly dependency update" + body-path: pr_body.md + branch: weekly-lockfile-update-v1x + delete-branch: true + add-paths: uv.lock + labels: dependencies diff --git a/.gitignore b/.gitignore index 2478cac4b3..348785e4e1 100644 --- a/.gitignore +++ b/.gitignore @@ -143,6 +143,7 @@ venv.bak/ # mkdocs documentation /site +/.worktrees/ # mypy .mypy_cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c06b9028da..e0d56a22d3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -55,9 +55,9 @@ repos: language: system files: ^(pyproject\.toml|uv\.lock)$ pass_filenames: false - - id: readme-snippets - name: Check README snippets are up to date - entry: uv run --frozen python scripts/update_readme_snippets.py --check + - id: doc-snippets + name: Check doc snippets are up to date + entry: uv run --frozen python scripts/update_doc_snippets.py --check language: system - files: ^(README\.md|examples/.*\.py|scripts/update_readme_snippets\.py)$ + files: ^(README\.md|docs/.*\.md|examples/.*\.py|scripts/update_doc_snippets\.py)$ pass_filenames: false diff --git a/CLAUDE.md b/CLAUDE.md index cc2d360602..986e64d554 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,6 +24,11 @@ This document contains critical information about working with this codebase. Fo - Coverage: test edge cases and errors - New features require tests - Bug fixes require regression tests + - Avoid `anyio.sleep()` with a fixed duration to wait for async operations. Instead: + - Use `anyio.Event` — set it in the callback/handler, `await event.wait()` in the test + - For stream messages, use `await stream.receive()` instead of `sleep()` + `receive_nowait()` + - Exception: `sleep()` is appropriate when testing time-based features (e.g., timeouts) + - Wrap indefinite waits (`event.wait()`, `stream.receive()`) in `anyio.fail_after(5)` to prevent hangs - For commits fixing bugs or adding features based on user reports add: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c18937f5b3..2312c9465c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,6 +64,28 @@ pre-commit run --all-files 9. Submit a pull request to the same branch you branched from +## Dependency Update Policy + +See [DEPENDENCY_POLICY.md](DEPENDENCY_POLICY.md) for the full dependency update policy. + +When bumping a dependency version manually, update the constraint in `pyproject.toml` then run `uv lock --resolution lowest-direct` (see [RELEASE.md](RELEASE.md)). + +Security-relevant dependency updates (P0) are applied within 7 days of public disclosure and backported to active release branches. + +The SDK currently supports Python 3.10 through 3.14. New CPython releases are supported within one minor SDK release of their stable release date. + +## Triage Process + +New issues are triaged by a maintainer within 2 business days. Triage means adding an appropriate label and determining whether the issue is valid. + +Issues are labeled per the [SDK Tiering System](https://modelcontextprotocol.io/community/sdk-tiers): + +- **Type** (pick one): `bug`, `enhancement`, `question` +- **Status** (pick one): `needs confirmation`, `needs repro`, `ready for work`, `good first issue`, `help wanted` +- **Priority** (if actionable): `P0`, `P1`, `P2`, `P3` + +P0 issues are security vulnerabilities (CVSS ≥ 7.0) or core functionality failures that prevent basic MCP operations (connection establishment, message exchange, or use of core primitives). P0 issues must be resolved within 7 days. + ## Code Style - We use `ruff` for linting and formatting diff --git a/DEPENDENCY_POLICY.md b/DEPENDENCY_POLICY.md new file mode 100644 index 0000000000..7db632e7f0 --- /dev/null +++ b/DEPENDENCY_POLICY.md @@ -0,0 +1,30 @@ +# Dependency Policy + +As a library consumed by downstream projects, the MCP Python SDK takes a conservative approach to dependency updates. Dependencies are kept stable unless there is a specific reason to update, such as a security vulnerability, a bug fix, or a need for new functionality. + +## Update Triggers + +Dependencies are updated when: + +- A **security vulnerability** is disclosed (via GitHub security alerts or PyPI advisories) in a dependency that directly affects the SDK's functionality or its consumers. +- A bug in a dependency directly affects the SDK. +- A new dependency feature is needed for SDK development. +- A dependency drops support for a Python version the SDK still targets. + +Routine version bumps without a clear motivation are avoided to minimize churn for downstream consumers. + +## What We Don't Do + +The SDK does not run ad-hoc version bumps for PyPI dependencies. Updating a dependency can force downstream consumers to adopt that update transitively, which can be disruptive for projects with strict dependency policies. + +Dependencies are only updated when there is a concrete reason, not simply because a newer version is available. + +## Automated Tooling + +- **Lockfile refresh**: The lockfile is updated automatically every Thursday at 08:00 UTC by the [`weekly-lockfile-update.yml`](.github/workflows/weekly-lockfile-update.yml) workflow, which runs `uv lock --upgrade` and opens a PR. This does not alter the minimum or maximum versions for dependencies of the `mcp` package itself. +- **GitHub security updates** are enabled at the repository level and automatically open pull requests for packages with known vulnerabilities. This is a GitHub repo setting, separate from the `dependabot.yml` configuration. +- **GitHub Actions versions** are kept up to date via Dependabot on a monthly schedule (see `.github/dependabot.yml`). + +## Pinning and Ranges + +Production dependencies use compatible-release specifiers (`~=`) or lower-bound constraints (`>=`) to allow compatible updates. Exact versions are pinned only when necessary to work around a specific issue. The lockfile (`uv.lock`) records exact resolved versions for reproducible installs. diff --git a/README.md b/README.md index e7a6e955b9..9eaef01af8 100644 --- a/README.md +++ b/README.md @@ -13,57 +13,20 @@ +> **This documents v1.x, the stable release line of the MCP Python SDK. v2 is in alpha.** +> +> v2 pre-releases are published to PyPI as `2.0.0aN`. Installers never select a pre-release unless you opt in (for example `pip install mcp==2.0.0a1`), so v1.x users are unaffected. **If your package depends on `mcp`, add a `<2` upper bound to your version constraint (for example `mcp>=1.27,<2`) before the stable v2 release lands.** See the [v2 documentation](https://github.com/modelcontextprotocol/python-sdk/blob/main/README.v2.md) and the [migration guide](https://github.com/modelcontextprotocol/python-sdk/blob/main/docs/migration.md) for what's changing. +> +> v1.x remains recommended for production use. It is in maintenance mode and continues to receive critical bug fixes and security patches. + ## 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) - - [FastMCP Properties](#fastmcp-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) @@ -174,7 +137,7 @@ if __name__ == "__main__": mcp.run(transport="streamable-http") ``` -_Full example: [examples/snippets/servers/fastmcp_quickstart.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/fastmcp_quickstart.py)_ +_Full example: [examples/snippets/servers/fastmcp_quickstart.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/fastmcp_quickstart.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: @@ -206,2347 +169,14 @@ The [Model Context Protocol (MCP)](https://modelcontextprotocol.io) lets you bui - Define interaction patterns through **Prompts** (reusable templates for LLM interactions) - And more! -## Core Concepts - -### Server - -The FastMCP 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.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession - - -# 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: FastMCP) -> 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 = FastMCP("My App", lifespan=app_lifespan) - - -# Access type-safe lifespan context in tools -@mcp.tool() -def query_db(ctx: Context[ServerSession, 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.fastmcp import FastMCP - -mcp = FastMCP(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.fastmcp import FastMCP - -mcp = FastMCP(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 FastMCP framework and provides access to MCP capabilities: - - -```python -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession - -mcp = FastMCP(name="Progress Example") - - -@mcp.tool() -async def long_running_task(task_name: str, ctx: Context[ServerSession, None], steps: int = 5) -> str: - """Execute a task with progress updates.""" - await ctx.info(f"Starting: {task_name}") - - 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}") - - 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 FastMCP 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 pydantic import BaseModel - -from mcp.server.fastmcp import FastMCP -from mcp.types import CallToolResult, TextContent - -mcp = FastMCP("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")], - structuredContent={"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.fastmcp import FastMCP - -mcp = FastMCP("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.fastmcp import FastMCP -from mcp.server.fastmcp.prompts import base - -mcp = FastMCP(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.fastmcp import FastMCP, Icon - -# Create an icon from a file path or URL -icon = Icon( - src="icon.png", - mimeType="image/png", - sizes="64x64" -) - -# Add icons to server -mcp = FastMCP( - "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/fastmcp/icons_demo.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/fastmcp/icons_demo.py)_ - -### Images - -FastMCP provides an `Image` class that automatically handles image data: - - -```python -"""Example showing image handling with FastMCP.""" - -from PIL import Image as PILImage - -from mcp.server.fastmcp import FastMCP, Image - -mcp = FastMCP("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.fastmcp import Context, FastMCP - -mcp = FastMCP(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) -``` - -#### 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.fastmcp` - Access to the FastMCP server instance (see [FastMCP Properties](#fastmcp-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(message)` - Send debug log message -- `await ctx.info(message)` - Send info log message -- `await ctx.warning(message)` - Send warning log message -- `await ctx.error(message)` - Send error log message -- `await ctx.log(level, message, 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.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession - -mcp = FastMCP(name="Progress Example") - - -@mcp.tool() -async def long_running_task(task_name: str, ctx: Context[ServerSession, None], steps: int = 5) -> str: - """Execute a task with progress updates.""" - await ctx.info(f"Starting: {task_name}") - - 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}") - - 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)_ - - -### Completions - -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: - -Client usage: - - -```python -""" -cd to the `examples/snippets` directory and run: - uv run completion-client -""" - -import asyncio -import os - -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client -from mcp.types import PromptReference, ResourceTemplateReference - -# Create server parameters for stdio connection -server_params = StdioServerParameters( - 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", "")}, -) - - -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() - - # List available resource templates - templates = await session.list_resource_templates() - print("Available resource templates:") - for template in templates.resourceTemplates: - print(f" - {template.uriTemplate}") - - # List available prompts - prompts = await session.list_prompts() - print("\nAvailable prompts:") - for prompt in prompts.prompts: - print(f" - {prompt.name}") - - # Complete resource template arguments - if templates.resourceTemplates: - template = templates.resourceTemplates[0] - print(f"\nCompleting arguments for resource template: {template.uriTemplate}") - - # Complete without context - result = await session.complete( - ref=ResourceTemplateReference(type="ref/resource", uri=template.uriTemplate), - 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.uriTemplate), - 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}") - - 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}") - - -def main(): - """Entry point for the completion client.""" - asyncio.run(run()) - - -if __name__ == "__main__": - main() -``` - -_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 pydantic import BaseModel, Field - -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession -from mcp.shared.exceptions import UrlElicitationRequiredError -from mcp.types import ElicitRequestURLParams - -mcp = FastMCP(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[ServerSession, None]) -> 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[ServerSession, None]) -> 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[ServerSession, None]) -> 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}", - elicitationId=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) -- `validation_error`: Any validation error message - -### Sampling - -Tools can interact with LLMs through sampling (generating text): - - -```python -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession -from mcp.types import SamplingMessage, TextContent - -mcp = FastMCP(name="Sampling Example") - - -@mcp.tool() -async def generate_poem(topic: str, ctx: Context[ServerSession, None]) -> str: - """Generate a poem using LLM sampling.""" - prompt = f"Write a short poem about {topic}" - - result = await ctx.session.create_message( - 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.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession - -mcp = FastMCP(name="Notifications Example") - - -@mcp.tool() -async def process_data(data: str, ctx: Context[ServerSession, None]) -> str: - """Process data with logging.""" - # Different log levels - await ctx.debug(f"Debug: Processing '{data}'") - await ctx.info("Info: Starting processing") - await ctx.warning("Warning: This is experimental") - await ctx.error("Error: (This is just a demo)") - - # 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.fastmcp import FastMCP - - -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 FastMCP instance as a Resource Server -mcp = FastMCP( - "Weather Service", - json_response=True, - # 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") -``` - -_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/`](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](src/mcp/server/auth/provider.py) for more details on implementing token validation. - -### FastMCP Properties - -The FastMCP server instance accessible via `ctx.fastmcp` provides access to server configuration and metadata: - -- `ctx.fastmcp.name` - The server's name as defined during initialization -- `ctx.fastmcp.instructions` - Server instructions/description provided to clients -- `ctx.fastmcp.website_url` - Optional website URL for the server -- `ctx.fastmcp.icons` - Optional list of icons for UI display -- `ctx.fastmcp.settings` - Complete server configuration object containing: - - `debug` - Debug mode flag - - `log_level` - Current logging level - - `host` and `port` - Server network configuration - - `mount_path`, `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.fastmcp.name, - "instructions": ctx.fastmcp.instructions, - "debug_mode": ctx.fastmcp.settings.debug, - "log_level": ctx.fastmcp.settings.log_level, - "host": ctx.fastmcp.settings.host, - "port": ctx.fastmcp.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 -- `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: - - `progressToken` - Token for progress notifications - - Other client-provided metadata -- `ctx.request_context.request` - The original MCP request object for advanced processing -- `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.fastmcp import FastMCP - -mcp = FastMCP("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 FastMCP 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.fastmcp import FastMCP - -# Stateless server with JSON responses (recommended) -mcp = FastMCP("StatelessServer", stateless_http=True, json_response=True) - -# Other configuration options: -# Stateless server with SSE streaming responses -# mcp = FastMCP("StatelessServer", stateless_http=True) - -# Stateful server with session persistence -# mcp = FastMCP("StatefulServer") - - -# 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 -if __name__ == "__main__": - 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 FastMCP 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.fastmcp import FastMCP - -# Create the Echo server -echo_mcp = FastMCP(name="EchoServer", stateless_http=True, json_response=True) - - -@echo_mcp.tool() -def echo(message: str) -> str: - """A simple echo tool""" - return f"Echo: {message}" - - -# Create the Math server -math_mcp = FastMCP(name="MathServer", stateless_http=True, json_response=True) - - -@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()), - Mount("/math", math_mcp.streamable_http_app()), - ], - 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.settings.streamable_http_path = "/" -# math_mcp.settings.streamable_http_path = "/" -``` - -_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/`](examples/servers/simple-streamablehttp/) -- Stateless server: [`examples/servers/simple-streamablehttp-stateless/`](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.fastmcp import FastMCP - -# Create MCP server -mcp = FastMCP("My App", json_response=True) - - -@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 -app = Starlette( - routes=[ - Mount("/", app=mcp.streamable_http_app()), - ], - 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.fastmcp import FastMCP - -# Create MCP server -mcp = FastMCP("MCP Host App", json_response=True) - - -@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 -app = Starlette( - routes=[ - Host("mcp.acme.corp", app=mcp.streamable_http_app()), - ], - 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.fastmcp import FastMCP - -# Create multiple MCP servers -api_mcp = FastMCP("API Server", json_response=True) -chat_mcp = FastMCP("Chat Server", json_response=True) - - -@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}" - - -# Configure servers to mount at the root of each path -# This means endpoints will be at /api and /chat instead of /api/mcp and /chat/mcp -api_mcp.settings.streamable_http_path = "/" -chat_mcp.settings.streamable_http_path = "/" - - -# 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 -app = Starlette( - routes=[ - Mount("/api", app=api_mcp.streamable_http_app()), - Mount("/chat", app=chat_mcp.streamable_http_app()), - ], - 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 during FastMCP initialization. - -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.fastmcp import FastMCP - -# Configure streamable_http_path during initialization -# This server will mount at the root of wherever it's mounted -mcp_at_root = FastMCP( - "My Server", - json_response=True, - streamable_http_path="/", -) - - -@mcp_at_root.tool() -def process_data(data: str) -> str: - """Process some data""" - return f"Processed: {data}" - - -# Mount at /process - endpoints will be at /process instead of /process/mcp -app = Starlette( - routes=[ - Mount("/process", app=mcp_at_root.streamable_http_app()), - ] -) -``` - -_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.fastmcp import FastMCP - - -mcp = FastMCP("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())) -``` - -When mounting multiple MCP servers under different paths, you can configure the mount path in several ways: - -```python -from starlette.applications import Starlette -from starlette.routing import Mount -from mcp.server.fastmcp import FastMCP - -# Create multiple MCP servers -github_mcp = FastMCP("GitHub API") -browser_mcp = FastMCP("Browser") -curl_mcp = FastMCP("Curl") -search_mcp = FastMCP("Search") - -# Method 1: Configure mount paths via settings (recommended for persistent configuration) -github_mcp.settings.mount_path = "/github" -browser_mcp.settings.mount_path = "/browser" - -# Method 2: Pass mount path directly to sse_app (preferred for ad-hoc mounting) -# This approach doesn't modify the server's settings permanently - -# Create Starlette app with multiple mounted servers -app = Starlette( - routes=[ - # Using settings-based configuration - Mount("/github", app=github_mcp.sse_app()), - Mount("/browser", app=browser_mcp.sse_app()), - # Using direct mount path parameter - Mount("/curl", app=curl_mcp.sse_app("/curl")), - Mount("/search", app=search_mcp.sse_app("/search")), - ] -) - -# Method 3: For direct execution, you can also pass the mount path to run() -if __name__ == "__main__": - search_mcp.run(transport="sse", mount_path="/search") -``` - -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 Any - -import mcp.server.stdio -import mcp.types as types -from mcp.server.lowlevel import NotificationOptions, Server -from mcp.server.models import InitializationOptions - - -# 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}] - - -@asynccontextmanager -async def server_lifespan(_server: Server) -> AsyncIterator[dict[str, Any]]: - """Manage server startup and shutdown lifecycle.""" - # Initialize resources on startup - db = await Database.connect() - try: - yield {"db": db} - finally: - # Clean up on shutdown - await db.disconnect() - - -# Pass lifespan to server -server = Server("example-server", lifespan=server_lifespan) - - -@server.list_tools() -async def handle_list_tools() -> list[types.Tool]: - """List available tools.""" - return [ - types.Tool( - name="query_db", - description="Query the database", - inputSchema={ - "type": "object", - "properties": {"query": {"type": "string", "description": "SQL query to execute"}}, - "required": ["query"], - }, - ) - ] - - -@server.call_tool() -async def query_db(name: str, arguments: dict[str, Any]) -> list[types.TextContent]: - """Handle database query tool call.""" - if name != "query_db": - raise ValueError(f"Unknown tool: {name}") - - # Access lifespan context - ctx = server.request_context - db = ctx.lifespan_context["db"] - - # Execute query - results = await db.query(arguments["query"]) - - return [types.TextContent(type="text", text=f"Query results: {results}")] - - -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, - InitializationOptions( - server_name="example-server", - server_version="0.1.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), - ) - - -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.server.stdio -import mcp.types as types -from mcp.server.lowlevel import NotificationOptions, Server -from mcp.server.models import InitializationOptions - -# Create a server instance -server = Server("example-server") - - -@server.list_prompts() -async def handle_list_prompts() -> list[types.Prompt]: - """List available prompts.""" - return [ - types.Prompt( - name="example-prompt", - description="An example prompt template", - arguments=[types.PromptArgument(name="arg1", description="Example argument", required=True)], - ) - ] - - -@server.get_prompt() -async def handle_get_prompt(name: str, arguments: dict[str, str] | None) -> types.GetPromptResult: - """Get a specific prompt by name.""" - if name != "example-prompt": - raise ValueError(f"Unknown prompt: {name}") - - arg1_value = (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}"), - ) - ], - ) - - -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, - InitializationOptions( - server_name="example", - server_version="0.1.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), - ) - - -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 -from typing import Any - -import mcp.server.stdio -import mcp.types as types -from mcp.server.lowlevel import NotificationOptions, Server -from mcp.server.models import InitializationOptions - -server = Server("example-server") - - -@server.list_tools() -async def list_tools() -> list[types.Tool]: - """List available tools with structured output schemas.""" - return [ - types.Tool( - name="get_weather", - description="Get current weather for a city", - inputSchema={ - "type": "object", - "properties": {"city": {"type": "string", "description": "City name"}}, - "required": ["city"], - }, - outputSchema={ - "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"], - }, - ) - ] - - -@server.call_tool() -async def call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]: - """Handle tool calls with structured output.""" - if name == "get_weather": - city = arguments["city"] - - # Simulated weather data - in production, call a weather API - weather_data = { - "temperature": 22.5, - "condition": "partly cloudy", - "humidity": 65, - "city": city, # Include the requested city - } - - # low-level server will validate structured output against the tool's - # output schema, and additionally serialize it into a TextContent block - # for backwards compatibility with pre-2025-06-18 clients. - return weather_data - else: - raise ValueError(f"Unknown tool: {name}") - - -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, - InitializationOptions( - server_name="structured-output-example", - server_version="0.1.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), - ) - - -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)_ - - -Tools can return data in four ways: - -1. **Content only**: Return a list of content blocks (default behavior before spec revision 2025-06-18) -2. **Structured data only**: Return a dictionary that will be serialized to JSON (Introduced in spec revision 2025-06-18) -3. **Both**: Return a tuple of (content, structured_data) preferred option to use for backwards compatibility -4. **Direct CallToolResult**: Return `CallToolResult` directly for full control (including `_meta` field) - -When an `outputSchema` is defined, the server automatically validates the structured output against the schema. This ensures type safety and helps catch errors early. - -##### Returning CallToolResult Directly - -For full control over the response including the `_meta` field (for passing data to client applications without exposing it to the model), return `CallToolResult` directly: - - -```python -""" -Run from the repository root: - uv run examples/snippets/servers/lowlevel/direct_call_tool_result.py -""" - -import asyncio -from typing import Any - -import mcp.server.stdio -import mcp.types as types -from mcp.server.lowlevel import NotificationOptions, Server -from mcp.server.models import InitializationOptions - -server = Server("example-server") - - -@server.list_tools() -async def list_tools() -> list[types.Tool]: - """List available tools.""" - return [ - types.Tool( - name="advanced_tool", - description="Tool with full control including _meta field", - inputSchema={ - "type": "object", - "properties": {"message": {"type": "string"}}, - "required": ["message"], - }, - ) - ] - - -@server.call_tool() -async def handle_call_tool(name: str, arguments: dict[str, Any]) -> types.CallToolResult: - """Handle tool calls by returning CallToolResult directly.""" - if name == "advanced_tool": - message = str(arguments.get("message", "")) - return types.CallToolResult( - content=[types.TextContent(type="text", text=f"Processed: {message}")], - structuredContent={"result": "success", "message": message}, - _meta={"hidden": "data for client applications only"}, - ) - - raise ValueError(f"Unknown tool: {name}") - - -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, - InitializationOptions( - server_name="example", - server_version="0.1.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), - ) - - -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)_ - - -**Note:** When returning `CallToolResult`, you bypass the automatic content/structured conversion. You must construct the complete response yourself. - -### 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 MCP server decorators. -""" - -from pydantic import AnyUrl - -import mcp.types as types -from mcp.server.lowlevel import Server - -# Initialize the server -server = Server("paginated-server") - -# Sample data to paginate -ITEMS = [f"Item {i}" for i in range(1, 101)] # 100 items - - -@server.list_resources() -async def list_resources_paginated(request: types.ListResourcesRequest) -> types.ListResourcesResult: - """List resources with pagination support.""" - page_size = 10 - - # Extract cursor from request params - cursor = request.params.cursor if request.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=AnyUrl(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, nextCursor=next_cursor) -``` - -_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.client.session import ClientSession -from mcp.client.stdio import StdioServerParameters, stdio_client -from mcp.types import PaginatedRequestParams, Resource - - -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.nextCursor: - cursor = result.nextCursor - 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](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 - -from pydantic import AnyUrl - -from mcp import ClientSession, StdioServerParameters, types -from mcp.client.stdio import stdio_client -from mcp.shared.context import RequestContext - -# Create server parameters for stdio connection -server_params = StdioServerParameters( - command="uv", # Using uv to run the server - args=["run", "server", "fastmcp_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: RequestContext[ClientSession, None], 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", - stopReason="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 fastmcp_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 fastmcp_quickstart) - resource_content = await session.read_resource(AnyUrl("greeting://World")) - content_block = resource_content.contents[0] - if isinstance(content_block, types.TextContent): - print(f"Resource content: {content_block.text}") - - # Call a tool (add tool from fastmcp_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.structuredContent - 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", "fastmcp_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.resourceTemplates: - 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 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() -> tuple[str, str | None]: - callback_url = input("Paste callback URL: ") - params = parse_qs(urlparse(callback_url).query) - return params["code"][0], params.get("state", [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/`](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 - -from mcp import ClientSession, StdioServerParameters, types -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, "structuredContent") and result.structuredContent: - # Access structured data directly - user_data = result.structuredContent - 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}") - elif isinstance(resource, types.BlobResourceContents): - 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.mimeType}): {len(content.data)} bytes") - - # Example 5: Handling errors - result = await session.call_tool("failing_tool", {}) - if result.isError: - 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()) -``` - -### 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 +- [Building Servers](docs/server.md) -- tools, resources, prompts, logging, completions, sampling, elicitation, transports, ASGI mounting +- [Writing Clients](docs/client.md) -- connecting to servers, using tools/resources/prompts, display utilities +- [Authorization](docs/authorization.md) -- OAuth 2.1, token verification, client authentication +- [Low-Level Server](docs/low-level-server.md) -- direct handler registration for advanced use cases +- [Protocol Features](docs/protocol.md) -- MCP primitives, server capabilities +- [Testing](docs/testing.md) -- in-memory transport testing with pytest - [API Reference](https://modelcontextprotocol.github.io/python-sdk/api/) - [Experimental Features (Tasks)](https://modelcontextprotocol.github.io/python-sdk/experimental/tasks/) - [Model Context Protocol documentation](https://modelcontextprotocol.io) diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000000..9f9bb31e0c --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,22 @@ +# Roadmap + +## Spec Implementation Tracking + +The SDK tracks implementation of MCP spec components via GitHub Projects, with a dedicated project board for each spec revision. For example, see the [2025-11-25 spec revision board](https://github.com/orgs/modelcontextprotocol/projects/26). + +## Current Focus Areas + +### Next Spec Revision + +The next MCP specification revision is being developed in the [protocol repository](https://github.com/modelcontextprotocol/modelcontextprotocol). Key areas expected in the next revision include extensions and stateless transports. + +The SDK has historically implemented spec changes promptly as they are finalized, with dedicated project boards tracking component-level progress for each revision. + +### v2 + +A major version of the SDK is in active development, tracked via [GitHub Project](https://github.com/orgs/modelcontextprotocol/projects/31). Target milestones: + +- **Alpha**: ~mid-March 2026 +- **Beta**: ~May 2026 + +The v2 release is planned to align with the next spec release, expected around mid-2026. diff --git a/VERSIONING.md b/VERSIONING.md new file mode 100644 index 0000000000..a89d4c3b80 --- /dev/null +++ b/VERSIONING.md @@ -0,0 +1,40 @@ +# Versioning Policy + +The MCP Python SDK (`mcp`) follows [Semantic Versioning 2.0.0](https://semver.org/). + +## Version Format + +`MAJOR.MINOR.PATCH` + +- **MAJOR**: Incremented for breaking changes (see below). +- **MINOR**: Incremented for new features that are backward-compatible. +- **PATCH**: Incremented for backward-compatible bug fixes. + +## What Constitutes a Breaking Change + +The following changes are considered breaking and require a major version bump: + +- Removing or renaming a public API export (class, function, type, or constant). +- Changing the signature of a public function or method in a way that breaks existing callers (removing parameters, changing required/optional status, changing types). +- Removing or renaming a public type or dataclass/TypedDict field. +- Changing the behavior of an existing API in a way that breaks documented contracts. +- Dropping support for a Python version that is still receiving security updates. +- Removing support for a transport type. +- Changes to the MCP protocol version that require client/server code changes. + +The following are **not** considered breaking: + +- Adding new optional parameters to existing functions. +- Adding new exports, types, or classes. +- Adding new optional fields to existing types. +- Bug fixes that correct behavior to match documented intent. +- Internal refactoring that does not affect the public API. +- Adding support for new MCP spec features. +- Changes to dev dependencies or build tooling. + +## How Breaking Changes Are Communicated + +1. **Changelog**: All breaking changes are documented in the GitHub release notes with migration instructions. +2. **Deprecation**: When feasible, APIs are deprecated for at least one minor release before removal using `warnings.warn()` with `DeprecationWarning`, which surfaces warnings at runtime and through static analysis tooling. +3. **Migration guide**: Major version releases include a migration guide describing what changed and how to update. +4. **PR labels**: Pull requests containing breaking changes are labeled with `breaking change`. diff --git a/docs/authorization.md b/docs/authorization.md index 4b6208bdfc..171871ee58 100644 --- a/docs/authorization.md +++ b/docs/authorization.md @@ -1,5 +1,177 @@ # Authorization -!!! warning "Under Construction" +This page covers OAuth 2.1 authentication for both MCP servers and clients. - This page is currently being written. Check back soon for complete documentation. +## Server-Side 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-11-25/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.fastmcp import FastMCP + + +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 FastMCP instance as a Resource Server +mcp = FastMCP( + "Weather Service", + json_response=True, + # 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") +``` + +_Full example: [examples/snippets/servers/oauth_server.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/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/v1.x/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/v1.x/src/mcp/server/auth/provider.py) for more details on implementing token validation. + +## Client-Side Authentication + +The SDK includes [authorization support](https://modelcontextprotocol.io/specification/2025-11-25/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 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() -> tuple[str, str | None]: + callback_url = input("Paste callback URL: ") + params = parse_qs(urlparse(callback_url).query) + return params["code"][0], params.get("state", [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/v1.x/examples/snippets/clients/oauth_client.py)_ + + +For a complete working example, see [`examples/clients/simple-auth-client/`](https://github.com/modelcontextprotocol/python-sdk/tree/v1.x/examples/clients/simple-auth-client). diff --git a/docs/client.md b/docs/client.md new file mode 100644 index 0000000000..77c2729f27 --- /dev/null +++ b/docs/client.md @@ -0,0 +1,410 @@ +# Writing MCP Clients + +The SDK provides a high-level client interface for connecting to MCP servers using various [transports](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports): + + +```python +""" +cd to the `examples/snippets/clients` directory and run: + uv run client +""" + +import asyncio +import os + +from pydantic import AnyUrl + +from mcp import ClientSession, StdioServerParameters, types +from mcp.client.stdio import stdio_client +from mcp.shared.context import RequestContext + +# Create server parameters for stdio connection +server_params = StdioServerParameters( + command="uv", # Using uv to run the server + args=["run", "server", "fastmcp_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: RequestContext[ClientSession, None], 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", + stopReason="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 fastmcp_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 fastmcp_quickstart) + resource_content = await session.read_resource(AnyUrl("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 fastmcp_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.structuredContent + 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/v1.x/examples/snippets/clients/stdio_client.py)_ + + +Clients can also connect using [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-11-25/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/v1.x/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", "fastmcp_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.resourceTemplates: + 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/v1.x/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 OAuth 2.1 client authentication, see [Authorization](authorization.md#client-side-authentication). + +## Roots + +### Listing Roots + +Clients can provide a `list_roots_callback` so that servers can discover the client's workspace roots (directories, project folders, etc.): + + +```python +from mcp import ClientSession, types +from mcp.shared.context import RequestContext + + +async def handle_list_roots( + context: RequestContext[ClientSession, None], +) -> types.ListRootsResult: + """Return the client's workspace roots.""" + return types.ListRootsResult( + roots=[ + types.Root(uri="file:///home/user/project", name="My Project"), + types.Root(uri="file:///home/user/data", name="Data Folder"), + ] + ) + + +# Pass the callback when creating the session +session = ClientSession( + read_stream, + write_stream, + list_roots_callback=handle_list_roots, +) +``` + +_Full example: [examples/snippets/clients/roots_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/clients/roots_example.py)_ + + +When a `list_roots_callback` is provided, the client automatically declares the `roots` capability (with `listChanged=True`) during initialization. + +### Roots Change Notifications + +When the client's workspace roots change (e.g., a folder is added or removed), notify the server: + +```python +# After roots change, notify the server +await session.send_roots_list_changed() +``` + +## SSE Transport (Legacy) + +For servers that use the older SSE transport, use `sse_client()` from `mcp.client.sse`: + + +```python +import asyncio + +from mcp import ClientSession +from mcp.client.sse import sse_client + + +async def main(): + async with sse_client("http://localhost:8000/sse") as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + + tools = await session.list_tools() + print(f"Available tools: {[t.name for t in tools.tools]}") + + +asyncio.run(main()) +``` + +_Full example: [examples/snippets/clients/sse_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/clients/sse_client.py)_ + + +The `sse_client()` function accepts optional `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters. The SSE transport is considered legacy; prefer [Streamable HTTP](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) for new servers. + +## Ping + +Send a ping to verify the server is responsive: + +```python +# After session.initialize() +result = await session.send_ping() +# Returns EmptyResult on success; raises on timeout +``` + +## Logging + +### Receiving Log Messages + +Pass a `logging_callback` to receive log messages from the server: + + +```python +from mcp import ClientSession, types + + +async def handle_log(params: types.LoggingMessageNotificationParams) -> None: + """Handle log messages from the server.""" + print(f"[{params.level}] {params.data}") + + +session = ClientSession( + read_stream, + write_stream, + logging_callback=handle_log, +) +``` + +_Full example: [examples/snippets/clients/logging_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/clients/logging_client.py)_ + + +### Setting the Server Log Level + +Request that the server change its minimum log level: + +```python +await session.set_logging_level("debug") +``` + +The `level` parameter is a `LoggingLevel` string: `"debug"`, `"info"`, `"notice"`, `"warning"`, `"error"`, `"critical"`, `"alert"`, or `"emergency"`. + +## 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 + +from mcp import ClientSession, StdioServerParameters, types +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, "structuredContent") and result.structuredContent: + # Access structured data directly + user_data = result.structuredContent + 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}") + elif isinstance(resource, types.BlobResourceContents): + 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.mimeType}): {len(content.data)} bytes") + + # Example 5: Handling errors + result = await session.call_tool("failing_tool", {}) + if result.isError: + 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()) +``` 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/experimental/index.md b/docs/experimental/index.md index 1d496b3f10..3b7f113ad7 100644 --- a/docs/experimental/index.md +++ b/docs/experimental/index.md @@ -1,12 +1,13 @@ # Experimental Features -!!! warning "Experimental APIs" +!!! warning "Deprecated" - The features in this section are experimental and may change without notice. - They track the evolving MCP specification and are not yet stable. + The experimental tasks API is deprecated and will be removed in mcp 2.0. + Tasks (SEP-1686) were removed from the MCP specification and are expected + to return as a separate MCP extension in a future release. This section documents experimental features in the MCP Python SDK. These features -implement draft specifications that are still being refined. +are deprecated and remain available on the 1.x line only for existing users. ## Available Experimental Features @@ -36,8 +37,10 @@ async def handle_get_task(request: GetTaskRequest) -> GetTaskResult: result = await session.experimental.call_tool_as_task("tool_name", {"arg": "value"}) ``` +Accessing the `.experimental` properties emits a `DeprecationWarning`. + ## Providing Feedback -Since these features are experimental, feedback is especially valuable. If you encounter -issues or have suggestions, please open an issue on the +If you rely on these features and have feedback on their deprecation or the planned +MCP extension, please open an issue on the [python-sdk repository](https://github.com/modelcontextprotocol/python-sdk/issues). diff --git a/docs/experimental/tasks-client.md b/docs/experimental/tasks-client.md index cfd23e4e14..acc8392026 100644 --- a/docs/experimental/tasks-client.md +++ b/docs/experimental/tasks-client.md @@ -1,8 +1,10 @@ # Client Task Usage -!!! warning "Experimental" +!!! warning "Deprecated" - Tasks are an experimental feature. The API may change without notice. + The experimental tasks API is deprecated and will be removed in mcp 2.0. + Tasks (SEP-1686) were removed from the MCP specification and are expected + to return as a separate MCP extension in a future release. This guide covers calling task-augmented tools from clients, handling the `input_required` status, and advanced patterns like receiving task requests from servers. diff --git a/docs/experimental/tasks-server.md b/docs/experimental/tasks-server.md index 761dc5de5c..1760e197be 100644 --- a/docs/experimental/tasks-server.md +++ b/docs/experimental/tasks-server.md @@ -1,8 +1,10 @@ # Server Task Implementation -!!! warning "Experimental" +!!! warning "Deprecated" - Tasks are an experimental feature. The API may change without notice. + The experimental tasks API is deprecated and will be removed in mcp 2.0. + Tasks (SEP-1686) were removed from the MCP specification and are expected + to return as a separate MCP extension in a future release. This guide covers implementing task support in MCP servers, from basic setup to advanced patterns like elicitation and sampling within tasks. @@ -53,6 +55,29 @@ That's it. `enable_tasks()` automatically: - Registers handlers for `tasks/get`, `tasks/result`, `tasks/list`, `tasks/cancel` - Updates server capabilities +## Task Visibility + +Task IDs generated by `run_task()` embed an opaque marker identifying the session that +created the task, and the default handlers use it to restrict each session to its own +tasks: `tasks/get`, `tasks/result`, and `tasks/cancel` respond with "task not found" for +another session's task, and `tasks/list` returns only the requesting session's tasks. A +client that reconnects gets a new session and can no longer reach tasks it created on the +previous one. + +A task ID has no session marker when it was passed to `run_task()` explicitly, when the +task was created directly through the `TaskStore`, or when the server runs in stateless +mode (each request gets a fresh session, so tasks must remain reachable across requests). +Such tasks are accessible to any requestor that presents the exact task ID, and are never +included in `tasks/list` responses because the server cannot tell which session they +belong to. Treat these task IDs as capabilities: generate them with enough entropy that +they cannot be guessed, share them only with the intended recipient, and prefer short +TTLs. Passing an explicit `task_id` to `run_task()` is deprecated for this reason. + +To scope tasks to something other than the session — for example a user identity from your +authorization layer — register your own handlers with `@server.experimental.get_task()`, +`@server.experimental.get_task_result()`, `@server.experimental.list_tasks()`, and +`@server.experimental.cancel_task()` instead of relying on the defaults. + ## Tool Declaration Tools declare task support via the `execution.taskSupport` field: diff --git a/docs/experimental/tasks.md b/docs/experimental/tasks.md index 2d4d06a025..e5dd78fb20 100644 --- a/docs/experimental/tasks.md +++ b/docs/experimental/tasks.md @@ -1,9 +1,10 @@ # Tasks -!!! warning "Experimental" +!!! warning "Deprecated" - Tasks are an experimental feature tracking the draft MCP specification. - The API may change without notice. + The experimental tasks API is deprecated and will be removed in mcp 2.0. + Tasks (SEP-1686) were removed from the MCP specification and are expected + to return as a separate MCP extension in a future release. Tasks enable asynchronous request handling in MCP. Instead of blocking until an operation completes, the receiver creates a task, returns immediately, and the requestor polls for the result. diff --git a/docs/index.md b/docs/index.md index eb5ddf4000..48f3ace1ce 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,5 +1,8 @@ # MCP Python SDK +!!! tip "Looking for the upcoming v2?" + See the [v2 development documentation](https://py.sdk.modelcontextprotocol.io/v2/). + 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. This Python SDK implements the full MCP specification, making it easy to: @@ -58,9 +61,11 @@ npx -y @modelcontextprotocol/inspector 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 +2. **[Build servers](server.md)** - tools, resources, prompts, transports, ASGI mounting +3. **[Write clients](client.md)** - connect to servers, use tools/resources/prompts +4. **[Explore authorization](authorization.md)** - add security to your servers +5. **[Use low-level APIs](low-level-server.md)** - for advanced customization +6. **[Protocol features](protocol.md)** - MCP primitives, server capabilities ## API Reference diff --git a/docs/low-level-server.md b/docs/low-level-server.md index a5b4f3df33..27547e7956 100644 --- a/docs/low-level-server.md +++ b/docs/low-level-server.md @@ -1,5 +1,490 @@ # Low-Level Server -!!! warning "Under Construction" +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. - This page is currently being written. Check back soon for complete documentation. +## Lifespan + + +```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 Any + +import mcp.server.stdio +import mcp.types as types +from mcp.server.lowlevel import NotificationOptions, Server +from mcp.server.models import InitializationOptions + + +# 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}] + + +@asynccontextmanager +async def server_lifespan(_server: Server) -> AsyncIterator[dict[str, Any]]: + """Manage server startup and shutdown lifecycle.""" + # Initialize resources on startup + db = await Database.connect() + try: + yield {"db": db} + finally: + # Clean up on shutdown + await db.disconnect() + + +# Pass lifespan to server +server = Server("example-server", lifespan=server_lifespan) + + +@server.list_tools() +async def handle_list_tools() -> list[types.Tool]: + """List available tools.""" + return [ + types.Tool( + name="query_db", + description="Query the database", + inputSchema={ + "type": "object", + "properties": {"query": {"type": "string", "description": "SQL query to execute"}}, + "required": ["query"], + }, + ) + ] + + +@server.call_tool() +async def query_db(name: str, arguments: dict[str, Any]) -> list[types.TextContent]: + """Handle database query tool call.""" + if name != "query_db": + raise ValueError(f"Unknown tool: {name}") + + # Access lifespan context + ctx = server.request_context + db = ctx.lifespan_context["db"] + + # Execute query + results = await db.query(arguments["query"]) + + return [types.TextContent(type="text", text=f"Query results: {results}")] + + +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, + InitializationOptions( + server_name="example-server", + server_version="0.1.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) + + +if __name__ == "__main__": + import asyncio + + asyncio.run(run()) +``` + +_Full example: [examples/snippets/servers/lowlevel/lifespan.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/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 + +## Basic Example + + +```python +""" +Run from the repository root: +uv run examples/snippets/servers/lowlevel/basic.py +""" + +import asyncio + +import mcp.server.stdio +import mcp.types as types +from mcp.server.lowlevel import NotificationOptions, Server +from mcp.server.models import InitializationOptions + +# Create a server instance +server = Server("example-server") + + +@server.list_prompts() +async def handle_list_prompts() -> list[types.Prompt]: + """List available prompts.""" + return [ + types.Prompt( + name="example-prompt", + description="An example prompt template", + arguments=[types.PromptArgument(name="arg1", description="Example argument", required=True)], + ) + ] + + +@server.get_prompt() +async def handle_get_prompt(name: str, arguments: dict[str, str] | None) -> types.GetPromptResult: + """Get a specific prompt by name.""" + if name != "example-prompt": + raise ValueError(f"Unknown prompt: {name}") + + arg1_value = (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}"), + ) + ], + ) + + +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, + InitializationOptions( + server_name="example", + server_version="0.1.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) + + +if __name__ == "__main__": + asyncio.run(run()) +``` + +_Full example: [examples/snippets/servers/lowlevel/basic.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/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 +from typing import Any + +import mcp.server.stdio +import mcp.types as types +from mcp.server.lowlevel import NotificationOptions, Server +from mcp.server.models import InitializationOptions + +server = Server("example-server") + + +@server.list_tools() +async def list_tools() -> list[types.Tool]: + """List available tools with structured output schemas.""" + return [ + types.Tool( + name="get_weather", + description="Get current weather for a city", + inputSchema={ + "type": "object", + "properties": {"city": {"type": "string", "description": "City name"}}, + "required": ["city"], + }, + outputSchema={ + "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"], + }, + ) + ] + + +@server.call_tool() +async def call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]: + """Handle tool calls with structured output.""" + if name == "get_weather": + city = arguments["city"] + + # Simulated weather data - in production, call a weather API + weather_data = { + "temperature": 22.5, + "condition": "partly cloudy", + "humidity": 65, + "city": city, # Include the requested city + } + + # low-level server will validate structured output against the tool's + # output schema, and additionally serialize it into a TextContent block + # for backwards compatibility with pre-2025-06-18 clients. + return weather_data + else: + raise ValueError(f"Unknown tool: {name}") + + +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, + InitializationOptions( + server_name="structured-output-example", + server_version="0.1.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) + + +if __name__ == "__main__": + asyncio.run(run()) +``` + +_Full example: [examples/snippets/servers/lowlevel/structured_output.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/lowlevel/structured_output.py)_ + + +Tools can return data in four ways: + +1. **Content only**: Return a list of content blocks (default behavior before spec revision 2025-06-18) +2. **Structured data only**: Return a dictionary that will be serialized to JSON (Introduced in spec revision 2025-06-18) +3. **Both**: Return a tuple of (content, structured_data) preferred option to use for backwards compatibility +4. **Direct CallToolResult**: Return `CallToolResult` directly for full control (including `_meta` field) + +When an `outputSchema` is defined, the server automatically validates the structured output against the schema. This ensures type safety and helps catch errors early. + +### Returning CallToolResult Directly + +For full control over the response including the `_meta` field (for passing data to client applications without exposing it to the model), return `CallToolResult` directly: + + +```python +""" +Run from the repository root: + uv run examples/snippets/servers/lowlevel/direct_call_tool_result.py +""" + +import asyncio +from typing import Any + +import mcp.server.stdio +import mcp.types as types +from mcp.server.lowlevel import NotificationOptions, Server +from mcp.server.models import InitializationOptions + +server = Server("example-server") + + +@server.list_tools() +async def list_tools() -> list[types.Tool]: + """List available tools.""" + return [ + types.Tool( + name="advanced_tool", + description="Tool with full control including _meta field", + inputSchema={ + "type": "object", + "properties": {"message": {"type": "string"}}, + "required": ["message"], + }, + ) + ] + + +@server.call_tool() +async def handle_call_tool(name: str, arguments: dict[str, Any]) -> types.CallToolResult: + """Handle tool calls by returning CallToolResult directly.""" + if name == "advanced_tool": + message = str(arguments.get("message", "")) + return types.CallToolResult( + content=[types.TextContent(type="text", text=f"Processed: {message}")], + structuredContent={"result": "success", "message": message}, + _meta={"hidden": "data for client applications only"}, + ) + + raise ValueError(f"Unknown tool: {name}") + + +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, + InitializationOptions( + server_name="example", + server_version="0.1.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) + + +if __name__ == "__main__": + asyncio.run(run()) +``` + +_Full example: [examples/snippets/servers/lowlevel/direct_call_tool_result.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/lowlevel/direct_call_tool_result.py)_ + + +**Note:** When returning `CallToolResult`, you bypass the automatic content/structured conversion. You must construct the complete response yourself. + +## 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 MCP server decorators. +""" + +from pydantic import AnyUrl + +import mcp.types as types +from mcp.server.lowlevel import Server + +# Initialize the server +server = Server("paginated-server") + +# Sample data to paginate +ITEMS = [f"Item {i}" for i in range(1, 101)] # 100 items + + +@server.list_resources() +async def list_resources_paginated(request: types.ListResourcesRequest) -> types.ListResourcesResult: + """List resources with pagination support.""" + page_size = 10 + + # Extract cursor from request params + cursor = request.params.cursor if request.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=AnyUrl(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, nextCursor=next_cursor) +``` + +_Full example: [examples/snippets/servers/pagination_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/pagination_example.py)_ + + +### Client-side Consumption + + +```python +""" +Example of consuming paginated MCP endpoints from a client. +""" + +import asyncio + +from mcp.client.session import ClientSession +from mcp.client.stdio import StdioServerParameters, stdio_client +from mcp.types import PaginatedRequestParams, Resource + + +async def list_all_resources() -> None: + """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.nextCursor: + cursor = result.nextCursor + 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/v1.x/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/v1.x/examples/servers/simple-pagination) for a complete implementation. diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000000..2c4604d8ce --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,153 @@ +# Protocol Features + +This page covers cross-cutting MCP protocol features. + +## 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 | + +## Ping + +Both clients and servers can send ping requests to check that the other side is responsive: + +```python +# From a client +result = await session.send_ping() + +# From a server (via ServerSession) +result = await server_session.send_ping() +``` + +Both return an `EmptyResult` on success. If the remote side does not respond within the session timeout, an exception is raised. + +## Cancellation + +Either side can cancel a previously-issued request by sending a `CancelledNotification`: + + +```python +import mcp.types as types +from mcp import ClientSession + + +async def cancel_request(session: ClientSession) -> None: + """Send a cancellation notification for a previously-issued request.""" + await session.send_notification( + types.ClientNotification( + types.CancelledNotification( + params=types.CancelledNotificationParams( + requestId="request-id-to-cancel", + reason="User navigated away", + ) + ) + ) + ) +``` + +_Full example: [examples/snippets/clients/cancellation.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/clients/cancellation.py)_ + + +The `CancelledNotificationParams` fields: + +- `requestId` (optional): The ID of the request to cancel. Required for non-task cancellations. +- `reason` (optional): A human-readable string describing why the request was cancelled. + +## Capability Negotiation + +During initialization, the client and server exchange capability declarations. The Python SDK automatically declares capabilities based on which callbacks and handlers are registered: + +**Client capabilities** (auto-declared when callbacks are provided): + +- `sampling` -- declared when `sampling_callback` is passed to `ClientSession` +- `roots` -- declared when `list_roots_callback` is passed to `ClientSession` +- `elicitation` -- declared when `elicitation_callback` is passed to `ClientSession` + +**Server capabilities** (auto-declared when handlers are registered): + +- `prompts` -- declared when a `list_prompts` handler is registered +- `resources` -- declared when a `list_resources` handler is registered +- `tools` -- declared when a `list_tools` handler is registered +- `logging` -- declared when a `set_logging_level` handler is registered +- `completions` -- declared when a `completion` handler is registered + +After initialization, clients can inspect server capabilities: + +```python +capabilities = session.get_server_capabilities() +if capabilities and capabilities.tools: + tools = await session.list_tools() +``` + +## Protocol Version Negotiation + +The SDK defines `LATEST_PROTOCOL_VERSION` and `SUPPORTED_PROTOCOL_VERSIONS` in `mcp.shared.version`: + +```python +from mcp.shared.version import LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS + +# LATEST_PROTOCOL_VERSION is the version the SDK advertises during initialization +# SUPPORTED_PROTOCOL_VERSIONS lists all versions the SDK can work with +``` + +During initialization, the client sends `LATEST_PROTOCOL_VERSION`. If the server responds with a version not in `SUPPORTED_PROTOCOL_VERSIONS`, the client raises a `RuntimeError`. This ensures both sides agree on a compatible protocol version before exchanging messages. + +## JSON Schema (2020-12) + +MCP uses [JSON Schema 2020-12](https://json-schema.org/draft/2020-12) for tool input schemas, output schemas, and elicitation schemas. When using Pydantic models, schemas are generated automatically via `model_json_schema()`: + + +```python +from pydantic import BaseModel, Field + + +class SearchParams(BaseModel): + query: str = Field(description="Search query string") + max_results: int = Field(default=10, description="Maximum results to return") + + +# Pydantic generates a JSON Schema 2020-12 compatible schema: +schema = SearchParams.model_json_schema() +# { +# "properties": { +# "query": {"description": "Search query string", "type": "string"}, +# "max_results": { +# "default": 10, +# "description": "Maximum results to return", +# "type": "integer", +# }, +# }, +# "required": ["query"], +# "title": "SearchParams", +# "type": "object", +# } +``` + +_Full example: [examples/snippets/servers/json_schema_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/json_schema_example.py)_ + + +For FastMCP tools, input schemas are derived automatically from function signatures. For structured output, the output schema is derived from the return type annotation. + +## Pagination + +For pagination details, see: + +- Server-side implementation: [Low-Level Server - Pagination](low-level-server.md#pagination-advanced) +- Client-side consumption: [Low-Level Server - Client-side Consumption](low-level-server.md#client-side-consumption) diff --git a/docs/server.md b/docs/server.md new file mode 100644 index 0000000000..6340687c31 --- /dev/null +++ b/docs/server.md @@ -0,0 +1,1831 @@ +# Building MCP Servers + +## Core Concepts + +### Server + +The FastMCP 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.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession + + +# 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: FastMCP) -> 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 = FastMCP("My App", lifespan=app_lifespan) + + +# Access type-safe lifespan context in tools +@mcp.tool() +def query_db(ctx: Context[ServerSession, 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/v1.x/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.fastmcp import FastMCP + +mcp = FastMCP(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/v1.x/examples/snippets/servers/basic_resource.py)_ + + +#### Resource Templates and Template Reading + +Resources with URI parameters (e.g., `{name}`) are registered as templates. When a client reads a templated resource, the URI parameters are extracted and passed to the function: + + +```python +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("Template Example") + + +@mcp.resource("users://{user_id}/profile") +def get_user_profile(user_id: str) -> str: + """Read a specific user's profile. The user_id is extracted from the URI.""" + return f'{{"user_id": "{user_id}", "name": "User {user_id}"}}' +``` + +_Full example: [examples/snippets/servers/resource_templates.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/resource_templates.py)_ + + +Clients read a template resource by providing a concrete URI: + +```python +# Client-side: read a template resource with a concrete URI +content = await session.read_resource("users://alice/profile") +``` + +Templates with multiple parameters work the same way: + +```python +@mcp.resource("repos://{owner}/{repo}/readme") +def get_readme(owner: str, repo: str) -> str: + """Each URI parameter becomes a function argument.""" + return f"README for {owner}/{repo}" +``` + +#### Binary Resources + +Resources can return binary data by returning `bytes` instead of `str`. Set the `mime_type` to indicate the content type: + + +```python +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("Binary Resource Example") + + +@mcp.resource("images://logo.png", mime_type="image/png") +def get_logo() -> bytes: + """Return a binary image resource.""" + with open("logo.png", "rb") as f: + return f.read() +``` + +_Full example: [examples/snippets/servers/binary_resources.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/binary_resources.py)_ + + +Binary content is automatically base64-encoded and returned as `BlobResourceContents` in the MCP response. + +#### Resource Subscriptions + +Clients can subscribe to resource updates. Use the low-level server API to handle subscription and unsubscription requests: + + +```python +from mcp.server.lowlevel import Server + +server = Server("Subscription Example") + +subscriptions: dict[str, set[str]] = {} # uri -> set of session ids + + +@server.subscribe_resource() +async def handle_subscribe(uri) -> None: + """Handle a client subscribing to a resource.""" + subscriptions.setdefault(str(uri), set()).add("current_session") + + +@server.unsubscribe_resource() +async def handle_unsubscribe(uri) -> None: + """Handle a client unsubscribing from a resource.""" + if str(uri) in subscriptions: + subscriptions[str(uri)].discard("current_session") +``` + +_Full example: [examples/snippets/servers/resource_subscriptions.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/resource_subscriptions.py)_ + + +When a subscribed resource changes, notify clients with `send_resource_updated()`: + +```python +from pydantic import AnyUrl + +# After modifying resource data: +await session.send_resource_updated(AnyUrl("resource://my-resource")) +``` + +### 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.fastmcp import FastMCP + +mcp = FastMCP(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/v1.x/examples/snippets/servers/basic_tool.py)_ + + +#### Error Handling + +When a tool encounters an error, it should signal this to the client rather than returning a normal result. The MCP protocol uses the `isError` flag on `CallToolResult` to distinguish error responses from successful ones. There are three ways to handle errors: + + +```python +"""Example showing how to handle and return errors from tools.""" + +from mcp.server.fastmcp import FastMCP +from mcp.server.fastmcp.exceptions import ToolError +from mcp.types import CallToolResult, TextContent + +mcp = FastMCP("Tool Error Handling Example") + + +# Option 1: Raise ToolError for expected error conditions. +# The error message is returned to the client with isError=True. +@mcp.tool() +def divide(a: float, b: float) -> float: + """Divide two numbers.""" + if b == 0: + raise ToolError("Cannot divide by zero") + return a / b + + +# Option 2: Unhandled exceptions are automatically caught and +# converted to error responses with isError=True. +@mcp.tool() +def read_config(path: str) -> str: + """Read a configuration file.""" + # If this raises FileNotFoundError, the client receives an + # error response like "Error executing tool read_config: ..." + with open(path) as f: + return f.read() + + +# Option 3: Return CallToolResult directly for full control +# over error responses, including custom content. +@mcp.tool() +def validate_input(data: str) -> CallToolResult: + """Validate input data.""" + errors: list[str] = [] + if len(data) < 3: + errors.append("Input must be at least 3 characters") + if not data.isascii(): + errors.append("Input must be ASCII only") + + if errors: + return CallToolResult( + content=[TextContent(type="text", text="\n".join(errors))], + isError=True, + ) + return CallToolResult( + content=[TextContent(type="text", text="Validation passed")], + ) +``` + +_Full example: [examples/snippets/servers/tool_errors.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/tool_errors.py)_ + + +- **`ToolError`** is the preferred approach for most cases — raise it with a descriptive message and the framework handles the rest. +- **Unhandled exceptions** are caught automatically, so tools won't crash the server. The exception message is forwarded to the client as an error response. +- **`CallToolResult`** with `isError=True` gives full control when you need to customize the error content or include multiple content items. + +Tools can optionally receive a Context object by including a parameter with the `Context` type annotation. This context is automatically injected by the FastMCP framework and provides access to MCP capabilities: + + +```python +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession + +mcp = FastMCP(name="Progress Example") + + +@mcp.tool() +async def long_running_task(task_name: str, ctx: Context[ServerSession, None], steps: int = 5) -> str: + """Execute a task with progress updates.""" + await ctx.info(f"Starting: {task_name}") + + 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}") + + return f"Task '{task_name}' completed" +``` + +_Full example: [examples/snippets/servers/tool_progress.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/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 FastMCP 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 pydantic import BaseModel + +from mcp.server.fastmcp import FastMCP +from mcp.types import CallToolResult, TextContent + +mcp = FastMCP("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")], + structuredContent={"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/v1.x/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.fastmcp import FastMCP + +mcp = FastMCP("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/v1.x/examples/snippets/servers/structured_output.py)_ + + +### Prompts + +Prompts are reusable templates that help LLMs interact with your server effectively: + + +```python +from mcp.server.fastmcp import FastMCP +from mcp.server.fastmcp.prompts import base + +mcp = FastMCP(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/v1.x/examples/snippets/servers/basic_prompt.py)_ + + +#### Prompts with Embedded Resources + +Prompts can include embedded resources to provide file contents or data alongside the conversation messages: + + +```python +import mcp.types as types +from mcp.server.fastmcp import FastMCP +from mcp.server.fastmcp.prompts import base + +mcp = FastMCP("Embedded Resource Prompt Example") + + +@mcp.prompt() +def review_file(filename: str) -> list[base.Message]: + """Review a file with its contents embedded.""" + file_content = open(filename).read() + return [ + base.UserMessage( + content=types.TextContent(type="text", text=f"Please review {filename}:"), + ), + base.UserMessage( + content=types.EmbeddedResource( + type="resource", + resource=types.TextResourceContents( + uri=f"file://{filename}", + text=file_content, + mimeType="text/plain", + ), + ), + ), + ] +``` + +_Full example: [examples/snippets/servers/prompt_embedded_resources.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/prompt_embedded_resources.py)_ + + +#### Prompts with Image Content + +Prompts can include images using `ImageContent` or the `Image` helper class: + + +```python +import mcp.types as types +from mcp.server.fastmcp import FastMCP +from mcp.server.fastmcp.prompts import base +from mcp.server.fastmcp.utilities.types import Image + +mcp = FastMCP("Image Prompt Example") + + +@mcp.prompt() +def describe_image(image_path: str) -> list[base.Message]: + """Prompt that includes an image for analysis.""" + img = Image(path=image_path) + return [ + base.UserMessage( + content=types.TextContent(type="text", text="Describe this image:"), + ), + base.UserMessage( + content=img.to_image_content(), + ), + ] +``` + +_Full example: [examples/snippets/servers/prompt_image_content.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/prompt_image_content.py)_ + + +#### Prompt Change Notifications + +When your server dynamically adds or removes prompts, notify connected clients: + + +```python +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession + +mcp = FastMCP("Dynamic Prompts") + + +@mcp.tool() +async def update_prompts(ctx: Context[ServerSession, None]) -> str: + """Update available prompts and notify clients.""" + # ... modify prompts ... + await ctx.session.send_prompt_list_changed() + return "Prompts updated" +``` + +_Full example: [examples/snippets/servers/prompt_change_notifications.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/prompt_change_notifications.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.fastmcp import FastMCP, Icon + +# Create an icon from a file path or URL +icon = Icon( + src="icon.png", + mimeType="image/png", + sizes=["64x64"] +) + +# Add icons to server +mcp = FastMCP( + "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/fastmcp/icons_demo.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/fastmcp/icons_demo.py)_ + +### Images + +FastMCP provides an `Image` class that automatically handles image data: + + +```python +"""Example showing image handling with FastMCP.""" + +from PIL import Image as PILImage + +from mcp.server.fastmcp import FastMCP, Image + +mcp = FastMCP("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/v1.x/examples/snippets/servers/images.py)_ + + +### Audio + +FastMCP provides an `Audio` class for returning audio data from tools, similar to `Image`: + + +```python +from mcp.server.fastmcp import FastMCP +from mcp.server.fastmcp.utilities.types import Audio + +mcp = FastMCP("Audio Example") + + +@mcp.tool() +def get_audio_from_file(file_path: str) -> Audio: + """Return audio from a file path (format auto-detected from extension).""" + return Audio(path=file_path) + + +@mcp.tool() +def get_audio_from_bytes(raw_audio: bytes) -> Audio: + """Return audio from raw bytes with explicit format.""" + return Audio(data=raw_audio, format="wav") +``` + +_Full example: [examples/snippets/servers/audio_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/audio_example.py)_ + + +The `Audio` class accepts `path` or `data` (mutually exclusive) and an optional `format` string. Supported formats include `wav`, `mp3`, `ogg`, `flac`, `aac`, and `m4a`. When using a file path, the MIME type is inferred from the file extension. + +### Embedded Resource Results + +Tools can return `EmbeddedResource` to attach file contents or data inline in the result: + + +```python +from mcp.server.fastmcp import FastMCP +from mcp.types import EmbeddedResource, TextResourceContents + +mcp = FastMCP("Embedded Resource Example") + + +@mcp.tool() +def read_config(path: str) -> EmbeddedResource: + """Read a config file and return it as an embedded resource.""" + with open(path) as f: + content = f.read() + return EmbeddedResource( + type="resource", + resource=TextResourceContents( + uri=f"file://{path}", + text=content, + mimeType="application/json", + ), + ) +``` + +_Full example: [examples/snippets/servers/embedded_resource_results.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/embedded_resource_results.py)_ + + +For binary embedded resources, use `BlobResourceContents` with base64-encoded data: + + +```python +import base64 + +from mcp.server.fastmcp import FastMCP +from mcp.types import BlobResourceContents, EmbeddedResource + +mcp = FastMCP("Binary Embedded Resource Example") + + +@mcp.tool() +def read_binary_file(path: str) -> EmbeddedResource: + """Read a binary file and return it as an embedded resource.""" + with open(path, "rb") as f: + data = base64.b64encode(f.read()).decode() + return EmbeddedResource( + type="resource", + resource=BlobResourceContents( + uri=f"file://{path}", + blob=data, + mimeType="application/octet-stream", + ), + ) +``` + +_Full example: [examples/snippets/servers/embedded_resource_results_binary.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/embedded_resource_results_binary.py)_ + + +### Tool Change Notifications + +When your server dynamically adds or removes tools at runtime, notify connected clients so they can refresh their tool list: + + +```python +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession + +mcp = FastMCP("Dynamic Tools") + + +@mcp.tool() +async def register_plugin(name: str, ctx: Context[ServerSession, None]) -> str: + """Dynamically register a new tool and notify the client.""" + # ... register the plugin's tools ... + + # Notify the client that the tool list has changed + await ctx.session.send_tool_list_changed() + + return f"Plugin '{name}' registered" +``` + +_Full example: [examples/snippets/servers/tool_change_notifications.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/tool_change_notifications.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.fastmcp import Context, FastMCP + +mcp = FastMCP(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) +``` + +#### 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.fastmcp` - Access to the FastMCP server instance (see [FastMCP Properties](#fastmcp-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(message)` - Send debug log message +- `await ctx.info(message)` - Send info log message +- `await ctx.warning(message)` - Send warning log message +- `await ctx.error(message)` - Send error log message +- `await ctx.log(level, message, 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.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession + +mcp = FastMCP(name="Progress Example") + + +@mcp.tool() +async def long_running_task(task_name: str, ctx: Context[ServerSession, None], steps: int = 5) -> str: + """Execute a task with progress updates.""" + await ctx.info(f"Starting: {task_name}") + + 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}") + + return f"Task '{task_name}' completed" +``` + +_Full example: [examples/snippets/servers/tool_progress.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/tool_progress.py)_ + + +### Completions + +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: + +Client usage: + + +```python +""" +cd to the `examples/snippets` directory and run: + uv run completion-client +""" + +import asyncio +import os + +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client +from mcp.types import PromptReference, ResourceTemplateReference + +# Create server parameters for stdio connection +server_params = StdioServerParameters( + 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", "")}, +) + + +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() + + # List available resource templates + templates = await session.list_resource_templates() + print("Available resource templates:") + for template in templates.resourceTemplates: + print(f" - {template.uriTemplate}") + + # List available prompts + prompts = await session.list_prompts() + print("\nAvailable prompts:") + for prompt in prompts.prompts: + print(f" - {prompt.name}") + + # Complete resource template arguments + if templates.resourceTemplates: + template = templates.resourceTemplates[0] + print(f"\nCompleting arguments for resource template: {template.uriTemplate}") + + # Complete without context + result = await session.complete( + ref=ResourceTemplateReference(type="ref/resource", uri=template.uriTemplate), + 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.uriTemplate), + 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}") + + 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}") + + +def main(): + """Entry point for the completion client.""" + asyncio.run(run()) + + +if __name__ == "__main__": + main() +``` + +_Full example: [examples/snippets/clients/completion_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/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 pydantic import BaseModel, Field + +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession +from mcp.shared.exceptions import UrlElicitationRequiredError +from mcp.types import ElicitRequestURLParams + +mcp = FastMCP(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[ServerSession, None]) -> 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[ServerSession, None]) -> 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[ServerSession, None]) -> 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}", + elicitationId=elicitation_id, + ) + ] + ) +``` + +_Full example: [examples/snippets/servers/elicitation.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/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) + +#### Elicitation with Enum Values + +To present a dropdown or selection list in elicitation forms, use `json_schema_extra` with an `enum` key on a `str` field. Do not use `Literal` -- use a plain `str` field with the enum constraint in the JSON schema: + + +```python +from pydantic import BaseModel, Field + +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession + +mcp = FastMCP("Enum Elicitation Example") + + +class ColorPreference(BaseModel): + color: str = Field( + description="Pick your favorite color", + json_schema_extra={"enum": ["red", "green", "blue", "yellow"]}, + ) + + +@mcp.tool() +async def pick_color(ctx: Context[ServerSession, None]) -> str: + """Ask the user to pick a color from a list.""" + result = await ctx.elicit( + message="Choose a color:", + schema=ColorPreference, + ) + if result.action == "accept": + return f"You picked: {result.data.color}" + return "No color selected" +``` + +_Full example: [examples/snippets/servers/elicitation_enum.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/elicitation_enum.py)_ + + +#### Elicitation Complete Notification + +For URL mode elicitations, send a completion notification after the out-of-band interaction finishes. This tells the client that the elicitation is done and it may retry any blocked requests: + + +```python +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession + +mcp = FastMCP("Elicit Complete Example") + + +@mcp.tool() +async def handle_oauth_callback(elicitation_id: str, ctx: Context[ServerSession, None]) -> str: + """Called when OAuth flow completes out-of-band.""" + # ... process the callback ... + + # Notify the client that the elicitation is done + await ctx.session.send_elicit_complete(elicitation_id) + + return "Authorization complete" +``` + +_Full example: [examples/snippets/servers/elicitation_complete.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/elicitation_complete.py)_ + + +### Sampling + +Tools can interact with LLMs through sampling (generating text): + + +```python +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession +from mcp.types import SamplingMessage, TextContent + +mcp = FastMCP(name="Sampling Example") + + +@mcp.tool() +async def generate_poem(topic: str, ctx: Context[ServerSession, None]) -> str: + """Generate a poem using LLM sampling.""" + prompt = f"Write a short poem about {topic}" + + result = await ctx.session.create_message( + 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/v1.x/examples/snippets/servers/sampling.py)_ + + +### Logging and Notifications + +Tools can send logs and notifications through the context: + + +```python +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession + +mcp = FastMCP(name="Notifications Example") + + +@mcp.tool() +async def process_data(data: str, ctx: Context[ServerSession, None]) -> str: + """Process data with logging.""" + # Different log levels + await ctx.debug(f"Debug: Processing '{data}'") + await ctx.info("Info: Starting processing") + await ctx.warning("Warning: This is experimental") + await ctx.error("Error: (This is just a demo)") + + # 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/v1.x/examples/snippets/servers/notifications.py)_ + + +#### Setting the Logging Level + +Clients can request a minimum logging level via `logging/setLevel`. Use the low-level server API to handle this: + + +```python +import mcp.types as types +from mcp.server.lowlevel import Server + +server = Server("Logging Level Example") + +current_level: types.LoggingLevel = "warning" + + +@server.set_logging_level() +async def handle_set_level(level: types.LoggingLevel) -> None: + """Handle client request to change the logging level.""" + global current_level + current_level = level +``` + +_Full example: [examples/snippets/servers/set_logging_level.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/set_logging_level.py)_ + + +When this handler is registered, the server automatically declares the `logging` capability during initialization. + +### Authentication + +For OAuth 2.1 server and client authentication, see [Authorization](authorization.md). + +### FastMCP Properties + +The FastMCP server instance accessible via `ctx.fastmcp` provides access to server configuration and metadata: + +- `ctx.fastmcp.name` - The server's name as defined during initialization +- `ctx.fastmcp.instructions` - Server instructions/description provided to clients +- `ctx.fastmcp.website_url` - Optional website URL for the server +- `ctx.fastmcp.icons` - Optional list of icons for UI display +- `ctx.fastmcp.settings` - Complete server configuration object containing: + - `debug` - Debug mode flag + - `log_level` - Current logging level + - `host` and `port` - Server network configuration + - `mount_path`, `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.fastmcp.name, + "instructions": ctx.fastmcp.instructions, + "debug_mode": ctx.fastmcp.settings.debug, + "log_level": ctx.fastmcp.settings.log_level, + "host": ctx.fastmcp.settings.host, + "port": ctx.fastmcp.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 +- `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: + - `progressToken` - Token for progress notifications + - Other client-provided metadata +- `ctx.request_context.request` - The original MCP request object for advanced processing +- `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/v1.x/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.fastmcp import FastMCP + +mcp = FastMCP("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/v1.x/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 FastMCP 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.fastmcp import FastMCP + +# Stateless server with JSON responses (recommended) +mcp = FastMCP("StatelessServer", stateless_http=True, json_response=True) + +# Other configuration options: +# Stateless server with SSE streaming responses +# mcp = FastMCP("StatelessServer", stateless_http=True) + +# Stateful server with session persistence +# mcp = FastMCP("StatefulServer") + + +# 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 +if __name__ == "__main__": + mcp.run(transport="streamable-http") +``` + +_Full example: [examples/snippets/servers/streamable_config.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/streamable_config.py)_ + + +You can mount multiple FastMCP 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.fastmcp import FastMCP + +# Create the Echo server +echo_mcp = FastMCP(name="EchoServer", stateless_http=True, json_response=True) + + +@echo_mcp.tool() +def echo(message: str) -> str: + """A simple echo tool""" + return f"Echo: {message}" + + +# Create the Math server +math_mcp = FastMCP(name="MathServer", stateless_http=True, json_response=True) + + +@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()), + Mount("/math", math_mcp.streamable_http_app()), + ], + 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.settings.streamable_http_path = "/" +# math_mcp.settings.streamable_http_path = "/" +``` + +_Full example: [examples/snippets/servers/streamable_starlette_mount.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/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/v1.x/examples/servers/simple-streamablehttp) +- Stateless server: [`examples/servers/simple-streamablehttp-stateless/`](https://github.com/modelcontextprotocol/python-sdk/tree/v1.x/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.fastmcp import FastMCP + +# Create MCP server +mcp = FastMCP("My App", json_response=True) + + +@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 +app = Starlette( + routes=[ + Mount("/", app=mcp.streamable_http_app()), + ], + lifespan=lifespan, +) +``` + +_Full example: [examples/snippets/servers/streamable_http_basic_mounting.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/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.fastmcp import FastMCP + +# Create MCP server +mcp = FastMCP("MCP Host App", json_response=True) + + +@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 +app = Starlette( + routes=[ + Host("mcp.acme.corp", app=mcp.streamable_http_app()), + ], + lifespan=lifespan, +) +``` + +_Full example: [examples/snippets/servers/streamable_http_host_mounting.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/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.fastmcp import FastMCP + +# Create multiple MCP servers +api_mcp = FastMCP("API Server", json_response=True) +chat_mcp = FastMCP("Chat Server", json_response=True) + + +@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}" + + +# Configure servers to mount at the root of each path +# This means endpoints will be at /api and /chat instead of /api/mcp and /chat/mcp +api_mcp.settings.streamable_http_path = "/" +chat_mcp.settings.streamable_http_path = "/" + + +# 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 +app = Starlette( + routes=[ + Mount("/api", app=api_mcp.streamable_http_app()), + Mount("/chat", app=chat_mcp.streamable_http_app()), + ], + lifespan=lifespan, +) +``` + +_Full example: [examples/snippets/servers/streamable_http_multiple_servers.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/streamable_http_multiple_servers.py)_ + + +##### Path configuration at initialization + + +```python +""" +Example showing path configuration during FastMCP initialization. + +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.fastmcp import FastMCP + +# Configure streamable_http_path during initialization +# This server will mount at the root of wherever it's mounted +mcp_at_root = FastMCP( + "My Server", + json_response=True, + streamable_http_path="/", +) + + +@mcp_at_root.tool() +def process_data(data: str) -> str: + """Process some data""" + return f"Processed: {data}" + + +# Mount at /process - endpoints will be at /process instead of /process/mcp +app = Starlette( + routes=[ + Mount("/process", app=mcp_at_root.streamable_http_app()), + ] +) +``` + +_Full example: [examples/snippets/servers/streamable_http_path_config.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/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-11-25/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.fastmcp import FastMCP + + +mcp = FastMCP("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())) +``` + +When mounting multiple MCP servers under different paths, you can configure the mount path in several ways: + +```python +from starlette.applications import Starlette +from starlette.routing import Mount +from mcp.server.fastmcp import FastMCP + +# Create multiple MCP servers +github_mcp = FastMCP("GitHub API") +browser_mcp = FastMCP("Browser") +curl_mcp = FastMCP("Curl") +search_mcp = FastMCP("Search") + +# Method 1: Configure mount paths via settings (recommended for persistent configuration) +github_mcp.settings.mount_path = "/github" +browser_mcp.settings.mount_path = "/browser" + +# Method 2: Pass mount path directly to sse_app (preferred for ad-hoc mounting) +# This approach doesn't modify the server's settings permanently + +# Create Starlette app with multiple mounted servers +app = Starlette( + routes=[ + # Using settings-based configuration + Mount("/github", app=github_mcp.sse_app()), + Mount("/browser", app=browser_mcp.sse_app()), + # Using direct mount path parameter + Mount("/curl", app=curl_mcp.sse_app("/curl")), + Mount("/search", app=search_mcp.sse_app("/search")), + ] +) + +# Method 3: For direct execution, you can also pass the mount path to run() +if __name__ == "__main__": + search_mcp.run(transport="sse", mount_path="/search") +``` + +For more information on mounting applications in Starlette, see the [Starlette documentation](https://www.starlette.io/routing/#submounting-routes). + +## Advanced Usage + +For the low-level server API, pagination, and direct handler registration, see [Low-Level Server](low-level-server.md). diff --git a/examples/clients/simple-chatbot/pyproject.toml b/examples/clients/simple-chatbot/pyproject.toml index 564b42df33..ce0724902d 100644 --- a/examples/clients/simple-chatbot/pyproject.toml +++ b/examples/clients/simple-chatbot/pyproject.toml @@ -16,7 +16,6 @@ classifiers = [ ] dependencies = [ "python-dotenv>=1.0.0", - "requests>=2.31.0", "mcp", "uvicorn>=0.32.1", ] diff --git a/examples/servers/simple-auth/mcp_simple_auth/auth_server.py b/examples/servers/simple-auth/mcp_simple_auth/auth_server.py index 80a2e8b8a3..aa8306ba2a 100644 --- a/examples/servers/simple-auth/mcp_simple_auth/auth_server.py +++ b/examples/servers/simple-auth/mcp_simple_auth/auth_server.py @@ -123,6 +123,8 @@ async def introspect_handler(request: Request) -> Response: "iat": int(time.time()), "token_type": "Bearer", "aud": access_token.resource, # RFC 8707 audience claim + "sub": access_token.subject, # RFC 7662 subject + "iss": str(server_settings.server_url), } ) diff --git a/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py b/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py index e3a25d3e8c..fc1ef1df94 100644 --- a/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py +++ b/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py @@ -186,6 +186,7 @@ async def handle_simple_callback(self, username: str, password: str, state: str) scopes=[self.settings.mcp_scope], code_challenge=code_challenge, resource=resource, # RFC 8707 + subject=username, ) self.auth_codes[new_code] = auth_code @@ -224,6 +225,7 @@ async def exchange_authorization_code( scopes=authorization_code.scopes, expires_at=int(time.time()) + 3600, resource=authorization_code.resource, # RFC 8707 + subject=authorization_code.subject, ) # Store user data mapping for this token diff --git a/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py b/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py index 5228d034e4..641095a125 100644 --- a/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py +++ b/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py @@ -75,6 +75,8 @@ async def verify_token(self, token: str) -> AccessToken | None: scopes=data.get("scope", "").split() if data.get("scope") else [], expires_at=data.get("exp"), resource=data.get("aud"), # Include resource in token + subject=data.get("sub"), # RFC 7662 subject (resource owner) + claims=data, ) except Exception as e: logger.warning(f"Token introspection failed: {e}") diff --git a/examples/snippets/clients/cancellation.py b/examples/snippets/clients/cancellation.py new file mode 100644 index 0000000000..fa6e71e072 --- /dev/null +++ b/examples/snippets/clients/cancellation.py @@ -0,0 +1,16 @@ +import mcp.types as types +from mcp import ClientSession + + +async def cancel_request(session: ClientSession) -> None: + """Send a cancellation notification for a previously-issued request.""" + await session.send_notification( + types.ClientNotification( + types.CancelledNotification( + params=types.CancelledNotificationParams( + requestId="request-id-to-cancel", + reason="User navigated away", + ) + ) + ) + ) diff --git a/examples/snippets/clients/logging_client.py b/examples/snippets/clients/logging_client.py new file mode 100644 index 0000000000..84937f5b30 --- /dev/null +++ b/examples/snippets/clients/logging_client.py @@ -0,0 +1,13 @@ +from mcp import ClientSession, types + + +async def handle_log(params: types.LoggingMessageNotificationParams) -> None: + """Handle log messages from the server.""" + print(f"[{params.level}] {params.data}") + + +session = ClientSession( + read_stream, + write_stream, + logging_callback=handle_log, +) diff --git a/examples/snippets/clients/roots_example.py b/examples/snippets/clients/roots_example.py new file mode 100644 index 0000000000..09c174c9c0 --- /dev/null +++ b/examples/snippets/clients/roots_example.py @@ -0,0 +1,22 @@ +from mcp import ClientSession, types +from mcp.shared.context import RequestContext + + +async def handle_list_roots( + context: RequestContext[ClientSession, None], +) -> types.ListRootsResult: + """Return the client's workspace roots.""" + return types.ListRootsResult( + roots=[ + types.Root(uri="file:///home/user/project", name="My Project"), + types.Root(uri="file:///home/user/data", name="Data Folder"), + ] + ) + + +# Pass the callback when creating the session +session = ClientSession( + read_stream, + write_stream, + list_roots_callback=handle_list_roots, +) diff --git a/examples/snippets/clients/sse_client.py b/examples/snippets/clients/sse_client.py new file mode 100644 index 0000000000..71439cc1c6 --- /dev/null +++ b/examples/snippets/clients/sse_client.py @@ -0,0 +1,16 @@ +import asyncio + +from mcp import ClientSession +from mcp.client.sse import sse_client + + +async def main(): + async with sse_client("http://localhost:8000/sse") as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + + tools = await session.list_tools() + print(f"Available tools: {[t.name for t in tools.tools]}") + + +asyncio.run(main()) diff --git a/examples/snippets/clients/stdio_client.py b/examples/snippets/clients/stdio_client.py index ac978035d4..23719d0f58 100644 --- a/examples/snippets/clients/stdio_client.py +++ b/examples/snippets/clients/stdio_client.py @@ -62,7 +62,7 @@ async def run(): # Read a resource (greeting resource from fastmcp_quickstart) resource_content = await session.read_resource(AnyUrl("greeting://World")) content_block = resource_content.contents[0] - if isinstance(content_block, types.TextContent): + if isinstance(content_block, types.TextResourceContents): print(f"Resource content: {content_block.text}") # Call a tool (add tool from fastmcp_quickstart) diff --git a/examples/snippets/clients/url_elicitation_client.py b/examples/snippets/clients/url_elicitation_client.py index 56457512c6..706c6751b2 100644 --- a/examples/snippets/clients/url_elicitation_client.py +++ b/examples/snippets/clients/url_elicitation_client.py @@ -24,8 +24,7 @@ import asyncio import json -import subprocess -import sys +import logging import webbrowser from typing import Any from urllib.parse import urlparse @@ -36,6 +35,8 @@ from mcp.shared.exceptions import McpError, UrlElicitationRequiredError from mcp.types import URL_ELICITATION_REQUIRED +logger = logging.getLogger(__name__) + async def handle_elicitation( context: RequestContext[ClientSession, Any], @@ -56,15 +57,19 @@ async def handle_elicitation( ) +ALLOWED_SCHEMES = {"http", "https"} + + async def handle_url_elicitation( params: types.ElicitRequestParams, ) -> types.ElicitResult: """Handle URL mode elicitation - show security warning and optionally open browser. This function demonstrates the security-conscious approach to URL elicitation: - 1. Display the full URL and domain for user inspection - 2. Show the server's reason for requesting this interaction - 3. Require explicit user consent before opening any URL + 1. Validate the URL scheme before prompting the user + 2. Display the full URL and domain for user inspection + 3. Show the server's reason for requesting this interaction + 4. Require explicit user consent before opening any URL """ # Extract URL parameters - these are available on URL mode requests url = getattr(params, "url", None) @@ -75,6 +80,12 @@ async def handle_url_elicitation( print("Error: No URL provided in elicitation request") return types.ElicitResult(action="cancel") + # Reject dangerous URL schemes before prompting the user + parsed = urlparse(str(url)) + if parsed.scheme.lower() not in ALLOWED_SCHEMES: + print(f"\nRejecting URL with disallowed scheme '{parsed.scheme}': {url}") + return types.ElicitResult(action="decline") + # Extract domain for security display domain = extract_domain(url) @@ -105,7 +116,11 @@ async def handle_url_elicitation( # Open the browser print(f"\nOpening browser to: {url}") - open_browser(url) + try: + webbrowser.open(url) + except Exception: + logger.exception("Failed to open browser") + print(f"Please manually open: {url}") print("Waiting for you to complete the interaction in your browser...") print("(The server will continue once you've finished)") @@ -121,20 +136,6 @@ def extract_domain(url: str) -> str: return "unknown" -def open_browser(url: str) -> None: - """Open URL in the default browser.""" - try: - if sys.platform == "darwin": - subprocess.run(["open", url], check=False) - elif sys.platform == "win32": - subprocess.run(["start", url], shell=True, check=False) - else: - webbrowser.open(url) - except Exception as e: - print(f"Failed to open browser: {e}") - print(f"Please manually open: {url}") - - async def call_tool_with_error_handling( session: ClientSession, tool_name: str, diff --git a/examples/snippets/servers/audio_example.py b/examples/snippets/servers/audio_example.py new file mode 100644 index 0000000000..c5dc890923 --- /dev/null +++ b/examples/snippets/servers/audio_example.py @@ -0,0 +1,16 @@ +from mcp.server.fastmcp import FastMCP +from mcp.server.fastmcp.utilities.types import Audio + +mcp = FastMCP("Audio Example") + + +@mcp.tool() +def get_audio_from_file(file_path: str) -> Audio: + """Return audio from a file path (format auto-detected from extension).""" + return Audio(path=file_path) + + +@mcp.tool() +def get_audio_from_bytes(raw_audio: bytes) -> Audio: + """Return audio from raw bytes with explicit format.""" + return Audio(data=raw_audio, format="wav") diff --git a/examples/snippets/servers/binary_resources.py b/examples/snippets/servers/binary_resources.py new file mode 100644 index 0000000000..ea2b6d0b14 --- /dev/null +++ b/examples/snippets/servers/binary_resources.py @@ -0,0 +1,10 @@ +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("Binary Resource Example") + + +@mcp.resource("images://logo.png", mime_type="image/png") +def get_logo() -> bytes: + """Return a binary image resource.""" + with open("logo.png", "rb") as f: + return f.read() diff --git a/examples/snippets/servers/elicitation_complete.py b/examples/snippets/servers/elicitation_complete.py new file mode 100644 index 0000000000..a7a9a3d2d2 --- /dev/null +++ b/examples/snippets/servers/elicitation_complete.py @@ -0,0 +1,15 @@ +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession + +mcp = FastMCP("Elicit Complete Example") + + +@mcp.tool() +async def handle_oauth_callback(elicitation_id: str, ctx: Context[ServerSession, None]) -> str: + """Called when OAuth flow completes out-of-band.""" + # ... process the callback ... + + # Notify the client that the elicitation is done + await ctx.session.send_elicit_complete(elicitation_id) + + return "Authorization complete" diff --git a/examples/snippets/servers/elicitation_enum.py b/examples/snippets/servers/elicitation_enum.py new file mode 100644 index 0000000000..2f609cafb1 --- /dev/null +++ b/examples/snippets/servers/elicitation_enum.py @@ -0,0 +1,25 @@ +from pydantic import BaseModel, Field + +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession + +mcp = FastMCP("Enum Elicitation Example") + + +class ColorPreference(BaseModel): + color: str = Field( + description="Pick your favorite color", + json_schema_extra={"enum": ["red", "green", "blue", "yellow"]}, + ) + + +@mcp.tool() +async def pick_color(ctx: Context[ServerSession, None]) -> str: + """Ask the user to pick a color from a list.""" + result = await ctx.elicit( + message="Choose a color:", + schema=ColorPreference, + ) + if result.action == "accept": + return f"You picked: {result.data.color}" + return "No color selected" diff --git a/examples/snippets/servers/embedded_resource_results.py b/examples/snippets/servers/embedded_resource_results.py new file mode 100644 index 0000000000..a807d270ff --- /dev/null +++ b/examples/snippets/servers/embedded_resource_results.py @@ -0,0 +1,19 @@ +from mcp.server.fastmcp import FastMCP +from mcp.types import EmbeddedResource, TextResourceContents + +mcp = FastMCP("Embedded Resource Example") + + +@mcp.tool() +def read_config(path: str) -> EmbeddedResource: + """Read a config file and return it as an embedded resource.""" + with open(path) as f: + content = f.read() + return EmbeddedResource( + type="resource", + resource=TextResourceContents( + uri=f"file://{path}", + text=content, + mimeType="application/json", + ), + ) diff --git a/examples/snippets/servers/embedded_resource_results_binary.py b/examples/snippets/servers/embedded_resource_results_binary.py new file mode 100644 index 0000000000..c2688d4c78 --- /dev/null +++ b/examples/snippets/servers/embedded_resource_results_binary.py @@ -0,0 +1,21 @@ +import base64 + +from mcp.server.fastmcp import FastMCP +from mcp.types import BlobResourceContents, EmbeddedResource + +mcp = FastMCP("Binary Embedded Resource Example") + + +@mcp.tool() +def read_binary_file(path: str) -> EmbeddedResource: + """Read a binary file and return it as an embedded resource.""" + with open(path, "rb") as f: + data = base64.b64encode(f.read()).decode() + return EmbeddedResource( + type="resource", + resource=BlobResourceContents( + uri=f"file://{path}", + blob=data, + mimeType="application/octet-stream", + ), + ) diff --git a/examples/snippets/servers/json_schema_example.py b/examples/snippets/servers/json_schema_example.py new file mode 100644 index 0000000000..128fc1ae10 --- /dev/null +++ b/examples/snippets/servers/json_schema_example.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel, Field + + +class SearchParams(BaseModel): + query: str = Field(description="Search query string") + max_results: int = Field(default=10, description="Maximum results to return") + + +# Pydantic generates a JSON Schema 2020-12 compatible schema: +schema = SearchParams.model_json_schema() +# { +# "properties": { +# "query": {"description": "Search query string", "type": "string"}, +# "max_results": { +# "default": 10, +# "description": "Maximum results to return", +# "type": "integer", +# }, +# }, +# "required": ["query"], +# "title": "SearchParams", +# "type": "object", +# } diff --git a/examples/snippets/servers/prompt_change_notifications.py b/examples/snippets/servers/prompt_change_notifications.py new file mode 100644 index 0000000000..e85114574b --- /dev/null +++ b/examples/snippets/servers/prompt_change_notifications.py @@ -0,0 +1,12 @@ +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession + +mcp = FastMCP("Dynamic Prompts") + + +@mcp.tool() +async def update_prompts(ctx: Context[ServerSession, None]) -> str: + """Update available prompts and notify clients.""" + # ... modify prompts ... + await ctx.session.send_prompt_list_changed() + return "Prompts updated" diff --git a/examples/snippets/servers/prompt_embedded_resources.py b/examples/snippets/servers/prompt_embedded_resources.py new file mode 100644 index 0000000000..987f81bd5e --- /dev/null +++ b/examples/snippets/servers/prompt_embedded_resources.py @@ -0,0 +1,26 @@ +import mcp.types as types +from mcp.server.fastmcp import FastMCP +from mcp.server.fastmcp.prompts import base + +mcp = FastMCP("Embedded Resource Prompt Example") + + +@mcp.prompt() +def review_file(filename: str) -> list[base.Message]: + """Review a file with its contents embedded.""" + file_content = open(filename).read() + return [ + base.UserMessage( + content=types.TextContent(type="text", text=f"Please review {filename}:"), + ), + base.UserMessage( + content=types.EmbeddedResource( + type="resource", + resource=types.TextResourceContents( + uri=f"file://{filename}", + text=file_content, + mimeType="text/plain", + ), + ), + ), + ] diff --git a/examples/snippets/servers/prompt_image_content.py b/examples/snippets/servers/prompt_image_content.py new file mode 100644 index 0000000000..32a11437eb --- /dev/null +++ b/examples/snippets/servers/prompt_image_content.py @@ -0,0 +1,20 @@ +import mcp.types as types +from mcp.server.fastmcp import FastMCP +from mcp.server.fastmcp.prompts import base +from mcp.server.fastmcp.utilities.types import Image + +mcp = FastMCP("Image Prompt Example") + + +@mcp.prompt() +def describe_image(image_path: str) -> list[base.Message]: + """Prompt that includes an image for analysis.""" + img = Image(path=image_path) + return [ + base.UserMessage( + content=types.TextContent(type="text", text="Describe this image:"), + ), + base.UserMessage( + content=img.to_image_content(), + ), + ] diff --git a/examples/snippets/servers/resource_subscriptions.py b/examples/snippets/servers/resource_subscriptions.py new file mode 100644 index 0000000000..13f42d7126 --- /dev/null +++ b/examples/snippets/servers/resource_subscriptions.py @@ -0,0 +1,18 @@ +from mcp.server.lowlevel import Server + +server = Server("Subscription Example") + +subscriptions: dict[str, set[str]] = {} # uri -> set of session ids + + +@server.subscribe_resource() +async def handle_subscribe(uri) -> None: + """Handle a client subscribing to a resource.""" + subscriptions.setdefault(str(uri), set()).add("current_session") + + +@server.unsubscribe_resource() +async def handle_unsubscribe(uri) -> None: + """Handle a client unsubscribing from a resource.""" + if str(uri) in subscriptions: + subscriptions[str(uri)].discard("current_session") diff --git a/examples/snippets/servers/resource_templates.py b/examples/snippets/servers/resource_templates.py new file mode 100644 index 0000000000..3a0b9d0086 --- /dev/null +++ b/examples/snippets/servers/resource_templates.py @@ -0,0 +1,9 @@ +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("Template Example") + + +@mcp.resource("users://{user_id}/profile") +def get_user_profile(user_id: str) -> str: + """Read a specific user's profile. The user_id is extracted from the URI.""" + return f'{{"user_id": "{user_id}", "name": "User {user_id}"}}' diff --git a/examples/snippets/servers/set_logging_level.py b/examples/snippets/servers/set_logging_level.py new file mode 100644 index 0000000000..4442f97942 --- /dev/null +++ b/examples/snippets/servers/set_logging_level.py @@ -0,0 +1,13 @@ +import mcp.types as types +from mcp.server.lowlevel import Server + +server = Server("Logging Level Example") + +current_level: types.LoggingLevel = "warning" + + +@server.set_logging_level() +async def handle_set_level(level: types.LoggingLevel) -> None: + """Handle client request to change the logging level.""" + global current_level + current_level = level diff --git a/examples/snippets/servers/tool_change_notifications.py b/examples/snippets/servers/tool_change_notifications.py new file mode 100644 index 0000000000..6a416f5427 --- /dev/null +++ b/examples/snippets/servers/tool_change_notifications.py @@ -0,0 +1,15 @@ +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession + +mcp = FastMCP("Dynamic Tools") + + +@mcp.tool() +async def register_plugin(name: str, ctx: Context[ServerSession, None]) -> str: + """Dynamically register a new tool and notify the client.""" + # ... register the plugin's tools ... + + # Notify the client that the tool list has changed + await ctx.session.send_tool_list_changed() + + return f"Plugin '{name}' registered" diff --git a/examples/snippets/servers/tool_errors.py b/examples/snippets/servers/tool_errors.py new file mode 100644 index 0000000000..42c8b0159a --- /dev/null +++ b/examples/snippets/servers/tool_errors.py @@ -0,0 +1,49 @@ +"""Example showing how to handle and return errors from tools.""" + +from mcp.server.fastmcp import FastMCP +from mcp.server.fastmcp.exceptions import ToolError +from mcp.types import CallToolResult, TextContent + +mcp = FastMCP("Tool Error Handling Example") + + +# Option 1: Raise ToolError for expected error conditions. +# The error message is returned to the client with isError=True. +@mcp.tool() +def divide(a: float, b: float) -> float: + """Divide two numbers.""" + if b == 0: + raise ToolError("Cannot divide by zero") + return a / b + + +# Option 2: Unhandled exceptions are automatically caught and +# converted to error responses with isError=True. +@mcp.tool() +def read_config(path: str) -> str: + """Read a configuration file.""" + # If this raises FileNotFoundError, the client receives an + # error response like "Error executing tool read_config: ..." + with open(path) as f: + return f.read() + + +# Option 3: Return CallToolResult directly for full control +# over error responses, including custom content. +@mcp.tool() +def validate_input(data: str) -> CallToolResult: + """Validate input data.""" + errors: list[str] = [] + if len(data) < 3: + errors.append("Input must be at least 3 characters") + if not data.isascii(): + errors.append("Input must be ASCII only") + + if errors: + return CallToolResult( + content=[TextContent(type="text", text="\n".join(errors))], + isError=True, + ) + return CallToolResult( + content=[TextContent(type="text", text="Validation passed")], + ) diff --git a/mkdocs.yml b/mkdocs.yml index 22c323d9d4..7245f239b4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -4,8 +4,8 @@ strict: true repo_name: modelcontextprotocol/python-sdk repo_url: https://github.com/modelcontextprotocol/python-sdk -edit_uri: edit/main/docs/ -site_url: https://modelcontextprotocol.github.io/python-sdk +edit_uri: edit/v1.x/docs/ +site_url: https://py.sdk.modelcontextprotocol.io/ # TODO(Marcelo): Add Anthropic copyright? # copyright: © Model Context Protocol 2025 to present @@ -14,7 +14,9 @@ nav: - Introduction: index.md - Installation: installation.md - Documentation: - - Concepts: concepts.md + - Building Servers: server.md + - Writing Clients: client.md + - Protocol Features: protocol.md - Low-Level Server: low-level-server.md - Authorization: authorization.md - Testing: testing.md diff --git a/pyproject.toml b/pyproject.toml index 078a1dfdcb..8611f5c4b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ maintainers = [ keywords = ["git", "mcp", "llm", "automation"] license = { text = "MIT" } classifiers = [ - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", @@ -20,19 +20,23 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] dependencies = [ "anyio>=4.5", - "httpx>=0.27.1", + "httpx>=0.27.1,<1.0.0", "httpx-sse>=0.4", - "pydantic>=2.11.0,<3.0.0", - "starlette>=0.27", + "pydantic>=2.12.0,<3.0.0; python_version >= '3.14'", + "pydantic>=2.11.0,<3.0.0; python_version < '3.14'", + "starlette>=0.48.0; python_version >= '3.14'", + "starlette>=0.27; python_version < '3.14'", "python-multipart>=0.0.9", "sse-starlette>=1.6.1", "pydantic-settings>=2.5.2", "uvicorn>=0.31.1; sys_platform != 'emscripten'", "jsonschema>=4.20.0", - "pywin32>=310; sys_platform == 'win32'", + "pywin32>=311; sys_platform == 'win32' and python_version >= '3.14'", + "pywin32>=310; sys_platform == 'win32' and python_version < '3.14'", "pyjwt[crypto]>=2.10.1", "typing-extensions>=4.9.0", "typing-inspection>=0.4.1", @@ -67,7 +71,7 @@ dev = [ docs = [ "mkdocs>=1.6.1", "mkdocs-glightbox>=0.4.0", - "mkdocs-material[imaging]>=9.5.45", + "mkdocs-material[imaging]>=9.6.19", "mkdocstrings-python>=1.12.2", ] @@ -85,6 +89,7 @@ bump = true [project.urls] Homepage = "https://modelcontextprotocol.io" +Documentation = "https://py.sdk.modelcontextprotocol.io/" Repository = "https://github.com/modelcontextprotocol/python-sdk" Issues = "https://github.com/modelcontextprotocol/python-sdk/issues" @@ -102,8 +107,20 @@ venv = ".venv" # those private functions instead of testing the private functions directly. It makes it easier to maintain the code source # and refactor code that is not public. executionEnvironments = [ + # The experimental tasks API and the WebSocket transport are deprecated; the suites and + # examples that cover them intentionally use the deprecated APIs. + { root = "tests/experimental", extraPaths = ["."], reportUnusedFunction = false, reportPrivateUsage = false, reportDeprecated = false }, + { root = "tests/shared/test_ws.py", extraPaths = ["."], reportUnusedFunction = false, reportPrivateUsage = false, reportDeprecated = false }, { root = "tests", extraPaths = ["."], reportUnusedFunction = false, reportPrivateUsage = false }, + { root = "examples/servers/simple-task", reportUnusedFunction = false, reportDeprecated = false }, + { root = "examples/servers/simple-task-interactive", reportUnusedFunction = false, reportDeprecated = false }, { root = "examples/servers", reportUnusedFunction = false }, + { root = "examples/snippets/clients/logging_client.py", reportUndefinedVariable = false, reportUnknownArgumentType = false }, + { root = "examples/snippets/clients/roots_example.py", reportUndefinedVariable = false, reportUnknownArgumentType = false, reportArgumentType = false }, + { root = "examples/snippets/servers/embedded_resource_results.py", reportArgumentType = false }, + { root = "examples/snippets/servers/embedded_resource_results_binary.py", reportArgumentType = false }, + { root = "examples/snippets/servers/prompt_embedded_resources.py", reportArgumentType = false }, + { root = "examples/snippets/servers/resource_subscriptions.py", reportUnknownParameterType = false, reportMissingParameterType = false, reportUnknownArgumentType = false }, ] [tool.ruff] @@ -129,6 +146,9 @@ mccabe.max-complexity = 24 # Default is 10 "__init__.py" = ["F401"] "tests/server/fastmcp/test_func_metadata.py" = ["E501"] "tests/shared/test_progress_notifications.py" = ["PLW0603"] +"examples/snippets/clients/logging_client.py" = ["F821"] +"examples/snippets/clients/roots_example.py" = ["F821"] +"examples/snippets/servers/set_logging_level.py" = ["PLW0603"] [tool.ruff.lint.pylint] allow-magic-value-types = ["bytes", "float", "int", "str"] diff --git a/scripts/build-docs.sh b/scripts/build-docs.sh new file mode 100755 index 0000000000..5a61309acf --- /dev/null +++ b/scripts/build-docs.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# +# Build combined v1 + v2 MkDocs documentation for GitHub Pages. +# +# v1 docs (from the v1.x branch) are placed at the site root. +# v2 docs (from main) are placed under /v2/. +# +# Both branches are fetched fresh from origin, so the output is identical +# regardless of which branch triggered the workflow. This script is intended +# to run in CI; for local single-branch preview use `uv run mkdocs serve`. +# +# Usage: +# scripts/build-docs.sh [output-dir] +# +# Default output directory: site +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +OUTPUT_DIR="$(cd "$REPO_ROOT" && mkdir -p "${1:-site}" && cd "${1:-site}" && pwd)" +V1_WORKTREE="$REPO_ROOT/.worktrees/v1-docs" +V2_WORKTREE="$REPO_ROOT/.worktrees/v2-docs" + +cleanup() { + cd "$REPO_ROOT" + git worktree remove --force "$V1_WORKTREE" 2>/dev/null || true + git worktree remove --force "$V2_WORKTREE" 2>/dev/null || true + rmdir "$REPO_ROOT/.worktrees" 2>/dev/null || true +} +trap cleanup EXIT + +rm -rf "${OUTPUT_DIR:?}"/* + +build_branch() { + local branch="$1" worktree="$2" dest="$3" + + echo "=== Building docs for ${branch} ===" + git fetch origin "$branch" + git worktree remove --force "$worktree" 2>/dev/null || true + rm -rf "$worktree" + git worktree add --detach "$worktree" "origin/${branch}" + + ( + cd "$worktree" + uv sync --frozen --group docs + uv run --frozen --no-sync mkdocs build --site-dir "$dest" + ) +} + +build_branch v1.x "$V1_WORKTREE" "$OUTPUT_DIR" +build_branch main "$V2_WORKTREE" "$OUTPUT_DIR/v2" + +echo "=== Combined docs built at $OUTPUT_DIR ===" diff --git a/scripts/update_readme_snippets.py b/scripts/update_doc_snippets.py similarity index 71% rename from scripts/update_readme_snippets.py rename to scripts/update_doc_snippets.py index d325333fff..4feb14d5f0 100755 --- a/scripts/update_readme_snippets.py +++ b/scripts/update_doc_snippets.py @@ -1,13 +1,13 @@ #!/usr/bin/env python3 """ -Update README.md with live code snippets from example files. +Update documentation files with live code snippets from example files. -This script finds specially marked code blocks in README.md and updates them -with the actual code from the referenced files. +This script finds specially marked code blocks in README.md and docs/*.md +and updates them with the actual code from the referenced files. Usage: - python scripts/update_readme_snippets.py - python scripts/update_readme_snippets.py --check # Check mode for CI + python scripts/update_doc_snippets.py + python scripts/update_doc_snippets.py --check # Check mode for CI """ import argparse @@ -25,7 +25,7 @@ def get_github_url(file_path: str) -> str: Returns: GitHub URL """ - base_url = "https://github.com/modelcontextprotocol/python-sdk/blob/main" + base_url = "https://github.com/modelcontextprotocol/python-sdk/blob/v1.x" return f"{base_url}/{file_path}" @@ -92,21 +92,21 @@ def process_snippet_block(match: re.Match[str], check_mode: bool = False) -> str return full_match -def update_readme_snippets(readme_path: Path = Path("README.md"), check_mode: bool = False) -> bool: - """Update code snippets in README.md with live code from source files. +def update_doc_snippets(doc_path: Path, check_mode: bool = False) -> bool: + """Update code snippets in a documentation file with live code from source files. Args: - readme_path: Path to the README file + doc_path: Path to the documentation file check_mode: If True, only check if updates are needed without modifying Returns: True if file is up to date or was updated, False if check failed """ - if not readme_path.exists(): - print(f"Error: README file not found: {readme_path}") + if not doc_path.exists(): + print(f"Error: Documentation file not found: {doc_path}") return False - content = readme_path.read_text() + content = doc_path.read_text() original_content = content # Pattern to match snippet-source blocks @@ -123,35 +123,45 @@ def update_readme_snippets(readme_path: Path = Path("README.md"), check_mode: bo if check_mode: if updated_content != original_content: print( - f"Error: {readme_path} has outdated code snippets. " - "Run 'python scripts/update_readme_snippets.py' to update." + f"Error: {doc_path} has outdated code snippets. Run 'python scripts/update_doc_snippets.py' to update." ) return False else: - print(f"✓ {readme_path} code snippets are up to date") + print(f"✓ {doc_path} code snippets are up to date") return True else: if updated_content != original_content: - readme_path.write_text(updated_content) - print(f"✓ Updated {readme_path}") + doc_path.write_text(updated_content) + print(f"✓ Updated {doc_path}") else: - print(f"✓ {readme_path} already up to date") + print(f"✓ {doc_path} already up to date") return True def main(): """Main entry point.""" - parser = argparse.ArgumentParser(description="Update README code snippets from source files") + parser = argparse.ArgumentParser(description="Update documentation code snippets from source files") parser.add_argument( "--check", action="store_true", help="Check mode - verify snippets are up to date without modifying" ) - parser.add_argument("--readme", default="README.md", help="Path to README file (default: README.md)") args = parser.parse_args() - success = update_readme_snippets(Path(args.readme), check_mode=args.check) - - if not success: + # Collect all documentation files to process + doc_files: list[Path] = [Path("README.md")] + docs_dir = Path("docs") + if docs_dir.exists(): + doc_files.extend(sorted(docs_dir.glob("*.md"))) + + all_success = True + for doc_path in doc_files: + if not doc_path.exists(): + continue + success = update_doc_snippets(doc_path, check_mode=args.check) + if not success: + all_success = False + + if not all_success: sys.exit(1) diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py index cd96a7566d..0ec0879688 100644 --- a/src/mcp/client/auth/oauth2.py +++ b/src/mcp/client/auth/oauth2.py @@ -267,6 +267,15 @@ def __init__( ) self._initialized = False + async def _validate_resource_match(self, prm: ProtectedResourceMetadata) -> None: + """Validate that PRM resource matches the server URL per RFC 8707.""" + prm_resource = str(prm.resource) if prm.resource else None + if not prm_resource: + return # pragma: no cover + default_resource = resource_url_from_server_url(self.context.server_url) + if not check_resource_allowed(requested_resource=default_resource, configured_resource=prm_resource): + raise OAuthFlowError(f"Protected resource {prm_resource} does not match expected {default_resource}") + async def _handle_protected_resource_response(self, response: httpx.Response) -> bool: """ Handle protected resource metadata discovery response. @@ -520,6 +529,8 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx. prm = await handle_protected_resource_response(discovery_response) if prm: + # Validate PRM resource matches server URL (RFC 8707) + await self._validate_resource_match(prm) self.context.protected_resource_metadata = prm # todo: try all authorization_servers to find the OASM diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 8519f15cec..86f2676dcb 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -1,4 +1,5 @@ import logging +import warnings from datetime import timedelta from typing import Any, Protocol, overload @@ -17,6 +18,15 @@ DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0") +# Type checkers only surface `@deprecated` messages given as string literals, so the same text is +# repeated inline at each deprecated entry point (here, server/session.py, server/lowlevel/server.py). +# Keep those copies, the filterwarnings marks in the tasks test suites, and the pytest.warns match in +# tests/experimental/tasks/test_deprecations.py prefix-aligned when rewording. +_EXPERIMENTAL_TASKS_DEPRECATION = ( + "The experimental tasks API is deprecated and will be removed in mcp 2.0: tasks (SEP-1686) were removed" + " from the MCP specification and are expected to return as a separate MCP extension." +) + logger = logging.getLogger("client") @@ -143,6 +153,8 @@ def __init__( self._experimental_features: ExperimentalClientFeatures | None = None # Experimental: Task handlers (use defaults if not provided) + if experimental_task_handlers is not None: + warnings.warn(_EXPERIMENTAL_TASKS_DEPRECATION, DeprecationWarning, stacklevel=2) self._task_handlers = experimental_task_handlers or ExperimentalTaskHandlers() async def initialize(self) -> types.InitializeResult: @@ -204,10 +216,16 @@ def get_server_capabilities(self) -> types.ServerCapabilities | None: return self._server_capabilities @property + @deprecated( + "The experimental tasks API is deprecated and will be removed in mcp 2.0: tasks (SEP-1686) were removed" + " from the MCP specification and are expected to return as a separate MCP extension." + ) def experimental(self) -> ExperimentalClientFeatures: """Experimental APIs for tasks and other features. - WARNING: These APIs are experimental and may change without notice. + Deprecated: the experimental tasks API will be removed in mcp 2.0. Tasks + (SEP-1686) were removed from the MCP specification and are expected to + return as a separate MCP extension. Example: status = await session.experimental.get_task(task_id) diff --git a/src/mcp/client/sse.py b/src/mcp/client/sse.py index b2ac67744e..0d7fa0fb46 100644 --- a/src/mcp/client/sse.py +++ b/src/mcp/client/sse.py @@ -8,8 +8,7 @@ import httpx from anyio.abc import TaskStatus from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from httpx_sse import aconnect_sse -from httpx_sse._exceptions import SSEError +from httpx_sse import SSEError, aconnect_sse import mcp.types as types from mcp.shared._httpx_utils import McpHttpClientFactory, create_mcp_http_client diff --git a/src/mcp/client/websocket.py b/src/mcp/client/websocket.py index e8c8d9af87..a0e1b98801 100644 --- a/src/mcp/client/websocket.py +++ b/src/mcp/client/websocket.py @@ -6,6 +6,7 @@ import anyio from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from pydantic import ValidationError +from typing_extensions import deprecated from websockets.asyncio.client import connect as ws_connect from websockets.typing import Subprotocol @@ -15,6 +16,10 @@ logger = logging.getLogger(__name__) +@deprecated( + "The WebSocket client transport is deprecated and will be removed in mcp 2.0. WebSocket was never part of" + " the MCP specification; use the streamable HTTP transport (`streamable_http_client`) instead." +) @asynccontextmanager async def websocket_client( url: str, @@ -25,6 +30,9 @@ async def websocket_client( """ WebSocket client transport for MCP, symmetrical to the server version. + Deprecated: this transport will be removed in mcp 2.0. WebSocket was never + part of the MCP specification; use the streamable HTTP transport instead. + Connects to 'url' using the 'mcp' subprotocol, then yields: (read_stream, write_stream) diff --git a/src/mcp/server/auth/middleware/bearer_auth.py b/src/mcp/server/auth/middleware/bearer_auth.py index 64c9b8841f..300b298924 100644 --- a/src/mcp/server/auth/middleware/bearer_auth.py +++ b/src/mcp/server/auth/middleware/bearer_auth.py @@ -1,6 +1,6 @@ import json import time -from typing import Any +from typing import Any, TypedDict from pydantic import AnyHttpUrl from starlette.authentication import AuthCredentials, AuthenticationBackend, SimpleUser @@ -19,6 +19,30 @@ def __init__(self, auth_info: AccessToken): self.scopes = auth_info.scopes +class AuthorizationContext(TypedDict): + client_id: str + issuer: str | None + subject: str | None + + +def authorization_context(user: AuthenticatedUser) -> AuthorizationContext: + """Identify the principal `user` represents, for transports to compare + against the principal that created a session. Components the token + verifier does not supply are `None`, so the comparison degrades to the + remaining components. + + See `examples/servers/simple-auth/mcp_simple_auth/token_verifier.py` for + a verifier that populates `subject` and `claims` from an introspection + response.""" + token = user.access_token + issuer = (token.claims or {}).get("iss") + return AuthorizationContext( + client_id=token.client_id, + issuer=str(issuer) if issuer is not None else None, + subject=token.subject, + ) + + class BearerAuthBackend(AuthenticationBackend): """ Authentication backend that validates Bearer tokens using a TokenVerifier. diff --git a/src/mcp/server/auth/provider.py b/src/mcp/server/auth/provider.py index 96296c148e..310baff5fd 100644 --- a/src/mcp/server/auth/provider.py +++ b/src/mcp/server/auth/provider.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Generic, Literal, Protocol, TypeVar +from typing import Any, Generic, Literal, Protocol, TypeVar from urllib.parse import parse_qs, urlencode, urlparse, urlunparse from pydantic import AnyUrl, BaseModel @@ -25,6 +25,7 @@ class AuthorizationCode(BaseModel): redirect_uri: AnyUrl redirect_uri_provided_explicitly: bool resource: str | None = None # RFC 8707 resource indicator + subject: str | None = None # resource owner; propagate to the issued AccessToken class RefreshToken(BaseModel): @@ -32,6 +33,7 @@ class RefreshToken(BaseModel): client_id: str scopes: list[str] expires_at: int | None = None + subject: str | None = None # resource owner; propagate to refreshed AccessTokens class AccessToken(BaseModel): @@ -40,6 +42,8 @@ class AccessToken(BaseModel): scopes: list[str] expires_at: int | None = None resource: str | None = None # RFC 8707 resource indicator + subject: str | None = None # RFC 7662/9068 `sub`: resource owner; unique only per issuer + claims: dict[str, Any] | None = None # additional claims (e.g. `iss`, `act`) RegistrationErrorCode = Literal[ diff --git a/src/mcp/server/experimental/__init__.py b/src/mcp/server/experimental/__init__.py index 824bb8b8be..91c6dcf3e8 100644 --- a/src/mcp/server/experimental/__init__.py +++ b/src/mcp/server/experimental/__init__.py @@ -8,4 +8,5 @@ - mcp.server.experimental.task_support.TaskSupport - mcp.server.experimental.task_result_handler.TaskResultHandler - mcp.server.experimental.request_context.Experimental +- mcp.server.experimental.task_scope (session scoping of task IDs) """ diff --git a/src/mcp/server/experimental/request_context.py b/src/mcp/server/experimental/request_context.py index 78e75beb6a..9caad02074 100644 --- a/src/mcp/server/experimental/request_context.py +++ b/src/mcp/server/experimental/request_context.py @@ -7,11 +7,15 @@ WARNING: These APIs are experimental and may change without notice. """ +import warnings from collections.abc import Awaitable, Callable from dataclasses import dataclass, field -from typing import Any +from typing import Any, overload + +from typing_extensions import deprecated from mcp.server.experimental.task_context import ServerTaskContext +from mcp.server.experimental.task_scope import scoped_task_id from mcp.server.experimental.task_support import TaskSupport from mcp.server.session import ServerSession from mcp.shared.exceptions import McpError @@ -29,6 +33,14 @@ Tool, ) +EXPLICIT_TASK_ID_DEPRECATION = ( + "Passing an explicit task_id to run_task is deprecated. A task created with an " + "explicit ID is not associated with the session that created it: any requestor " + "that presents the ID can read its status and result or cancel it, and it never " + "appears in tasks/list. Omit task_id to let the SDK generate an ID associated " + "with the creating session." +) + @dataclass class Experimental: @@ -143,6 +155,25 @@ def can_use_tool(self, tool_task_mode: TaskExecutionMode | None) -> bool: return False return True + @overload + async def run_task( + self, + work: Callable[[ServerTaskContext], Awaitable[Result]], + *, + task_id: None = None, + model_immediate_response: str | None = None, + ) -> CreateTaskResult: ... + + @overload + @deprecated(EXPLICIT_TASK_ID_DEPRECATION) + async def run_task( + self, + work: Callable[[ServerTaskContext], Awaitable[Result]], + *, + task_id: str, + model_immediate_response: str | None = None, + ) -> CreateTaskResult: ... + async def run_task( self, work: Callable[[ServerTaskContext], Awaitable[Result]], @@ -167,9 +198,17 @@ async def run_task( When work() returns a Result, the task is auto-completed with that result. If work() raises an exception, the task is auto-failed. + Generated task IDs embed the session's task scope so that the default + task handlers only serve the task to the session that created it. An + explicitly provided `task_id` is used verbatim and is not associated + with the session, so any session can access it through the default + handlers; passing one is deprecated for that reason. + Args: work: Async function that does the actual work - task_id: Optional task ID (generated if not provided) + task_id: Deprecated. Optional task ID, used verbatim and not + associated with the creating session. Omit it to let the SDK + generate one. model_immediate_response: Optional string to include in _meta as io.modelcontextprotocol/model-immediate-response @@ -196,6 +235,8 @@ async def work(task: ServerTaskContext) -> CallToolResult: WARNING: This API is experimental and may change without notice. """ + if task_id is not None: + warnings.warn(EXPLICIT_TASK_ID_DEPRECATION, DeprecationWarning, stacklevel=2) if self._task_support is None: raise RuntimeError("Task support not enabled. Call server.experimental.enable_tasks() first.") if self._session is None: @@ -210,6 +251,12 @@ async def work(task: ServerTaskContext) -> CallToolResult: # Access task_group via TaskSupport - raises if not in run() context task_group = support.task_group + if task_id is None: + features = self._session._experimental # pyright: ignore[reportPrivateUsage] + session_scope = features.task_session_scope + if session_scope is not None: + task_id = scoped_task_id(session_scope) + task = await support.store.create_task(self.task_metadata, task_id) task_ctx = ServerTaskContext( diff --git a/src/mcp/server/experimental/session_features.py b/src/mcp/server/experimental/session_features.py index 4842da5175..c118537fa2 100644 --- a/src/mcp/server/experimental/session_features.py +++ b/src/mcp/server/experimental/session_features.py @@ -40,6 +40,12 @@ class ExperimentalServerSessionFeatures: def __init__(self, session: "ServerSession") -> None: self._session = session + # Opaque marker identifying this session for task scoping. Assigned by + # TaskSupport.configure_session(). Task IDs generated by run_task() + # embed it so the default task handlers can restrict task access to + # the session that created the task. None means tasks created on this + # session are not associated with it (e.g. stateless servers). + self.task_session_scope: str | None = None async def get_task(self, task_id: str) -> types.GetTaskResult: """ diff --git a/src/mcp/server/experimental/task_context.py b/src/mcp/server/experimental/task_context.py index e6e14fc938..c9af82be41 100644 --- a/src/mcp/server/experimental/task_context.py +++ b/src/mcp/server/experimental/task_context.py @@ -488,12 +488,13 @@ async def elicit_as_task( create_result = CreateTaskResult.model_validate(response_data) client_task_id = create_result.task.taskId - # Poll the client's task using session.experimental - async for _ in self._session.experimental.poll_task(client_task_id): + # Poll the client's task using the session's experimental features + features = self._session._experimental # pyright: ignore[reportPrivateUsage] + async for _ in features.poll_task(client_task_id): pass # Get final result from client - result = await self._session.experimental.get_task_result( + result = await features.get_task_result( client_task_id, ElicitResult, ) @@ -594,12 +595,13 @@ async def create_message_as_task( create_result = CreateTaskResult.model_validate(response_data) client_task_id = create_result.task.taskId - # Poll the client's task using session.experimental - async for _ in self._session.experimental.poll_task(client_task_id): + # Poll the client's task using the session's experimental features + features = self._session._experimental # pyright: ignore[reportPrivateUsage] + async for _ in features.poll_task(client_task_id): pass # Get final result from client - result = await self._session.experimental.get_task_result( + result = await features.get_task_result( client_task_id, CreateMessageResult, ) diff --git a/src/mcp/server/experimental/task_result_handler.py b/src/mcp/server/experimental/task_result_handler.py index 0b869216e8..09f2a0a4e9 100644 --- a/src/mcp/server/experimental/task_result_handler.py +++ b/src/mcp/server/experimental/task_result_handler.py @@ -46,6 +46,11 @@ class TaskResultHandler: 4. Blocks until task reaches terminal state 5. Returns the final result + Prefer `server.experimental.enable_tasks()`, whose default tasks/result + handler wraps `handle()` and only serves tasks created by the requesting + session. A custom handler that calls `handle()` directly is responsible + for deciding which requestors may access which tasks. + Usage: # Create handler with store and queue handler = TaskResultHandler(task_store, message_queue) @@ -55,9 +60,6 @@ class TaskResultHandler: async def handle_task_result(req: GetTaskPayloadRequest) -> GetTaskPayloadResult: ctx = server.request_context return await handler.handle(req, ctx.session, ctx.request_id) - - # Or use the convenience method - handler.register(server) """ def __init__( @@ -127,9 +129,15 @@ async def handle( # The stored result contains the actual payload data # Per spec: tasks/result MUST include _meta with related-task metadata related_task = RelatedTaskMetadata(taskId=task_id) - related_task_meta: dict[str, Any] = {RELATED_TASK_METADATA_KEY: related_task.model_dump(by_alias=True)} + related_task_meta: dict[str, Any] = { + RELATED_TASK_METADATA_KEY: related_task.model_dump( + by_alias=True, + mode="json", + exclude_none=True, + ) + } if result is not None: - result_data = result.model_dump(by_alias=True) + result_data = result.model_dump(by_alias=True, mode="json", exclude_none=True) existing_meta: dict[str, Any] = result_data.get("_meta") or {} result_data["_meta"] = {**existing_meta, **related_task_meta} return GetTaskPayloadResult.model_validate(result_data) diff --git a/src/mcp/server/experimental/task_scope.py b/src/mcp/server/experimental/task_scope.py new file mode 100644 index 0000000000..c33cf55725 --- /dev/null +++ b/src/mcp/server/experimental/task_scope.py @@ -0,0 +1,75 @@ +""" +Session scoping for experimental task identifiers. + +Task IDs generated by `run_task()` embed an opaque, per-session marker (the +"session scope") so that the default task handlers can tell which session +created a task. The default handlers for tasks/get, tasks/result, tasks/list, +and tasks/cancel only operate on tasks created by the requesting session. + +Task IDs without a session scope (explicitly provided IDs, IDs created +directly through a TaskStore, or IDs created in stateless mode) have no known +creator. They can be used with tasks/get, tasks/result, and tasks/cancel from +any session - possession of the ID is what grants access - but they are never +included in tasks/list responses. + +WARNING: These APIs are experimental and may change without notice. +""" + +import re +from uuid import uuid4 + +__all__ = [ + "new_session_scope", + "scoped_task_id", + "session_scope_of", + "task_in_session_scope", + "task_listable_in_session_scope", +] + +# A scoped task ID has the form "<32 hex chars>:". Both halves must +# match exactly so that explicitly chosen task IDs are never mistaken for +# scoped ones. \Z rather than $ so a trailing newline cannot match. +_SCOPED_TASK_ID = re.compile( + r"\A(?P[0-9a-f]{32}):" + r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\Z" +) + + +def new_session_scope() -> str: + """Create a new opaque session scope token.""" + return uuid4().hex + + +def scoped_task_id(session_scope: str) -> str: + """Generate a task ID associated with the given session scope.""" + return f"{session_scope}:{uuid4()}" + + +def session_scope_of(task_id: str) -> str | None: + """Return the session scope embedded in a task ID, or None if it has none.""" + match = _SCOPED_TASK_ID.match(task_id) + return match.group("scope") if match else None + + +def task_in_session_scope(task_id: str, session_scope: str | None) -> bool: + """Whether a task may be used by a requestor with the given session scope. + + Used by tasks/get, tasks/result, and tasks/cancel. A task whose ID carries + no session scope has no known creator, so possession of the ID is what + grants access to it: it can be used from any session. + """ + embedded = session_scope_of(task_id) + return embedded is None or embedded == session_scope + + +def task_listable_in_session_scope(task_id: str, session_scope: str | None) -> bool: + """Whether a task may be included in a tasks/list response for the given session scope. + + Used by tasks/list. Listing is stricter than access by ID: a task is only + listed to the session that created it. Tasks with no session scope are + never listed because they have no known creator, and requestors with no + session scope are never shown any tasks because the server cannot tell + them apart. + """ + embedded = session_scope_of(task_id) + return embedded is not None and embedded == session_scope diff --git a/src/mcp/server/experimental/task_support.py b/src/mcp/server/experimental/task_support.py index dbb2ed6d2b..80f4e1cf86 100644 --- a/src/mcp/server/experimental/task_support.py +++ b/src/mcp/server/experimental/task_support.py @@ -13,6 +13,7 @@ from anyio.abc import TaskGroup from mcp.server.experimental.task_result_handler import TaskResultHandler +from mcp.server.experimental.task_scope import new_session_scope from mcp.server.session import ServerSession from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore from mcp.shared.experimental.tasks.message_queue import InMemoryTaskMessageQueue, TaskMessageQueue @@ -83,7 +84,7 @@ async def run(self) -> AsyncIterator[None]: finally: self._task_group = None - def configure_session(self, session: ServerSession) -> None: + def configure_session(self, session: ServerSession, *, stateless: bool = False) -> None: """ Configure a session for task support. @@ -91,12 +92,24 @@ def configure_session(self, session: ServerSession) -> None: responses to queued requests (elicitation, sampling) are routed back to the waiting resolvers. + It also assigns the session a task session scope. Task IDs generated + by `run_task()` embed this scope, and the default task handlers only + operate on tasks created by the requesting session. Stateless sessions + are not assigned a scope: each request runs on a fresh session, so a + task created by one request could never be retrieved by a later one if + tasks were bound to the session that created them. + Called automatically by Server.run() for each new session. Args: session: The session to configure + stateless: Whether the session belongs to a stateless server run """ session.add_response_router(self.handler) + if not stateless: + features = session._experimental # pyright: ignore[reportPrivateUsage] + if features.task_session_scope is None: + features.task_session_scope = new_session_scope() @classmethod def in_memory(cls) -> "TaskSupport": diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index 7a43bd7cf0..8f62ce2e54 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -1282,9 +1282,14 @@ async def log( related_request_id=self.request_id, ) + # TODO(maxisbey): see if this is needed otherwise remove @property def client_id(self) -> str | None: - """Get the client ID if available.""" + """Get the client ID if available. + + Note: this reads from the MCP request's `_meta` params, not the OAuth + bearer token. For that, use `get_access_token().client_id`. + """ return ( getattr(self.request_context.meta, "client_id", None) if self.request_context.meta else None ) # pragma: no cover diff --git a/src/mcp/server/fastmcp/utilities/context_injection.py b/src/mcp/server/fastmcp/utilities/context_injection.py index 66d0cbaa0c..45e0aea755 100644 --- a/src/mcp/server/fastmcp/utilities/context_injection.py +++ b/src/mcp/server/fastmcp/utilities/context_injection.py @@ -25,7 +25,7 @@ def find_context_parameter(fn: Callable[..., Any]) -> str | None: # Get type hints to properly resolve string annotations try: hints = typing.get_type_hints(fn) - except Exception: + except Exception: # pragma: no cover # If we can't resolve type hints, we can't find the context parameter return None diff --git a/src/mcp/server/fastmcp/utilities/func_metadata.py b/src/mcp/server/fastmcp/utilities/func_metadata.py index fa443d2fcb..241100d317 100644 --- a/src/mcp/server/fastmcp/utilities/func_metadata.py +++ b/src/mcp/server/fastmcp/utilities/func_metadata.py @@ -10,6 +10,7 @@ BaseModel, ConfigDict, Field, + PydanticUserError, RootModel, WithJsonSchema, create_model, @@ -411,9 +412,16 @@ def _try_create_model_and_schema( # Use StrictJsonSchema to raise exceptions instead of warnings try: schema = model.model_json_schema(schema_generator=StrictJsonSchema) - except (TypeError, ValueError, pydantic_core.SchemaError, pydantic_core.ValidationError) as e: + except ( + PydanticUserError, + TypeError, + ValueError, + pydantic_core.SchemaError, + pydantic_core.ValidationError, + ) as e: # These are expected errors when a type can't be converted to a Pydantic schema - # TypeError: When Pydantic can't handle the type + # PydanticUserError: When Pydantic can't handle the type (e.g. PydanticInvalidForJsonSchema); + # subclasses TypeError on pydantic <2.13 and RuntimeError on pydantic >=2.13 # ValueError: When there are issues with the type definition (including our custom warnings) # SchemaError: When Pydantic can't build a schema # ValidationError: When validation fails diff --git a/src/mcp/server/lowlevel/experimental.py b/src/mcp/server/lowlevel/experimental.py index 0e6655b3de..546757bf28 100644 --- a/src/mcp/server/lowlevel/experimental.py +++ b/src/mcp/server/lowlevel/experimental.py @@ -9,6 +9,7 @@ from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING +from mcp.server.experimental.task_scope import task_in_session_scope, task_listable_in_session_scope from mcp.server.experimental.task_support import TaskSupport from mcp.server.lowlevel.func_inspection import create_call_wrapper from mcp.shared.exceptions import McpError @@ -31,6 +32,8 @@ ServerResult, ServerTasksCapability, ServerTasksRequestsCapability, + Task, + TasksCallCapability, TasksCancelCapability, TasksListCapability, TasksToolsCapability, @@ -79,7 +82,7 @@ def update_capabilities(self, capabilities: ServerCapabilities) -> None: capabilities.tasks.cancel = TasksCancelCapability() capabilities.tasks.requests = ServerTasksRequestsCapability( - tools=TasksToolsCapability() + tools=TasksToolsCapability(call=TasksCallCapability()) ) # assuming always supported for now def enable_tasks( @@ -124,8 +127,39 @@ def enable_tasks( return self._task_support + def _requestor_session_scope(self) -> str | None: + """Return the task session scope of the session making the current request.""" + session = self._server.request_context.session + return session._experimental.task_session_scope # pyright: ignore[reportPrivateUsage] + + def _require_task_in_requestor_scope(self, task_id: str) -> None: + """Reject task IDs that belong to a different session. + + Task IDs generated by `run_task()` embed the creating session's + scope. The default handlers treat a task created by another session + exactly like a task that does not exist, so a requestor cannot tell + whether such a task exists. Task IDs without an embedded scope are + accepted from any session. + + Raises: + McpError: With INVALID_PARAMS if the task belongs to another session. + """ + if not task_in_session_scope(task_id, self._requestor_session_scope()): + raise McpError( + ErrorData( + code=INVALID_PARAMS, + message=f"Task not found: {task_id}", + ) + ) + def _register_default_task_handlers(self) -> None: - """Register default handlers for task operations.""" + """Register default handlers for task operations. + + Each default handler only operates on tasks created by the requesting + session (see `_require_task_in_requestor_scope`), and tasks/list only + returns the requesting session's own tasks (see + `task_listable_in_session_scope`). + """ assert self._task_support is not None support = self._task_support @@ -133,6 +167,7 @@ def _register_default_task_handlers(self) -> None: if GetTaskRequest not in self._request_handlers: async def _default_get_task(req: GetTaskRequest) -> ServerResult: + self._require_task_in_requestor_scope(req.params.taskId) task = await support.store.get_task(req.params.taskId) if task is None: raise McpError( @@ -159,6 +194,7 @@ async def _default_get_task(req: GetTaskRequest) -> ServerResult: if GetTaskPayloadRequest not in self._request_handlers: async def _default_get_task_result(req: GetTaskPayloadRequest) -> ServerResult: + self._require_task_in_requestor_scope(req.params.taskId) ctx = self._server.request_context result = await support.handler.handle(req, ctx.session, ctx.request_id) return ServerResult(result) @@ -169,9 +205,26 @@ async def _default_get_task_result(req: GetTaskPayloadRequest) -> ServerResult: if ListTasksRequest not in self._request_handlers: async def _default_list_tasks(req: ListTasksRequest) -> ServerResult: - cursor = req.params.cursor if req.params else None - tasks, next_cursor = await support.store.list_tasks(cursor) - return ServerResult(ListTasksResult(tasks=tasks, nextCursor=next_cursor)) + requestor_scope = self._requestor_session_scope() + if requestor_scope is None: + # The server cannot tell this requestor apart from any + # other, so there are no tasks it can be shown. + return ServerResult(ListTasksResult(tasks=[])) + # Return every task that belongs to the requesting session in + # a single page. The store's pagination cursor is never sent + # to the requestor: it is derived from the unfiltered listing, + # so it could identify a task belonging to a different + # session. For the same reason the request's cursor is not + # forwarded to the store. + own_tasks: list[Task] = [] + cursor: str | None = None + while True: + page, cursor = await support.store.list_tasks(cursor) + own_tasks.extend( + task for task in page if task_listable_in_session_scope(task.taskId, requestor_scope) + ) + if cursor is None: + return ServerResult(ListTasksResult(tasks=own_tasks)) self._request_handlers[ListTasksRequest] = _default_list_tasks @@ -179,6 +232,7 @@ async def _default_list_tasks(req: ListTasksRequest) -> ServerResult: if CancelTaskRequest not in self._request_handlers: async def _default_cancel_task(req: CancelTaskRequest) -> ServerResult: + self._require_task_in_requestor_scope(req.params.taskId) result = await cancel_task(support.store, req.params.taskId) return ServerResult(result) diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 67453624c3..25a8fde37c 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -80,7 +80,7 @@ async def main(): import jsonschema from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from pydantic import AnyUrl -from typing_extensions import TypeVar +from typing_extensions import TypeVar, deprecated import mcp.types as types from mcp.server.experimental.request_context import Experimental @@ -244,10 +244,16 @@ def request_context( return request_ctx.get() @property + @deprecated( + "The experimental tasks API is deprecated and will be removed in mcp 2.0: tasks (SEP-1686) were removed" + " from the MCP specification and are expected to return as a separate MCP extension." + ) def experimental(self) -> ExperimentalHandlers: """Experimental APIs for tasks and other features. - WARNING: These APIs are experimental and may change without notice. + Deprecated: the experimental tasks API will be removed in mcp 2.0. Tasks + (SEP-1686) were removed from the MCP specification and are expected to + return as a separate MCP extension. """ # We create this inline so we only add these capabilities _if_ they're actually used @@ -667,20 +673,27 @@ async def run( # Configure task support for this session if enabled task_support = self._experimental_handlers.task_support if self._experimental_handlers else None if task_support is not None: - task_support.configure_session(session) + task_support.configure_session(session, stateless=stateless) await stack.enter_async_context(task_support.run()) async with anyio.create_task_group() as tg: - async for message in session.incoming_messages: - logger.debug("Received message: %s", message) - - tg.start_soon( - self._handle_message, - message, - session, - lifespan_context, - raise_exceptions, - ) + try: + async for message in session.incoming_messages: + logger.debug("Received message: %s", message) + + tg.start_soon( + self._handle_message, + message, + session, + lifespan_context, + raise_exceptions, + ) + finally: + # Transport closed: cancel in-flight handlers. Without this the + # TG join waits for them, and when they eventually try to + # respond they hit a closed write stream (the session's + # _receive_loop closed it when the read stream ended). + tg.cancel_scope.cancel() async def _handle_message( self, @@ -763,12 +776,18 @@ async def _handle_request( response = await handler(req) except McpError as err: # pragma: no cover response = err.error - except anyio.get_cancelled_exc_class(): # pragma: no cover - logger.info( - "Request %s cancelled - duplicate response suppressed", - message.request_id, - ) - return + except anyio.get_cancelled_exc_class(): + if message.cancelled: + # Client sent CancelledNotification; responder.cancel() already + # sent an error response, so skip the duplicate. + logger.info( + "Request %s cancelled - duplicate response suppressed", + message.request_id, + ) + return + # Transport-close cancellation from the TG in run(); re-raise so the + # TG swallows its own cancellation. + raise except Exception as err: # pragma: no cover if raise_exceptions: raise err @@ -777,16 +796,24 @@ async def _handle_request( # Reset the global state after we are done if token is not None: # pragma: no branch request_ctx.reset(token) - - await message.respond(response) else: # pragma: no cover - await message.respond( - types.ErrorData( - code=types.METHOD_NOT_FOUND, - message="Method not found", - ) + response = types.ErrorData( + code=types.METHOD_NOT_FOUND, + message="Method not found", ) + try: + await message.respond(response) + except (anyio.BrokenResourceError, anyio.ClosedResourceError): + # Transport closed between handler unblocking and respond. Happens + # when _receive_loop's finally wakes a handler blocked on + # send_request: the handler runs to respond() before run()'s TG + # cancel fires, but after the write stream closed. Closed if our + # end closed (_receive_loop's async-with exit); Broken if the peer + # end closed first (streamable_http terminate()). + logger.debug("Response for %s dropped - transport closed", message.request_id) + return + logger.debug("Response sent") async def _handle_notification(self, notify: Any): diff --git a/src/mcp/server/session.py b/src/mcp/server/session.py index 8f0baa3e9c..f80971b012 100644 --- a/src/mcp/server/session.py +++ b/src/mcp/server/session.py @@ -44,6 +44,7 @@ async def handle_list_prompts(ctx: RequestContext) -> list[types.Prompt]: import anyio.lowlevel from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from pydantic import AnyUrl +from typing_extensions import deprecated import mcp.types as types from mcp.server.experimental.session_features import ExperimentalServerSessionFeatures @@ -108,14 +109,25 @@ def client_params(self) -> types.InitializeRequestParams | None: return self._client_params # pragma: no cover @property + def _experimental(self) -> ExperimentalServerSessionFeatures: + """Internal accessor for experimental features that skips the deprecation warning.""" + if self._experimental_features is None: + self._experimental_features = ExperimentalServerSessionFeatures(self) + return self._experimental_features + + @property + @deprecated( + "The experimental tasks API is deprecated and will be removed in mcp 2.0: tasks (SEP-1686) were removed" + " from the MCP specification and are expected to return as a separate MCP extension." + ) def experimental(self) -> ExperimentalServerSessionFeatures: """Experimental APIs for server→client task operations. - WARNING: These APIs are experimental and may change without notice. + Deprecated: the experimental tasks API will be removed in mcp 2.0. Tasks + (SEP-1686) were removed from the MCP specification and are expected to + return as a separate MCP extension. """ - if self._experimental_features is None: - self._experimental_features = ExperimentalServerSessionFeatures(self) - return self._experimental_features + return self._experimental def check_client_capability(self, capability: types.ClientCapabilities) -> bool: # pragma: no cover """Check if the client supports a specific capability.""" @@ -685,7 +697,5 @@ async def _handle_incoming(self, req: ServerRequestResponder) -> None: await self._incoming_message_stream_writer.send(req) @property - def incoming_messages( - self, - ) -> MemoryObjectReceiveStream[ServerRequestResponder]: + def incoming_messages(self) -> MemoryObjectReceiveStream[ServerRequestResponder]: return self._incoming_message_stream_reader diff --git a/src/mcp/server/sse.py b/src/mcp/server/sse.py index 19af93fd16..489785c4c9 100644 --- a/src/mcp/server/sse.py +++ b/src/mcp/server/sse.py @@ -52,6 +52,7 @@ async def handle_sse(request): from starlette.types import Receive, Scope, Send import mcp.types as types +from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, AuthorizationContext, authorization_context from mcp.server.transport_security import ( TransportSecurityMiddleware, TransportSecuritySettings, @@ -75,6 +76,9 @@ class SseServerTransport: _endpoint: str _read_stream_writers: dict[UUID, MemoryObjectSendStream[SessionMessage | Exception]] + # Identity of the credential that created each session; requests for a + # session must present the same credential. + _session_owners: dict[UUID, AuthorizationContext] _security: TransportSecurityMiddleware def __init__(self, endpoint: str, security_settings: TransportSecuritySettings | None = None) -> None: @@ -115,6 +119,7 @@ def __init__(self, endpoint: str, security_settings: TransportSecuritySettings | self._endpoint = endpoint self._read_stream_writers = {} + self._session_owners = {} self._security = TransportSecurityMiddleware(security_settings) logger.debug(f"SseServerTransport initialized with endpoint: {endpoint}") @@ -142,6 +147,9 @@ async def connect_sse(self, scope: Scope, receive: Receive, send: Send): # prag write_stream, write_stream_reader = anyio.create_memory_object_stream(0) session_id = uuid4() + user = scope.get("user") + if isinstance(user, AuthenticatedUser): + self._session_owners[session_id] = authorization_context(user) self._read_stream_writers[session_id] = read_stream_writer logger.debug(f"Created new session with ID: {session_id}") @@ -177,26 +185,34 @@ async def sse_writer(): } ) - async with anyio.create_task_group() as tg: - - async def response_wrapper(scope: Scope, receive: Receive, send: Send): - """ - The EventSourceResponse returning signals a client close / disconnect. - In this case we close our side of the streams to signal the client that - the connection has been closed. - """ - await EventSourceResponse(content=sse_stream_reader, data_sender_callable=sse_writer)( - scope, receive, send - ) - await read_stream_writer.aclose() - await write_stream_reader.aclose() - logging.debug(f"Client session disconnected {session_id}") - - logger.debug("Starting SSE response task") - tg.start_soon(response_wrapper, scope, receive, send) - - logger.debug("Yielding read and write streams") - yield (read_stream, write_stream) + try: + async with anyio.create_task_group() as tg: + + async def response_wrapper(scope: Scope, receive: Receive, send: Send): + """ + The EventSourceResponse returning signals a client close / disconnect. + In this case we close our side of the streams to signal the client that + the connection has been closed. + """ + await EventSourceResponse(content=sse_stream_reader, data_sender_callable=sse_writer)( + scope, receive, send + ) + await read_stream_writer.aclose() + await write_stream_reader.aclose() + await sse_stream_reader.aclose() + logging.debug(f"Client session disconnected {session_id}") + + logger.debug("Starting SSE response task") + tg.start_soon(response_wrapper, scope, receive, send) + + logger.debug("Yielding read and write streams") + yield (read_stream, write_stream) + finally: + # The connection is gone: stop routing messages to this session + # and drop its entries so they do not accumulate for the lifetime + # of the transport. + self._read_stream_writers.pop(session_id, None) + self._session_owners.pop(session_id, None) async def handle_post_message(self, scope: Scope, receive: Receive, send: Send) -> None: # pragma: no cover logger.debug("Handling POST message") @@ -227,6 +243,15 @@ async def handle_post_message(self, scope: Scope, receive: Receive, send: Send) response = Response("Could not find session", status_code=404) return await response(scope, receive, send) + user = scope.get("user") + requestor = authorization_context(user) if isinstance(user, AuthenticatedUser) else None + if requestor != self._session_owners.get(session_id): + # A session can only be used with the credential that created it. + # Respond exactly as if the session did not exist. + logger.warning("Rejecting message for session %s: credential does not match", session_id) + response = Response("Could not find session", status_code=404) + return await response(scope, receive, send) + body = await request.body() logger.debug(f"Received JSON: {body}") diff --git a/src/mcp/server/stdio.py b/src/mcp/server/stdio.py index bcb9247abb..ec029f135b 100644 --- a/src/mcp/server/stdio.py +++ b/src/mcp/server/stdio.py @@ -44,7 +44,7 @@ async def stdio_server( # python is platform-dependent (Windows is particularly problematic), so we # re-wrap the underlying binary stream to ensure UTF-8. if not stdin: - stdin = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8")) + stdin = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace")) if not stdout: stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8")) @@ -63,7 +63,7 @@ async def stdin_reader(): async for line in stdin: try: message = types.JSONRPCMessage.model_validate_json(line) - except Exception as exc: # pragma: no cover + except Exception as exc: await read_stream_writer.send(exc) continue diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index 2613b530c4..8e8d902ccc 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -14,8 +14,9 @@ from collections.abc import AsyncGenerator, Awaitable, Callable from contextlib import asynccontextmanager from dataclasses import dataclass +from functools import partial from http import HTTPStatus -from typing import Any +from typing import Any, Final import anyio from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream @@ -60,6 +61,11 @@ # Special key for the standalone GET stream GET_STREAM_KEY = "_GET_stream" +# Buffer for the per-request `_request_streams` so the serial `message_router` +# can deposit a response and move on instead of head-of-line blocking the +# whole session on a lazily-started `sse_writer`. See #1764. +REQUEST_STREAM_BUFFER_SIZE: Final = 16 + # Session ID validation pattern (visible ASCII characters ranging from 0x21 to 0x7E) # Pattern ensures entire string contains only valid characters by using ^ and $ anchors SESSION_ID_PATTERN = re.compile(r"^[\x21-\x7E]+$") @@ -67,6 +73,8 @@ # Type aliases StreamId = str EventId = str +# An SSE event-dict as accepted by sse-starlette (`event`, `data`, `id`, `retry`). +SSEEvent = dict[str, Any] @dataclass @@ -178,8 +186,10 @@ def __init__( MemoryObjectReceiveStream[EventMessage], ], ] = {} - self._sse_stream_writers: dict[RequestId, MemoryObjectSendStream[dict[str, str]]] = {} + self._sse_stream_writers: dict[RequestId, MemoryObjectSendStream[SSEEvent]] = {} self._terminated = False + # Idle timeout cancel scope; managed by the session manager. + self.idle_scope: anyio.CancelScope | None = None @property def is_terminated(self) -> bool: @@ -265,31 +275,48 @@ async def close_standalone_stream_callback() -> None: return SessionMessage(message, metadata=metadata) - async def _maybe_send_priming_event( - self, - request_id: RequestId, - sse_stream_writer: MemoryObjectSendStream[dict[str, Any]], - protocol_version: str, - ) -> None: - """Send priming event for SSE resumability if event_store is configured. + async def _mint_priming_event(self, stream_id: StreamId, protocol_version: str) -> SSEEvent | None: + """Store the priming cursor for `stream_id` and return its SSE wire form. - Only sends priming events to clients with protocol version >= 2025-11-25, - which includes the fix for handling empty SSE data. Older clients would - crash trying to parse empty data as JSON. + Called before the request is dispatched so the priming row precedes + anything `message_router` can store for this stream. Returns `None` + when no event store is configured or the client predates 2025-11-25 + (older clients cannot parse the empty-data event). """ if not self._event_store: - return - # Priming events have empty data which older clients cannot handle. + return None if protocol_version < "2025-11-25": - return - priming_event_id = await self._event_store.store_event( - str(request_id), # Convert RequestId to StreamId (str) - None, # Priming event has no payload - ) - priming_event: dict[str, str | int] = {"id": priming_event_id, "data": ""} + return None + priming_event_id = await self._event_store.store_event(stream_id, None) + priming_event: SSEEvent = {"id": priming_event_id, "data": ""} if self._retry_interval is not None: priming_event["retry"] = self._retry_interval - await sse_stream_writer.send(priming_event) + return priming_event + + async def _run_sse_writer( # pragma: no cover + self, + request_id: RequestId, + sse_stream_writer: MemoryObjectSendStream[SSEEvent], + request_stream_reader: MemoryObjectReceiveStream[EventMessage], + priming_event: SSEEvent | None, + ) -> None: + """Forward `_request_streams[request_id]` onto the SSE wire for one POST.""" + try: + async with sse_stream_writer, request_stream_reader: + if priming_event is not None: + await sse_stream_writer.send(priming_event) + async for event_message in request_stream_reader: + await sse_stream_writer.send(self._create_event_data(event_message)) + if isinstance(event_message.message.root, JSONRPCResponse | JSONRPCError): + break + except anyio.ClosedResourceError: + logger.debug("SSE stream closed by close_sse_stream()") + except Exception: + logger.exception("Error in SSE writer") + finally: + logger.debug("Closing SSE writer") + self._sse_stream_writers.pop(request_id, None) + await self._clean_up_memory_streams(request_id) def _create_error_response( self, @@ -346,7 +373,7 @@ def _get_session_id(self, request: Request) -> str | None: # pragma: no cover """Extract the session ID from request headers.""" return request.headers.get(MCP_SESSION_ID_HEADER) - def _create_event_data(self, event_message: EventMessage) -> dict[str, str]: # pragma: no cover + def _create_event_data(self, event_message: EventMessage) -> SSEEvent: # pragma: no cover """Create event data dictionary from an EventMessage.""" event_data = { "event": "message", @@ -528,13 +555,13 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re else request.headers.get(MCP_PROTOCOL_VERSION_HEADER, DEFAULT_NEGOTIATED_VERSION) ) - # Extract the request ID outside the try block for proper scope - request_id = str(message.root.id) # pragma: no cover - # Register this stream for the request ID - self._request_streams[request_id] = anyio.create_memory_object_stream[EventMessage](0) # pragma: no cover - request_stream_reader = self._request_streams[request_id][1] # pragma: no cover + request_id = str(message.root.id) if self.is_json_response_enabled: # pragma: no cover + self._request_streams[request_id] = anyio.create_memory_object_stream[EventMessage]( + REQUEST_STREAM_BUFFER_SIZE + ) + request_stream_reader = self._request_streams[request_id][1] # Process the message metadata = ServerMessageMetadata(request_context=request) session_message = SessionMessage(message, metadata=metadata) @@ -578,44 +605,19 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re finally: await self._clean_up_memory_streams(request_id) else: # pragma: no cover - # Create SSE stream - sse_stream_writer, sse_stream_reader = anyio.create_memory_object_stream[dict[str, str]](0) + # Mint the priming event before any per-request state exists: + # `EventStore.store_event` is user code and may raise, in which + # case the outer handler returns a 500 with nothing to clean up. + # Still strictly precedes dispatch, so storage order == wire order. + priming_event = await self._mint_priming_event(request_id, protocol_version) - # Store writer reference so close_sse_stream() can close it + sse_stream_writer, sse_stream_reader = anyio.create_memory_object_stream[SSEEvent](0) self._sse_stream_writers[request_id] = sse_stream_writer + self._request_streams[request_id] = anyio.create_memory_object_stream[EventMessage]( + REQUEST_STREAM_BUFFER_SIZE + ) + request_stream_reader = self._request_streams[request_id][1] - async def sse_writer(): - # Get the request ID from the incoming request message - try: - async with sse_stream_writer, request_stream_reader: - # Send priming event for SSE resumability - await self._maybe_send_priming_event(request_id, sse_stream_writer, protocol_version) - - # Process messages from the request-specific stream - async for event_message in request_stream_reader: - # Build the event data - event_data = self._create_event_data(event_message) - await sse_stream_writer.send(event_data) - - # If response, remove from pending streams and close - if isinstance( - event_message.message.root, - JSONRPCResponse | JSONRPCError, - ): - break - except anyio.ClosedResourceError: - # Expected when close_sse_stream() is called - logger.debug("SSE stream closed by close_sse_stream()") - except Exception: - logger.exception("Error in SSE writer") - finally: - logger.debug("Closing SSE writer") - self._sse_stream_writers.pop(request_id, None) - await self._clean_up_memory_streams(request_id) - - # Create and start EventSourceResponse - # SSE stream mode (original behavior) - # Set up headers headers = { "Cache-Control": "no-cache, no-transform", "Connection": "keep-alive", @@ -624,7 +626,9 @@ async def sse_writer(): } response = EventSourceResponse( content=sse_stream_reader, - data_sender_callable=sse_writer, + data_sender_callable=partial( + self._run_sse_writer, request_id, sse_stream_writer, request_stream_reader, priming_event + ), headers=headers, ) @@ -642,16 +646,15 @@ async def sse_writer(): await sse_stream_reader.aclose() await self._clean_up_memory_streams(request_id) - except Exception as err: # pragma: no cover + except Exception as err: logger.exception("Error handling POST request") response = self._create_error_response( - f"Error handling POST request: {err}", + "Error handling POST request", HTTPStatus.INTERNAL_SERVER_ERROR, INTERNAL_ERROR, ) await response(scope, receive, send) - if writer: - await writer.send(Exception(err)) + await writer.send(Exception(err)) return async def _handle_get_request(self, request: Request, send: Send) -> None: # pragma: no cover @@ -704,13 +707,15 @@ async def _handle_get_request(self, request: Request, send: Send) -> None: # pr return # Create SSE stream - sse_stream_writer, sse_stream_reader = anyio.create_memory_object_stream[dict[str, str]](0) + sse_stream_writer, sse_stream_reader = anyio.create_memory_object_stream[SSEEvent](0) async def standalone_sse_writer(): try: # Create a standalone message stream for server-initiated messages - self._request_streams[GET_STREAM_KEY] = anyio.create_memory_object_stream[EventMessage](0) + self._request_streams[GET_STREAM_KEY] = anyio.create_memory_object_stream[EventMessage]( + REQUEST_STREAM_BUFFER_SIZE + ) standalone_stream_reader = self._request_streams[GET_STREAM_KEY][1] async with sse_stream_writer, standalone_stream_reader: @@ -773,8 +778,12 @@ async def terminate(self) -> None: """Terminate the current session, closing all streams. Once terminated, all requests with this session ID will receive 404 Not Found. + Calling this method multiple times is safe (idempotent). """ + if self._terminated: # pragma: no cover + return + self._terminated = True logger.info(f"Terminating session: {self.mcp_session_id}") @@ -897,7 +906,7 @@ async def _replay_events(self, last_event_id: str, request: Request, send: Send) replay_protocol_version = request.headers.get(MCP_PROTOCOL_VERSION_HEADER, DEFAULT_NEGOTIATED_VERSION) # Create SSE stream for replay - sse_stream_writer, sse_stream_reader = anyio.create_memory_object_stream[dict[str, str]](0) + sse_stream_writer, sse_stream_reader = anyio.create_memory_object_stream[SSEEvent](0) async def replay_sender(): try: @@ -912,22 +921,32 @@ async def send_event(event_message: EventMessage) -> None: # If stream ID not in mapping, create it if stream_id and stream_id not in self._request_streams: - # Register SSE writer so close_sse_stream() can close it - self._sse_stream_writers[stream_id] = sse_stream_writer - - # Send priming event for this new connection - await self._maybe_send_priming_event(stream_id, sse_stream_writer, replay_protocol_version) - - # Create new request streams for this connection - self._request_streams[stream_id] = anyio.create_memory_object_stream[EventMessage](0) - msg_reader = self._request_streams[stream_id][1] - - # Forward messages to SSE - async with msg_reader: - async for event_message in msg_reader: - event_data = self._create_event_data(event_message) - - await sse_stream_writer.send(event_data) + try: + # Register SSE writer so close_sse_stream() can close it + self._sse_stream_writers[stream_id] = sse_stream_writer + + # Prime the resumed connection so the client sees the stream + # is re-registered. The replay→live-tail ordering window here + # is pre-existing and tracked separately. + priming_event = await self._mint_priming_event(stream_id, replay_protocol_version) + if priming_event is not None: + await sse_stream_writer.send(priming_event) + + # Create new request streams for this connection + self._request_streams[stream_id] = anyio.create_memory_object_stream[EventMessage]( + REQUEST_STREAM_BUFFER_SIZE + ) + msg_reader = self._request_streams[stream_id][1] + + # Forward messages to SSE + async with msg_reader: + async for event_message in msg_reader: + event_data = self._create_event_data(event_message) + + await sse_stream_writer.send(event_data) + finally: + self._sse_stream_writers.pop(stream_id, None) + await self._clean_up_memory_streams(stream_id) except anyio.ClosedResourceError: # Expected when close_sse_stream() is called logger.debug("Replay SSE stream closed by close_sse_stream()") diff --git a/src/mcp/server/streamable_http_manager.py b/src/mcp/server/streamable_http_manager.py index 6a16724177..1a1a85721d 100644 --- a/src/mcp/server/streamable_http_manager.py +++ b/src/mcp/server/streamable_http_manager.py @@ -5,7 +5,6 @@ import contextlib import logging from collections.abc import AsyncIterator -from http import HTTPStatus from typing import Any from uuid import uuid4 @@ -15,6 +14,7 @@ from starlette.responses import Response from starlette.types import Receive, Scope, Send +from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, AuthorizationContext, authorization_context from mcp.server.lowlevel.server import Server as MCPServer from mcp.server.streamable_http import ( MCP_SESSION_ID_HEADER, @@ -38,6 +38,7 @@ class StreamableHTTPSessionManager: 2. Resumability via an optional event store 3. Connection management and lifecycle 4. Request handling and transport setup + 5. Idle session cleanup via optional timeout Important: Only one StreamableHTTPSessionManager instance should be created per application. The instance cannot be reused after its run() context has @@ -45,16 +46,20 @@ class StreamableHTTPSessionManager: Args: app: The MCP server instance - event_store: Optional event store for resumability support. - If provided, enables resumable connections where clients - can reconnect and receive missed events. - If None, sessions are still tracked but not resumable. + event_store: Optional event store for resumability support. If provided, enables resumable connections + where clients can reconnect and receive missed events. If None, sessions are still tracked but not + resumable. json_response: Whether to use JSON responses instead of SSE streams - stateless: If True, creates a completely fresh transport for each request - with no session tracking or state persistence between requests. + stateless: If True, creates a completely fresh transport for each request with no session tracking or + state persistence between requests. security_settings: Optional transport security settings. - retry_interval: Retry interval in milliseconds to suggest to clients in SSE - retry field. Used for SSE polling behavior. + retry_interval: Retry interval in milliseconds to suggest to clients in SSE retry field. Used for SSE + polling behavior. + session_idle_timeout: Optional idle timeout in seconds for stateful sessions. If set, sessions that + receive no HTTP requests for this duration will be automatically terminated and removed. When + retry_interval is also configured, ensure the idle timeout comfortably exceeds the retry interval to + avoid reaping sessions during normal SSE polling gaps. Default is None (no timeout). A value of 1800 + (30 minutes) is recommended for most deployments. """ def __init__( @@ -65,17 +70,27 @@ def __init__( stateless: bool = False, security_settings: TransportSecuritySettings | None = None, retry_interval: int | None = None, + session_idle_timeout: float | None = None, ): + if session_idle_timeout is not None and session_idle_timeout <= 0: + raise ValueError("session_idle_timeout must be a positive number of seconds") + if stateless and session_idle_timeout is not None: + raise RuntimeError("session_idle_timeout is not supported in stateless mode") + self.app = app self.event_store = event_store self.json_response = json_response self.stateless = stateless self.security_settings = security_settings self.retry_interval = retry_interval + self.session_idle_timeout = session_idle_timeout # Session tracking (only used if not stateless) self._session_creation_lock = anyio.Lock() self._server_instances: dict[str, StreamableHTTPServerTransport] = {} + # Identity of the credential that created each session; requests for a + # session must present the same credential. + self._session_owners: dict[str, AuthorizationContext] = {} # The task group will be set during lifespan self._task_group = None @@ -123,6 +138,7 @@ async def lifespan(app: Starlette) -> AsyncIterator[None]: self._task_group = None # Clear any remaining server instances self._server_instances.clear() + self._session_owners.clear() async def handle_request( self, @@ -215,10 +231,33 @@ async def _handle_stateful_request( request = Request(scope, receive) request_mcp_session_id = request.headers.get(MCP_SESSION_ID_HEADER) + user = scope.get("user") + requestor = authorization_context(user) if isinstance(user, AuthenticatedUser) else None + # Existing session case - if request_mcp_session_id is not None and request_mcp_session_id in self._server_instances: # pragma: no cover + if request_mcp_session_id is not None and request_mcp_session_id in self._server_instances: transport = self._server_instances[request_mcp_session_id] + if requestor != self._session_owners.get(request_mcp_session_id): + # A session can only be used with the credential that created + # it. Respond exactly as if the session did not exist. + logger.warning( + "Rejecting request for session %s: credential does not match the one that created the session", + request_mcp_session_id[:64], + ) + body = JSONRPCError( + jsonrpc="2.0", id="server-error", error=ErrorData(code=INVALID_REQUEST, message="Session not found") + ) + response = Response( + body.model_dump_json(by_alias=True, exclude_none=True), + status_code=404, + media_type="application/json", + ) + await response(scope, receive, send) + return logger.debug("Session already exists, handling request directly") + # Push back idle deadline on activity + if transport.idle_scope is not None and self.session_idle_timeout is not None: # pragma: no cover + transport.idle_scope.deadline = anyio.current_time() + self.session_idle_timeout await transport.handle_request(scope, receive, send) return @@ -236,6 +275,8 @@ async def _handle_stateful_request( ) assert http_transport.mcp_session_id is not None + if requestor is not None: + self._session_owners[http_transport.mcp_session_id] = requestor self._server_instances[http_transport.mcp_session_id] = http_transport logger.info(f"Created new transport with session ID: {new_session_id}") @@ -245,19 +286,32 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE read_stream, write_stream = streams task_status.started() try: - await self.app.run( - read_stream, - write_stream, - self.app.create_initialization_options(), - stateless=False, # Stateful mode - ) - except Exception as e: - logger.error( - f"Session {http_transport.mcp_session_id} crashed: {e}", - exc_info=True, - ) + # Use a cancel scope for idle timeout — when the + # deadline passes the scope cancels app.run() and + # execution continues after the ``with`` block. + # Incoming requests push the deadline forward. + idle_scope = anyio.CancelScope() + if self.session_idle_timeout is not None: + idle_scope.deadline = anyio.current_time() + self.session_idle_timeout + http_transport.idle_scope = idle_scope + + with idle_scope: + await self.app.run( + read_stream, + write_stream, + self.app.create_initialization_options(), + stateless=False, + ) + + if idle_scope.cancelled_caught: + assert http_transport.mcp_session_id is not None + logger.info(f"Session {http_transport.mcp_session_id} idle timeout") + self._server_instances.pop(http_transport.mcp_session_id, None) + self._session_owners.pop(http_transport.mcp_session_id, None) + await http_transport.terminate() + except Exception: + logger.exception(f"Session {http_transport.mcp_session_id} crashed") finally: - # Only remove from instances if not terminated if ( # pragma: no branch http_transport.mcp_session_id and http_transport.mcp_session_id in self._server_instances @@ -269,6 +323,7 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE "active instances." ) del self._server_instances[http_transport.mcp_session_id] + self._session_owners.pop(http_transport.mcp_session_id, None) # Assert task group is not None for type checking assert self._task_group is not None @@ -279,19 +334,10 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE await http_transport.handle_request(scope, receive, send) else: # Unknown or expired session ID - return 404 per MCP spec - # TODO: Align error code once spec clarifies - # See: https://github.com/modelcontextprotocol/python-sdk/issues/1821 - error_response = JSONRPCError( - jsonrpc="2.0", - id="server-error", - error=ErrorData( - code=INVALID_REQUEST, - message="Session not found", - ), + body = JSONRPCError( + jsonrpc="2.0", id="server-error", error=ErrorData(code=INVALID_REQUEST, message="Session not found") ) response = Response( - content=error_response.model_dump_json(by_alias=True, exclude_none=True), - status_code=HTTPStatus.NOT_FOUND, - media_type="application/json", + body.model_dump_json(by_alias=True, exclude_none=True), status_code=404, media_type="application/json" ) await response(scope, receive, send) diff --git a/src/mcp/server/transport_security.py b/src/mcp/server/transport_security.py index ee1e4505a7..5022a1a2fe 100644 --- a/src/mcp/server/transport_security.py +++ b/src/mcp/server/transport_security.py @@ -3,7 +3,7 @@ import logging from pydantic import BaseModel, Field -from starlette.requests import Request +from starlette.requests import HTTPConnection from starlette.responses import Response logger = logging.getLogger(__name__) @@ -99,7 +99,7 @@ def _validate_content_type(self, content_type: str | None) -> bool: # pragma: n return True - async def validate_request(self, request: Request, is_post: bool = False) -> Response | None: + async def validate_request(self, request: HTTPConnection, is_post: bool = False) -> Response | None: """Validate request headers for DNS rebinding protection. Returns None if validation passes, or an error Response if validation fails. diff --git a/src/mcp/server/websocket.py b/src/mcp/server/websocket.py index 5d5efd16e9..d3526f2ad6 100644 --- a/src/mcp/server/websocket.py +++ b/src/mcp/server/websocket.py @@ -6,21 +6,48 @@ from pydantic_core import ValidationError from starlette.types import Receive, Scope, Send from starlette.websockets import WebSocket +from typing_extensions import deprecated import mcp.types as types +from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings from mcp.shared.message import SessionMessage logger = logging.getLogger(__name__) -@asynccontextmanager # pragma: no cover -async def websocket_server(scope: Scope, receive: Receive, send: Send): +@deprecated( # pragma: no cover + "The WebSocket server transport is deprecated and will be removed in mcp 2.0. WebSocket was never part of" + " the MCP specification; use the streamable HTTP transport instead." +) +@asynccontextmanager +async def websocket_server( + scope: Scope, + receive: Receive, + send: Send, + security_settings: TransportSecuritySettings | None = None, +): """ WebSocket server transport for MCP. This is an ASGI application, suitable to be used with a framework like Starlette and a server like Hypercorn. + + Set `security_settings` to enable Host/Origin header validation before the + handshake is accepted (same settings type as the SSE and Streamable HTTP + transports). When validation fails this raises `ValueError` after rejecting + the handshake. + + Deprecated: this transport will be removed in mcp 2.0. WebSocket was never + part of the MCP specification; use the streamable HTTP transport instead. """ websocket = WebSocket(scope, receive, send) + + security = TransportSecurityMiddleware(security_settings) + error_response = await security.validate_request(websocket, is_post=False) + if error_response is not None: + # Reject the handshake; the ASGI server maps a pre-accept close to HTTP 403. + await websocket.close() + raise ValueError("Request validation failed") + await websocket.accept(subprotocol="mcp") read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index d3290997e5..a985bef3f1 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -71,6 +71,24 @@ class OAuthClientMetadata(BaseModel): software_id: str | None = None software_version: str | None = None + @field_validator( + "client_uri", + "logo_uri", + "tos_uri", + "policy_uri", + "jwks_uri", + mode="before", + ) + @classmethod + def _empty_string_optional_url_to_none(cls, v: object) -> object: + # RFC 7591 §2 marks these URL fields OPTIONAL. Some authorization servers + # echo omitted metadata back as "" instead of dropping the keys, which + # AnyHttpUrl would otherwise reject — throwing away an otherwise valid + # registration response. Treat "" as absent. + if v == "": + return None + return v + def validate_scope(self, requested_scope: str | None) -> list[str] | None: if requested_scope is None: return None diff --git a/src/mcp/shared/auth_utils.py b/src/mcp/shared/auth_utils.py index 8f3c542f22..3ba880f40d 100644 --- a/src/mcp/shared/auth_utils.py +++ b/src/mcp/shared/auth_utils.py @@ -51,22 +51,17 @@ def check_resource_allowed(requested_resource: str, configured_resource: str) -> if requested.scheme.lower() != configured.scheme.lower() or requested.netloc.lower() != configured.netloc.lower(): return False - # Handle cases like requested=/foo and configured=/foo/ + # Normalize trailing slashes before comparison so that + # "/foo" and "/foo/" are treated as equivalent. requested_path = requested.path configured_path = configured.path - - # If requested path is shorter, it cannot be a child - if len(requested_path) < len(configured_path): - return False - - # Check if the requested path starts with the configured path - # Ensure both paths end with / for proper comparison - # This ensures that paths like "/api123" don't incorrectly match "/api" if not requested_path.endswith("/"): requested_path += "/" if not configured_path.endswith("/"): configured_path += "/" + # Check hierarchical match: requested must start with configured path. + # The trailing-slash normalization ensures "/api123/" won't match "/api/". return requested_path.startswith(configured_path) diff --git a/src/mcp/shared/session.py b/src/mcp/shared/session.py index 3033acd0eb..35a83fcf1b 100644 --- a/src/mcp/shared/session.py +++ b/src/mcp/shared/session.py @@ -108,7 +108,7 @@ def __exit__( ) -> None: """Exit the context manager, performing cleanup and notifying completion.""" try: - if self._completed: # pragma: no branch + if self._completed: self._on_complete(self) finally: self._entered = False @@ -445,7 +445,9 @@ async def _receive_loop(self) -> None: finally: # after the read stream is closed, we need to send errors # to any pending requests - for id, stream in self._response_streams.items(): + # Snapshot: stream.send() wakes the waiter, whose finally pops + # from _response_streams before the next __next__() call. + for id, stream in list(self._response_streams.items()): error = ErrorData(code=CONNECTION_CLOSED, message="Connection closed") try: await stream.send(JSONRPCError(jsonrpc="2.0", id=id, error=error)) diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 593d5cfe06..5f8bc14107 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -13,6 +13,7 @@ from pydantic import AnyHttpUrl, AnyUrl from mcp.client.auth import OAuthClientProvider, PKCEParameters +from mcp.client.auth.exceptions import OAuthFlowError from mcp.client.auth.utils import ( build_oauth_authorization_server_metadata_discovery_urls, build_protected_resource_metadata_discovery_urls, @@ -965,7 +966,7 @@ async def test_auth_flow_with_no_tokens(self, oauth_provider: OAuthClientProvide # Send a successful discovery response with minimal protected resource metadata discovery_response = httpx.Response( 200, - content=b'{"resource": "https://api.example.com/mcp", "authorization_servers": ["https://auth.example.com"]}', + content=b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://auth.example.com"]}', request=discovery_request, ) @@ -2030,3 +2031,85 @@ async def callback_handler() -> tuple[str, str | None]: await auth_flow.asend(final_response) except StopAsyncIteration: pass + + +@pytest.mark.anyio +async def test_validate_resource_rejects_mismatched_resource( + client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage +) -> None: + """Client must reject PRM resource that doesn't match server URL.""" + provider = OAuthClientProvider( + server_url="https://api.example.com/v1/mcp", + client_metadata=client_metadata, + storage=mock_storage, + ) + provider._initialized = True + + prm = ProtectedResourceMetadata( + resource=AnyHttpUrl("https://evil.example.com/mcp"), + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + ) + with pytest.raises(OAuthFlowError, match="does not match expected"): + await provider._validate_resource_match(prm) + + +@pytest.mark.anyio +async def test_validate_resource_accepts_matching_resource( + client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage +) -> None: + """Client must accept PRM resource that matches server URL.""" + provider = OAuthClientProvider( + server_url="https://api.example.com/v1/mcp", + client_metadata=client_metadata, + storage=mock_storage, + ) + provider._initialized = True + + prm = ProtectedResourceMetadata( + resource=AnyHttpUrl("https://api.example.com/v1/mcp"), + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + ) + # Should not raise + await provider._validate_resource_match(prm) + + +@pytest.mark.anyio +async def test_validate_resource_accepts_root_url_with_trailing_slash( + client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage +) -> None: + """Root URLs with trailing slash normalization should match.""" + provider = OAuthClientProvider( + server_url="https://api.example.com/", + client_metadata=client_metadata, + storage=mock_storage, + ) + provider._initialized = True + + prm = ProtectedResourceMetadata( + resource=AnyHttpUrl("https://api.example.com/"), + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + ) + # Should not raise - both already have trailing slashes + await provider._validate_resource_match(prm) + + +@pytest.mark.anyio +async def test_get_resource_url_falls_back_when_prm_mismatches( + client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage +) -> None: + """get_resource_url returns canonical URL when PRM resource doesn't match.""" + provider = OAuthClientProvider( + server_url="https://api.example.com/v1/mcp", + client_metadata=client_metadata, + storage=mock_storage, + ) + provider._initialized = True + + # Set PRM with a resource that is NOT a parent of the server URL + provider.context.protected_resource_metadata = ProtectedResourceMetadata( + resource=AnyHttpUrl("https://other.example.com/mcp"), + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + ) + + # get_resource_url should return the canonical server URL, not the PRM resource + assert provider.context.get_resource_url() == "https://api.example.com/v1/mcp" diff --git a/tests/client/test_stdio.py b/tests/client/test_stdio.py index ba58da7321..987a3ca486 100644 --- a/tests/client/test_stdio.py +++ b/tests/client/test_stdio.py @@ -1,4 +1,5 @@ import errno +import gc import os import shutil import sys @@ -8,9 +9,16 @@ import anyio import pytest +from anyio.abc import Process from mcp.client.session import ClientSession -from mcp.client.stdio import StdioServerParameters, _create_platform_compatible_process, stdio_client +from mcp.client.stdio import ( + StdioServerParameters, + _create_platform_compatible_process, + _terminate_process_tree, + stdio_client, +) +from mcp.os.win32.utilities import FallbackProcess from mcp.shared.exceptions import McpError from mcp.shared.message import SessionMessage from mcp.types import CONNECTION_CLOSED, JSONRPCMessage, JSONRPCRequest, JSONRPCResponse @@ -219,6 +227,79 @@ def sigint_handler(signum, frame): raise +async def _wait_for_first_write(path: str) -> None: + """Poll until the file at *path* exists and has grown beyond its initial empty state. + + The marker files below are created empty before the writer is spawned, so any + growth proves the writing process booted and reached its write loop. Polling + replaces fixed startup sleeps, which flake on loaded machines where interpreter + startup can exceed any fixed window. Bounded so a writer that never starts + fails the test instead of hanging it. + """ + with anyio.fail_after(15): + while not os.path.exists(path) or os.path.getsize(path) == 0: + await anyio.sleep(0.05) + + +async def _wait_for_writes_to_stop(path: str) -> None: + """Poll until the file at *path* stops growing. + + Returns once the size is unchanged across three successive 0.3 second gaps + (each three times the writers' 0.1 second write interval), so a writer that + is merely starved of CPU for a single gap is not mistaken for a terminated + one. Any observed growth resets the consecutive-stable counter. The sentinel + forces at least one non-stable iteration before counting starts. If the file + never stops growing, the timeout fails the test: a writer that survives + _terminate_process_tree is a genuine cleanup failure that must not be masked. + """ + last_size = -1 + stable_pairs = 0 + with anyio.fail_after(15): + while True: + current_size = os.path.getsize(path) + if current_size == last_size: + stable_pairs += 1 + else: + stable_pairs = 0 + last_size = current_size + if stable_pairs == 3: + return + await anyio.sleep(0.3) + + +async def _dispose_process(proc: Process | FallbackProcess) -> None: + """Reap a dead process and close its pipe streams inside the test that spawned it. + + Without this, the subprocess transports stay referenced by the per-test event + loop, become garbage only after that loop closes, and their GC-time + ResourceWarnings fire during a later test on the same worker (on Windows + proactor the warning can itself die in __repr__ on a closed pipe). An in-test + gc.collect() cannot catch that, so the process is reaped and closed + deterministically here. Draining stdout to EOF guarantees the event loop has + observed the pipe closure (anyio's reader aclose alone does not close the + underlying transport), which lets asyncio close the subprocess transport + before the test returns. + + Precondition: the WHOLE process tree must already be confirmed dead. wait() + tolerates an already-exited process and returns promptly, but on the Windows + fallback path it runs popen.wait in a thread and is effectively uncancellable, + and stdout only reaches EOF once every tree member that inherited the pipe + handle is gone. The timeout fails the test rather than hanging it if that + precondition is ever violated. + """ + with anyio.fail_after(15): + await proc.wait() + assert proc.stdin is not None + await proc.stdin.aclose() + assert proc.stdout is not None + while True: + try: + await proc.stdout.receive() + except anyio.EndOfStream: + break + await proc.stdout.aclose() + + class TestChildProcessCleanup: """ Tests for child process cleanup functionality using _terminate_process_tree. @@ -259,84 +340,71 @@ async def test_basic_child_process_cleanup(self): with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: parent_marker = f.name - try: - # Parent script that spawns a child process - parent_script = textwrap.dedent( - f""" - import subprocess - import sys - import time - import os - - # Mark that parent started - with open({escape_path_for_python(parent_marker)}, 'w') as f: - f.write('parent started\\n') - - # Child script that writes continuously - child_script = f''' - import time - with open({escape_path_for_python(marker_file)}, 'a') as f: - while True: - f.write(f"{time.time()}") - f.flush() - time.sleep(0.1) - ''' - - # Start the child process - child = subprocess.Popen([sys.executable, '-c', child_script]) - - # Parent just sleeps + # Parent script that spawns a child process + parent_script = textwrap.dedent( + f""" + import subprocess + import sys + import time + import os + + # Mark that parent started + with open({escape_path_for_python(parent_marker)}, 'w') as f: + f.write('parent started\\n') + + # Child script that writes continuously + child_script = f''' + import time + with open({escape_path_for_python(marker_file)}, 'a') as f: while True: + f.write(f"{time.time()}") + f.flush() time.sleep(0.1) - """ - ) + ''' - print("\nStarting child process termination test...") + # Start the child process + child = subprocess.Popen([sys.executable, '-c', child_script]) - # Start the parent process - proc = await _create_platform_compatible_process(sys.executable, ["-c", parent_script]) + # Parent just sleeps + while True: + time.sleep(0.1) + """ + ) - # Wait for processes to start - await anyio.sleep(0.5) + # Start the parent process + proc = await _create_platform_compatible_process(sys.executable, ["-c", parent_script]) + tree_killed = False - # Verify parent started - assert os.path.exists(parent_marker), "Parent process didn't start" + try: + # Wait for the parent to start and the child to reach its write loop + await _wait_for_first_write(parent_marker) + assert os.path.getsize(parent_marker) > 0, "Parent process didn't start" - # Verify child is writing - if os.path.exists(marker_file): # pragma: no branch - initial_size = os.path.getsize(marker_file) - await anyio.sleep(0.3) - size_after_wait = os.path.getsize(marker_file) - assert size_after_wait > initial_size, "Child process should be writing" - print(f"Child is writing (file grew from {initial_size} to {size_after_wait} bytes)") + await _wait_for_first_write(marker_file) + assert os.path.getsize(marker_file) > 0, "Child process should be writing" # Terminate using our function - print("Terminating process and children...") - from mcp.client.stdio import _terminate_process_tree - await _terminate_process_tree(proc) + tree_killed = True - # Verify processes stopped - await anyio.sleep(0.5) - if os.path.exists(marker_file): # pragma: no branch - size_after_cleanup = os.path.getsize(marker_file) - await anyio.sleep(0.5) - final_size = os.path.getsize(marker_file) - - print(f"After cleanup: file size {size_after_cleanup} -> {final_size}") - assert final_size == size_after_cleanup, ( - f"Child process still running! File grew by {final_size - size_after_cleanup} bytes" - ) - - print("SUCCESS: Child process was properly terminated") + # Verify the child stopped writing; a survivor times out and fails the test + await _wait_for_writes_to_stop(marker_file) + # Tree is dead: reap and close the process so nothing leaks into later tests + await _dispose_process(proc) finally: + if not tree_killed: # pragma: no cover - cleanup only reached when the test failed mid-flight + await _terminate_process_tree(proc) + await _dispose_process(proc) # Clean up files for f in [marker_file, parent_marker]: try: os.unlink(f) except OSError: # pragma: no cover pass + # Collect subprocess transports now, while this test's warning filters + # are active, so GC-time ResourceWarnings cannot hit a later test + gc.collect() @pytest.mark.anyio @pytest.mark.filterwarnings("ignore::ResourceWarning" if sys.platform == "win32" else "default") @@ -353,88 +421,83 @@ async def test_nested_process_tree(self): with tempfile.NamedTemporaryFile(mode="w", delete=False) as f3: grandchild_file = f3.name - try: - # Simple nested process tree test - # We create parent -> child -> grandchild, each writing to a file - parent_script = textwrap.dedent( - f""" - import subprocess - import sys - import time - import os - - # Child will spawn grandchild and write to child file - child_script = f'''import subprocess - import sys - import time - - # Grandchild just writes to file - grandchild_script = \"\"\"import time - with open({escape_path_for_python(grandchild_file)}, 'a') as f: - while True: - f.write(f"gc {{time.time()}}") - f.flush() - time.sleep(0.1)\"\"\" - - # Spawn grandchild - subprocess.Popen([sys.executable, '-c', grandchild_script]) - - # Child writes to its file - with open({escape_path_for_python(child_file)}, 'a') as f: - while True: - f.write(f"c {time.time()}") - f.flush() - time.sleep(0.1)''' - - # Spawn child process - subprocess.Popen([sys.executable, '-c', child_script]) - - # Parent writes to its file - with open({escape_path_for_python(parent_file)}, 'a') as f: - while True: - f.write(f"p {time.time()}") - f.flush() - time.sleep(0.1) - """ - ) + # Simple nested process tree test + # We create parent -> child -> grandchild, each writing to a file + parent_script = textwrap.dedent( + f""" + import subprocess + import sys + import time + import os + + # Child will spawn grandchild and write to child file + child_script = f'''import subprocess + import sys + import time + + # Grandchild just writes to file + grandchild_script = \"\"\"import time + with open({escape_path_for_python(grandchild_file)}, 'a') as f: + while True: + f.write(f"gc {{time.time()}}") + f.flush() + time.sleep(0.1)\"\"\" - # Start the parent process - proc = await _create_platform_compatible_process(sys.executable, ["-c", parent_script]) + # Spawn grandchild + subprocess.Popen([sys.executable, '-c', grandchild_script]) - # Let all processes start - await anyio.sleep(1.0) + # Child writes to its file + with open({escape_path_for_python(child_file)}, 'a') as f: + while True: + f.write(f"c {time.time()}") + f.flush() + time.sleep(0.1)''' - # Verify all are writing - for file_path, name in [(parent_file, "parent"), (child_file, "child"), (grandchild_file, "grandchild")]: - if os.path.exists(file_path): # pragma: no branch - initial_size = os.path.getsize(file_path) - await anyio.sleep(0.3) - new_size = os.path.getsize(file_path) - assert new_size > initial_size, f"{name} process should be writing" + # Spawn child process + subprocess.Popen([sys.executable, '-c', child_script]) - # Terminate the whole tree - from mcp.client.stdio import _terminate_process_tree + # Parent writes to its file + with open({escape_path_for_python(parent_file)}, 'a') as f: + while True: + f.write(f"p {time.time()}") + f.flush() + time.sleep(0.1) + """ + ) - await _terminate_process_tree(proc) + # Start the parent process + proc = await _create_platform_compatible_process(sys.executable, ["-c", parent_script]) + tree_killed = False - # Verify all stopped - await anyio.sleep(0.5) + try: + # Wait for every level of the tree to reach its write loop for file_path, name in [(parent_file, "parent"), (child_file, "child"), (grandchild_file, "grandchild")]: - if os.path.exists(file_path): # pragma: no branch - size1 = os.path.getsize(file_path) - await anyio.sleep(0.3) - size2 = os.path.getsize(file_path) - assert size1 == size2, f"{name} still writing after cleanup!" + await _wait_for_first_write(file_path) + assert os.path.getsize(file_path) > 0, f"{name} process should be writing" + + # Terminate the whole tree + await _terminate_process_tree(proc) + tree_killed = True - print("SUCCESS: All processes in tree terminated") + # Verify every level stopped writing; a survivor times out and fails the test + for file_path in (parent_file, child_file, grandchild_file): + await _wait_for_writes_to_stop(file_path) + # Tree is dead: reap and close the process so nothing leaks into later tests + await _dispose_process(proc) finally: + if not tree_killed: # pragma: no cover - cleanup only reached when the test failed mid-flight + await _terminate_process_tree(proc) + await _dispose_process(proc) # Clean up all marker files for f in [parent_file, child_file, grandchild_file]: try: os.unlink(f) except OSError: # pragma: no cover pass + # Collect subprocess transports now, while this test's warning filters + # are active, so GC-time ResourceWarnings cannot hit a later test + gc.collect() @pytest.mark.anyio @pytest.mark.filterwarnings("ignore::ResourceWarning" if sys.platform == "win32" else "default") @@ -448,72 +511,67 @@ async def test_early_parent_exit(self): with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: marker_file = f.name - try: - # Parent that spawns child and waits briefly - parent_script = textwrap.dedent( - f""" - import subprocess - import sys - import time - import signal - - # Child that continues running - child_script = f'''import time - with open({escape_path_for_python(marker_file)}, 'a') as f: - while True: - f.write(f"child {time.time()}") - f.flush() - time.sleep(0.1)''' - - # Start child in same process group - subprocess.Popen([sys.executable, '-c', child_script]) - - # Parent waits a bit then exits on SIGTERM - def handle_term(sig, frame): - sys.exit(0) - - signal.signal(signal.SIGTERM, handle_term) - - # Wait + # Parent that spawns child and waits briefly + parent_script = textwrap.dedent( + f""" + import subprocess + import sys + import time + import signal + + # Child that continues running + child_script = f'''import time + with open({escape_path_for_python(marker_file)}, 'a') as f: while True: - time.sleep(0.1) - """ - ) + f.write(f"child {time.time()}") + f.flush() + time.sleep(0.1)''' - # Start the parent process - proc = await _create_platform_compatible_process(sys.executable, ["-c", parent_script]) + # Start child in same process group + subprocess.Popen([sys.executable, '-c', child_script]) - # Let child start writing - await anyio.sleep(0.5) + # Parent waits a bit then exits on SIGTERM + def handle_term(sig, frame): + sys.exit(0) - # Verify child is writing - if os.path.exists(marker_file): # pragma: no cover - size1 = os.path.getsize(marker_file) - await anyio.sleep(0.3) - size2 = os.path.getsize(marker_file) - assert size2 > size1, "Child should be writing" + signal.signal(signal.SIGTERM, handle_term) - # Terminate - this will kill the process group even if parent exits first - from mcp.client.stdio import _terminate_process_tree + # Wait + while True: + time.sleep(0.1) + """ + ) - await _terminate_process_tree(proc) + # Start the parent process + proc = await _create_platform_compatible_process(sys.executable, ["-c", parent_script]) + tree_killed = False + + try: + # Wait for the child to reach its write loop + await _wait_for_first_write(marker_file) + assert os.path.getsize(marker_file) > 0, "Child should be writing" - # Verify child stopped - await anyio.sleep(0.5) - if os.path.exists(marker_file): # pragma: no branch - size3 = os.path.getsize(marker_file) - await anyio.sleep(0.3) - size4 = os.path.getsize(marker_file) - assert size3 == size4, "Child should be terminated" + # Terminate - this will kill the process group even if parent exits first + await _terminate_process_tree(proc) + tree_killed = True - print("SUCCESS: Child terminated even with parent exit during cleanup") + # Verify the child stopped writing; a survivor times out and fails the test + await _wait_for_writes_to_stop(marker_file) + # Tree is dead: reap and close the process so nothing leaks into later tests + await _dispose_process(proc) finally: + if not tree_killed: # pragma: no cover - cleanup only reached when the test failed mid-flight + await _terminate_process_tree(proc) + await _dispose_process(proc) # Clean up marker file try: os.unlink(marker_file) except OSError: # pragma: no cover pass + # Collect subprocess transports now, while this test's warning filters + # are active, so GC-time ResourceWarnings cannot hit a later test + gc.collect() @pytest.mark.anyio diff --git a/tests/experimental/tasks/conftest.py b/tests/experimental/tasks/conftest.py new file mode 100644 index 0000000000..77502190a2 --- /dev/null +++ b/tests/experimental/tasks/conftest.py @@ -0,0 +1,18 @@ +"""Shared configuration for the experimental tasks suite.""" + +from pathlib import Path + +import pytest + +_HERE = Path(__file__).parent + +# The tasks suite intentionally exercises the deprecated experimental tasks API. +_TASKS_DEPRECATION_IGNORE = pytest.mark.filterwarnings( + "ignore:The experimental tasks API is deprecated:DeprecationWarning" +) + + +def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: + for item in items: + if _HERE in item.path.parents: + item.add_marker(_TASKS_DEPRECATION_IGNORE) diff --git a/tests/experimental/tasks/server/test_run_task_flow.py b/tests/experimental/tasks/server/test_run_task_flow.py index 7f680beb66..9e21746e28 100644 --- a/tests/experimental/tasks/server/test_run_task_flow.py +++ b/tests/experimental/tasks/server/test_run_task_flow.py @@ -227,6 +227,10 @@ async def test_enable_tasks_auto_registers_handlers() -> None: assert caps_after.tasks is not None assert caps_after.tasks.list is not None assert caps_after.tasks.cancel is not None + # Verify nested call capability is present + assert caps_after.tasks.requests is not None + assert caps_after.tasks.requests.tools is not None + assert caps_after.tasks.requests.tools.call is not None @pytest.mark.anyio diff --git a/tests/experimental/tasks/server/test_server.py b/tests/experimental/tasks/server/test_server.py index 7209ed412a..08099fc507 100644 --- a/tests/experimental/tasks/server/test_server.py +++ b/tests/experimental/tasks/server/test_server.py @@ -312,7 +312,7 @@ async def run_server(): async with anyio.create_task_group() as tg: async def handle_messages(): - async for message in server_session.incoming_messages: + async for message in server_session.incoming_messages: # pragma: no cover await server._handle_message(message, server_session, {}, False) tg.start_soon(handle_messages) @@ -392,7 +392,7 @@ async def run_server(): ) as server_session: async with anyio.create_task_group() as tg: - async def handle_messages(): + async def handle_messages(): # pragma: no cover async for message in server_session.incoming_messages: await server._handle_message(message, server_session, {}, False) @@ -506,13 +506,14 @@ async def run_server() -> None: # Create a task directly in the store for testing task = await store.create_task(TaskMetadata(ttl=60000)) - # Test list_tasks (default handler) + # Test list_tasks (default handler). Tasks created directly in the + # store have no session scope, so they are reachable by ID but not + # included in tasks/list (see test_task_scope.py). list_result = await client_session.send_request( ClientRequest(ListTasksRequest()), ListTasksResult, ) - assert len(list_result.tasks) == 1 - assert list_result.tasks[0].taskId == task.taskId + assert list_result.tasks == [] # Test get_task (default handler - found) get_result = await client_session.send_request( diff --git a/tests/experimental/tasks/server/test_task_result_handler.py b/tests/experimental/tasks/server/test_task_result_handler.py index db5b9edc70..411d318ed1 100644 --- a/tests/experimental/tasks/server/test_task_result_handler.py +++ b/tests/experimental/tasks/server/test_task_result_handler.py @@ -67,6 +67,29 @@ async def test_handle_returns_result_for_completed_task( assert "io.modelcontextprotocol/related-task" in response.meta +@pytest.mark.anyio +async def test_handle_omits_none_fields_from_completed_task_payload( + store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue, handler: TaskResultHandler +) -> None: + """Test task result payloads omit optional None fields instead of serializing null.""" + task = await store.create_task(TaskMetadata(ttl=60000), task_id="test-task") + result = CallToolResult(content=[TextContent(type="text", text="Done!")]) + await store.store_result(task.taskId, result) + await store.update_task(task.taskId, status="completed") + + mock_session = Mock() + mock_session.send_message = AsyncMock() + + request = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(taskId=task.taskId)) + response = await handler.handle(request, mock_session, "req-1") + + payload = response.model_dump(by_alias=True, mode="json") + assert payload["content"] == [{"type": "text", "text": "Done!"}] + assert "annotations" not in payload["content"][0] + assert "_meta" not in payload["content"][0] + assert "io.modelcontextprotocol/related-task" in payload["_meta"] + + @pytest.mark.anyio async def test_handle_raises_for_nonexistent_task( store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue, handler: TaskResultHandler diff --git a/tests/experimental/tasks/server/test_task_scope.py b/tests/experimental/tasks/server/test_task_scope.py new file mode 100644 index 0000000000..c13b728a86 --- /dev/null +++ b/tests/experimental/tasks/server/test_task_scope.py @@ -0,0 +1,150 @@ +"""Unit tests for the task session-scope helpers. + +A session scope is an opaque marker assigned to each session by +TaskSupport.configure_session(). Task IDs generated by run_task() embed it so +the default task handlers can tell which session created a task. See +test_task_visibility.py for the end-to-end behaviour these helpers produce. +""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +import anyio +import pytest + +from mcp.server import Server +from mcp.server.experimental.task_scope import ( + new_session_scope, + scoped_task_id, + session_scope_of, + task_in_session_scope, + task_listable_in_session_scope, +) +from mcp.server.experimental.task_support import TaskSupport +from mcp.server.lowlevel import NotificationOptions +from mcp.server.models import InitializationOptions +from mcp.server.session import ServerSession +from mcp.shared.message import SessionMessage + + +def test_new_session_scope_is_unique() -> None: + assert new_session_scope() != new_session_scope() + + +def test_scoped_task_id_round_trips_its_scope() -> None: + scope = new_session_scope() + + task_id = scoped_task_id(scope) + + assert session_scope_of(task_id) == scope + + +def test_scoped_task_ids_are_unique_within_a_scope() -> None: + scope = new_session_scope() + + assert scoped_task_id(scope) != scoped_task_id(scope) + + +@pytest.mark.parametrize( + "task_id", + [ + "plain-task-id", + "550e8400-e29b-41d4-a716-446655440000", # bare uuid4 + "", + # Right shape but the scope half is not 32 hex chars. + "not-a-scope:550e8400-e29b-41d4-a716-446655440000", + # Right scope half but the suffix is not a uuid4. + "0123456789abcdef0123456789abcdef:not-a-uuid", + # Uppercase hex is not produced by new_session_scope(). + "0123456789ABCDEF0123456789ABCDEF:550e8400-e29b-41d4-a716-446655440000", + ], +) +def test_session_scope_of_returns_none_for_unscoped_ids(task_id: str) -> None: + assert session_scope_of(task_id) is None + + +def test_a_scoped_task_is_usable_only_from_the_scope_that_created_it() -> None: + scope = new_session_scope() + task_id = scoped_task_id(scope) + + assert task_in_session_scope(task_id, scope) is True + assert task_in_session_scope(task_id, new_session_scope()) is False + assert task_in_session_scope(task_id, None) is False + + +def test_an_unscoped_task_is_usable_from_any_scope() -> None: + assert task_in_session_scope("plain-task-id", new_session_scope()) is True + assert task_in_session_scope("plain-task-id", None) is True + + +def test_a_scoped_task_is_listable_only_in_the_scope_that_created_it() -> None: + scope = new_session_scope() + task_id = scoped_task_id(scope) + + assert task_listable_in_session_scope(task_id, scope) is True + assert task_listable_in_session_scope(task_id, new_session_scope()) is False + assert task_listable_in_session_scope(task_id, None) is False + + +def test_an_unscoped_task_is_never_listable() -> None: + assert task_listable_in_session_scope("plain-task-id", new_session_scope()) is False + assert task_listable_in_session_scope("plain-task-id", None) is False + + +@asynccontextmanager +async def _make_session() -> AsyncIterator[ServerSession]: + """Create a ServerSession suitable for inspecting configure_session().""" + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage | Exception](1) + options = InitializationOptions( + server_name="test", + server_version="0", + capabilities=Server("test").get_capabilities(NotificationOptions(), {}), + ) + async with ( + server_to_client_receive, + client_to_server_send, + ServerSession(client_to_server_receive, server_to_client_send, options) as session, + ): + yield session + + +@pytest.mark.anyio +async def test_configure_session_assigns_a_scope() -> None: + support = TaskSupport.in_memory() + async with _make_session() as session: + assert session.experimental.task_session_scope is None + + support.configure_session(session) + + assert session.experimental.task_session_scope is not None + + +@pytest.mark.anyio +async def test_configure_session_assigns_distinct_scopes_per_session() -> None: + support = TaskSupport.in_memory() + async with _make_session() as first, _make_session() as second: + support.configure_session(first) + support.configure_session(second) + + assert first.experimental.task_session_scope != second.experimental.task_session_scope + + +@pytest.mark.anyio +async def test_configure_session_is_idempotent() -> None: + support = TaskSupport.in_memory() + async with _make_session() as session: + support.configure_session(session) + scope = session.experimental.task_session_scope + support.configure_session(session) + + assert session.experimental.task_session_scope == scope + + +@pytest.mark.anyio +async def test_configure_session_assigns_no_scope_to_stateless_sessions() -> None: + support = TaskSupport.in_memory() + async with _make_session() as session: + support.configure_session(session, stateless=True) + + assert session.experimental.task_session_scope is None diff --git a/tests/experimental/tasks/server/test_task_visibility.py b/tests/experimental/tasks/server/test_task_visibility.py new file mode 100644 index 0000000000..2ee16399ea --- /dev/null +++ b/tests/experimental/tasks/server/test_task_visibility.py @@ -0,0 +1,321 @@ +"""End-to-end tests for which clients can see and control a task. + +Every test runs a real server and one or more in-memory client sessions. A +task started with run_task() belongs to the client session that started it: +that session can poll it, list it, and cancel it, while every other session +is told the task does not exist. Tasks whose IDs carry no session marker +(explicitly chosen IDs, or tasks on stateless servers) are usable by any +session that knows the ID, but are never listed. +""" + +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import AsyncExitStack +from typing import Any + +import anyio +import pytest +from anyio.abc import TaskGroup + +from mcp.client.session import ClientSession +from mcp.server import Server +from mcp.server.experimental.task_context import ServerTaskContext +from mcp.shared.exceptions import McpError +from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore +from mcp.shared.experimental.tasks.store import TaskStore +from mcp.shared.message import SessionMessage +from mcp.types import ( + TASK_REQUIRED, + CallToolResult, + CreateTaskResult, + ListTasksResult, + TextContent, + Tool, + ToolExecution, +) + +# The `connect` fixture: each call opens a new client session against the test server. +Connect = Callable[..., Awaitable[ClientSession]] + +# Enough tasks that the bundled in-memory store needs more than one page (of 10) +# to list them, so listings that span store pages are exercised. +MORE_TASKS_THAN_ONE_STORE_PAGE = 11 + + +def build_task_server(store: TaskStore | None = None) -> Server: + """Build a server exposing three task tools. + + - "greet" finishes immediately and returns a greeting. + - "long_running_job" keeps running until the server shuts down. + - "nightly_export" is a singleton job: every invocation uses the + explicitly chosen task ID "the-nightly-export". + """ + server = Server("task-visibility-test-server") + server.experimental.enable_tasks(store=store) + + @server.list_tools() + async def list_tools() -> list[Tool]: + return [ + Tool( + name=name, + description=name, + inputSchema={"type": "object"}, + execution=ToolExecution(taskSupport=TASK_REQUIRED), + ) + for name in ("greet", "long_running_job", "nightly_export") + ] + + @server.call_tool() + async def call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult | CreateTaskResult: + async def greet(task: ServerTaskContext) -> CallToolResult: + return CallToolResult(content=[TextContent(type="text", text=f"Hello, {arguments['name']}!")]) + + async def long_running_job(task: ServerTaskContext) -> CallToolResult: + await anyio.sleep_forever() + raise AssertionError("unreachable") # pragma: no cover + + async def nightly_export(task: ServerTaskContext) -> CallToolResult: + return CallToolResult(content=[TextContent(type="text", text="exported")]) + + run_task = server.request_context.experimental.run_task + if name == "nightly_export": + return await run_task(nightly_export, task_id="the-nightly-export") + return await run_task(greet if name == "greet" else long_running_job) + + return server + + +async def open_client( + server: Server, task_group: TaskGroup, stack: AsyncExitStack, *, stateless: bool = False +) -> ClientSession: + """Connect a new client session to `server` over in-memory streams.""" + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) + + async def run_server() -> None: + await server.run( + client_to_server_receive, + server_to_client_send, + server.create_initialization_options(), + stateless=stateless, + ) + + task_group.start_soon(run_server) + client = await stack.enter_async_context(ClientSession(server_to_client_receive, client_to_server_send)) + await client.initialize() + return client + + +@pytest.fixture +def task_server() -> Server: + return build_task_server() + + +@pytest.fixture +async def connect(task_server: Server) -> AsyncIterator[Connect]: + """A factory that opens a new client session against the test server on each call.""" + async with anyio.create_task_group() as task_group, AsyncExitStack() as stack: + + async def _connect(*, stateless: bool = False) -> ClientSession: + return await open_client(task_server, task_group, stack, stateless=stateless) + + yield _connect + task_group.cancel_scope.cancel() + + +async def start_task(client: ClientSession, tool: str = "long_running_job", **arguments: Any) -> str: + """Start `tool` as a task and return the new task's ID.""" + result = await client.experimental.call_tool_as_task(tool, arguments) + return result.task.taskId + + +async def wait_until_finished(client: ClientSession, task_id: str) -> None: + """Poll the task until it reaches a terminal status.""" + with anyio.fail_after(5): + async for _ in client.experimental.poll_task(task_id): + pass + + +async def listed_task_ids(client: ClientSession) -> list[str]: + """Return the IDs of every task the server lists for this client.""" + return [task.taskId for task in (await client.experimental.list_tasks()).tasks] + + +# --- What the client that started a task can do with it --- + + +@pytest.mark.anyio +async def test_a_client_can_poll_its_own_task_to_completion_and_read_the_result(connect: Connect) -> None: + client = await connect() + task_id = await start_task(client, "greet", name="Ada") + await wait_until_finished(client, task_id) + + result = await client.experimental.get_task_result(task_id, CallToolResult) + + assert result.content == [TextContent(type="text", text="Hello, Ada!")] + + +@pytest.mark.anyio +async def test_a_client_sees_its_own_task_when_listing_tasks(connect: Connect) -> None: + client = await connect() + task_id = await start_task(client) + + listed = await listed_task_ids(client) + + assert listed == [task_id] + + +@pytest.mark.anyio +async def test_a_client_can_cancel_its_own_task(connect: Connect) -> None: + client = await connect() + task_id = await start_task(client) + + cancelled = await client.experimental.cancel_task(task_id) + + assert cancelled.status == "cancelled" + + +# --- What a client cannot do with a task started by another client --- + + +@pytest.mark.anyio +async def test_a_client_cannot_get_the_status_of_another_clients_task(connect: Connect) -> None: + creator = await connect() + other_client = await connect() + task_id = await start_task(creator) + + with pytest.raises(McpError, match="Task not found"): + await other_client.experimental.get_task(task_id) + + +@pytest.mark.anyio +async def test_a_client_cannot_get_the_result_of_another_clients_task(connect: Connect) -> None: + creator = await connect() + other_client = await connect() + task_id = await start_task(creator, "greet", name="Ada") + await wait_until_finished(creator, task_id) + + with pytest.raises(McpError, match="Task not found"): + await other_client.experimental.get_task_result(task_id, CallToolResult) + + +@pytest.mark.anyio +async def test_a_client_cannot_cancel_another_clients_task(connect: Connect) -> None: + creator = await connect() + other_client = await connect() + task_id = await start_task(creator) + + with pytest.raises(McpError, match="Task not found"): + await other_client.experimental.cancel_task(task_id) + + # The task is unaffected. + assert (await creator.experimental.get_task(task_id)).status == "working" + + +@pytest.mark.anyio +async def test_a_client_does_not_see_another_clients_task_when_listing_tasks(connect: Connect) -> None: + creator = await connect() + other_client = await connect() + await start_task(creator) + + listed = await listed_task_ids(other_client) + + assert listed == [] + + +@pytest.mark.anyio +async def test_each_client_lists_only_its_own_tasks(connect: Connect) -> None: + first_client = await connect() + second_client = await connect() + first_task = await start_task(first_client) + second_task = await start_task(second_client) + + assert await listed_task_ids(first_client) == [first_task] + assert await listed_task_ids(second_client) == [second_task] + + +@pytest.mark.anyio +async def test_listing_tasks_reveals_nothing_about_other_clients_tasks_however_many_there_are( + connect: Connect, +) -> None: + """The listing must not identify other clients' tasks through any field, including the pagination cursor.""" + creator = await connect() + other_client = await connect() + for _ in range(MORE_TASKS_THAN_ONE_STORE_PAGE): + await start_task(creator) + + listing = await other_client.experimental.list_tasks() + + assert listing == ListTasksResult(tasks=[], nextCursor=None) + + +@pytest.mark.anyio +async def test_a_client_with_more_than_one_store_page_of_tasks_lists_all_of_them(connect: Connect) -> None: + client = await connect() + started = {await start_task(client) for _ in range(MORE_TASKS_THAN_ONE_STORE_PAGE)} + + listing = await client.experimental.list_tasks() + + assert {task.taskId for task in listing.tasks} == started + assert listing.nextCursor is None + + +# --- Tasks that do not belong to any client session --- + + +@pytest.mark.anyio +# Choosing the task ID instead of letting the SDK generate one is deprecated for +# exactly the behaviour this test demonstrates: the task is not tied to the +# session that created it. +@pytest.mark.filterwarnings("ignore:Passing an explicit task_id") +async def test_a_task_whose_id_was_chosen_by_the_server_is_accessible_to_every_client(connect: Connect) -> None: + creator = await connect() + other_client = await connect() + await wait_until_finished(creator, await start_task(creator, "nightly_export")) + + status = await other_client.experimental.get_task("the-nightly-export") + + assert status.status == "completed" + + +@pytest.mark.anyio +async def test_a_stateless_server_serves_a_task_to_any_session_that_knows_its_id(connect: Connect) -> None: + first_session = await connect(stateless=True) + second_session = await connect(stateless=True) + task_id = await start_task(first_session, "greet", name="Ada") + await wait_until_finished(second_session, task_id) + + result = await second_session.experimental.get_task_result(task_id, CallToolResult) + + assert result.content == [TextContent(type="text", text="Hello, Ada!")] + + +@pytest.mark.anyio +async def test_a_stateless_server_lists_no_tasks(connect: Connect) -> None: + session = await connect(stateless=True) + await start_task(session) + + listed = await listed_task_ids(session) + + assert listed == [] + + +# --- The behaviour does not depend on the bundled in-memory store --- + + +@pytest.mark.anyio +async def test_clients_are_isolated_when_the_server_uses_a_custom_task_store() -> None: + class CustomTaskStore(InMemoryTaskStore): + """A stand-in for a user-provided TaskStore implementation.""" + + server = build_task_server(store=CustomTaskStore()) + + async with anyio.create_task_group() as task_group, AsyncExitStack() as stack: + creator = await open_client(server, task_group, stack) + other_client = await open_client(server, task_group, stack) + task_id = await start_task(creator) + + with pytest.raises(McpError, match="Task not found"): + await other_client.experimental.get_task(task_id) + + assert (await creator.experimental.get_task(task_id)).status == "working" + task_group.cancel_scope.cancel() diff --git a/tests/experimental/tasks/test_deprecations.py b/tests/experimental/tasks/test_deprecations.py new file mode 100644 index 0000000000..1056e1fd94 --- /dev/null +++ b/tests/experimental/tasks/test_deprecations.py @@ -0,0 +1,72 @@ +"""Tests for the deprecation warnings on the experimental tasks entry points.""" + +import warnings + +import pytest + +import mcp.types as types +from mcp.client.experimental.task_handlers import ExperimentalTaskHandlers +from mcp.client.session import ClientSession +from mcp.server.lowlevel import Server +from mcp.server.models import InitializationOptions +from mcp.server.session import ServerSession +from mcp.shared.memory import create_client_server_memory_streams, create_connected_server_and_client_session + +_DEPRECATION_MATCH = "The experimental tasks API is deprecated" + + +@pytest.mark.anyio +async def test_client_session_experimental_property_is_deprecated() -> None: + async with create_client_server_memory_streams() as (client_streams, _): + read_stream, write_stream = client_streams + session = ClientSession(read_stream, write_stream) + with pytest.warns(DeprecationWarning, match=_DEPRECATION_MATCH): + features = session.experimental + # The cached path warns as well + with pytest.warns(DeprecationWarning, match=_DEPRECATION_MATCH): + assert session.experimental is features + + +@pytest.mark.anyio +async def test_client_session_experimental_task_handlers_kwarg_is_deprecated() -> None: + async with create_client_server_memory_streams() as (client_streams, _): + read_stream, write_stream = client_streams + with pytest.warns(DeprecationWarning, match=_DEPRECATION_MATCH): + ClientSession(read_stream, write_stream, experimental_task_handlers=ExperimentalTaskHandlers()) + + +@pytest.mark.anyio +async def test_server_session_experimental_property_is_deprecated() -> None: + init_options = InitializationOptions( + server_name="test-server", + server_version="0.1.0", + capabilities=types.ServerCapabilities(), + ) + async with create_client_server_memory_streams() as (_, server_streams): + read_stream, write_stream = server_streams + async with ServerSession(read_stream, write_stream, init_options) as session: + with pytest.warns(DeprecationWarning, match=_DEPRECATION_MATCH): + features = session.experimental + # The cached path warns as well. coverage.py misreports the branch arcs of the + # last statement in a nested `async with` body on Python 3.11+. + with pytest.warns(DeprecationWarning, match=_DEPRECATION_MATCH): # pragma: no branch + assert session.experimental is features + + +def test_lowlevel_server_experimental_property_is_deprecated() -> None: + server: Server = Server("test-server") + with pytest.warns(DeprecationWarning, match=_DEPRECATION_MATCH): + handlers = server.experimental + # The cached path warns as well + with pytest.warns(DeprecationWarning, match=_DEPRECATION_MATCH): + assert server.experimental is handlers + + +@pytest.mark.anyio +async def test_plain_session_usage_does_not_warn() -> None: + """Clients and servers that don't touch the tasks API must not see deprecation warnings.""" + server: Server = Server("test-server") + with warnings.catch_warnings(): + warnings.simplefilter("error") + async with create_connected_server_and_client_session(server) as session: + await session.send_ping() diff --git a/tests/experimental/tasks/test_request_context.py b/tests/experimental/tasks/test_request_context.py index 5fa5da81af..b204bec4f8 100644 --- a/tests/experimental/tasks/test_request_context.py +++ b/tests/experimental/tasks/test_request_context.py @@ -3,6 +3,7 @@ import pytest from mcp.server.experimental.request_context import Experimental +from mcp.server.experimental.task_context import ServerTaskContext from mcp.shared.exceptions import McpError from mcp.types import ( METHOD_NOT_FOUND, @@ -11,6 +12,7 @@ TASK_REQUIRED, ClientCapabilities, ClientTasksCapability, + Result, TaskMetadata, Tool, ToolExecution, @@ -164,3 +166,30 @@ def test_can_use_tool_forbidden_without_task_support() -> None: def test_can_use_tool_none_without_task_support() -> None: exp = Experimental(_client_capabilities=ClientCapabilities()) assert exp.can_use_tool(None) is True + + +@pytest.mark.anyio +async def test_run_task_with_an_explicit_task_id_emits_a_deprecation_warning() -> None: + """An explicitly provided task ID is not associated with the creating session, so passing one is deprecated.""" + exp = Experimental(task_metadata=TaskMetadata(ttl=60000)) + + async def work(task: ServerTaskContext) -> Result: + raise AssertionError("unreachable") # pragma: no cover + + with pytest.warns(DeprecationWarning, match="not associated with the session"): + # Task support is not configured, so the call fails after the + # deprecated argument has been reported. + with pytest.raises(RuntimeError, match="Task support not enabled"): + # The deliberate use of the deprecated overload is the point of this test. + await exp.run_task(work, task_id="explicitly-chosen") + + +@pytest.mark.anyio +async def test_run_task_without_a_task_id_does_not_warn() -> None: + exp = Experimental(task_metadata=TaskMetadata(ttl=60000)) + + async def work(task: ServerTaskContext) -> Result: + raise AssertionError("unreachable") # pragma: no cover + + with pytest.raises(RuntimeError, match="Task support not enabled"): + await exp.run_task(work) diff --git a/tests/server/fastmcp/auth/test_auth_integration.py b/tests/server/fastmcp/auth/test_auth_integration.py index 08fcabf276..7b7f68c425 100644 --- a/tests/server/fastmcp/auth/test_auth_integration.py +++ b/tests/server/fastmcp/auth/test_auth_integration.py @@ -54,6 +54,7 @@ async def authorize(self, client: OAuthClientInformationFull, params: Authorizat redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly, expires_at=time.time() + 300, scopes=params.scopes or ["read", "write"], + subject="test-user", ) self.auth_codes[code.code] = code @@ -80,6 +81,7 @@ async def exchange_authorization_code( client_id=client.client_id, scopes=authorization_code.scopes, expires_at=int(time.time()) + 3600, + subject=authorization_code.subject, ) self.refresh_tokens[refresh_token] = access_token @@ -109,6 +111,7 @@ async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_t client_id=token_info.client_id, scopes=token_info.scopes, expires_at=token_info.expires_at, + subject=token_info.subject, ) return refresh_obj @@ -142,6 +145,7 @@ async def exchange_refresh_token( client_id=client.client_id, scopes=scopes or token_info.scopes, expires_at=int(time.time()) + 3600, + subject=refresh_token.subject, ) self.refresh_tokens[new_refresh_token] = new_access_token @@ -170,6 +174,7 @@ async def load_access_token(self, token: str) -> AccessToken | None: client_id=token_info.client_id, scopes=token_info.scopes, expires_at=token_info.expires_at, + subject=token_info.subject, ) async def revoke_token(self, token: AccessToken | RefreshToken) -> None: @@ -783,6 +788,7 @@ async def test_authorization_get( assert auth_info.client_id == client_info["client_id"] assert "read" in auth_info.scopes assert "write" in auth_info.scopes + assert auth_info.subject == "test-user" # 6. Refresh the token response = await test_client.post( @@ -803,6 +809,10 @@ async def test_authorization_get( assert new_token_response["access_token"] != access_token assert new_token_response["refresh_token"] != refresh_token + refreshed_auth_info = await mock_oauth_provider.load_access_token(new_token_response["access_token"]) + assert refreshed_auth_info + assert refreshed_auth_info.subject == "test-user" + # 7. Revoke the token response = await test_client.post( "/revoke", diff --git a/tests/server/test_cancel_handling.py b/tests/server/test_cancel_handling.py index 47c49bb62b..e50ed58823 100644 --- a/tests/server/test_cancel_handling.py +++ b/tests/server/test_cancel_handling.py @@ -9,14 +9,21 @@ from mcp.server.lowlevel.server import Server from mcp.shared.exceptions import McpError from mcp.shared.memory import create_connected_server_and_client_session +from mcp.shared.message import SessionMessage from mcp.types import ( + LATEST_PROTOCOL_VERSION, CallToolRequest, CallToolRequestParams, CallToolResult, CancelledNotification, CancelledNotificationParams, + ClientCapabilities, ClientNotification, ClientRequest, + Implementation, + InitializeRequestParams, + JSONRPCNotification, + JSONRPCRequest, Tool, ) @@ -108,3 +115,156 @@ async def first_request(): assert isinstance(content, types.TextContent) assert content.text == "Call number: 2" assert call_count == 2 + + +@pytest.mark.anyio +async def test_server_cancels_in_flight_handlers_on_transport_close(): + """When the transport closes mid-request, server.run() must cancel in-flight + handlers rather than join on them. + + Without the cancel, the task group waits for the handler, which then tries + to respond through a write stream that _receive_loop already closed, + raising ClosedResourceError and crashing server.run() with exit code 1. + + This drives server.run() with raw memory streams because InMemoryTransport + wraps it in its own finally-cancel (_memory.py) which masks the bug. + """ + handler_started = anyio.Event() + handler_cancelled = anyio.Event() + server_run_returned = anyio.Event() + + server = Server("test") + + @server.call_tool() + async def handle_call_tool(name: str, arguments: dict[str, Any] | None) -> list[types.TextContent]: + handler_started.set() + try: + await anyio.sleep_forever() + finally: + handler_cancelled.set() + # unreachable: sleep_forever only exits via cancellation + raise AssertionError # pragma: no cover + + to_server, server_read = anyio.create_memory_object_stream[SessionMessage | Exception](10) + server_write, from_server = anyio.create_memory_object_stream[SessionMessage](10) + + async def run_server(): + await server.run(server_read, server_write, server.create_initialization_options()) + server_run_returned.set() + + init_req = JSONRPCRequest( + jsonrpc="2.0", + id=1, + method="initialize", + params=InitializeRequestParams( + protocolVersion=LATEST_PROTOCOL_VERSION, + capabilities=ClientCapabilities(), + clientInfo=Implementation(name="test", version="1.0"), + ).model_dump(by_alias=True, mode="json", exclude_none=True), + ) + initialized = JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized") + call_req = JSONRPCRequest( + jsonrpc="2.0", + id=2, + method="tools/call", + params=CallToolRequestParams(name="slow", arguments={}).model_dump(by_alias=True, mode="json"), + ) + + with anyio.fail_after(5): + async with anyio.create_task_group() as tg, to_server, server_read, server_write, from_server: + tg.start_soon(run_server) + + await to_server.send(SessionMessage(message=types.JSONRPCMessage(init_req))) + await from_server.receive() # init response + await to_server.send(SessionMessage(message=types.JSONRPCMessage(initialized))) + await to_server.send(SessionMessage(message=types.JSONRPCMessage(call_req))) + + await handler_started.wait() + + # Close the server's input stream — this is what stdin EOF does. + # server.run()'s incoming_messages loop ends, finally-cancel fires, + # handler gets CancelledError, server.run() returns. + await to_server.aclose() + + await server_run_returned.wait() + + assert handler_cancelled.is_set() + + +@pytest.mark.anyio +async def test_server_handles_transport_close_with_pending_server_to_client_requests(): + """When the transport closes while handlers are blocked on server→client + requests (sampling, roots, elicitation), server.run() must still exit cleanly. + + Two bugs covered: + 1. _receive_loop's finally iterates _response_streams with await checkpoints + inside; the woken handler's send_request finally pops from that dict + before the next __next__() — RuntimeError: dictionary changed size. + 2. The woken handler's MCPError is caught in _handle_request, which falls + through to respond() against a write stream _receive_loop already closed. + """ + handlers_started = 0 + both_started = anyio.Event() + server_run_returned = anyio.Event() + + server = Server("test") + + @server.call_tool() + async def handle_call_tool(name: str, arguments: dict[str, Any] | None) -> list[types.TextContent]: + nonlocal handlers_started + handlers_started += 1 + if handlers_started == 2: + both_started.set() + # Blocks on send_request waiting for a client response that never comes. + # _receive_loop's finally will wake this with CONNECTION_CLOSED. + await server.request_context.session.list_roots() + raise AssertionError # pragma: no cover + + to_server, server_read = anyio.create_memory_object_stream[SessionMessage | Exception](10) + server_write, from_server = anyio.create_memory_object_stream[SessionMessage](10) + + async def run_server(): + await server.run(server_read, server_write, server.create_initialization_options()) + server_run_returned.set() + + init_req = JSONRPCRequest( + jsonrpc="2.0", + id=1, + method="initialize", + params=InitializeRequestParams( + protocolVersion=LATEST_PROTOCOL_VERSION, + capabilities=ClientCapabilities(), + clientInfo=Implementation(name="test", version="1.0"), + ).model_dump(by_alias=True, mode="json", exclude_none=True), + ) + initialized = JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized") + + with anyio.fail_after(5): + async with anyio.create_task_group() as tg, to_server, server_read, server_write, from_server: + tg.start_soon(run_server) + + await to_server.send(SessionMessage(message=types.JSONRPCMessage(init_req))) + await from_server.receive() # init response + await to_server.send(SessionMessage(message=types.JSONRPCMessage(initialized))) + + # Two tool calls → two handlers → two _response_streams entries. + for rid in (2, 3): + call_req = JSONRPCRequest( + jsonrpc="2.0", + id=rid, + method="tools/call", + params=CallToolRequestParams(name="t", arguments={}).model_dump(by_alias=True, mode="json"), + ) + await to_server.send(SessionMessage(message=types.JSONRPCMessage(call_req))) + + await both_started.wait() + # Drain the two roots/list requests so send_request's _write_stream.send() + # completes and both handlers are parked at response_stream_reader.receive(). + await from_server.receive() + await from_server.receive() + + await to_server.aclose() + + # Without the fixes: RuntimeError (dict mutation) or ClosedResourceError + # (respond after write-stream close) escapes run_server and this hangs. + await server_run_returned.wait() diff --git a/tests/server/test_lowlevel_input_validation.py b/tests/server/test_lowlevel_input_validation.py index 47cb57232d..0614ad7c46 100644 --- a/tests/server/test_lowlevel_input_validation.py +++ b/tests/server/test_lowlevel_input_validation.py @@ -70,7 +70,7 @@ async def run_server(): async with anyio.create_task_group() as tg: async def handle_messages(): - async for message in server_session.incoming_messages: + async for message in server_session.incoming_messages: # pragma: no cover await server._handle_message(message, server_session, {}, False) tg.start_soon(handle_messages) diff --git a/tests/server/test_lowlevel_output_validation.py b/tests/server/test_lowlevel_output_validation.py index f735445212..b53ddfc826 100644 --- a/tests/server/test_lowlevel_output_validation.py +++ b/tests/server/test_lowlevel_output_validation.py @@ -71,7 +71,7 @@ async def run_server(): async with anyio.create_task_group() as tg: async def handle_messages(): - async for message in server_session.incoming_messages: + async for message in server_session.incoming_messages: # pragma: no cover await server._handle_message(message, server_session, {}, False) tg.start_soon(handle_messages) diff --git a/tests/server/test_lowlevel_tool_annotations.py b/tests/server/test_lowlevel_tool_annotations.py index f812c48777..d968c5f35b 100644 --- a/tests/server/test_lowlevel_tool_annotations.py +++ b/tests/server/test_lowlevel_tool_annotations.py @@ -67,7 +67,7 @@ async def run_server(): async with anyio.create_task_group() as tg: async def handle_messages(): - async for message in server_session.incoming_messages: + async for message in server_session.incoming_messages: # pragma: no cover await server._handle_message(message, server_session, {}, False) tg.start_soon(handle_messages) diff --git a/tests/server/test_session.py b/tests/server/test_session.py index 34f9c6e28e..ba1b44126d 100644 --- a/tests/server/test_session.py +++ b/tests/server/test_session.py @@ -410,11 +410,8 @@ async def test_create_message_tool_result_validation(): # Case 8: empty messages list - skips validation entirely # Covers the `if messages:` branch (line 280->302) - with anyio.move_on_after(0.01): - await session.create_message( - messages=[], - max_tokens=100, - ) + with anyio.move_on_after(0.01): # pragma: no cover + await session.create_message(messages=[], max_tokens=100) @pytest.mark.anyio diff --git a/tests/server/test_sse_security.py b/tests/server/test_sse_security.py index 010eaf6a25..0978b8a150 100644 --- a/tests/server/test_sse_security.py +++ b/tests/server/test_sse_security.py @@ -1,9 +1,13 @@ -"""Tests for SSE server DNS rebinding protection.""" +"""Tests for SSE server request validation.""" import logging import multiprocessing +import re import socket +from collections.abc import Iterator +from typing import Any +import anyio import httpx import pytest import uvicorn @@ -11,8 +15,11 @@ from starlette.requests import Request from starlette.responses import Response from starlette.routing import Mount, Route +from starlette.types import Message from mcp.server import Server +from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser +from mcp.server.auth.provider import AccessToken from mcp.server.sse import SseServerTransport from mcp.server.transport_security import TransportSecuritySettings from mcp.types import Tool @@ -22,6 +29,23 @@ SERVER_NAME = "test_sse_security_server" +@pytest.fixture(autouse=True) +def reset_sse_starlette_exit_event() -> Iterator[None]: + """sse-starlette<2 caches a module-level anyio.Event on AppStatus; clear it + around each test so it is never bound to a closed event loop. Clearing it + afterwards matters too: later test modules fork uvicorn subprocesses on + Linux and would otherwise inherit a stale event.""" + from sse_starlette.sse import AppStatus + + def clear() -> None: + if hasattr(AppStatus, "should_exit_event"): # pragma: no cover + setattr(AppStatus, "should_exit_event", None) + + clear() + yield + clear() + + @pytest.fixture def server_port() -> int: with socket.socket() as s: @@ -291,3 +315,114 @@ async def test_sse_security_post_valid_content_type(server_port: int): finally: process.terminate() process.join() + + +def _authenticated_user(client_id: str, subject: str | None = None, issuer: str | None = None) -> AuthenticatedUser: + """Build the scope["user"] value that AuthenticationMiddleware would set for this principal.""" + claims = {"iss": issuer} if issuer is not None else None + return AuthenticatedUser(AccessToken(token="token", client_id=client_id, scopes=[], subject=subject, claims=claims)) + + +def _sse_scope(method: str, path: str, user: AuthenticatedUser | None) -> dict[str, Any]: + """Build an ASGI scope for a request to the SSE transport.""" + scope: dict[str, Any] = { + "type": "http", + "method": method, + "path": path, + "root_path": "", + "query_string": b"", + "headers": [(b"content-type", b"application/json")], + } + if user is not None: + scope["user"] = user + return scope + + +async def _post_message(transport: SseServerTransport, session_id: str, user: AuthenticatedUser | None) -> int: + """POST a message to an SSE session as `user` and return the response status.""" + body = b'{"jsonrpc": "2.0", "id": 1, "method": "ping", "params": null}' + scope = _sse_scope("POST", "/messages/", user) + scope["query_string"] = f"session_id={session_id}".encode() + sent: list[Message] = [] + + async def receive() -> Message: + return {"type": "http.request", "body": body, "more_body": False} + + async def send(message: Message) -> None: + sent.append(message) + + await transport.handle_post_message(scope, receive, send) + response_start = next(msg for msg in sent if msg["type"] == "http.response.start") + return response_start["status"] + + +_Principal = tuple[str] | tuple[str, str] | tuple[str, str, str] + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("creator", "sender", "expected"), + [ + pytest.param(("client-a",), ("client-b",), 404, id="different-client"), + pytest.param(("client-a",), None, 404, id="unauthenticated-sender"), + pytest.param(("client-a", "alice"), ("client-a", "bob"), 404, id="same-client-different-subject"), + pytest.param(("client-a", "alice"), ("client-a",), 404, id="same-client-no-subject"), + pytest.param( + ("client-a", "alice", "https://i1"), ("client-a", "alice", "https://i2"), 404, id="different-issuer" + ), + pytest.param(None, ("client-a",), 404, id="unauthenticated-creator"), + pytest.param(("client-a",), ("client-a",), 202, id="same-client"), + pytest.param(("client-a", "alice"), ("client-a", "alice"), 202, id="same-client-and-subject"), + pytest.param(None, None, 202, id="both-unauthenticated"), + ], +) +async def test_sse_post_requires_the_credential_that_created_the_session( + creator: _Principal | None, + sender: _Principal | None, + expected: int, +): + """The session endpoint URL issued to one authenticated principal must not + accept messages from a request authenticated as a different one.""" + transport = SseServerTransport("/messages/") + session_id_received = anyio.Event() + session_ids: list[str] = [] + client_disconnected = anyio.Event() + + async def get_send(message: Message) -> None: + # The first body chunk is the SSE event announcing the session URI to POST messages to. + if message["type"] == "http.response.body" and not session_ids: + match = re.search(rb"session_id=([0-9a-f]{32})", message.get("body", b"")) + assert match is not None, f"expected the endpoint event first, got {message!r}" + session_ids.append(match.group(1).decode()) + session_id_received.set() + + async def get_receive() -> Message: + # The SSE client stays connected until the test signals otherwise. + await client_disconnected.wait() + return {"type": "http.disconnect"} + + creator_user = _authenticated_user(*creator) if creator is not None else None + sender_user = _authenticated_user(*sender) if sender is not None else None + + async def hold_sse_connection() -> None: + """Establish the SSE session as `creator` and keep it open, as a server would.""" + scope = _sse_scope("GET", "/sse", creator_user) + with anyio.fail_after(5): + async with transport.connect_sse(scope, get_receive, get_send) as (read_stream, write_stream): + async with read_stream, write_stream: # pragma: no branch + # ^ coverage.py misses the ->exit arc on 3.11+ when the body + # is nested inside multiple async with blocks + async for _ in read_stream: + pass + + async with anyio.create_task_group() as tg: + tg.start_soon(hold_sse_connection) + with anyio.fail_after(5): + await session_id_received.wait() + + assert await _post_message(transport, session_ids[0], sender_user) == expected + + client_disconnected.set() + + # Once the connection is gone the session is no longer routable. + assert await _post_message(transport, session_ids[0], creator_user) == 404 diff --git a/tests/server/test_stdio.py b/tests/server/test_stdio.py index 13cdde3d61..79467e3f1d 100644 --- a/tests/server/test_stdio.py +++ b/tests/server/test_stdio.py @@ -1,4 +1,6 @@ import io +import sys +from io import TextIOWrapper import anyio import pytest @@ -59,3 +61,34 @@ async def test_stdio_server(): assert len(received_responses) == 2 assert received_responses[0] == JSONRPCMessage(root=JSONRPCRequest(jsonrpc="2.0", id=3, method="ping")) assert received_responses[1] == JSONRPCMessage(root=JSONRPCResponse(jsonrpc="2.0", id=4, result={})) + + +@pytest.mark.anyio +async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch): + """Non-UTF-8 bytes on stdin must not crash the server. + + Invalid bytes are replaced with U+FFFD, which then fails JSON parsing and + is delivered as an in-stream exception. Subsequent valid messages must + still be processed. + """ + # \xff\xfe are invalid UTF-8 start bytes. + valid = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping") + raw_stdin = io.BytesIO(b"\xff\xfe\n" + valid.model_dump_json(by_alias=True, exclude_none=True).encode() + b"\n") + + # Replace sys.stdin with a wrapper whose .buffer is our raw bytes, so that + # stdio_server()'s default path wraps it with errors='replace'. + monkeypatch.setattr(sys, "stdin", TextIOWrapper(raw_stdin, encoding="utf-8")) + monkeypatch.setattr(sys, "stdout", TextIOWrapper(io.BytesIO(), encoding="utf-8")) + + with anyio.fail_after(5): + async with stdio_server() as (read_stream, write_stream): + await write_stream.aclose() + async with read_stream: # pragma: no branch + # First line: \xff\xfe -> U+FFFD U+FFFD -> JSON parse fails -> exception in stream + first = await read_stream.receive() + assert isinstance(first, Exception) + + # Second line: valid message still comes through + second = await read_stream.receive() + assert isinstance(second, SessionMessage) + assert second.message == JSONRPCMessage(root=valid) diff --git a/tests/server/test_streamable_http_manager.py b/tests/server/test_streamable_http_manager.py index af1b23619f..0ae07c43ad 100644 --- a/tests/server/test_streamable_http_manager.py +++ b/tests/server/test_streamable_http_manager.py @@ -6,9 +6,11 @@ import anyio import pytest -from starlette.types import Message +from starlette.types import Message, Scope from mcp.server import streamable_http_manager +from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser +from mcp.server.auth.provider import AccessToken from mcp.server.lowlevel import Server from mcp.server.streamable_http import MCP_SESSION_ID_HEADER, StreamableHTTPServerTransport from mcp.server.streamable_http_manager import StreamableHTTPSessionManager @@ -313,3 +315,243 @@ async def mock_receive(): assert error_data["id"] == "server-error" assert error_data["error"]["code"] == INVALID_REQUEST assert error_data["error"]["message"] == "Session not found" + + +@pytest.mark.anyio +async def test_idle_session_is_reaped(): + """After idle timeout fires, the session returns 404.""" + app = Server("test-idle-reap") + manager = StreamableHTTPSessionManager(app=app, session_idle_timeout=0.05) + + async with manager.run(): + sent_messages: list[Message] = [] + + async def mock_send(message: Message): + sent_messages.append(message) + + scope = { + "type": "http", + "method": "POST", + "path": "/mcp", + "headers": [(b"content-type", b"application/json")], + } + + async def mock_receive(): # pragma: no cover + return {"type": "http.request", "body": b"", "more_body": False} + + await manager.handle_request(scope, mock_receive, mock_send) + + session_id = None + for msg in sent_messages: # pragma: no branch + if msg["type"] == "http.response.start": # pragma: no branch + for header_name, header_value in msg.get("headers", []): # pragma: no branch + if header_name.decode().lower() == MCP_SESSION_ID_HEADER.lower(): + session_id = header_value.decode() + break + if session_id: # pragma: no branch + break + + assert session_id is not None, "Session ID not found in response headers" + + # Wait for the 50ms idle timeout to fire and cleanup to complete + await anyio.sleep(0.1) + + # Verify via public API: old session ID now returns 404 + response_messages: list[Message] = [] + + async def capture_send(message: Message): + response_messages.append(message) + + scope_with_session = { + "type": "http", + "method": "POST", + "path": "/mcp", + "headers": [ + (b"content-type", b"application/json"), + (b"mcp-session-id", session_id.encode()), + ], + } + + await manager.handle_request(scope_with_session, mock_receive, capture_send) + + response_start = next( + (msg for msg in response_messages if msg["type"] == "http.response.start"), + None, + ) + assert response_start is not None + assert response_start["status"] == 404 + + +def test_session_idle_timeout_rejects_non_positive(): + with pytest.raises(ValueError, match="positive number"): + StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=-1) + with pytest.raises(ValueError, match="positive number"): + StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=0) + + +def test_session_idle_timeout_rejects_stateless(): + with pytest.raises(RuntimeError, match="not supported in stateless"): + StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=30, stateless=True) + + +def _user(client_id: str, subject: str | None = None, issuer: str | None = None) -> AuthenticatedUser: + """Build the scope["user"] value that AuthenticationMiddleware would set for this principal.""" + claims = {"iss": issuer} if issuer is not None else None + return AuthenticatedUser(AccessToken(token="token", client_id=client_id, scopes=[], subject=subject, claims=claims)) + + +def _request_scope( + *, session_id: str | None = None, user: AuthenticatedUser | None = None, method: str = "POST" +) -> Scope: + """Build an ASGI scope for a request to the MCP endpoint.""" + headers = [ + (b"content-type", b"application/json"), + (b"accept", b"application/json, text/event-stream"), + ] + if session_id is not None: + headers.append((b"mcp-session-id", session_id.encode())) + scope: Scope = { + "type": "http", + "method": method, + "path": "/mcp", + "headers": headers, + } + if user is not None: + scope["user"] = user + return scope + + +async def _open_session(manager: StreamableHTTPSessionManager, user: AuthenticatedUser | None) -> str: + """Create a new session as `user` and return its session ID.""" + sent_messages: list[Message] = [] + + async def mock_send(message: Message) -> None: + sent_messages.append(message) + + async def mock_receive() -> Message: + return {"type": "http.request", "body": b"", "more_body": False} + + await manager.handle_request(_request_scope(user=user), mock_receive, mock_send) + + response_start = next(msg for msg in sent_messages if msg["type"] == "http.response.start") + headers = dict(response_start.get("headers", [])) + return headers[MCP_SESSION_ID_HEADER.encode()].decode() + + +async def _request_session( + manager: StreamableHTTPSessionManager, session_id: str, user: AuthenticatedUser | None, method: str = "POST" +) -> int: + """Send a request for an existing session as `user` and return the response status.""" + sent_messages: list[Message] = [] + + async def mock_send(message: Message) -> None: + sent_messages.append(message) + + async def mock_receive() -> Message: + return {"type": "http.request", "body": b"", "more_body": False} + + await manager.handle_request( + _request_scope(session_id=session_id, user=user, method=method), mock_receive, mock_send + ) + + response_start = next(msg for msg in sent_messages if msg["type"] == "http.response.start") + return response_start["status"] + + +@pytest.fixture +async def manager_with_live_session(): + """A running manager around a real `Server`. Sessions remain registered until + `manager.run()` exits because `Server.run` blocks waiting for an initialize message.""" + manager = StreamableHTTPSessionManager(app=Server("test-session-credentials")) + async with manager.run(): + yield manager + + +@pytest.mark.anyio +async def test_session_accepts_requests_from_the_credential_that_created_it( + manager_with_live_session: StreamableHTTPSessionManager, +) -> None: + """Requests presenting the same credential as the one that created the session are served.""" + manager = manager_with_live_session + session_id = await _open_session(manager, _user("client-a")) + + status = await _request_session(manager, session_id, _user("client-a")) + + # The request passes the manager's credential check and reaches the + # session's transport, instead of being answered with 404 by the manager. + assert status != 404 + + +@pytest.mark.anyio +@pytest.mark.parametrize("method", ["POST", "GET", "DELETE"]) +async def test_session_rejects_requests_from_a_different_credential( + manager_with_live_session: StreamableHTTPSessionManager, method: str +) -> None: + """A session created by one credential cannot be used with another credential, whatever the method.""" + manager = manager_with_live_session + session_id = await _open_session(manager, _user("client-a")) + + assert await _request_session(manager, session_id, _user("client-b"), method) == 404 + # The session is still registered and still serves its creator. + assert await _request_session(manager, session_id, _user("client-a")) != 404 + + +@pytest.mark.anyio +async def test_session_rejects_requests_from_a_different_subject_of_the_same_client( + manager_with_live_session: StreamableHTTPSessionManager, +) -> None: + """Two end-users that share an OAuth client cannot use each other's sessions.""" + manager = manager_with_live_session + session_id = await _open_session(manager, _user("client-a", subject="alice")) + + assert await _request_session(manager, session_id, _user("client-a", subject="bob")) == 404 + assert await _request_session(manager, session_id, _user("client-a", subject=None)) == 404 + assert await _request_session(manager, session_id, _user("client-a", subject="alice")) != 404 + + +@pytest.mark.anyio +async def test_session_rejects_requests_with_the_same_subject_from_a_different_issuer( + manager_with_live_session: StreamableHTTPSessionManager, +) -> None: + """A subject is unique only per issuer, so a colliding subject from a different issuer is not the same principal.""" + manager = manager_with_live_session + creator = _user("client-a", subject="alice", issuer="https://issuer.one") + session_id = await _open_session(manager, creator) + + other_issuer = _user("client-a", subject="alice", issuer="https://issuer.two") + assert await _request_session(manager, session_id, other_issuer) == 404 + assert await _request_session(manager, session_id, _user("client-a", subject="alice")) == 404 + assert await _request_session(manager, session_id, creator) != 404 + + +@pytest.mark.anyio +async def test_session_rejects_unauthenticated_requests_for_an_authenticated_session( + manager_with_live_session: StreamableHTTPSessionManager, +) -> None: + """A session created with a credential cannot be used without one.""" + manager = manager_with_live_session + session_id = await _open_session(manager, _user("client-a")) + + assert await _request_session(manager, session_id, None) == 404 + + +@pytest.mark.anyio +async def test_session_rejects_authenticated_requests_for_an_anonymous_session( + manager_with_live_session: StreamableHTTPSessionManager, +) -> None: + """A session created without a credential cannot be used with one.""" + manager = manager_with_live_session + session_id = await _open_session(manager, None) + + assert await _request_session(manager, session_id, _user("client-a")) == 404 + + +@pytest.mark.anyio +async def test_anonymous_session_accepts_anonymous_requests( + manager_with_live_session: StreamableHTTPSessionManager, +) -> None: + """Servers without authentication keep working: no credential on either side.""" + manager = manager_with_live_session + session_id = await _open_session(manager, None) + + assert await _request_session(manager, session_id, None) != 404 diff --git a/tests/server/test_streamable_http_router.py b/tests/server/test_streamable_http_router.py new file mode 100644 index 0000000000..e78c17e91f --- /dev/null +++ b/tests/server/test_streamable_http_router.py @@ -0,0 +1,116 @@ +"""Regression coverage for the StreamableHTTP per-session response router.""" + +import anyio +import pytest +from starlette.types import Message, Scope + +from mcp.server.streamable_http import ( + REQUEST_STREAM_BUFFER_SIZE, + EventCallback, + EventId, + EventMessage, + EventStore, + StreamableHTTPServerTransport, + StreamId, +) +from mcp.shared.message import SessionMessage +from mcp.types import JSONRPCMessage, JSONRPCResponse + + +class _PrimingFailingStore(EventStore): + async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId: + raise RuntimeError("backend unavailable") + + async def replay_events_after(self, last_event_id: EventId, send_callback: EventCallback) -> StreamId | None: + raise NotImplementedError + + +@pytest.mark.anyio +async def test_router_unconsumed_request_stream_does_not_block_siblings() -> None: + """A response whose `sse_writer` is not yet receiving must not park the router (#1764). + + Drives the routing layer directly (the production race does not reproduce + on loopback), so this pins the router semantics, not the call sites. + """ + transport = StreamableHTTPServerTransport(mcp_session_id="sid", is_json_response_enabled=False) + streams = transport._request_streams + async with transport.connect() as (_read_stream, write_stream): + # Model two concurrent POSTs at the point _handle_post_request has + # registered the per-request stream but A's sse_writer has not yet + # reached its first receive(). + streams["A"] = anyio.create_memory_object_stream[EventMessage](REQUEST_STREAM_BUFFER_SIZE) + streams["B"] = anyio.create_memory_object_stream[EventMessage](REQUEST_STREAM_BUFFER_SIZE) + a_send, a_recv = streams["A"] + b_reader = streams["B"][1] + b_received = anyio.Event() + + async def consume_b() -> None: + async with b_reader: + await b_reader.receive() + b_received.set() + + async def server_writes() -> None: + await write_stream.send(SessionMessage(JSONRPCMessage(JSONRPCResponse(jsonrpc="2.0", id="A", result={})))) + await write_stream.send(SessionMessage(JSONRPCMessage(JSONRPCResponse(jsonrpc="2.0", id="B", result={})))) + + async with anyio.create_task_group() as tg: + tg.start_soon(consume_b) + tg.start_soon(server_writes) + with anyio.fail_after(5): + await b_received.wait() + # A's response was buffered for its (late) consumer, not dropped. + assert a_send.statistics().current_buffer_used == 1 + await a_recv.aclose() + await a_send.aclose() + + +@pytest.mark.anyio +async def test_priming_store_failure_leaves_no_per_request_state() -> None: + """`EventStore.store_event` raising on the priming row must not leak per-request entries.""" + transport = StreamableHTTPServerTransport( + mcp_session_id=None, + is_json_response_enabled=False, + event_store=_PrimingFailingStore(), + ) + + body = b'{"jsonrpc":"2.0","id":"req-1","method":"tools/list","params":{}}' + scope: Scope = { + "type": "http", + "method": "POST", + "path": "/", + "query_string": b"", + "headers": [ + (b"accept", b"application/json, text/event-stream"), + (b"content-type", b"application/json"), + (b"mcp-protocol-version", b"2025-11-25"), + ], + } + body_sent = False + + async def receive() -> Message: + nonlocal body_sent + if not body_sent: + body_sent = True + return {"type": "http.request", "body": body, "more_body": False} + raise NotImplementedError + + sent: list[Message] = [] + + async def asgi_send(message: Message) -> None: + sent.append(message) + + async with transport.connect() as (read_stream, _write_stream): + async with anyio.create_task_group() as tg: + tg.start_soon(transport.handle_request, scope, receive, asgi_send) + with anyio.fail_after(5): + forwarded = await read_stream.receive() + assert isinstance(forwarded, Exception) + # handle_request has returned; connect()'s finally (which clears + # _request_streams unconditionally) has not yet run. + assert transport._request_streams == {} + assert transport._sse_stream_writers == {} + + assert sent[0]["type"] == "http.response.start" + assert sent[0]["status"] == 500 + body = b"".join(m.get("body", b"") for m in sent if m["type"] == "http.response.body") + assert b"backend unavailable" not in body diff --git a/tests/server/test_websocket_security.py b/tests/server/test_websocket_security.py new file mode 100644 index 0000000000..35f778080d --- /dev/null +++ b/tests/server/test_websocket_security.py @@ -0,0 +1,172 @@ +"""Tests for WebSocket server request validation.""" + +# pyright: reportDeprecated=false + +import logging +import multiprocessing +import socket +import warnings + +import pytest +import uvicorn +from starlette.applications import Starlette +from starlette.routing import WebSocketRoute +from starlette.types import Message, Scope +from starlette.websockets import WebSocket +from websockets.asyncio.client import connect +from websockets.exceptions import InvalidStatus +from websockets.typing import Subprotocol + +from mcp.server import Server +from mcp.server.transport_security import TransportSecuritySettings +from mcp.server.websocket import websocket_server +from tests.test_helpers import wait_for_server + +logger = logging.getLogger(__name__) +SERVER_NAME = "test_ws_security_server" + +# This suite intentionally exercises the deprecated WebSocket transport. +pytestmark = pytest.mark.filterwarnings( + "ignore:The WebSocket (client|server) transport is deprecated:DeprecationWarning" +) + + +@pytest.fixture +def server_port() -> int: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def run_server_with_settings(port: int, security_settings: TransportSecuritySettings | None = None): # pragma: no cover + """Run a WebSocket MCP server with the given security settings.""" + warnings.filterwarnings("ignore", category=DeprecationWarning) + server = Server(SERVER_NAME) + + async def handle_ws(websocket: WebSocket) -> None: + try: + async with websocket_server( + websocket.scope, websocket.receive, websocket.send, security_settings=security_settings + ) as streams: + await server.run(streams[0], streams[1], server.create_initialization_options()) + except ValueError as exc: + logger.debug(f"WebSocket connection failed validation: {exc}") + + app = Starlette(routes=[WebSocketRoute("/ws", endpoint=handle_ws)]) + uvicorn.run(app, host="127.0.0.1", port=port, log_level="error") + + +def start_server_process(port: int, security_settings: TransportSecuritySettings | None = None): + """Start the server in a subprocess and wait until it accepts connections.""" + process = multiprocessing.Process(target=run_server_with_settings, args=(port, security_settings)) + process.start() + wait_for_server(port) + return process + + +@pytest.mark.anyio +async def test_ws_security_default_settings(server_port: int) -> None: + """With no security settings the WebSocket transport accepts any Origin (matches SSE/StreamableHTTP default).""" + process = start_server_process(server_port) + try: + async with connect( + f"ws://127.0.0.1:{server_port}/ws", + subprotocols=[Subprotocol("mcp")], + additional_headers={"Origin": "http://evil.com"}, + ) as ws: + assert ws.subprotocol == "mcp" + finally: + process.terminate() + process.join() + + +@pytest.mark.anyio +async def test_ws_security_invalid_origin_header(server_port: int) -> None: + """An Origin not in allowed_origins is rejected before the handshake completes.""" + settings = TransportSecuritySettings( + enable_dns_rebinding_protection=True, allowed_hosts=["127.0.0.1:*"], allowed_origins=["http://localhost:*"] + ) + process = start_server_process(server_port, settings) + try: + with pytest.raises(InvalidStatus) as exc_info: + async with connect( + f"ws://127.0.0.1:{server_port}/ws", + subprotocols=[Subprotocol("mcp")], + additional_headers={"Origin": "http://evil.com"}, + ): + pytest.fail("handshake should have been rejected") # pragma: no cover + assert exc_info.value.response.status_code == 403 + finally: + process.terminate() + process.join() + + +@pytest.mark.anyio +async def test_ws_security_invalid_host_header(server_port: int) -> None: + """A Host not in allowed_hosts is rejected before the handshake completes.""" + settings = TransportSecuritySettings(enable_dns_rebinding_protection=True, allowed_hosts=["example.com"]) + process = start_server_process(server_port, settings) + try: + with pytest.raises(InvalidStatus) as exc_info: + async with connect(f"ws://127.0.0.1:{server_port}/ws", subprotocols=[Subprotocol("mcp")]): + pytest.fail("handshake should have been rejected") # pragma: no cover + assert exc_info.value.response.status_code == 403 + finally: + process.terminate() + process.join() + + +@pytest.mark.anyio +async def test_ws_security_allowed_origin(server_port: int) -> None: + """An Origin matching allowed_origins is accepted.""" + settings = TransportSecuritySettings( + enable_dns_rebinding_protection=True, allowed_hosts=["127.0.0.1:*"], allowed_origins=["http://localhost:*"] + ) + process = start_server_process(server_port, settings) + try: + async with connect( + f"ws://127.0.0.1:{server_port}/ws", + subprotocols=[Subprotocol("mcp")], + additional_headers={"Origin": "http://localhost:8080"}, + ) as ws: + assert ws.subprotocol == "mcp" + finally: + process.terminate() + process.join() + + +@pytest.mark.anyio +async def test_ws_security_disabled(server_port: int) -> None: + """Explicitly disabling protection accepts any Origin.""" + settings = TransportSecuritySettings(enable_dns_rebinding_protection=False) + process = start_server_process(server_port, settings) + try: + async with connect( + f"ws://127.0.0.1:{server_port}/ws", + subprotocols=[Subprotocol("mcp")], + additional_headers={"Origin": "http://evil.com"}, + ) as ws: + assert ws.subprotocol == "mcp" + finally: + process.terminate() + process.join() + + +@pytest.mark.anyio +async def test_ws_security_rejects_before_accept() -> None: + """A failing validation closes the connection before the handshake is accepted.""" + settings = TransportSecuritySettings(enable_dns_rebinding_protection=True, allowed_hosts=["example.com"]) + sent: list[Message] = [] + + async def receive() -> Message: + raise NotImplementedError + + async def send(message: Message) -> None: + sent.append(message) + + scope: Scope = {"type": "websocket", "headers": [(b"host", b"evil.com")]} + with pytest.raises(ValueError, match="Request validation failed"): + async with websocket_server(scope, receive, send, security_settings=settings): + pytest.fail("should not yield streams") # pragma: no cover + + assert [m["type"] for m in sent] == ["websocket.close"] diff --git a/tests/shared/test_auth.py b/tests/shared/test_auth.py index bd9f5a934d..e22659721f 100644 --- a/tests/shared/test_auth.py +++ b/tests/shared/test_auth.py @@ -1,6 +1,9 @@ """Tests for OAuth 2.0 shared code.""" -from mcp.shared.auth import OAuthMetadata +import pytest +from pydantic import ValidationError + +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthMetadata class TestOAuthMetadata: @@ -59,3 +62,80 @@ def test_oauth_with_jarm(self): "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"], } ) + + +# RFC 7591 §2 marks client_uri/logo_uri/tos_uri/policy_uri/jwks_uri as OPTIONAL. +# Some authorization servers echo the client's omitted metadata back as "" +# instead of dropping the keys; without coercion, AnyHttpUrl rejects "" and +# the whole registration response is thrown away even though the server +# returned a valid client_id. + + +@pytest.mark.parametrize( + "empty_field", + ["client_uri", "logo_uri", "tos_uri", "policy_uri", "jwks_uri"], +) +def test_optional_url_empty_string_coerced_to_none(empty_field: str): + data = { + "redirect_uris": ["https://example.com/callback"], + empty_field: "", + } + metadata = OAuthClientMetadata.model_validate(data) + assert getattr(metadata, empty_field) is None + + +def test_all_optional_urls_empty_together(): + data = { + "redirect_uris": ["https://example.com/callback"], + "client_uri": "", + "logo_uri": "", + "tos_uri": "", + "policy_uri": "", + "jwks_uri": "", + } + metadata = OAuthClientMetadata.model_validate(data) + assert metadata.client_uri is None + assert metadata.logo_uri is None + assert metadata.tos_uri is None + assert metadata.policy_uri is None + assert metadata.jwks_uri is None + + +def test_valid_url_passes_through_unchanged(): + data = { + "redirect_uris": ["https://example.com/callback"], + "client_uri": "https://udemy.com/", + } + metadata = OAuthClientMetadata.model_validate(data) + assert str(metadata.client_uri) == "https://udemy.com/" + + +def test_information_full_inherits_coercion(): + """OAuthClientInformationFull subclasses OAuthClientMetadata, so the + same coercion applies to DCR responses parsed via the full model.""" + data = { + "client_id": "abc123", + "redirect_uris": ["https://example.com/callback"], + "client_uri": "", + "logo_uri": "", + "tos_uri": "", + "policy_uri": "", + "jwks_uri": "", + } + info = OAuthClientInformationFull.model_validate(data) + assert info.client_id == "abc123" + assert info.client_uri is None + assert info.logo_uri is None + assert info.tos_uri is None + assert info.policy_uri is None + assert info.jwks_uri is None + + +def test_invalid_non_empty_url_still_rejected(): + """Coercion must only touch empty strings — garbage URLs still raise.""" + data = { + "redirect_uris": ["https://example.com/callback"], + "client_uri": "not a url", + } + with pytest.raises(ValidationError): + OAuthClientMetadata.model_validate(data) diff --git a/tests/shared/test_auth_utils.py b/tests/shared/test_auth_utils.py index 5b12dc6775..dd9436be6e 100644 --- a/tests/shared/test_auth_utils.py +++ b/tests/shared/test_auth_utils.py @@ -95,7 +95,7 @@ def test_trailing_slash_handling(self): """Trailing slashes should be handled correctly.""" # With and without trailing slashes assert check_resource_allowed("https://example.com/api/", "https://example.com/api") is True - assert check_resource_allowed("https://example.com/api", "https://example.com/api/") is False + assert check_resource_allowed("https://example.com/api", "https://example.com/api/") is True assert check_resource_allowed("https://example.com/api/v1", "https://example.com/api") is True assert check_resource_allowed("https://example.com/api/v1", "https://example.com/api/") is True diff --git a/tests/shared/test_session.py b/tests/shared/test_session.py index e609397e5e..f4010141d8 100644 --- a/tests/shared/test_session.py +++ b/tests/shared/test_session.py @@ -124,7 +124,7 @@ async def make_request(client_session: ClientSession): ) # Give cancellation time to process - with anyio.fail_after(1): + with anyio.fail_after(1): # pragma: no cover await ev_cancelled.wait() @@ -176,7 +176,7 @@ async def make_request(client_session: ClientSession): tg.start_soon(mock_server) tg.start_soon(make_request, client_session) - with anyio.fail_after(2): + with anyio.fail_after(2): # pragma: no cover await ev_response_received.wait() assert len(result_holder) == 1 @@ -232,7 +232,7 @@ async def make_request(client_session: ClientSession): tg.start_soon(mock_server) tg.start_soon(make_request, client_session) - with anyio.fail_after(2): + with anyio.fail_after(2): # pragma: no cover await ev_error_received.wait() assert len(error_holder) == 1 @@ -289,7 +289,7 @@ async def make_request(client_session: ClientSession): tg.start_soon(mock_server) tg.start_soon(make_request, client_session) - with anyio.fail_after(2): + with anyio.fail_after(2): # pragma: no cover await ev_timeout.wait() @@ -335,7 +335,7 @@ async def mock_server(): tg.start_soon(make_request, client_session) tg.start_soon(mock_server) - with anyio.fail_after(1): + with anyio.fail_after(1): # pragma: no cover await ev_closed.wait() - with anyio.fail_after(1): + with anyio.fail_after(1): # pragma: no cover await ev_response.wait() diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index 731dd20dd3..631c96e86c 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -1786,81 +1786,40 @@ async def test_handle_sse_event_skips_empty_data(): @pytest.mark.anyio -async def test_priming_event_not_sent_for_old_protocol_version(): - """Test that _maybe_send_priming_event skips for old protocol versions (backwards compat).""" - # Create a transport with an event store +async def test_priming_event_not_minted_for_old_protocol_version(): + """`_mint_priming_event` returns None for pre-2025-11-25 clients (backwards compat).""" transport = StreamableHTTPServerTransport( "/mcp", event_store=SimpleEventStore(), ) - # Create a mock stream writer - write_stream, read_stream = anyio.create_memory_object_stream[dict[str, Any]](1) - - try: - # Call _maybe_send_priming_event with OLD protocol version - should NOT send - await transport._maybe_send_priming_event("test-request-id", write_stream, "2025-06-18") - - # Nothing should have been written to the stream - assert write_stream.statistics().current_buffer_used == 0 - - # Now test with NEW protocol version - should send - await transport._maybe_send_priming_event("test-request-id-2", write_stream, "2025-11-25") - - # Should have written a priming event - assert write_stream.statistics().current_buffer_used == 1 - finally: - await write_stream.aclose() - await read_stream.aclose() + assert await transport._mint_priming_event("test-request-id", "2025-06-18") is None + event = await transport._mint_priming_event("test-request-id-2", "2025-11-25") + assert event is not None + assert event["data"] == "" + assert "retry" not in event @pytest.mark.anyio -async def test_priming_event_not_sent_without_event_store(): - """Test that _maybe_send_priming_event returns early when no event_store is configured.""" - # Create a transport WITHOUT an event store +async def test_priming_event_not_minted_without_event_store(): + """`_mint_priming_event` returns None when no event store is configured.""" transport = StreamableHTTPServerTransport("/mcp") - # Create a mock stream writer - write_stream, read_stream = anyio.create_memory_object_stream[dict[str, Any]](1) - - try: - # Call _maybe_send_priming_event - should return early without sending - await transport._maybe_send_priming_event("test-request-id", write_stream, "2025-11-25") - - # Nothing should have been written to the stream - assert write_stream.statistics().current_buffer_used == 0 - finally: - await write_stream.aclose() - await read_stream.aclose() + assert await transport._mint_priming_event("test-request-id", "2025-11-25") is None @pytest.mark.anyio async def test_priming_event_includes_retry_interval(): - """Test that _maybe_send_priming_event includes retry field when retry_interval is set.""" - # Create a transport with an event store AND retry_interval + """`_mint_priming_event` carries the configured `retry` field.""" transport = StreamableHTTPServerTransport( "/mcp", event_store=SimpleEventStore(), retry_interval=5000, ) - # Create a mock stream writer - write_stream, read_stream = anyio.create_memory_object_stream[dict[str, Any]](1) - - try: - # Call _maybe_send_priming_event with new protocol version - await transport._maybe_send_priming_event("test-request-id", write_stream, "2025-11-25") - - # Should have written a priming event with retry field - assert write_stream.statistics().current_buffer_used == 1 - - # Read the event and verify it has retry field - event = await read_stream.receive() - assert "retry" in event - assert event["retry"] == 5000 - finally: - await write_stream.aclose() - await read_stream.aclose() + event = await transport._mint_priming_event("test-request-id", "2025-11-25") + assert event is not None + assert event["retry"] == 5000 @pytest.mark.anyio diff --git a/tests/shared/test_ws.py b/tests/shared/test_ws.py index f093cb4927..64790e2b08 100644 --- a/tests/shared/test_ws.py +++ b/tests/shared/test_ws.py @@ -10,6 +10,7 @@ from pydantic import AnyUrl from starlette.applications import Starlette from starlette.routing import WebSocketRoute +from starlette.types import Message from starlette.websockets import WebSocket from mcp.client.session import ClientSession @@ -30,6 +31,11 @@ SERVER_NAME = "test_server_for_WS" +# This suite intentionally exercises the deprecated WebSocket transport. +pytestmark = pytest.mark.filterwarnings( + "ignore:The WebSocket (client|server) transport is deprecated:DeprecationWarning" +) + @pytest.fixture def server_port() -> int: @@ -198,3 +204,22 @@ async def test_ws_client_timeout( assert len(result.contents) > 0 assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Read example" + + +def test_websocket_client_is_deprecated() -> None: + """Creating the websocket_client context manager emits a DeprecationWarning.""" + with pytest.warns(DeprecationWarning, match="The WebSocket client transport is deprecated"): + websocket_client("ws://127.0.0.1:1/ws") + + +def test_websocket_server_is_deprecated() -> None: + """Creating the websocket_server context manager emits a DeprecationWarning.""" + + async def receive() -> Message: + raise NotImplementedError + + async def send(message: Message) -> None: + raise NotImplementedError + + with pytest.warns(DeprecationWarning, match="The WebSocket server transport is deprecated"): + websocket_server({"type": "websocket"}, receive, send) diff --git a/tests/test_examples.py b/tests/test_examples.py index 6f5464e394..51a5c52dbf 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -100,7 +100,7 @@ async def test_desktop(monkeypatch: pytest.MonkeyPatch): assert "/fake/path/file2.txt" in content.text -@pytest.mark.parametrize("example", find_examples("README.md"), ids=str) +@pytest.mark.parametrize("example", list(find_examples("README.md")), ids=str) def test_docs_examples(example: CodeExample, eval_example: EvalExample): ruff_ignore: list[str] = ["F841", "I001", "F821"] # F821: undefined names (snippets lack imports) diff --git a/uv.lock b/uv.lock index 757709acdf..43e6218c0b 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,12 @@ version = 1 revision = 3 requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform != 'win32'", +] [manifest] members = [ @@ -771,7 +777,8 @@ dependencies = [ { name = "httpx" }, { name = "httpx-sse" }, { name = "jsonschema" }, - { name = "pydantic" }, + { name = "pydantic", version = "2.11.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, @@ -779,7 +786,8 @@ dependencies = [ { name = "sse-starlette" }, { name = "starlette" }, { name = "typing-extensions" }, - { name = "typing-inspection" }, + { name = "typing-inspection", version = "0.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "typing-inspection", version = "0.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] @@ -819,18 +827,21 @@ docs = [ [package.metadata] requires-dist = [ { name = "anyio", specifier = ">=4.5" }, - { name = "httpx", specifier = ">=0.27.1" }, + { name = "httpx", specifier = ">=0.27.1,<1.0.0" }, { name = "httpx-sse", specifier = ">=0.4" }, { name = "jsonschema", specifier = ">=4.20.0" }, - { name = "pydantic", specifier = ">=2.11.0,<3.0.0" }, + { name = "pydantic", marker = "python_full_version < '3.14'", specifier = ">=2.11.0,<3.0.0" }, + { name = "pydantic", marker = "python_full_version >= '3.14'", specifier = ">=2.12.0,<3.0.0" }, { name = "pydantic-settings", specifier = ">=2.5.2" }, { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10.1" }, { name = "python-dotenv", marker = "extra == 'cli'", specifier = ">=1.0.0" }, { name = "python-multipart", specifier = ">=0.0.9" }, - { name = "pywin32", marker = "sys_platform == 'win32'", specifier = ">=310" }, + { name = "pywin32", marker = "python_full_version < '3.14' and sys_platform == 'win32'", specifier = ">=310" }, + { name = "pywin32", marker = "python_full_version >= '3.14' and sys_platform == 'win32'", specifier = ">=311" }, { name = "rich", marker = "extra == 'rich'", specifier = ">=13.9.4" }, { name = "sse-starlette", specifier = ">=1.6.1" }, - { name = "starlette", specifier = ">=0.27" }, + { name = "starlette", marker = "python_full_version < '3.14'", specifier = ">=0.27" }, + { name = "starlette", marker = "python_full_version >= '3.14'", specifier = ">=0.48.0" }, { name = "typer", marker = "extra == 'cli'", specifier = ">=0.16.0" }, { name = "typing-extensions", specifier = ">=4.9.0" }, { name = "typing-inspection", specifier = ">=0.4.1" }, @@ -856,7 +867,7 @@ dev = [ docs = [ { name = "mkdocs", specifier = ">=1.6.1" }, { name = "mkdocs-glightbox", specifier = ">=0.4.0" }, - { name = "mkdocs-material", extras = ["imaging"], specifier = ">=9.5.45" }, + { name = "mkdocs-material", extras = ["imaging"], specifier = ">=9.6.19" }, { name = "mkdocstrings-python", specifier = ">=1.12.2" }, ] @@ -935,7 +946,8 @@ dependencies = [ { name = "click" }, { name = "httpx" }, { name = "mcp" }, - { name = "pydantic" }, + { name = "pydantic", version = "2.11.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, { name = "pydantic-settings" }, { name = "sse-starlette" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, @@ -1003,7 +1015,6 @@ source = { editable = "examples/clients/simple-chatbot" } dependencies = [ { name = "mcp" }, { name = "python-dotenv" }, - { name = "requests" }, { name = "uvicorn" }, ] @@ -1018,7 +1029,6 @@ dev = [ requires-dist = [ { name = "mcp", editable = "." }, { name = "python-dotenv", specifier = ">=1.0.0" }, - { name = "requests", specifier = ">=2.31.0" }, { name = "uvicorn", specifier = ">=0.32.1" }, ] @@ -1785,23 +1795,50 @@ wheels = [ name = "pydantic" version = "2.11.7" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform != 'win32'", +] dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, + { name = "annotated-types", marker = "python_full_version < '3.14'" }, + { name = "pydantic-core", version = "2.33.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "typing-extensions", marker = "python_full_version < '3.14'" }, + { name = "typing-inspection", version = "0.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", +] +dependencies = [ + { name = "annotated-types", marker = "python_full_version >= '3.14'" }, + { name = "pydantic-core", version = "2.46.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, + { name = "typing-inspection", version = "0.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + [[package]] name = "pydantic-core" version = "2.33.2" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform != 'win32'", +] dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } wheels = [ @@ -1883,14 +1920,136 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, ] +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", +] +dependencies = [ + { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + [[package]] name = "pydantic-settings" version = "2.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic" }, + { name = "pydantic", version = "2.11.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, { name = "python-dotenv" }, - { name = "typing-inspection" }, + { name = "typing-inspection", version = "0.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "typing-inspection", version = "0.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583, upload-time = "2025-06-24T13:26:46.841Z" } wheels = [ @@ -2533,14 +2692,34 @@ wheels = [ name = "typing-inspection" version = "0.4.1" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform != 'win32'", +] dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", +] +dependencies = [ + { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "urllib3" version = "2.5.0"