```
├── .claude/
├── commands/
├── review-pr.md (800 tokens)
├── skills/
├── test-quality/
├── SKILL.md (2k tokens)
├── .git-blame-ignore-revs (100 tokens)
├── .gitattributes (omitted)
├── .github/
├── ISSUE_TEMPLATE/
├── bug.yaml (400 tokens)
├── config.yaml
├── feature-request.yaml (200 tokens)
├── question.yaml (200 tokens)
├── v2-feedback.yaml (300 tokens)
├── actions/
├── conformance/
├── client.py (5.6k tokens)
├── expected-failures.2025-11-25.yml (300 tokens)
├── expected-failures.2026-07-28.yml (300 tokens)
├── expected-failures.yml (500 tokens)
├── run-client.sh (800 tokens)
├── run-server.sh (300 tokens)
├── dependabot.yml (100 tokens)
├── pull_request_template.md (300 tokens)
├── scripts/
├── docs_preview.js (1000 tokens)
├── docs_preview.test.js (2.3k tokens)
├── pr_intake_gate.js (3.4k tokens)
├── pr_intake_gate.test.js (4.1k tokens)
├── workflows/
├── claude.yml (300 tokens)
├── conformance.yml (1300 tokens)
├── deploy-docs.yml (500 tokens)
├── docs-preview-cleanup.yml (400 tokens)
├── docs-preview.yml (1600 tokens)
├── main.yml (100 tokens)
├── publish-pypi.yml (300 tokens)
├── require-linked-issue.yml (800 tokens)
├── shared.yml (1100 tokens)
├── zizmor.yml (100 tokens)
├── .gitignore (700 tokens)
├── .pre-commit-config.yaml (300 tokens)
├── AGENTS.md (1700 tokens)
├── CLAUDE.md
├── CODE_OF_CONDUCT.md (1000 tokens)
├── CONTRIBUTING.md (2.4k tokens)
├── DEPENDENCY_POLICY.md (400 tokens)
├── LICENSE (omitted)
├── README.md (900 tokens)
├── RELEASE.md (1600 tokens)
├── ROADMAP.md (400 tokens)
├── SECURITY.md (300 tokens)
├── VERSIONING.md (800 tokens)
├── docs/
├── .overrides/
├── .icons/
├── mcp.svg (200 tokens)
├── advanced/
├── apps.md (1400 tokens)
├── extensions.md (2.5k tokens)
├── header-parameters.md (600 tokens)
├── index.md (300 tokens)
├── low-level-server.md (2.9k tokens)
├── middleware.md (1500 tokens)
├── pagination.md (1000 tokens)
├── client/
├── caching.md (3k tokens)
├── callbacks.md (1800 tokens)
├── identity-assertion.md (3.3k tokens)
├── index.md (2.5k tokens)
├── oauth-clients.md (2.4k tokens)
├── session-groups.md (900 tokens)
├── subscriptions.md (1300 tokens)
├── transports.md (2000 tokens)
├── deprecated.md (2.2k tokens)
├── extra.css (900 tokens)
├── favicon.svg (200 tokens)
├── get-started/
├── first-steps.md (1500 tokens)
├── index.md (500 tokens)
├── installation.md (600 tokens)
├── real-host.md (1900 tokens)
├── testing.md (800 tokens)
├── handlers/
├── cancellation.md (600 tokens)
├── context.md (1300 tokens)
├── dependencies.md (2.1k tokens)
├── elicitation.md (2.3k tokens)
├── index.md (300 tokens)
├── lifespan.md (800 tokens)
├── logging.md (900 tokens)
├── multi-round-trip.md (3.6k tokens)
├── progress.md (900 tokens)
├── sampling-and-roots.md (800 tokens)
├── subscriptions.md (2.2k tokens)
├── index.md (700 tokens)
├── js/
├── language-switch.js (200 tokens)
├── migration.md (38.8k tokens)
├── protocol-versions.md (1400 tokens)
├── run/
├── asgi.md (1600 tokens)
├── authorization.md (1600 tokens)
├── deploy.md (3.1k tokens)
├── index.md (1700 tokens)
├── legacy-clients.md (2.5k tokens)
├── opentelemetry.md (900 tokens)
├── servers/
├── completions.md (1100 tokens)
├── handling-errors.md (1800 tokens)
├── index.md (300 tokens)
├── media.md (1500 tokens)
├── prompts.md (1800 tokens)
├── resources.md (1200 tokens)
├── structured-output.md (2k tokens)
├── tools.md (1400 tokens)
├── uri-templates.md (2.4k tokens)
├── translations.md (500 tokens)
├── troubleshooting.md (5.7k tokens)
├── whats-new.md (5.3k tokens)
├── docs_src/
├── __init__.py (100 tokens)
├── apps/
├── __init__.py
├── report.html
├── tutorial001.py (200 tokens)
├── tutorial001_client.py (100 tokens)
├── tutorial002.py (100 tokens)
├── tutorial003.py (100 tokens)
├── asgi/
├── __init__.py
├── tutorial001.py
├── tutorial002.py (100 tokens)
├── tutorial003.py (200 tokens)
├── tutorial004.py (100 tokens)
├── tutorial005.py (300 tokens)
├── tutorial006.py (100 tokens)
├── authorization/
├── __init__.py
├── tutorial001.py (200 tokens)
├── tutorial002.py (200 tokens)
├── caching/
├── __init__.py
├── tutorial001.py (100 tokens)
├── tutorial002.py (100 tokens)
├── tutorial003.py (200 tokens)
├── cancellation/
├── __init__.py
├── tutorial001.py (100 tokens)
├── tutorial002.py (100 tokens)
├── client/
├── __init__.py
├── tutorial001.py (300 tokens)
├── tutorial001_client.py (100 tokens)
├── tutorial002.py (100 tokens)
├── tutorial003.py (100 tokens)
├── tutorial004.py (100 tokens)
├── tutorial005.py (100 tokens)
├── tutorial006.py (100 tokens)
├── tutorial007.py (100 tokens)
├── client_callbacks/
├── __init__.py
├── tutorial001.py (100 tokens)
├── tutorial002.py (100 tokens)
├── tutorial003.py (200 tokens)
├── tutorial004.py (100 tokens)
├── client_transports/
├── __init__.py
├── tutorial001.py (100 tokens)
├── tutorial002.py
├── tutorial003.py (100 tokens)
├── tutorial004.py (100 tokens)
├── tutorial005.py (100 tokens)
├── completions/
├── __init__.py
├── tutorial001.py (100 tokens)
├── tutorial002.py (200 tokens)
├── tutorial003.py (300 tokens)
├── context/
├── __init__.py
├── tutorial001.py (100 tokens)
├── tutorial002.py (100 tokens)
├── tutorial003.py (100 tokens)
├── dependencies/
├── __init__.py
├── tutorial001.py (100 tokens)
├── tutorial002.py (200 tokens)
├── tutorial003.py (200 tokens)
├── tutorial004.py (200 tokens)
├── deploy/
├── __init__.py
├── tutorial001.py (100 tokens)
├── tutorial002.py (200 tokens)
├── tutorial003.py (200 tokens)
├── tutorial004.py (100 tokens)
├── elicitation/
├── __init__.py
├── tutorial001.py (200 tokens)
├── tutorial002.py (200 tokens)
├── tutorial003.py (200 tokens)
├── tutorial004.py (300 tokens)
├── extensions/
├── __init__.py
├── tutorial001.py
├── tutorial002.py
├── tutorial003.py (100 tokens)
├── tutorial003_client.py (100 tokens)
├── tutorial004.py (200 tokens)
├── tutorial004_client.py (200 tokens)
├── tutorial005.py (200 tokens)
├── tutorial006.py (200 tokens)
├── tutorial006_client.py (300 tokens)
├── tutorial007.py (200 tokens)
├── tutorial007_client.py (200 tokens)
├── first_steps/
├── __init__.py
├── tutorial001.py (100 tokens)
├── tutorial001_client.py
├── handling_errors/
├── __init__.py
├── tutorial001.py (100 tokens)
├── tutorial002.py (100 tokens)
├── tutorial003.py (100 tokens)
├── tutorial004.py (100 tokens)
├── header_parameters/
├── __init__.py
├── tutorial001.py (100 tokens)
├── tutorial002.py (200 tokens)
├── tutorial003.py (300 tokens)
├── identity_assertion/
├── __init__.py
├── tutorial001.py (400 tokens)
├── tutorial002.py (900 tokens)
├── index/
├── __init__.py
├── tutorial001.py (100 tokens)
├── legacy_clients/
├── __init__.py
├── tutorial001.py (100 tokens)
├── tutorial001_client.py (200 tokens)
├── tutorial002.py (200 tokens)
├── tutorial003.py (100 tokens)
├── lifespan/
├── __init__.py
├── tutorial001.py (200 tokens)
├── tutorial002.py (200 tokens)
├── logging/
├── __init__.py
├── tutorial001.py (100 tokens)
├── lowlevel/
├── __init__.py
├── tutorial001.py (200 tokens)
├── tutorial002.py (300 tokens)
├── tutorial003.py (300 tokens)
├── tutorial004.py (300 tokens)
├── tutorial005.py (300 tokens)
├── tutorial006.py (300 tokens)
├── tutorial007.py (200 tokens)
├── media/
├── __init__.py
├── tutorial001.py (100 tokens)
├── tutorial002.py (100 tokens)
├── tutorial003.py (100 tokens)
├── tutorial004.py (100 tokens)
├── tutorial005.py (100 tokens)
├── middleware/
├── __init__.py
├── tutorial001.py (300 tokens)
├── tutorial002.py (400 tokens)
├── mrtr/
├── __init__.py
├── tutorial001.py (300 tokens)
├── tutorial002.py (200 tokens)
├── tutorial003.py (100 tokens)
├── tutorial004.py (200 tokens)
├── tutorial005.py (300 tokens)
├── oauth_clients/
├── __init__.py
├── tutorial001.py (400 tokens)
├── tutorial002.py (300 tokens)
├── opentelemetry/
├── __init__.py
├── tutorial001.py
├── pagination/
├── __init__.py
├── tutorial001.py (200 tokens)
├── tutorial002.py (100 tokens)
├── progress/
├── __init__.py
├── tutorial001.py (100 tokens)
├── tutorial002.py (100 tokens)
├── prompts/
├── __init__.py
├── tutorial001.py
├── tutorial002.py (100 tokens)
├── tutorial003.py (100 tokens)
├── tutorial004.py (200 tokens)
├── tutorial005.py (100 tokens)
├── tutorial006.py (200 tokens)
├── protocol_versions/
├── __init__.py
├── tutorial001.py
├── tutorial002.py
├── tutorial003.py
├── tutorial004.py (100 tokens)
├── real_host/
├── __init__.py
├── tutorial001.py (200 tokens)
├── resources/
├── __init__.py
├── tutorial001.py
├── tutorial002.py (100 tokens)
├── tutorial003.py (100 tokens)
├── run/
├── __init__.py
├── tutorial001.py (100 tokens)
├── tutorial002.py (100 tokens)
├── tutorial003.py (100 tokens)
├── sampling_and_roots/
├── __init__.py
├── tutorial001.py (100 tokens)
├── tutorial002.py (100 tokens)
├── session_groups/
├── __init__.py
├── tutorial001.py (100 tokens)
├── tutorial002.py
├── tutorial003.py (100 tokens)
├── tutorial004.py (200 tokens)
├── structured_output/
├── __init__.py
├── tutorial001.py (100 tokens)
├── tutorial002.py (100 tokens)
├── tutorial003.py (100 tokens)
├── tutorial004.py (100 tokens)
├── tutorial005.py (100 tokens)
├── tutorial006.py (100 tokens)
├── tutorial007.py (100 tokens)
├── tutorial008.py (100 tokens)
├── tutorial009.py (100 tokens)
├── subscriptions/
├── __init__.py
├── tutorial001.py (200 tokens)
├── tutorial002.py (300 tokens)
├── tutorial003.py (200 tokens)
├── tutorial004_anyio.py (200 tokens)
├── tutorial004_asyncio.py (200 tokens)
├── tutorial004_trio.py (200 tokens)
├── tutorial005.py (100 tokens)
├── tutorial006.py (300 tokens)
├── tutorial007.py
├── testing/
├── __init__.py
├── tutorial001.py
├── tools/
├── __init__.py
├── tutorial001.py
├── tutorial002.py (100 tokens)
├── tutorial003.py (100 tokens)
├── tutorial004.py (100 tokens)
├── tutorial005.py (100 tokens)
├── troubleshooting/
├── __init__.py
├── tutorial001.py (100 tokens)
├── tutorial002.py (100 tokens)
├── tutorial003.py
├── tutorial004.py (100 tokens)
├── tutorial005.py (100 tokens)
├── tutorial006.py (100 tokens)
├── tutorial007.py (100 tokens)
├── tutorial008.py (100 tokens)
├── uri_templates/
├── __init__.py
├── tutorial001.py (300 tokens)
├── tutorial002.py (100 tokens)
├── tutorial003.py (100 tokens)
├── tutorial004.py (200 tokens)
├── tutorial005.py (400 tokens)
├── whats_new/
├── __init__.py
├── tutorial001.py (200 tokens)
├── examples/
├── README.md (300 tokens)
├── clients/
├── simple-auth-client/
├── README.md (500 tokens)
├── mcp_simple_auth_client/
├── __init__.py
├── main.py (2.9k tokens)
├── pyproject.toml (200 tokens)
├── simple-chatbot/
├── .python-version
├── README.md (700 tokens)
├── mcp_simple_chatbot/
├── .env.example
├── main.py (3k tokens)
├── requirements.txt
├── servers_config.json
├── test.db
├── pyproject.toml (200 tokens)
├── sse-polling-client/
├── README.md (200 tokens)
├── mcp_sse_polling_client/
├── __init__.py
├── main.py (600 tokens)
├── pyproject.toml (200 tokens)
├── mcpserver/
├── complex_inputs.py (100 tokens)
├── desktop.py (100 tokens)
├── direct_call_tool_result_return.py (100 tokens)
├── echo.py (100 tokens)
├── icons_demo.py (300 tokens)
├── logging_and_progress.py (200 tokens)
├── mcp.png
├── memory.py (2.1k tokens)
├── parameter_descriptions.py (100 tokens)
├── readme-quickstart.py (100 tokens)
├── screenshot.py (200 tokens)
├── simple_echo.py
├── text_me.py (400 tokens)
├── unicode_example.py (300 tokens)
├── weather_structured.py (1500 tokens)
├── pyproject.toml (100 tokens)
├── servers/
├── everything-server/
├── README.md (200 tokens)
├── mcp_everything_server/
├── __init__.py
├── __main__.py
├── server.py (6k tokens)
├── pyproject.toml (200 tokens)
├── simple-auth/
├── README.md (800 tokens)
├── mcp_simple_auth/
├── __init__.py
├── __main__.py
├── auth_server.py (1300 tokens)
├── legacy_as_server.py (900 tokens)
├── py.typed
├── server.py (1100 tokens)
├── simple_auth_provider.py (2.1k tokens)
├── token_verifier.py (1000 tokens)
├── pyproject.toml (200 tokens)
├── simple-pagination/
├── README.md (500 tokens)
├── mcp_simple_pagination/
├── __init__.py
├── __main__.py
├── server.py (1200 tokens)
├── pyproject.toml (200 tokens)
├── simple-prompt/
├── .python-version
├── README.md (300 tokens)
├── mcp_simple_prompt/
├── __init__.py
├── __main__.py
├── server.py (600 tokens)
├── pyproject.toml (200 tokens)
├── simple-resource/
├── .python-version
├── README.md (300 tokens)
├── mcp_simple_resource/
├── __init__.py
├── __main__.py
├── server.py (500 tokens)
├── pyproject.toml (200 tokens)
├── simple-streamablehttp-stateless/
├── README.md (300 tokens)
├── mcp_simple_streamablehttp_stateless/
├── __init__.py
├── __main__.py
├── server.py (800 tokens)
├── pyproject.toml (200 tokens)
├── simple-streamablehttp/
├── README.md (400 tokens)
├── mcp_simple_streamablehttp/
├── __init__.py
├── __main__.py
├── event_store.py (700 tokens)
├── server.py (1000 tokens)
├── pyproject.toml (200 tokens)
├── simple-tool/
├── .python-version
├── README.md (200 tokens)
├── mcp_simple_tool/
├── __init__.py
├── __main__.py
├── server.py (500 tokens)
├── pyproject.toml (200 tokens)
├── sse-polling-demo/
├── README.md (200 tokens)
├── mcp_sse_polling_demo/
├── __init__.py
├── __main__.py
├── event_store.py (700 tokens)
├── server.py (1100 tokens)
├── pyproject.toml (200 tokens)
├── structured-output-lowlevel/
├── mcp_structured_output_lowlevel/
├── __init__.py
├── __main__.py (600 tokens)
├── pyproject.toml
├── snippets/
├── clients/
├── __init__.py
├── completion_client.py (600 tokens)
├── display_utilities.py (400 tokens)
├── identity_assertion_client.py (700 tokens)
├── oauth_client.py (600 tokens)
├── pagination_client.py (300 tokens)
├── parsing_tool_results.py (500 tokens)
├── stdio_client.py (600 tokens)
├── streamable_basic.py (200 tokens)
├── url_elicitation_client.py (2.1k tokens)
├── pyproject.toml (100 tokens)
├── servers/
├── __init__.py (300 tokens)
├── basic_prompt.py (100 tokens)
├── basic_resource.py (100 tokens)
├── basic_tool.py (100 tokens)
├── completion.py (300 tokens)
├── direct_call_tool_result.py (200 tokens)
├── direct_execution.py (100 tokens)
├── elicitation.py (700 tokens)
├── identity_assertion_server.py (1000 tokens)
├── images.py (100 tokens)
├── lifespan_example.py (300 tokens)
├── lowlevel/
├── __init__.py
├── basic.py (400 tokens)
├── direct_call_tool_result.py (400 tokens)
├── lifespan.py (600 tokens)
├── structured_output.py (500 tokens)
├── mcpserver_quickstart.py (200 tokens)
├── notifications.py (100 tokens)
├── oauth_server.py (300 tokens)
├── pagination_example.py (200 tokens)
├── sampling.py (200 tokens)
├── streamable_config.py (200 tokens)
├── streamable_http_basic_mounting.py (200 tokens)
├── streamable_http_host_mounting.py (200 tokens)
├── streamable_http_multiple_servers.py (300 tokens)
├── streamable_http_path_config.py (200 tokens)
├── streamable_starlette_mount.py (300 tokens)
├── structured_output.py (500 tokens)
├── tool_progress.py (100 tokens)
├── stories/
├── README.md (1900 tokens)
├── __init__.py (100 tokens)
├── _harness.py (1900 tokens)
├── _hosting.py (700 tokens)
├── _shared/
├── __init__.py
├── auth.py (1400 tokens)
├── apps/
├── README.md (300 tokens)
├── __init__.py
├── client.py (300 tokens)
├── server.py (300 tokens)
├── bearer_auth/
├── README.md (900 tokens)
├── __init__.py
├── client.py (300 tokens)
├── server.py (400 tokens)
├── server_lowlevel.py (400 tokens)
├── caching/
├── README.md (200 tokens)
├── custom_methods/
├── README.md (400 tokens)
├── __init__.py
├── client.py (200 tokens)
├── server.py (200 tokens)
├── dual_era/
├── README.md (600 tokens)
├── __init__.py
├── client.py (500 tokens)
├── server.py (200 tokens)
├── server_lowlevel.py (400 tokens)
├── error_handling/
├── README.md (400 tokens)
├── __init__.py
├── client.py (300 tokens)
├── server.py (300 tokens)
├── server_lowlevel.py (400 tokens)
├── events/
├── README.md (200 tokens)
├── extensions/
├── README.md (400 tokens)
├── __init__.py
├── client.py (400 tokens)
├── server.py (400 tokens)
├── identity_assertion/
├── README.md (1000 tokens)
├── __init__.py
├── client.py (600 tokens)
├── idp.py (400 tokens)
├── server.py (1000 tokens)
├── server_lowlevel.py (500 tokens)
├── json_response/
├── README.md (700 tokens)
├── __init__.py
├── client.py (600 tokens)
├── server.py (200 tokens)
├── server_lowlevel.py (300 tokens)
├── legacy_elicitation/
├── README.md (700 tokens)
├── __init__.py
├── client.py (300 tokens)
├── server.py (400 tokens)
├── server_lowlevel.py (600 tokens)
├── legacy_routing/
├── README.md (1000 tokens)
├── __init__.py
├── client.py (500 tokens)
├── server.py (500 tokens)
├── server_lowlevel.py (400 tokens)
├── lifespan/
├── README.md (500 tokens)
├── __init__.py
├── client.py (200 tokens)
├── server.py (200 tokens)
├── server_lowlevel.py (400 tokens)
├── manifest.toml (1200 tokens)
├── middleware/
├── README.md (500 tokens)
├── __init__.py
├── client.py (200 tokens)
├── server.py (400 tokens)
├── mrtr/
├── README.md (900 tokens)
├── __init__.py
├── client.py (700 tokens)
├── server.py (400 tokens)
├── server_lowlevel.py (500 tokens)
├── oauth/
├── README.md (1000 tokens)
├── __init__.py
├── client.py (600 tokens)
├── server.py (300 tokens)
├── server_lowlevel.py (500 tokens)
├── oauth_client_credentials/
├── README.md (700 tokens)
├── __init__.py
├── client.py (400 tokens)
├── server.py (700 tokens)
├── server_lowlevel.py (800 tokens)
├── pagination/
├── README.md (500 tokens)
├── __init__.py
├── client.py (200 tokens)
├── server.py (100 tokens)
├── server_lowlevel.py (300 tokens)
├── parallel_calls/
├── README.md (500 tokens)
├── __init__.py
├── client.py (300 tokens)
├── server.py (200 tokens)
├── server_lowlevel.py (300 tokens)
├── prompts/
├── README.md (400 tokens)
├── __init__.py
├── client.py (300 tokens)
├── server.py (300 tokens)
├── server_lowlevel.py (700 tokens)
├── reconnect/
├── README.md (500 tokens)
├── __init__.py
├── client.py (500 tokens)
├── server.py (100 tokens)
├── server_lowlevel.py (300 tokens)
├── refund_desk/
├── README.md (1000 tokens)
├── __init__.py
├── client.py (1100 tokens)
├── server.py (800 tokens)
├── resources/
├── README.md (400 tokens)
├── __init__.py
├── client.py (200 tokens)
├── server.py (100 tokens)
├── server_lowlevel.py (500 tokens)
├── roots/
├── README.md (500 tokens)
├── __init__.py
├── client.py (200 tokens)
├── server.py (100 tokens)
├── server_lowlevel.py (300 tokens)
├── sampling/
├── README.md (500 tokens)
├── __init__.py
├── client.py (200 tokens)
├── server.py (200 tokens)
├── server_lowlevel.py (400 tokens)
├── schema_validators/
├── README.md (500 tokens)
├── __init__.py
├── client.py (300 tokens)
├── server.py (300 tokens)
├── server_lowlevel.py (400 tokens)
├── serve_one/
├── README.md (500 tokens)
├── __init__.py
├── client.py (300 tokens)
├── server.py (900 tokens)
├── skills/
├── README.md (100 tokens)
├── sse_polling/
├── README.md (700 tokens)
├── __init__.py
├── client.py (300 tokens)
├── event_store.py (300 tokens)
├── server.py (300 tokens)
├── server_lowlevel.py (400 tokens)
├── standalone_get/
├── README.md (600 tokens)
├── __init__.py
├── client.py (300 tokens)
├── server.py (200 tokens)
├── server_lowlevel.py (400 tokens)
├── starlette_mount/
├── README.md (500 tokens)
├── __init__.py
├── client.py (200 tokens)
├── server.py (300 tokens)
├── stateless_legacy/
├── README.md (600 tokens)
├── __init__.py
├── client.py (300 tokens)
├── server.py (200 tokens)
├── server_lowlevel.py (300 tokens)
├── stickynotes/
├── README.md (600 tokens)
├── __init__.py
├── client.py (700 tokens)
├── server.py (700 tokens)
├── server_lowlevel.py (900 tokens)
├── streaming/
├── README.md (800 tokens)
├── __init__.py
├── client.py (500 tokens)
├── server.py (300 tokens)
├── server_lowlevel.py (500 tokens)
├── subscriptions/
├── README.md (600 tokens)
├── __init__.py
├── client.py (400 tokens)
├── server.py (200 tokens)
├── server_lowlevel.py (600 tokens)
├── tasks/
├── README.md (200 tokens)
├── tools/
├── README.md (300 tokens)
├── __init__.py
├── client.py (300 tokens)
├── server.py (200 tokens)
├── server_lowlevel.py (500 tokens)
├── i18n/
├── README.md (600 tokens)
├── de/
├── glossary.json (3.7k tokens)
├── instructions.md (2.2k tokens)
├── notices.md (200 tokens)
├── pages/
├── advanced/
├── apps.md (1600 tokens)
├── extensions.md (3k tokens)
├── header-parameters.md (700 tokens)
├── index.md (400 tokens)
├── low-level-server.md (3.3k tokens)
├── middleware.md (1800 tokens)
├── pagination.md (1200 tokens)
├── client/
├── caching.md (3.6k tokens)
├── callbacks.md (2.1k tokens)
├── identity-assertion.md (3.8k tokens)
├── index.md (2.9k tokens)
├── oauth-clients.md (2.8k tokens)
├── session-groups.md (1100 tokens)
├── subscriptions.md (1500 tokens)
├── transports.md (2.3k tokens)
├── deprecated.md (2.6k tokens)
├── get-started/
├── first-steps.md (1800 tokens)
├── index.md (500 tokens)
├── installation.md (700 tokens)
├── real-host.md (2.3k tokens)
├── testing.md (1000 tokens)
├── handlers/
├── cancellation.md (700 tokens)
├── context.md (1500 tokens)
├── dependencies.md (2.5k tokens)
├── elicitation.md (2.6k tokens)
├── index.md (400 tokens)
├── lifespan.md (1000 tokens)
├── logging.md (1100 tokens)
├── multi-round-trip.md (4.3k tokens)
├── progress.md (1100 tokens)
├── sampling-and-roots.md (1000 tokens)
├── subscriptions.md (2.6k tokens)
├── index.md (800 tokens)
├── protocol-versions.md (1700 tokens)
├── run/
├── asgi.md (1900 tokens)
├── authorization.md (1900 tokens)
├── deploy.md (3.7k tokens)
├── index.md (1900 tokens)
├── legacy-clients.md (3k tokens)
├── opentelemetry.md (1100 tokens)
├── servers/
├── completions.md (1300 tokens)
├── handling-errors.md (2.1k tokens)
├── index.md (400 tokens)
├── media.md (1700 tokens)
├── prompts.md (2.1k tokens)
├── resources.md (1400 tokens)
├── structured-output.md (2.3k tokens)
├── tools.md (1700 tokens)
├── uri-templates.md (2.8k tokens)
├── translations.md (600 tokens)
├── troubleshooting.md (6.7k tokens)
├── whats-new.md (6.1k tokens)
├── es/
├── glossary.json (3.6k tokens)
├── instructions.md (2.1k tokens)
├── notices.md (200 tokens)
├── pages/
├── advanced/
├── apps.md (1700 tokens)
├── extensions.md (2.9k tokens)
├── header-parameters.md (700 tokens)
├── index.md (400 tokens)
├── low-level-server.md (3.2k tokens)
├── middleware.md (1700 tokens)
├── pagination.md (1200 tokens)
├── client/
├── caching.md (3.5k tokens)
├── callbacks.md (2k tokens)
├── identity-assertion.md (3.6k tokens)
├── index.md (2.8k tokens)
├── oauth-clients.md (2.7k tokens)
├── session-groups.md (1100 tokens)
├── subscriptions.md (1500 tokens)
├── transports.md (2.2k tokens)
├── deprecated.md (2.5k tokens)
├── get-started/
├── first-steps.md (1800 tokens)
├── index.md (500 tokens)
├── installation.md (600 tokens)
├── real-host.md (2.2k tokens)
├── testing.md (1000 tokens)
├── handlers/
├── cancellation.md (700 tokens)
├── context.md (1500 tokens)
├── dependencies.md (2.5k tokens)
├── elicitation.md (2.5k tokens)
├── index.md (300 tokens)
├── lifespan.md (900 tokens)
├── logging.md (1100 tokens)
├── multi-round-trip.md (4.1k tokens)
├── progress.md (1000 tokens)
├── sampling-and-roots.md (1000 tokens)
├── subscriptions.md (2.5k tokens)
├── index.md (800 tokens)
├── protocol-versions.md (1600 tokens)
├── run/
├── asgi.md (1900 tokens)
├── authorization.md (1900 tokens)
├── deploy.md (3.5k tokens)
├── index.md (1900 tokens)
├── legacy-clients.md (2.9k tokens)
├── opentelemetry.md (1100 tokens)
├── servers/
├── completions.md (1200 tokens)
├── handling-errors.md (2k tokens)
├── index.md (400 tokens)
├── media.md (1600 tokens)
├── prompts.md (2k tokens)
├── resources.md (1300 tokens)
├── structured-output.md (2.3k tokens)
├── tools.md (1600 tokens)
├── uri-templates.md (2.8k tokens)
├── translations.md (600 tokens)
├── troubleshooting.md (6.5k tokens)
├── whats-new.md (6k tokens)
├── fr/
├── glossary.json (3.7k tokens)
├── instructions.md (2.2k tokens)
├── notices.md (200 tokens)
├── pages/
├── advanced/
├── apps.md (1700 tokens)
├── extensions.md (3k tokens)
├── header-parameters.md (700 tokens)
├── index.md (400 tokens)
├── low-level-server.md (3.4k tokens)
├── middleware.md (1800 tokens)
├── pagination.md (1200 tokens)
├── client/
├── caching.md (3.6k tokens)
├── callbacks.md (2.2k tokens)
├── identity-assertion.md (3.8k tokens)
├── index.md (2.9k tokens)
├── oauth-clients.md (2.9k tokens)
├── session-groups.md (1100 tokens)
├── subscriptions.md (1500 tokens)
├── transports.md (2.4k tokens)
├── deprecated.md (2.7k tokens)
├── get-started/
├── first-steps.md (1800 tokens)
├── index.md (600 tokens)
├── installation.md (700 tokens)
├── real-host.md (2.3k tokens)
├── testing.md (1000 tokens)
├── handlers/
├── cancellation.md (700 tokens)
├── context.md (1500 tokens)
├── dependencies.md (2.6k tokens)
├── elicitation.md (2.7k tokens)
├── index.md (400 tokens)
├── lifespan.md (1000 tokens)
├── logging.md (1200 tokens)
├── multi-round-trip.md (4.4k tokens)
├── progress.md (1100 tokens)
├── sampling-and-roots.md (1000 tokens)
├── subscriptions.md (2.6k tokens)
├── index.md (800 tokens)
├── protocol-versions.md (1700 tokens)
├── run/
├── asgi.md (2k tokens)
├── authorization.md (2000 tokens)
├── deploy.md (3.8k tokens)
├── index.md (2000 tokens)
├── legacy-clients.md (3k tokens)
├── opentelemetry.md (1100 tokens)
├── servers/
├── completions.md (1300 tokens)
├── handling-errors.md (2.1k tokens)
├── index.md (400 tokens)
├── media.md (1700 tokens)
├── prompts.md (2.1k tokens)
├── resources.md (1400 tokens)
├── structured-output.md (2.3k tokens)
├── tools.md (1700 tokens)
├── uri-templates.md (3k tokens)
├── translations.md (600 tokens)
├── troubleshooting.md (6.7k tokens)
├── whats-new.md (6.4k tokens)
├── general-prompt.md (800 tokens)
├── hi/
├── glossary.json (3.4k tokens)
├── instructions.md (2.1k tokens)
├── notices.md (200 tokens)
├── pages/
├── advanced/
├── apps.md (1600 tokens)
├── extensions.md (2.7k tokens)
├── header-parameters.md (700 tokens)
├── index.md (400 tokens)
├── low-level-server.md (3.1k tokens)
├── middleware.md (1600 tokens)
├── pagination.md (1100 tokens)
├── client/
├── caching.md (3.2k tokens)
├── callbacks.md (2000 tokens)
├── identity-assertion.md (3.5k tokens)
├── index.md (2.6k tokens)
├── oauth-clients.md (2.6k tokens)
├── session-groups.md (1000 tokens)
├── subscriptions.md (1400 tokens)
├── transports.md (2.1k tokens)
├── deprecated.md (2.4k tokens)
├── get-started/
├── first-steps.md (1600 tokens)
├── index.md (500 tokens)
├── installation.md (600 tokens)
├── real-host.md (2.1k tokens)
├── testing.md (900 tokens)
├── handlers/
├── cancellation.md (700 tokens)
├── context.md (1400 tokens)
├── dependencies.md (2.3k tokens)
├── elicitation.md (2.4k tokens)
├── index.md (300 tokens)
├── lifespan.md (900 tokens)
├── logging.md (1000 tokens)
├── multi-round-trip.md (3.8k tokens)
├── progress.md (1000 tokens)
├── sampling-and-roots.md (900 tokens)
├── subscriptions.md (2.4k tokens)
├── index.md (800 tokens)
├── protocol-versions.md (1500 tokens)
├── run/
├── asgi.md (1800 tokens)
├── authorization.md (1800 tokens)
├── deploy.md (3.3k tokens)
├── index.md (1800 tokens)
├── legacy-clients.md (2.7k tokens)
├── opentelemetry.md (1000 tokens)
├── servers/
├── completions.md (1200 tokens)
├── handling-errors.md (1900 tokens)
├── index.md (300 tokens)
├── media.md (1600 tokens)
├── prompts.md (1900 tokens)
├── resources.md (1300 tokens)
├── structured-output.md (2.2k tokens)
├── tools.md (1600 tokens)
├── uri-templates.md (2.6k tokens)
├── translations.md (600 tokens)
├── troubleshooting.md (6.2k tokens)
├── whats-new.md (5.5k tokens)
├── ja/
├── glossary.json (2.3k tokens)
├── instructions.md (1800 tokens)
├── notices.md (100 tokens)
├── pages/
├── advanced/
├── apps.md (1200 tokens)
├── extensions.md (1800 tokens)
├── header-parameters.md (500 tokens)
├── index.md (200 tokens)
├── low-level-server.md (2.2k tokens)
├── middleware.md (1100 tokens)
├── pagination.md (800 tokens)
├── client/
├── caching.md (2000 tokens)
├── callbacks.md (1400 tokens)
├── identity-assertion.md (2.4k tokens)
├── index.md (2k tokens)
├── oauth-clients.md (1700 tokens)
├── session-groups.md (800 tokens)
├── subscriptions.md (900 tokens)
├── transports.md (1500 tokens)
├── deprecated.md (1800 tokens)
├── get-started/
├── first-steps.md (1200 tokens)
├── index.md (400 tokens)
├── installation.md (500 tokens)
├── real-host.md (1500 tokens)
├── testing.md (700 tokens)
├── handlers/
├── cancellation.md (500 tokens)
├── context.md (1000 tokens)
├── dependencies.md (1500 tokens)
├── elicitation.md (1600 tokens)
├── index.md (200 tokens)
├── lifespan.md (700 tokens)
├── logging.md (700 tokens)
├── multi-round-trip.md (2.5k tokens)
├── progress.md (700 tokens)
├── sampling-and-roots.md (600 tokens)
├── subscriptions.md (1700 tokens)
├── index.md (600 tokens)
├── protocol-versions.md (1100 tokens)
├── run/
├── asgi.md (1300 tokens)
├── authorization.md (1300 tokens)
├── deploy.md (2.2k tokens)
├── index.md (1300 tokens)
├── legacy-clients.md (1800 tokens)
├── opentelemetry.md (700 tokens)
├── servers/
├── completions.md (900 tokens)
├── handling-errors.md (1300 tokens)
├── index.md (200 tokens)
├── media.md (1200 tokens)
├── prompts.md (1500 tokens)
├── resources.md (900 tokens)
├── structured-output.md (1700 tokens)
├── tools.md (1100 tokens)
├── uri-templates.md (1800 tokens)
├── translations.md (300 tokens)
├── troubleshooting.md (4.6k tokens)
├── whats-new.md (3.9k tokens)
├── ko/
├── glossary.json (1700 tokens)
├── instructions.md (1500 tokens)
├── notices.md (100 tokens)
├── pages/
├── advanced/
├── apps.md (1200 tokens)
├── extensions.md (1800 tokens)
├── header-parameters.md (500 tokens)
├── index.md (200 tokens)
├── low-level-server.md (2.3k tokens)
├── middleware.md (1100 tokens)
├── pagination.md (800 tokens)
├── client/
├── caching.md (2000 tokens)
├── callbacks.md (1400 tokens)
├── identity-assertion.md (2.4k tokens)
├── index.md (2k tokens)
├── oauth-clients.md (1700 tokens)
├── session-groups.md (800 tokens)
├── subscriptions.md (900 tokens)
├── transports.md (1500 tokens)
├── deprecated.md (1800 tokens)
├── get-started/
├── first-steps.md (1200 tokens)
├── index.md (400 tokens)
├── installation.md (500 tokens)
├── real-host.md (1500 tokens)
├── testing.md (700 tokens)
├── handlers/
├── cancellation.md (500 tokens)
├── context.md (1000 tokens)
├── dependencies.md (1500 tokens)
├── elicitation.md (1700 tokens)
├── index.md (200 tokens)
├── lifespan.md (600 tokens)
├── logging.md (700 tokens)
├── multi-round-trip.md (2.5k tokens)
├── progress.md (800 tokens)
├── sampling-and-roots.md (600 tokens)
├── subscriptions.md (1700 tokens)
├── index.md (600 tokens)
├── protocol-versions.md (1100 tokens)
├── run/
├── asgi.md (1300 tokens)
├── authorization.md (1200 tokens)
├── deploy.md (2.2k tokens)
├── index.md (1300 tokens)
├── legacy-clients.md (1800 tokens)
├── opentelemetry.md (700 tokens)
├── servers/
├── completions.md (900 tokens)
├── handling-errors.md (1400 tokens)
├── index.md (200 tokens)
├── media.md (1100 tokens)
├── prompts.md (1400 tokens)
├── resources.md (900 tokens)
├── structured-output.md (1700 tokens)
├── tools.md (1200 tokens)
├── uri-templates.md (1800 tokens)
├── translations.md (400 tokens)
├── troubleshooting.md (4.6k tokens)
├── whats-new.md (4k tokens)
├── languages.yml (200 tokens)
├── notices.md (200 tokens)
├── pt/
├── glossary.json (1800 tokens)
├── instructions.md (2.1k tokens)
├── notices.md (200 tokens)
├── pages/
├── advanced/
├── apps.md (1600 tokens)
├── extensions.md (2.8k tokens)
├── header-parameters.md (700 tokens)
├── index.md (400 tokens)
├── low-level-server.md (3.1k tokens)
├── middleware.md (1700 tokens)
├── pagination.md (1100 tokens)
├── client/
├── caching.md (3.3k tokens)
├── callbacks.md (2k tokens)
├── identity-assertion.md (3.5k tokens)
├── index.md (2.7k tokens)
├── oauth-clients.md (2.6k tokens)
├── session-groups.md (1100 tokens)
├── subscriptions.md (1400 tokens)
├── transports.md (2.2k tokens)
├── deprecated.md (2.5k tokens)
├── get-started/
├── first-steps.md (1700 tokens)
├── index.md (500 tokens)
├── installation.md (600 tokens)
├── real-host.md (2.2k tokens)
├── testing.md (900 tokens)
├── handlers/
├── cancellation.md (700 tokens)
├── context.md (1500 tokens)
├── dependencies.md (2.5k tokens)
├── elicitation.md (2.5k tokens)
├── index.md (300 tokens)
├── lifespan.md (900 tokens)
├── logging.md (1000 tokens)
├── multi-round-trip.md (4k tokens)
├── progress.md (1000 tokens)
├── sampling-and-roots.md (900 tokens)
├── subscriptions.md (2.4k tokens)
├── index.md (800 tokens)
├── protocol-versions.md (1600 tokens)
├── run/
├── asgi.md (1800 tokens)
├── authorization.md (1800 tokens)
├── deploy.md (3.5k tokens)
├── index.md (1800 tokens)
├── legacy-clients.md (2.8k tokens)
├── opentelemetry.md (1100 tokens)
├── servers/
├── completions.md (1200 tokens)
├── handling-errors.md (2000 tokens)
├── index.md (400 tokens)
├── media.md (1600 tokens)
├── prompts.md (2000 tokens)
├── resources.md (1300 tokens)
├── structured-output.md (2.2k tokens)
├── tools.md (1600 tokens)
├── uri-templates.md (2.8k tokens)
├── translations.md (600 tokens)
├── troubleshooting.md (6.3k tokens)
├── whats-new.md (5.8k tokens)
├── ru/
├── glossary.json (2.7k tokens)
├── instructions.md (2.1k tokens)
├── notices.md (200 tokens)
├── pages/
├── advanced/
├── apps.md (1600 tokens)
├── extensions.md (2.8k tokens)
├── header-parameters.md (700 tokens)
├── index.md (400 tokens)
├── low-level-server.md (3.2k tokens)
├── middleware.md (1700 tokens)
├── pagination.md (1200 tokens)
├── client/
├── caching.md (3.3k tokens)
├── callbacks.md (2k tokens)
├── identity-assertion.md (3.6k tokens)
├── index.md (2.7k tokens)
├── oauth-clients.md (2.6k tokens)
├── session-groups.md (1100 tokens)
├── subscriptions.md (1400 tokens)
├── transports.md (2.2k tokens)
├── deprecated.md (2.6k tokens)
├── get-started/
├── first-steps.md (1700 tokens)
├── index.md (500 tokens)
├── installation.md (600 tokens)
├── real-host.md (2.1k tokens)
├── testing.md (900 tokens)
├── handlers/
├── cancellation.md (700 tokens)
├── context.md (1500 tokens)
├── dependencies.md (2.4k tokens)
├── elicitation.md (2.4k tokens)
├── index.md (400 tokens)
├── lifespan.md (900 tokens)
├── logging.md (1100 tokens)
├── multi-round-trip.md (4.1k tokens)
├── progress.md (1000 tokens)
├── sampling-and-roots.md (1000 tokens)
├── subscriptions.md (2.5k tokens)
├── index.md (800 tokens)
├── protocol-versions.md (1600 tokens)
├── run/
├── asgi.md (1900 tokens)
├── authorization.md (1800 tokens)
├── deploy.md (3.5k tokens)
├── index.md (1900 tokens)
├── legacy-clients.md (2.9k tokens)
├── opentelemetry.md (1000 tokens)
├── servers/
├── completions.md (1200 tokens)
├── handling-errors.md (2k tokens)
├── index.md (300 tokens)
├── media.md (1600 tokens)
├── prompts.md (2000 tokens)
├── resources.md (1300 tokens)
├── structured-output.md (2.2k tokens)
├── tools.md (1600 tokens)
├── uri-templates.md (2.7k tokens)
├── translations.md (600 tokens)
├── troubleshooting.md (6.4k tokens)
├── whats-new.md (5.8k tokens)
├── tr/
├── glossary.json (2.9k tokens)
├── instructions.md (2.1k tokens)
├── notices.md (200 tokens)
├── pages/
├── advanced/
├── apps.md (1600 tokens)
├── extensions.md (2.8k tokens)
├── header-parameters.md (700 tokens)
├── index.md (400 tokens)
├── low-level-server.md (3.1k tokens)
├── middleware.md (1600 tokens)
├── pagination.md (1100 tokens)
├── client/
├── caching.md (3.3k tokens)
├── callbacks.md (2000 tokens)
├── identity-assertion.md (3.6k tokens)
├── index.md (2.7k tokens)
├── oauth-clients.md (2.6k tokens)
├── session-groups.md (1100 tokens)
├── subscriptions.md (1400 tokens)
├── transports.md (2.2k tokens)
├── deprecated.md (2.5k tokens)
├── get-started/
├── first-steps.md (1700 tokens)
├── index.md (500 tokens)
├── installation.md (600 tokens)
├── real-host.md (2.1k tokens)
├── testing.md (900 tokens)
├── handlers/
├── cancellation.md (700 tokens)
├── context.md (1400 tokens)
├── dependencies.md (2.4k tokens)
├── elicitation.md (2.4k tokens)
├── index.md (300 tokens)
├── lifespan.md (900 tokens)
├── logging.md (1000 tokens)
├── multi-round-trip.md (4k tokens)
├── progress.md (1000 tokens)
├── sampling-and-roots.md (900 tokens)
├── subscriptions.md (2.4k tokens)
├── index.md (800 tokens)
├── protocol-versions.md (1500 tokens)
├── run/
├── asgi.md (1800 tokens)
├── authorization.md (1800 tokens)
├── deploy.md (3.5k tokens)
├── index.md (1800 tokens)
├── legacy-clients.md (2.8k tokens)
├── opentelemetry.md (1000 tokens)
├── servers/
├── completions.md (1200 tokens)
├── handling-errors.md (1900 tokens)
├── index.md (300 tokens)
├── media.md (1600 tokens)
├── prompts.md (2000 tokens)
├── resources.md (1300 tokens)
├── structured-output.md (2.2k tokens)
├── tools.md (1600 tokens)
├── uri-templates.md (2.7k tokens)
├── translations.md (600 tokens)
├── troubleshooting.md (6.3k tokens)
├── whats-new.md (5.7k tokens)
├── uk/
├── glossary.json (3.2k tokens)
├── instructions.md (2k tokens)
├── notices.md (200 tokens)
├── pages/
├── advanced/
├── apps.md (1600 tokens)
├── extensions.md (2.8k tokens)
├── header-parameters.md (700 tokens)
├── index.md (300 tokens)
├── low-level-server.md (3.1k tokens)
├── middleware.md (1600 tokens)
├── pagination.md (1100 tokens)
├── client/
├── caching.md (3.2k tokens)
├── callbacks.md (2000 tokens)
├── identity-assertion.md (3.4k tokens)
├── index.md (2.7k tokens)
├── oauth-clients.md (2.6k tokens)
├── session-groups.md (1000 tokens)
├── subscriptions.md (1400 tokens)
├── transports.md (2.1k tokens)
├── deprecated.md (2.5k tokens)
├── get-started/
├── first-steps.md (1700 tokens)
├── index.md (500 tokens)
├── installation.md (600 tokens)
├── real-host.md (2.1k tokens)
├── testing.md (900 tokens)
├── handlers/
├── cancellation.md (700 tokens)
├── context.md (1500 tokens)
├── dependencies.md (2.4k tokens)
├── elicitation.md (2.4k tokens)
├── index.md (400 tokens)
├── lifespan.md (900 tokens)
├── logging.md (1000 tokens)
├── multi-round-trip.md (4k tokens)
├── progress.md (1000 tokens)
├── sampling-and-roots.md (900 tokens)
├── subscriptions.md (2.4k tokens)
├── index.md (800 tokens)
├── protocol-versions.md (1600 tokens)
├── run/
├── asgi.md (1800 tokens)
├── authorization.md (1800 tokens)
├── deploy.md (3.5k tokens)
├── index.md (1800 tokens)
├── legacy-clients.md (2.8k tokens)
├── opentelemetry.md (1000 tokens)
├── servers/
├── completions.md (1200 tokens)
├── handling-errors.md (2000 tokens)
├── index.md (300 tokens)
├── media.md (1600 tokens)
├── prompts.md (1900 tokens)
├── resources.md (1300 tokens)
├── structured-output.md (2.1k tokens)
├── tools.md (1600 tokens)
├── uri-templates.md (2.7k tokens)
├── translations.md (600 tokens)
├── troubleshooting.md (6.3k tokens)
├── whats-new.md (5.7k tokens)
├── zh-hant/
├── glossary.json (4.4k tokens)
├── instructions.md (2.2k tokens)
├── notices.md (100 tokens)
├── pages/
├── advanced/
├── apps.md (1000 tokens)
├── extensions.md (1500 tokens)
├── header-parameters.md (400 tokens)
├── index.md (200 tokens)
├── low-level-server.md (1900 tokens)
├── middleware.md (900 tokens)
├── pagination.md (600 tokens)
├── client/
├── caching.md (1600 tokens)
├── callbacks.md (1200 tokens)
├── identity-assertion.md (1900 tokens)
├── index.md (1700 tokens)
├── oauth-clients.md (1400 tokens)
├── session-groups.md (700 tokens)
├── subscriptions.md (700 tokens)
├── transports.md (1300 tokens)
├── deprecated.md (1500 tokens)
├── get-started/
├── first-steps.md (1000 tokens)
├── index.md (300 tokens)
├── installation.md (400 tokens)
├── real-host.md (1200 tokens)
├── testing.md (600 tokens)
├── handlers/
├── cancellation.md (400 tokens)
├── context.md (800 tokens)
├── dependencies.md (1200 tokens)
├── elicitation.md (1300 tokens)
├── index.md (200 tokens)
├── lifespan.md (500 tokens)
├── logging.md (600 tokens)
├── multi-round-trip.md (2k tokens)
├── progress.md (600 tokens)
├── sampling-and-roots.md (500 tokens)
├── subscriptions.md (1400 tokens)
├── index.md (500 tokens)
├── protocol-versions.md (900 tokens)
├── run/
├── asgi.md (1000 tokens)
├── authorization.md (1000 tokens)
├── deploy.md (1800 tokens)
├── index.md (1100 tokens)
├── legacy-clients.md (1400 tokens)
├── opentelemetry.md (600 tokens)
├── servers/
├── completions.md (700 tokens)
├── handling-errors.md (1100 tokens)
├── index.md (200 tokens)
├── media.md (1000 tokens)
├── prompts.md (1200 tokens)
├── resources.md (800 tokens)
├── structured-output.md (1500 tokens)
├── tools.md (1000 tokens)
├── uri-templates.md (1500 tokens)
├── translations.md (300 tokens)
├── troubleshooting.md (4k tokens)
├── whats-new.md (3.2k tokens)
├── zh/
├── glossary.json (1700 tokens)
├── instructions.md (1700 tokens)
├── notices.md (100 tokens)
├── pages/
├── advanced/
├── apps.md (1000 tokens)
├── extensions.md (1500 tokens)
├── header-parameters.md (400 tokens)
├── index.md (200 tokens)
├── low-level-server.md (1900 tokens)
├── middleware.md (900 tokens)
├── pagination.md (600 tokens)
├── client/
├── caching.md (1500 tokens)
├── callbacks.md (1200 tokens)
├── identity-assertion.md (1900 tokens)
├── index.md (1700 tokens)
├── oauth-clients.md (1400 tokens)
├── session-groups.md (600 tokens)
├── subscriptions.md (700 tokens)
├── transports.md (1200 tokens)
├── deprecated.md (1500 tokens)
├── get-started/
├── first-steps.md (900 tokens)
├── index.md (300 tokens)
├── installation.md (400 tokens)
├── real-host.md (1200 tokens)
├── testing.md (600 tokens)
├── handlers/
├── cancellation.md (400 tokens)
├── context.md (800 tokens)
├── dependencies.md (1200 tokens)
├── elicitation.md (1300 tokens)
├── index.md (200 tokens)
├── lifespan.md (500 tokens)
├── logging.md (500 tokens)
├── multi-round-trip.md (2000 tokens)
├── progress.md (600 tokens)
├── sampling-and-roots.md (500 tokens)
├── subscriptions.md (1400 tokens)
├── index.md (500 tokens)
├── protocol-versions.md (900 tokens)
├── run/
├── asgi.md (1000 tokens)
├── authorization.md (1100 tokens)
├── deploy.md (1800 tokens)
├── index.md (1000 tokens)
├── legacy-clients.md (1400 tokens)
├── opentelemetry.md (600 tokens)
├── servers/
├── completions.md (700 tokens)
├── handling-errors.md (1100 tokens)
├── index.md (200 tokens)
├── media.md (1000 tokens)
├── prompts.md (1200 tokens)
├── resources.md (800 tokens)
├── structured-output.md (1500 tokens)
├── tools.md (900 tokens)
├── uri-templates.md (1500 tokens)
├── translations.md (300 tokens)
├── troubleshooting.md (3.9k tokens)
├── whats-new.md (3.2k tokens)
├── mkdocs.yml (1500 tokens)
├── pyproject.toml (2.5k tokens)
├── schema/
├── 2025-11-25.json (34.9k tokens)
├── 2026-07-28.json (36.1k tokens)
├── PINNED.json (100 tokens)
├── README.md (100 tokens)
├── scripts/
├── build-docs.sh (700 tokens)
├── docs/
├── build.sh (800 tokens)
├── build_config.py (2.2k tokens)
├── check_crossrefs.py (1500 tokens)
├── check_render_order.py (1000 tokens)
├── gen_ref_pages.py (2000 tokens)
├── llms_txt.py (3.3k tokens)
├── translations.py (10.8k tokens)
├── gen_surface_types.py (3.1k tokens)
├── serve-docs.sh (200 tokens)
├── test (100 tokens)
├── update_readme_snippets.py (1000 tokens)
├── src/
├── mcp-types/
├── README.md (100 tokens)
├── mcp_types/
├── __init__.py (2.3k tokens)
├── _types.py (16k tokens)
├── _v2025_11_25/
├── __init__.py (20k tokens)
├── _v2026_07_28/
├── __init__.py (23.4k tokens)
├── _wire_base.py (100 tokens)
├── jsonrpc.py (900 tokens)
├── methods.py (7.2k tokens)
├── py.typed
├── version.py (600 tokens)
├── pyproject.toml (300 tokens)
├── mcp/
├── __init__.py (700 tokens)
├── cli/
├── __init__.py
├── claude.py (1200 tokens)
├── cli.py (3k tokens)
├── client/
├── __init__.py (200 tokens)
├── __main__.py (500 tokens)
├── _input_required.py (1100 tokens)
├── _memory.py (1000 tokens)
├── _probe.py (1100 tokens)
├── _transport.py (100 tokens)
├── auth/
├── __init__.py (100 tokens)
├── exceptions.py (100 tokens)
├── extensions/
├── __init__.py
├── client_credentials.py (3.6k tokens)
├── identity_assertion.py (2.2k tokens)
├── oauth2.py (8k tokens)
├── utils.py (3.5k tokens)
├── caching.py (3.5k tokens)
├── client.py (9.1k tokens)
├── context.py
├── extension.py (1700 tokens)
├── session.py (13.9k tokens)
├── session_group.py (3.6k tokens)
├── sse.py (1700 tokens)
├── stdio.py (2.8k tokens)
├── streamable_http.py (7.5k tokens)
├── subscriptions.py (2.3k tokens)
├── os/
├── __init__.py
├── posix/
├── __init__.py
├── utilities.py (500 tokens)
├── win32/
├── __init__.py
├── utilities.py (2.1k tokens)
├── py.typed
├── server/
├── __init__.py (100 tokens)
├── __main__.py (100 tokens)
├── _otel.py (700 tokens)
├── _streamable_http_modern.py (4.5k tokens)
├── apps.py (1900 tokens)
├── auth/
├── __init__.py
├── errors.py
├── handlers/
├── __init__.py
├── authorize.py (1900 tokens)
├── metadata.py (200 tokens)
├── register.py (1400 tokens)
├── revoke.py (600 tokens)
├── token.py (2.5k tokens)
├── json_response.py (100 tokens)
├── middleware/
├── __init__.py
├── auth_context.py (300 tokens)
├── bearer_auth.py (1200 tokens)
├── client_auth.py (1000 tokens)
├── provider.py (3k tokens)
├── routes.py (2.1k tokens)
├── settings.py (700 tokens)
├── caching.py (700 tokens)
├── connection.py (4.4k tokens)
├── context.py (1700 tokens)
├── elicitation.py (1400 tokens)
├── extension.py (1600 tokens)
├── fastmcp.py (200 tokens)
├── lowlevel/
├── __init__.py
├── helper_types.py (100 tokens)
├── server.py (7.4k tokens)
├── mcpserver/
├── __init__.py (300 tokens)
├── context.py (3.4k tokens)
├── exceptions.py (700 tokens)
├── prompts/
├── __init__.py
├── base.py (1700 tokens)
├── manager.py (400 tokens)
├── resolve.py (7.2k tokens)
├── resources/
├── __init__.py (100 tokens)
├── base.py (300 tokens)
├── resource_manager.py (1000 tokens)
├── templates.py (2k tokens)
├── types.py (1800 tokens)
├── server.py (12k tokens)
├── tools/
├── __init__.py
├── base.py (2000 tokens)
├── tool_manager.py (600 tokens)
├── utilities/
├── __init__.py
├── context_injection.py (400 tokens)
├── func_metadata.py (6.2k tokens)
├── logging.py (200 tokens)
├── types.py (700 tokens)
├── models.py (100 tokens)
├── request_state.py (4k tokens)
├── runner.py (7.9k tokens)
├── session.py (4.1k tokens)
├── sse.py (2.6k tokens)
├── stdio.py (1600 tokens)
├── streamable_http.py (10.4k tokens)
├── streamable_http_manager.py (4.4k tokens)
├── subscriptions.py (2.2k tokens)
├── transport_security.py (1400 tokens)
├── validation.py (700 tokens)
├── shared/
├── __init__.py
├── _callable_inspection.py (200 tokens)
├── _compat.py (200 tokens)
├── _context_streams.py (800 tokens)
├── _httpx_utils.py (1900 tokens)
├── _otel.py (300 tokens)
├── _request_clock.py (500 tokens)
├── _stream_protocols.py (300 tokens)
├── auth.py (2.4k tokens)
├── auth_utils.py (600 tokens)
├── context.py (600 tokens)
├── direct_dispatcher.py (2.9k tokens)
├── dispatcher.py (2.2k tokens)
├── exceptions.py (800 tokens)
├── extension.py (200 tokens)
├── inbound.py (5.2k tokens)
├── jsonrpc_dispatcher.py (7.8k tokens)
├── memory.py (300 tokens)
├── message.py (500 tokens)
├── metadata_utils.py (300 tokens)
├── path_security.py (1200 tokens)
├── peer.py (2000 tokens)
├── subscriptions.py (800 tokens)
├── tool_name_validation.py (900 tokens)
├── transport_context.py (400 tokens)
├── uri_template.py (8.8k tokens)
├── types/
├── __init__.py (300 tokens)
├── jsonrpc.py (100 tokens)
├── methods.py (100 tokens)
├── version.py (100 tokens)
├── tests/
├── __init__.py
├── _stamp.py (300 tokens)
├── cli/
├── __init__.py
├── test_claude.py (1800 tokens)
├── test_utils.py (800 tokens)
├── client/
├── __init__.py
├── auth/
├── extensions/
├── test_client_credentials.py (5k tokens)
├── test_identity_assertion.py (3.3k tokens)
├── conftest.py (1000 tokens)
├── test_auth.py (31.7k tokens)
├── test_caching.py (9.7k tokens)
├── test_client.py (8.9k tokens)
├── test_client_caching.py (13.6k tokens)
├── test_client_extensions.py (4.5k tokens)
├── test_extension.py (2.9k tokens)
├── test_http_unicode.py (1400 tokens)
├── test_input_required.py (2.4k tokens)
├── test_list_methods_cursor.py (900 tokens)
├── test_list_roots_callback.py (400 tokens)
├── test_logging_callback.py (700 tokens)
├── test_notification_response.py (2.6k tokens)
├── test_output_schema_validation.py (1400 tokens)
├── test_probe.py (2.8k tokens)
├── test_sampling_callback.py (1100 tokens)
├── test_scope_bug_1630.py (1300 tokens)
├── test_send_request_mcp_name.py (2.1k tokens)
├── test_session.py (18.9k tokens)
├── test_session_claims.py (4k tokens)
├── test_session_concurrency.py (1300 tokens)
├── test_session_group.py (3.4k tokens)
├── test_session_notification_bindings.py (2.4k tokens)
├── test_session_promotions.py (1300 tokens)
├── test_stdio.py (11.9k tokens)
├── test_streamable_http.py (11.6k tokens)
├── test_subscriptions.py (6.2k tokens)
├── test_transport_stream_cleanup.py (900 tokens)
├── transports/
├── __init__.py
├── test_memory.py (1000 tokens)
├── conftest.py (900 tokens)
├── docs/
├── __init__.py
├── test_build_config.py (1200 tokens)
├── test_translations.py (11.8k tokens)
├── docs_src/
├── __init__.py
├── _helpers.py (100 tokens)
├── test_apps.py (900 tokens)
├── test_asgi.py (2.3k tokens)
├── test_authorization.py (1100 tokens)
├── test_caching.py (1900 tokens)
├── test_cancellation.py (1900 tokens)
├── test_client.py (1900 tokens)
├── test_client_callbacks.py (1200 tokens)
├── test_client_transports.py (900 tokens)
├── test_completions.py (1100 tokens)
├── test_context.py (800 tokens)
├── test_dependencies.py (1500 tokens)
├── test_deploy.py (2000 tokens)
├── test_deprecated.py (1300 tokens)
├── test_elicitation.py (3k tokens)
├── test_extensions.py (1500 tokens)
├── test_first_steps.py (800 tokens)
├── test_handling_errors.py (1300 tokens)
├── test_header_parameters.py (1900 tokens)
├── test_identity_assertion.py (1900 tokens)
├── test_index.py (300 tokens)
├── test_legacy_clients.py (1600 tokens)
├── test_lifespan.py (1000 tokens)
├── test_logging.py (500 tokens)
├── test_lowlevel.py (1800 tokens)
├── test_media.py (1000 tokens)
├── test_middleware.py (3k tokens)
├── test_mrtr.py (1900 tokens)
├── test_oauth_clients.py (1300 tokens)
├── test_opentelemetry.py (300 tokens)
├── test_pagination.py (700 tokens)
├── test_progress.py (900 tokens)
├── test_prompts.py (1000 tokens)
├── test_protocol_versions.py (1100 tokens)
├── test_real_host.py (500 tokens)
├── test_resources.py (1000 tokens)
├── test_run.py (500 tokens)
├── test_sampling_and_roots.py (500 tokens)
├── test_session_groups.py (1100 tokens)
├── test_shape.py (1400 tokens)
├── test_structured_output.py (2.2k tokens)
├── test_subscriptions.py (3.7k tokens)
├── test_testing.py (200 tokens)
├── test_tools.py (1000 tokens)
├── test_troubleshooting.py (3.3k tokens)
├── test_uri_templates.py (2.3k tokens)
├── test_whats_new.py (600 tokens)
├── examples/
├── __init__.py
├── conftest.py (1200 tokens)
├── test_stories.py (700 tokens)
├── test_stories_smoke.py (500 tokens)
├── test_story_shape.py (1200 tokens)
├── interaction/
├── README.md (3.7k tokens)
├── __init__.py
├── _connect.py (3.7k tokens)
├── _helpers.py (700 tokens)
├── _modern_vocab.py (700 tokens)
├── _requirements.py (44k tokens)
├── auth/
├── __init__.py
├── _harness.py (4.4k tokens)
├── _provider.py (2.2k tokens)
├── test_as_handlers.py (2.9k tokens)
├── test_authorize_token.py (3.7k tokens)
├── test_bearer.py (2k tokens)
├── test_discovery.py (3.9k tokens)
├── test_flow.py (2.2k tokens)
├── test_identity_assertion.py (2.6k tokens)
├── test_lifecycle.py (4.8k tokens)
├── test_login_time.py (2.9k tokens)
├── conftest.py (700 tokens)
├── lowlevel/
├── __init__.py
├── test_cancellation.py (4.4k tokens)
├── test_client_connect.py (3.5k tokens)
├── test_completion.py (1200 tokens)
├── test_elicitation.py (5.9k tokens)
├── test_flows.py (2000 tokens)
├── test_initialize.py (3.3k tokens)
├── test_list_changed.py (1200 tokens)
├── test_logging.py (1700 tokens)
├── test_meta.py (600 tokens)
├── test_pagination.py (2.1k tokens)
├── test_ping.py (400 tokens)
├── test_progress.py (2.7k tokens)
├── test_prompts.py (1700 tokens)
├── test_resources.py (2.6k tokens)
├── test_roots.py (1500 tokens)
├── test_sampling.py (6.2k tokens)
├── test_subscriptions.py (1200 tokens)
├── test_timeouts.py (1800 tokens)
├── test_tools.py (4.5k tokens)
├── test_wire.py (3.4k tokens)
├── mcpserver/
├── __init__.py
├── test_completion.py (300 tokens)
├── test_context.py (2.2k tokens)
├── test_extensions.py (1500 tokens)
├── test_prompts.py (1500 tokens)
├── test_resources.py (1600 tokens)
├── test_subscriptions.py (500 tokens)
├── test_tools.py (3.6k tokens)
├── test_coverage.py (3.3k tokens)
├── transports/
├── __init__.py (100 tokens)
├── _bridge.py (1500 tokens)
├── _event_store.py (500 tokens)
├── _stdio_server.py (500 tokens)
├── test_bridge.py (900 tokens)
├── test_client_transport_http.py (3.3k tokens)
├── test_flows.py (1100 tokens)
├── test_hosting_http.py (3.6k tokens)
├── test_hosting_http_modern.py (10k tokens)
├── test_hosting_resume.py (4.3k tokens)
├── test_hosting_session.py (1700 tokens)
├── test_legacy_wire.py (800 tokens)
├── test_sse.py (800 tokens)
├── test_stdio.py (1000 tokens)
├── test_streamable_http.py (2.8k tokens)
├── issues/
├── test_100_tool_listing.py (200 tokens)
├── test_129_resource_templates.py (200 tokens)
├── test_1338_icons_and_metadata.py (900 tokens)
├── test_1363_race_condition_streamable_http.py (2.4k tokens)
├── test_141_resource_templates.py (1000 tokens)
├── test_152_resource_mime_type.py (1100 tokens)
├── test_1574_resource_uri_validation.py (1100 tokens)
├── test_1754_mime_type_parameters.py (500 tokens)
├── test_176_progress_token.py (300 tokens)
├── test_188_concurrency.py (600 tokens)
├── test_192_request_id.py (700 tokens)
├── test_342_base64_encoding.py (400 tokens)
├── test_355_type_error.py (200 tokens)
├── test_552_windows_hang.py (400 tokens)
├── test_88_random_error.py (1000 tokens)
├── test_973_url_decoding.py (500 tokens)
├── server/
├── __init__.py
├── auth/
├── __init__.py
├── middleware/
├── __init__.py
├── test_auth_context.py (700 tokens)
├── test_bearer_auth.py (4k tokens)
├── test_error_handling.py (2.5k tokens)
├── test_identity_assertion.py (3.1k tokens)
├── test_protected_resource.py (1500 tokens)
├── test_provider.py (700 tokens)
├── test_routes.py (600 tokens)
├── test_settings.py (400 tokens)
├── conftest.py (300 tokens)
├── lowlevel/
├── __init__.py
├── test_helper_types.py (400 tokens)
├── test_server_discover.py (2.4k tokens)
├── test_server_listing.py (900 tokens)
├── test_server_pagination.py (600 tokens)
├── mcpserver/
├── __init__.py
├── auth/
├── __init__.py
├── test_auth_integration.py (13.6k tokens)
├── prompts/
├── __init__.py
├── test_base.py (2.3k tokens)
├── test_manager.py (800 tokens)
├── resources/
├── __init__.py
├── test_file_resources.py (1400 tokens)
├── test_function_resources.py (1600 tokens)
├── test_resource_manager.py (1000 tokens)
├── test_resource_template.py (3.7k tokens)
├── test_resources.py (1500 tokens)
├── servers/
├── __init__.py
├── test_file_server.py (800 tokens)
├── test_elicitation.py (3.5k tokens)
├── test_extension.py (3.9k tokens)
├── test_func_metadata.py (11k tokens)
├── test_integration.py (2.9k tokens)
├── test_parameter_descriptions.py (200 tokens)
├── test_resolve.py (24k tokens)
├── test_server.py (26.6k tokens)
├── test_title.py (1700 tokens)
├── test_tool_manager.py (6.2k tokens)
├── test_url_elicitation.py (2.8k tokens)
├── test_url_elicitation_error_throw.py (900 tokens)
├── tools/
├── __init__.py
├── test_base.py (1100 tokens)
├── test_apps.py (2.7k tokens)
├── test_caching.py (2.3k tokens)
├── test_cancel_handling.py (2.1k tokens)
├── test_completion_with_context.py (1300 tokens)
├── test_connection.py (3.6k tokens)
├── test_extensions_capability.py (1200 tokens)
├── test_fastmcp.py (500 tokens)
├── test_lifespan.py (1500 tokens)
├── test_lowlevel_exception_handling.py (400 tokens)
├── test_lowlevel_tool_annotations.py (300 tokens)
├── test_otel.py (3.1k tokens)
├── test_read_resource.py (400 tokens)
├── test_request_state.py (4k tokens)
├── test_request_state_boundary.py (11.4k tokens)
├── test_runner.py (19.7k tokens)
├── test_server_context.py (1000 tokens)
├── test_session.py (2.9k tokens)
├── test_sse_security.py (4.9k tokens)
├── test_stateless_mode.py (1700 tokens)
├── test_stdio.py (5.8k tokens)
├── test_streamable_http_manager.py (9.2k tokens)
├── test_streamable_http_modern.py (12.5k tokens)
├── test_streamable_http_router.py (1300 tokens)
├── test_streamable_http_security.py (1200 tokens)
├── test_subscriptions.py (3.4k tokens)
├── test_transport_security.py (1700 tokens)
├── test_validation.py (1200 tokens)
├── shared/
├── __init__.py
├── conftest.py (400 tokens)
├── test_auth.py (2.1k tokens)
├── test_auth_utils.py (1200 tokens)
├── test_context.py (1100 tokens)
├── test_context_streams.py (100 tokens)
├── test_dispatcher.py (5.3k tokens)
├── test_exceptions.py (1200 tokens)
├── test_extension.py (300 tokens)
├── test_httpx_utils.py (2.4k tokens)
├── test_inbound.py (8.7k tokens)
├── test_jsonrpc_dispatcher.py (23.8k tokens)
├── test_otel.py (800 tokens)
├── test_path_security.py (1000 tokens)
├── test_peer.py (1800 tokens)
├── test_request_clock.py (500 tokens)
├── test_sse.py (4.2k tokens)
├── test_streamable_http.py (19.8k tokens)
├── test_tool_name_validation.py (1400 tokens)
├── test_uri_template.py (8k tokens)
├── test_examples.py (1100 tokens)
├── test_types.py (3.7k tokens)
├── transports/
├── __init__.py
├── stdio/
├── __init__.py
├── _liveness.py (700 tokens)
├── conftest.py (600 tokens)
├── test_lifecycle.py (1900 tokens)
├── test_posix.py (800 tokens)
├── test_windows.py (1800 tokens)
├── types/
├── __init__.py
├── test_methods.py (9.8k tokens)
├── test_parity.py (2000 tokens)
├── test_request_name_param.py (300 tokens)
├── test_version.py (500 tokens)
├── test_wire_frames.py (800 tokens)
├── uv.lock (omitted)
```
## /.claude/commands/review-pr.md
Review the pull request: $ARGUMENTS
Follow these steps carefully. Use the `gh` CLI for all GitHub interactions.
## Step 1: Resolve the PR
Parse `$ARGUMENTS` to determine the PR. It can be:
- A full URL like `https://github.com/owner/repo/pull/123`
- A `owner/repo#123` reference
- A bare number like `123` (use the current repo)
- A description — search for it with `gh pr list --search "<description>" --limit 5` and pick the best match
Once resolved, fetch the PR metadata:
```bash
gh pr view <PR> --json number,title,body,author,state,baseRefName,headRefName,url,labels,milestone,additions,deletions,changedFiles,createdAt,updatedAt,mergedAt,reviewDecision,reviews,assignees
```
## Step 2: Gather the diff
Get the full diff of the PR:
```bash
gh pr diff <PR>
```
If the diff is very large (>3000 lines), focus on the most important files first and summarize the rest.
## Step 3: Collect PR discussion context
Fetch all comments and review threads:
```bash
gh api repos/{owner}/{repo}/pulls/{number}/comments --paginate
gh api repos/{owner}/{repo}/issues/{number}/comments --paginate
gh api repos/{owner}/{repo}/pulls/{number}/reviews --paginate
```
Pay attention to:
- Reviewer feedback and requested changes
- Author responses and explanations
- Any unresolved conversations
- Approval or rejection status
## Step 4: Find and read linked issues
Look for issue references in:
- The PR body (patterns like `#123`, `fixes #123`, `closes #123`, `resolves #123`)
- The PR branch name (patterns like `issue-123`, `fix/123`)
- Commit messages
For each linked issue, fetch its content:
```bash
gh issue view <number> --json title,body,comments,labels,state
```
Read through issue comments to understand the original problem, user reports, and any discussed solutions.
## Step 5: Analyze and validate
With all context gathered, analyze the PR critically:
1. **Intent alignment**: Does the code change actually solve the problem described in the PR and/or linked issues?
2. **Completeness**: Are there aspects of the issue or requested feature that the PR doesn't address?
3. **Scope**: Does the PR include changes unrelated to the stated goal? Are there unnecessary modifications?
4. **Correctness**: Based on the diff, are there obvious bugs, edge cases, or logic errors?
5. **Testing**: Does the PR include tests? Are they meaningful and do they cover the important cases?
6. **Breaking changes**: Could this PR break existing functionality or APIs?
7. **Unresolved feedback**: Are there reviewer comments that haven't been addressed?
## Step 6: Produce the review summary
Present the summary in this format:
---
### PR Review: `<title>` (<url>)
**Author:** <author> | **Status:** <state> | **Review decision:** <decision>
**Base:** `<base>` ← `<head>` | **Changed files:** <n> | **+<additions> / -<deletions>**
#### Problem
<1-3 sentences describing what problem this PR is trying to solve, based on the PR description and linked issues>
#### Solution
<1-3 sentences describing the approach taken in the code>
#### Key changes
<Bulleted list of the most important changes, grouped by theme. Include file paths.>
#### Linked issues
<List of linked issues with their title, state, and a one-line summary of the discussion>
#### Discussion highlights
<Summary of important comments from reviewers and the author. Flag any unresolved threads.>
#### Concerns
<List any issues found during validation: bugs, missing tests, scope creep, unaddressed feedback, etc. If none, say "No concerns found.">
#### Verdict
<One of: APPROVE / REQUEST CHANGES / NEEDS DISCUSSION, with a brief justification>
#### Suggested action
<Clear recommendation for the reviewer: what to approve, what to push back on, what to ask about>
---
## /.claude/skills/test-quality/SKILL.md
---
name: test-quality
description: Test quality bar for this repo. Read when writing, reviewing, or designing tests — covers naming, abstraction level, assertions, determinism, and the process for agent-driven test work.
---
# Test & code quality guide
What "best practice for new work" means in this repo. Each rule carries its recorded reasoning
where one exists; a rule with no stated why is a convention — follow it anyway.
## Naming & shape
- **Test names are behaviour sentences** stating the observable outcome, not the feature being
poked: `test_elicit_form_decline_returns_no_content`, never `test_elicit_form_decline`.
- Plain top-level `test_*` functions; no `Test` classes (legacy files have them — don't copy).
- **Docstrings: 1–2 sentences of behaviour, honest about provenance** — spec-mandated,
SDK-defined, or pinning a known gap? Say which: provenance is the triage key when the test
later fails. A pinned-gap assertion breaking usually means a change *fixed* the gap; a
spec-mandated assertion breaking means a regression.
- Define things in dependency order; nothing forward-references. For client↔server tests:
handlers → server construction → client setup → act → assert — the test reads in the order
the conversation happens.
- Inline the server (or equivalent setup) in the test, so the whole observable behaviour fits
on one screen. Lift to a file-level fixture only when several tests in *that file* genuinely
share it; never share across files.
- A big multi-step test is fine when the property is irreducibly multi-step (e.g. resumability).
Split when a failure wouldn't tell you which claim broke — not for shortness. Compensate with
a numbered "Steps:" docstring so a reader sees the choreography before the body.
## Level of abstraction
- **Drive through the highest-level public API that can observe the property.** Hand-built wire
requests are brittle and don't prove the user-facing contract; tests that stay above the
internals keep working when the internals change. Drop to raw HTTP only when the assertion is
about something the high-level API *cannot* observe (status codes, headers, wire framing).
- **Scripting a peer over raw streams is a last resort**, reserved for behaviour the typed API
cannot *produce* (malformed input, an impossible peer response). First ask what it would take
for the public API to express it — often a small helper suffices. Every such test's docstring
states why the public API couldn't do it.
- **In-memory / in-process first.** HTTP-, SSE-, and auth-shaped behaviour can all be driven
through an in-process ASGI transport; threads only when necessary, subprocesses only when the
process boundary is itself the thing under test. In-process isn't just faster — it surfaces
bugs (a real stream leak was found this way) that subprocess indirection masks.
- **Tests read like real user code**: no aliasing shims in conftest, no walls of suppressions,
no private imports unless that is genuinely the documented way to do the thing.
- The assertion must prove the round trip — no side-channel state. What the server saw comes
back through the protocol, or via a closure-captured list asserted after the call. Handlers
assert their dispatch identity first (`assert params.name == "add"`), proving the request
that arrived is the request the test sent.
## Assertions
- **Transformations** (input → output the SDK produced) → full-object `snapshot(...)` equality,
so an added or dropped field fails. Regenerate with `--inline-snapshot=fix` so intentional
changes arrive as a reviewable diff; never hand-edit snapshot literals.
- **Pass-through values** (opaque tokens, `_meta`, cursors) → identity against the same
variable you sent. A snapshot of a pass-through value only "matches" because a human checked
two literals correspond — it proves nothing.
- **Errors** → `pytest.raises` + `.code` against the named constant; snapshot SDK-authored
messages; never `match=` on message text. Third-party text (pydantic, jsonschema) → stable
prefix only — never pin text that changes with a dependency upgrade — with a comment saying so.
## Determinism
- **No sleeps, ever.** A sleep guesses at timing instead of waiting on the condition, so it
either flakes or pads the run — sleeps head the list of the older test code's failure modes.
Coordinate with `anyio.Event` so the wait ends exactly when the condition holds (that
discipline is why 529 e2e tests run in ~10 s). Sole exception: tests *of* time-based
features, with a comment.
- Bound every indefinite wait with `anyio.fail_after(5)`. **5 is the standard** — widening it
needs an articulable reason; "10 to be safe" is covering up a flake, not fixing one, and
unexplained widenings propagate (one agent used 10 and every later one copied it).
- **Never assert wall-clock time, even with huge margins.** `elapsed < 0.9` on a ~0.01 s
operation — a 90× margin — was still rejected in review. `fail_after` bounding a hang is the
only timing primitive allowed.
- **Concurrency tests must prove genuine interleaving.** Without barriers the scheduler is free
to serialize — "a" can start, finish, and return before "b" even begins — so two `start_soon`
calls prove nothing. Gate with events so all parties are mid-flight before any proceeds, emit
interleaved, then assert the demux.
- **Don't over-synchronize either.** When delivery ordering is guaranteed (notifications
emitted during a request you're awaiting, over a single ordered in-memory stream), a plain
collected list asserted after the call is correct; events are for messages not tied to an
awaited operation. Verify the ordering guarantee actually holds for your transport first.
- Async tests use anyio, not asyncio.
## Behaviour philosophy
- **Pin current behaviour; never xfail.** A green suite asserting what actually happens is a
regression bar for any refactor; an xfail proves nothing about it. Where current behaviour
falls short of spec, pin the divergent output and record the gap as data — a tracking issue,
with the docstring naming the known gap (suites with a requirements manifest record it as a
divergence entry). Not hidden, not skipped.
- **Hollow-proof check**: before claiming a test covers a behaviour, re-read the claim and ask
"which assertion proves *this*?" A passing test near a behaviour is not proof of it — a full
review of the e2e suite found two such cases even under this discipline.
## Hygiene
- No new `# pragma`, `# type: ignore`, `# noqa` by default — restructure first; a suppression
usually means a test or a type is missing. The narrow sanctioned escape hatches (and the
audit to run before pushing) are in AGENTS.md. In tests, narrow types with `assert
isinstance`; never `Any`/`object` when a real type exists.
- **Warnings raised during tests are findings, not noise** (the repo runs
`filterwarnings = error`). Fix the cause; if the fix can't land in the same change, scope the
suppression to the one fixture that needs it and track the real fix explicitly.
- Registered-but-never-invoked handler bodies are `raise NotImplementedError`, so they cannot
silently become load-bearing.
- Comments live next to the line they explain, not in docstrings; single backticks for code
refs; match the surrounding comment density (one-liners next to one-liners). No
`from __future__ import annotations` (py310+ repo).
- Test work doesn't change `src/` as a side effect — the one mechanical exception is deleting a
pragma a new test now covers. If a test can't be written without a library change, raise it
as a finding or defer the test; don't quietly edit `src/`.
## Process (for agent-driven work)
- **Small chunks.** ≤10 tests per human-reviewed batch; when fanning out to multiple agents,
≤5 per agent. Review quality scales inversely with batch size: the observed shortcuts
(over-stacked tests, a timing assertion, wrong abstraction level) all surfaced in one
oversized 27-test batch.
- **Design → review → implement, as separate steps.** The design deliverable is not just the
plan — it's the judgement calls (abstraction level, deferrals, contested assertions) stated
explicitly, so the reviewer vetoes them before implementation rather than discovering them
in the diff.
- **High-stakes areas get fresh adversarial reviewers** on both the design and the
implementation — fresh, because whoever designed it (or saw the proposed fix) anchors on the
same layer. SERIOUS findings (wrong assertion, missed MUST/SHOULD, an unrecorded known gap)
re-loop; style doesn't. Reserve the full panel for areas where being wrong is worse than
being slow.
- **Investigations pair a full-context look with a fresh agent given only the problem** —
never the proposed fix — and compare conclusions. The unbiased read regularly catches
anchoring on the wrong layer.
- **When review questions a decision, reconsider genuinely**: re-derive the tradeoff, state the
options, recommend with reasons. Defending the original choice is a valid outcome;
reflexively agreeing with every challenge is as bad as ignoring it.
- **Validate the reference artifact before building on it.** Whatever you treat as ground truth
(a spec import, a baseline, a generated list), check it *first* — discovering it was
incomplete after five batches costs far more than before batch one.
- **Verify as you go**: per-file `uv run --frozen pytest <file> -q` + pyright + ruff while
iterating; full suite, coverage, and `./scripts/test` (when `src/` was touched) at
integration.
- **Every agent writes notes**: what it did, what broke, and — most importantly — what it
couldn't decide, so open questions surface instead of being silently resolved by whoever hit
them.
- **Quality over speed is a stated goal, not a preference** ("rushing something out the door is
not the goal here, explicitly so"). Don't quietly de-scope agreed work mid-stream;
renegotiate scope explicitly.
- **Don't copy patterns from existing test code by default** — much of the repo's older test
code is below the current bar (sleeps, mocks, `Test` classes, raciness). These rules define
the bar for new work.
## /.git-blame-ignore-revs
```git-blame-ignore-revs path="/.git-blame-ignore-revs"
# Applied 120 line-length rule to all files: https://github.com/modelcontextprotocol/python-sdk/pull/856
543961968c0634e93d919d509cce23a1d6a56c21
# Added 100% code coverage baseline with pragma comments: https://github.com/modelcontextprotocol/python-sdk/pull/1553
89e9c43acf7e23cf766357d776ec1ce63ac2c58e
```
## /.github/ISSUE_TEMPLATE/bug.yaml
```yaml path="/.github/ISSUE_TEMPLATE/bug.yaml"
name: 🐛 MCP Python SDK Bug
description: Report a bug or unexpected behavior in the MCP Python SDK
labels: ["need confirmation"]
body:
- type: markdown
attributes:
value: Thank you for contributing to the MCP Python SDK! ✊
- type: checkboxes
id: checks
attributes:
label: Initial Checks
description: >
Both the 2.x stable line and the 1.x maintenance line are supported, and only
the newest release of each line receives fixes.
options:
- label: I confirm that I'm using the newest release of my line (the latest 2.x, or the latest 1.x if I'm still on v1)
required: true
- label: I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue
required: true
- type: dropdown
id: release-line
attributes:
label: Release line
description: Which major version of the SDK are you using?
options:
- 2.x (current stable)
- 1.x (maintenance line, v1.x branch)
validations:
required: true
- type: textarea
id: description
attributes:
label: Description
description: |
Please explain what you're seeing and what you would expect to see.
Please provide as much detail as possible to make understanding and solving your problem as quick as possible. 🙏
validations:
required: true
- type: textarea
id: example
attributes:
label: Example Code
description: >
If applicable, please add a self-contained,
[minimal, reproducible, example](https://stackoverflow.com/help/minimal-reproducible-example)
demonstrating the bug.
placeholder: |
from mcp.server.mcpserver import MCPServer
...
render: Python
- type: textarea
id: version
attributes:
label: Python & MCP Python SDK
description: |
Which version of Python and MCP Python SDK are you using?
render: Text
validations:
required: true
```
## /.github/ISSUE_TEMPLATE/config.yaml
```yaml path="/.github/ISSUE_TEMPLATE/config.yaml"
blank_issues_enabled: false
```
## /.github/ISSUE_TEMPLATE/feature-request.yaml
```yaml path="/.github/ISSUE_TEMPLATE/feature-request.yaml"
name: 🚀 MCP Python SDK Feature Request
description: "Suggest a new feature for the MCP Python SDK"
labels: ["feature request"]
body:
- type: markdown
attributes:
value: Thank you for contributing to the MCP Python SDK! ✊
- type: textarea
id: description
attributes:
label: Description
description: |
Please give as much detail as possible about the feature you would like to suggest. 🙏
You might like to add:
* A demo of how code might look when using the feature
* Your use case(s) for the feature
* Reference to other projects that have a similar feature
validations:
required: true
- type: textarea
id: references
attributes:
label: References
description: |
Please add any links or references that might help us understand your feature request better. 📚
```
## /.github/ISSUE_TEMPLATE/question.yaml
```yaml path="/.github/ISSUE_TEMPLATE/question.yaml"
name: ❓ MCP Python SDK Question
description: "Ask a question about the MCP Python SDK"
labels: ["question"]
body:
- type: markdown
attributes:
value: Thank you for reaching out to the MCP Python SDK community! We're here to help! 🤝
- type: textarea
id: question
attributes:
label: Question
description: |
Please provide as much detail as possible about your question. 🙏
You might like to include:
* Code snippets showing what you've tried
* Error messages you're encountering (if any)
* Expected vs actual behavior
* Your use case and what you're trying to achieve
validations:
required: true
- type: textarea
id: context
attributes:
label: Additional Context
description: |
Please provide any additional context that might help us better understand your question, such as:
* Your MCP Python SDK version
* Your Python version
* Relevant configuration or environment details 📝
```
## /.github/ISSUE_TEMPLATE/v2-feedback.yaml
```yaml path="/.github/ISSUE_TEMPLATE/v2-feedback.yaml"
name: v2 feedback
description: Bugs, API friction, or docs gaps in v2 of the SDK
title: "[v2] "
labels: ["v2"]
body:
- type: markdown
attributes:
value: |
Anything in v2 that broke, surprised you, or slowed you down is useful, including API friction and docs gaps.
Docs: https://py.sdk.modelcontextprotocol.io/ · Migration from v1: https://py.sdk.modelcontextprotocol.io/migration/
- type: textarea
id: what
attributes:
label: What happened?
description: What did you do, and what went wrong (or felt wrong)? Paste error output verbatim if there is any.
validations:
required: true
- type: textarea
id: expected
attributes:
label: What did you expect?
validations:
required: false
- type: textarea
id: repro
attributes:
label: Code to reproduce
description: The smallest snippet or repository that shows it. For docs feedback, link the page instead.
render: Python
validations:
required: false
- type: input
id: version
attributes:
label: SDK version
description: The published version (`pip show mcp`) or commit.
validations:
required: false
- type: dropdown
id: area
attributes:
label: Area
options:
- Server
- Client
- Transports
- Auth
- Documentation
- Migration
- Other
validations:
required: false
```
## /.github/actions/conformance/client.py
```py path="/.github/actions/conformance/client.py"
"""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)
- MCP_CONFORMANCE_PROTOCOL_VERSION env var -> spec version the harness mock
server is speaking (e.g. "2025-11-25", "2026-07-28"). Always set; when
--spec-version is omitted the harness picks per-scenario (LATEST_SPEC_VERSION
for active scenarios, DRAFT_PROTOCOL_VERSION for draft-only ones).
- Server URL as last CLI argument (sys.argv[1])
- Must exit 0 within the harness --timeout (CI passes 60s; the default is 30s)
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
json-schema-ref-no-deref - Connect, list tools (no $ref deref)
json-schema-2020-12-preservation - List tools, echo the focal inputSchema back verbatim
request-metadata - Connect with all callbacks; client stamps _meta
http-standard-headers - Tool, resource and prompt round-trips (Mcp-* headers checked)
http-invalid-tool-headers - List tools, call every surfaced tool (x-mcp-header filter)
http-custom-headers - Replay the harness's toolCalls (x-mcp-header -> Mcp-Param-*)
elicitation-sep1034-client-defaults - Elicitation with default accept callback
sep-2322-client-request-state - Drive the MRTR auto-loop (SEP-2322)
auth/client-credentials-jwt - Client credentials with private_key_jwt
auth/client-credentials-basic - Client credentials with client_secret_basic
auth/enterprise-managed-authorization - SEP-990 ID-JAG (RFC 8693 + RFC 7523 jwt-bearer)
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 httpx2
import mcp_types as types
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
from pydantic import AnyUrl
from mcp.client.auth import OAuthClientProvider, TokenStorage
from mcp.client.auth.extensions.client_credentials import (
ClientCredentialsOAuthProvider,
PrivateKeyJWTOAuthProvider,
SignedJWTParameters,
)
from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider
from mcp.client.auth.utils import build_protected_resource_metadata_discovery_urls
from mcp.client.client import Client
from mcp.client.context import ClientRequestContext
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.auth import AuthorizationCodeResult, OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
# 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__)
#: Spec version the harness is running this scenario at (e.g. "2025-11-25",
#: "2026-07-28"). The harness always sets this (when --spec-version is omitted
#: it picks per-scenario: LATEST_SPEC_VERSION for active scenarios,
#: DRAFT_PROTOCOL_VERSION for draft-only ones), so None means we were invoked
#: outside the harness.
PROTOCOL_VERSION: str | None = os.environ.get("MCP_CONFORMANCE_PROTOCOL_VERSION")
def client_mode() -> str:
"""Pick the Client(mode=) for the harness leg.
On a modern leg (2026-07-28+) -> 'auto' so Client.discover() runs and the
_meta envelope + MCP-Protocol-Version header are stamped on every request.
On a handshake-era leg -> 'legacy' so the initialize handshake runs exactly
as before (no server/discover probe is sent against a mock that would 400 it).
Outside the harness -> 'auto' (probe + fallback).
"""
if PROTOCOL_VERSION is None or PROTOCOL_VERSION in MODERN_PROTOCOL_VERSIONS:
return "auto"
return "legacy"
# 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
self._iss: 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 httpx2.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
iss_values = query_params.get("iss")
self._iss = iss_values[0] if iss_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) -> AuthorizationCodeResult:
"""Return the captured auth code, state, and iss."""
if self._auth_code is None:
raise RuntimeError("No authorization code available - was handle_redirect called?")
result = AuthorizationCodeResult(code=self._auth_code, state=self._state, iss=self._iss)
self._auth_code = None
self._state = None
self._iss = None
return result
# --- Stub callbacks (declare capabilities in _meta without doing real work) ---
async def stub_sampling_callback(
context: ClientRequestContext,
params: types.CreateMessageRequestParams,
) -> types.CreateMessageResult | types.ErrorData:
return types.CreateMessageResult(
role="assistant",
content=types.TextContent(type="text", text=""),
model="conformance-stub",
)
async def stub_list_roots_callback(context: ClientRequestContext) -> types.ListRootsResult | types.ErrorData:
return types.ListRootsResult(roots=[])
async def default_elicitation_callback(
context: ClientRequestContext,
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.requested_schema
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)
# --- Scenario Handlers ---
@register("initialize")
async def run_initialize(server_url: str) -> None:
"""Connect, initialize, list tools, close."""
async with Client(server_url, mode=client_mode()) as client:
logger.debug("Initialized successfully")
await client.list_tools()
logger.debug("Listed tools successfully")
@register("json-schema-ref-no-deref")
async def run_json_schema_ref_no_deref(server_url: str) -> None:
"""Initialize and list tools; the scenario fails only if the client fetches a network $ref.
The client never walks inputSchema or resolves $refs, so listing is enough (SEP-2106).
Pinned to mode='legacy': the harness reports PROTOCOL_VERSION=2026-07-28 for this
scenario but its mock server only speaks the handshake-era lifecycle and 400s a
modern-stamped tools/list. The check is lifecycle-agnostic so this is harmless.
"""
async with Client(server_url, mode="legacy") as client:
await client.list_tools()
@register("json-schema-2020-12-preservation")
async def run_json_schema_2020_12_preservation(server_url: str) -> None:
"""List tools, then echo the focal tool's inputSchema back verbatim (SEP-1613 / SEP-2106).
The harness diffs what the client round-trips through `json_schema_echo` against its
fixture to detect 2020-12 keywords ($schema, $defs, $anchor, additionalProperties,
allOf/anyOf, if/then/else) being stripped while parsing tools/list. Unlike
json-schema-ref-no-deref, this mock is version-aware, so client_mode() applies.
"""
async with Client(server_url, mode=client_mode()) as client:
listed = await client.list_tools()
focal = next(tool for tool in listed.tools if tool.name == "json_schema_2020_12_tool")
await client.call_tool("json_schema_echo", {"schema": focal.input_schema})
@register("tools_call")
async def run_tools_call(server_url: str) -> None:
"""Connect, list tools, call add_numbers(a=5, b=3), close."""
async with Client(server_url, mode=client_mode()) as client:
await client.list_tools()
result = await client.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, list tools, call test_reconnection, close."""
async with Client(server_url, mode=client_mode()) as client:
await client.list_tools()
result = await client.call_tool("test_reconnection", {})
logger.debug(f"test_reconnection result: {result}")
@register("request-metadata")
async def run_request_metadata(server_url: str) -> None:
"""Connect on the modern path with every client capability declared.
The scenario inspects every request's `_meta` envelope (SEP-2575) for
protocolVersion / clientInfo / clientCapabilities, and the matching
MCP-Protocol-Version header. mode='auto' makes the SDK send
server/discover (covering the unsupported-version retry check), then adopt
and stamp the envelope on the follow-up requests.
"""
async with Client(
server_url,
mode=client_mode(),
sampling_callback=stub_sampling_callback,
list_roots_callback=stub_list_roots_callback,
elicitation_callback=default_elicitation_callback,
) as client:
await client.list_tools()
result = await client.call_tool("add_numbers", {"a": 5, "b": 3})
logger.debug(f"add_numbers result: {result}")
@register("http-standard-headers")
async def run_http_standard_headers(server_url: str) -> None:
"""Touch tools, resources and prompts on the modern path so each standard header is checked (SEP-2243).
The scenario inspects Mcp-Method on each request and Mcp-Name on tools/call,
resources/read and prompts/get, and reports methods the client never sent as
SKIPPED rather than failed, so exercise one of each. initialize and
notifications/initialized stay SKIPPED: the modern path discovers via
server/discover and never sends them.
"""
async with Client(server_url, mode=client_mode()) as client:
tools = await client.list_tools()
if tools.tools:
await client.call_tool(tools.tools[0].name, _stub_required_args(tools.tools[0].input_schema))
resources = await client.list_resources()
if resources.resources:
await client.read_resource(resources.resources[0].uri)
prompts = await client.list_prompts()
if prompts.prompts:
await client.get_prompt(prompts.prompts[0].name)
def _stub_required_args(input_schema: dict[str, Any]) -> dict[str, Any]:
"""Minimal arguments satisfying a tool inputSchema's required list."""
by_type: dict[str, Any] = {
"string": "x",
"integer": 0,
"number": 0,
"boolean": False,
"object": {},
"array": [],
"null": None,
}
properties = input_schema.get("properties", {})
return {name: by_type.get(properties.get(name, {}).get("type"), "x") for name in input_schema.get("required", [])}
@register("http-invalid-tool-headers")
async def run_http_invalid_tool_headers(server_url: str) -> None:
"""List tools, then call every tool the SDK surfaces (SEP-2243).
The harness mock advertises one valid tool plus several with malformed
x-mcp-header annotations (empty, non-primitive type, duplicate, invalid
chars). The scenario passes if valid_tool is called and the malformed
ones are not -- so a conforming client filters them out of the list_tools
result and the loop below never sees them. The scenario sets
allowClientError, so a per-call failure is logged and skipped rather
than aborting the whole run.
"""
async with Client(server_url, mode=client_mode()) as client:
listed = await client.list_tools()
logger.debug(f"Surfaced tools: {[t.name for t in listed.tools]}")
for tool in listed.tools:
try:
await client.call_tool(tool.name, _stub_required_args(tool.input_schema))
except Exception:
logger.exception(f"call_tool({tool.name!r}) failed")
@register("http-custom-headers")
async def run_http_custom_headers(server_url: str) -> None:
"""List tools, then replay the harness's `toolCalls` so x-mcp-header args mirror into headers (SEP-2243).
The scenario supplies the exact arguments to send (including the null/edge-case values that
exercise omission and Base64 encoding) via the context `toolCalls`; using them verbatim is
what drives every per-parameter check. `list_tools` first so the SDK caches each tool's
annotations; a tool the SDK dropped (invalid annotations) is skipped. Per-call failures are
logged and skipped rather than aborting the run.
"""
tool_calls: list[dict[str, Any]] = []
if os.environ.get("MCP_CONFORMANCE_CONTEXT"):
tool_calls = get_conformance_context().get("toolCalls", [])
async with Client(server_url, mode=client_mode()) as client:
listed = await client.list_tools()
surfaced = {tool.name for tool in listed.tools}
logger.debug(f"Surfaced tools: {sorted(surfaced)}")
for call in tool_calls:
name = call["name"]
if name not in surfaced:
logger.debug(f"skipping {name!r}: not surfaced by list_tools")
continue
try:
await client.call_tool(name, call.get("arguments") or {})
except Exception:
logger.exception(f"call_tool({name!r}) failed")
@register("elicitation-sep1034-client-defaults")
async def run_elicitation_defaults(server_url: str) -> None:
"""Connect with elicitation callback that applies schema defaults."""
async with Client(server_url, mode=client_mode(), elicitation_callback=default_elicitation_callback) as client:
await client.list_tools()
result = await client.call_tool("test_client_elicitation_defaults", {})
logger.debug(f"test_client_elicitation_defaults result: {result}")
@register("sep-2322-client-request-state")
async def run_mrtr_client(server_url: str) -> None:
"""Drive the SEP-2322 client mock through `Client.call_tool`'s auto-loop.
The mock inspects raw `tools/call` params, so registering an
`elicitation_callback` and letting the driver run is enough to satisfy
all five wire-shape checks: the driver echoes `request_state` byte-exact
and omits it when the server sent none, every retry mints a fresh
JSON-RPC id, the unrelated call between auto-loops carries no MRTR
params, and the no-`resultType` response parses as a terminal
`CallToolResult` so the driver never retries it.
"""
async def confirm(
context: ClientRequestContext, params: types.ElicitRequestParams
) -> types.ElicitResult | types.ErrorData:
return types.ElicitResult(action="accept", content={"confirmed": True})
async with Client(server_url, mode=client_mode(), elicitation_callback=confirm) as client:
await client.list_tools()
await client.call_tool("test_mrtr_echo_state", {})
await client.call_tool("test_mrtr_unrelated", {})
await client.call_tool("test_mrtr_no_state", {})
result = await client.call_tool("test_mrtr_no_result_type", {})
assert isinstance(result, types.CallToolResult)
@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)
@register("auth/enterprise-managed-authorization")
async def run_enterprise_managed_authorization(server_url: str) -> None:
"""SEP-990 enterprise-managed authorization: RFC 8693 token-exchange at the
enterprise IdP for an ID-JAG, then RFC 7523 jwt-bearer at the MCP
authorization server."""
context = get_conformance_context()
client_id = context.get("client_id")
client_secret = context.get("client_secret")
idp_client_id = context.get("idp_client_id")
idp_id_token = context.get("idp_id_token")
idp_token_endpoint = context.get("idp_token_endpoint")
if not client_id:
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_id'")
if not client_secret:
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_secret'")
if not idp_client_id:
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'idp_client_id'")
if not idp_id_token:
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'idp_id_token'")
if not idp_token_endpoint:
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'idp_token_endpoint'")
# IdentityAssertionOAuthProvider takes the AS issuer as configuration (the
# SEP-990 trust model: the resource server is never asked which AS to use).
# The harness does not put the issuer in context, so for conformance we
# learn it from the harness's PRM document (RFC 9728); production
# deployments would supply it as static configuration instead.
prm_url = build_protected_resource_metadata_discovery_urls(None, server_url)[0]
async with httpx2.AsyncClient(timeout=30.0) as http:
prm = (await http.get(prm_url)).raise_for_status().json()
as_issuer = prm["authorization_servers"][0]
async def fetch_id_jag(audience: str, resource: str) -> str:
"""Leg 1 - RFC 8693 token-exchange at the enterprise IdP."""
async with httpx2.AsyncClient(timeout=30.0) as http:
resp = await http.post(
idp_token_endpoint,
data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"requested_token_type": "urn:ietf:params:oauth:token-type:id-jag",
"subject_token": idp_id_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
"audience": audience,
"resource": resource,
"client_id": idp_client_id,
},
)
resp.raise_for_status()
return resp.json()["access_token"]
oauth_auth = IdentityAssertionOAuthProvider(
server_url=server_url,
storage=InMemoryTokenStorage(),
client_id=client_id,
client_secret=client_secret,
issuer=as_issuer,
assertion_provider=fetch_id_jag,
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: httpx2.Auth) -> None:
"""Common session logic for all OAuth flows."""
http_client = httpx2.AsyncClient(auth=oauth_auth, timeout=30.0)
transport = streamable_http_client(url=server_url, http_client=http_client)
async with Client(transport, mode=client_mode(), elicitation_callback=default_elicitation_callback) as client:
logger.debug("Initialized successfully")
tools_result = await client.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 client.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]} <server-url>", file=sys.stderr)
sys.exit(1)
server_url = sys.argv[1]
scenario = os.environ.get("MCP_CONFORMANCE_SCENARIO")
logger.debug(f"Conformance protocol version: {PROTOCOL_VERSION!r} -> mode={client_mode()!r}")
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()
```
## /.github/actions/conformance/expected-failures.2025-11-25.yml
```yml path="/.github/actions/conformance/expected-failures.2025-11-25.yml"
# Expected failures at the 2025-11-25 wire, read by the `--suite all
# --spec-version 2025-11-25` server and client legs: every scenario the pinned
# harness marks applicable at 2025-11-25, run over the stateful handshake.
#
# That selection is a superset of the frozen 2025-11-25 requirement set (`npx
# $CONFORMANCE_PKG list --requirements 2025-11-25`), so an entry here for a
# scenario in that set means `conformance tier-check` scores the SDK below 100%
# for 2025-11-25 even though CI is green; link the tracking issue next to any
# such entry.
#
# Kept separate from expected-failures.yml even though the bare `--suite all`
# legs currently run these same scenarios at 2025-11-25 too: the harness's
# default wire is due to move (modelcontextprotocol/conformance#426) and this
# file must keep meaning 2025-11-25. Until then a failure here also needs an
# entry there. The file is passed even while empty: run-client.sh keys its solo
# re-verification off the harness's baseline output.
#
# Baseline established against the harness pinned via CONFORMANCE_PKG in
# .github/workflows/conformance.yml. New conformance releases are adopted by
# deliberately bumping that pin and reconciling all three expected-failures
# files in the same change. Entries MUST be removed as they start passing (the
# runner fails on stale entries).
client: []
server: []
```
## /.github/actions/conformance/expected-failures.2026-07-28.yml
```yml path="/.github/actions/conformance/expected-failures.2026-07-28.yml"
# Expected failures at the 2026-07-28 wire, read by the `--suite all
# --spec-version 2026-07-28` server and client legs: every scenario the pinned
# harness marks applicable at 2026-07-28, run stateless with per-request _meta.
#
# That selection is a superset of the frozen 2026-07-28 requirement set (`npx
# $CONFORMANCE_PKG list --requirements 2026-07-28`), so an entry here for a
# scenario in that set means `conformance tier-check` scores the SDK below 100%
# for 2026-07-28 even though CI is green; link the tracking issue next to any
# such entry.
#
# This baseline is separate from expected-failures.yml because entries are
# keyed by scenario name only: a scenario that passes at the harness default
# wire in the bare `--suite all` legs but fails when forced to 2026-07-28 (or
# vice versa) cannot be expressed in a shared file. Where the bare legs already
# default a scenario to 2026-07-28, a failure here also needs an entry there.
#
# Baseline established against the harness pinned via CONFORMANCE_PKG in
# .github/workflows/conformance.yml. New conformance releases are adopted by
# deliberately bumping that pin and reconciling all three expected-failures
# files in the same change.
#
# Entries are grouped by what unblocks them. As each gap closes the
# corresponding scenarios start passing and MUST be removed from this list
# (the runner fails on stale entries), so the baseline burns down per
# milestone.
client: []
server: []
```
## /.github/actions/conformance/expected-failures.yml
```yml path="/.github/actions/conformance/expected-failures.yml"
# Conformance scenarios not yet passing against the Python SDK on main, read by
# the bare `--suite all` server and client legs (every scenario shipped with the
# pinned harness, each at the harness's default wire for it). CI exits 0 if only
# these fail, exits 1 on unexpected failures or stale entries.
#
# The wire-pinned legs read expected-failures.2025-11-25.yml and
# expected-failures.2026-07-28.yml instead; see the header of
# .github/workflows/conformance.yml for the leg layout.
#
# Baseline established against the harness pinned via CONFORMANCE_PKG in
# .github/workflows/conformance.yml. New conformance releases are adopted by
# deliberately bumping that pin and reconciling all three expected-failures
# files in the same change.
#
# Entries are grouped by SEP. As each SEP lands in the SDK the corresponding
# scenarios start passing and MUST be removed from this list (the runner fails
# on stale entries), so the baseline burns down per milestone.
client:
# SEP-1932 (DPoP): the SDK's OAuth client does not implement DPoP proofs.
# The entries are per-check (conformance #406) because both scenarios
# pass their non-DPoP checks (discovery, token acquisition, request
# flow) live.
- auth/dpop:sep-1932-client-token-request-proof
- auth/dpop:sep-1932-client-dpop-auth-scheme
- auth/dpop:sep-1932-client-fresh-proof
- auth/dpop-nonce:sep-1932-client-token-request-proof
- auth/dpop-nonce:sep-1932-client-dpop-auth-scheme
- auth/dpop-nonce:sep-1932-client-fresh-proof
- auth/dpop-nonce:sep-1932-client-as-nonce
- auth/dpop-nonce:sep-1932-client-rs-nonce
# Workload identity federation: the OAuth client does not implement the
# urn:ietf:params:oauth:grant-type:jwt-bearer grant (it answers with
# authorization_code). Per-check for the same reason.
- auth/wif-jwt-bearer:wif-grant-type
server:
# SEP-2663 (io.modelcontextprotocol/tasks): the SDK does not implement the
# tasks extension yet. The bare `--suite all` leg is the only one that
# reaches these extension-tagged scenarios (they never match a
# --spec-version filter), so these entries live here only.
#
# `tasks-status-notifications` is intentionally NOT listed: the harness
# skips it unconditionally (pending its rewrite against subscriptions/
# listen), and a baseline entry for a scenario with no failing checks is
# flagged stale.
- tasks-lifecycle
- tasks-capability-negotiation
- tasks-wire-fields
- tasks-request-state-removal
- tasks-mrtr-input
- tasks-request-headers
- tasks-dispatch-and-envelope
- tasks-required-task-error
- tasks-mrtr-composition
```
## /.github/actions/conformance/run-client.sh
```sh path="/.github/actions/conformance/run-client.sh"
#!/bin/bash
# Run a client conformance suite, re-verifying unexpected failures solo.
# Concurrent suite runs on a hosted runner can push scenarios with real-time
# waits past tolerance; solo, a real failure fails again while a contention
# artifact passes. Failures that only reproduce under concurrency are excused.
set -uo pipefail
: "${CONFORMANCE_PKG:?set CONFORMANCE_PKG (pinned in .github/workflows/conformance.yml)}"
# One attempt: a solo failure on the quiet runner disproves the contention
# hypothesis; a second try would be the blind retry this script avoids.
SOLO_ATTEMPTS="${CONFORMANCE_SOLO_ATTEMPTS:-1}"
# Relative args resolve from the repo root; same contract as run-server.sh.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR/../../.." || exit 1
log="$(mktemp)"
trap 'rm -f "$log"' EXIT
npx --yes "$CONFORMANCE_PKG" client "$@" 2>&1 | tee "$log"
rc=${PIPESTATUS[0]}
if [ "$rc" -eq 0 ]; then
exit 0
fi
plain="$(sed 's/\x1b\[[0-9;]*m//g' "$log")"
# If the harness's summary wording changes, the list comes up empty and the
# original exit code passes through - never a false green.
mapfile -t scenarios < <(
printf '%s\n' "$plain" |
sed -n '/^Unexpected failures (not in baseline):$/,/^$/p' |
sed -n 's/^ ✗ //p'
)
if [ "${#scenarios[@]}" -eq 0 ]; then
exit "$rc"
fi
for scenario in "${scenarios[@]}"; do
if ! [[ "$scenario" =~ ^[A-Za-z0-9/_-]+$ ]]; then
echo "Extracted unexpected-failure name '${scenario}' does not look like a scenario name; passing the suite failure through." >&2
exit "$rc"
fi
done
# A stale baseline entry is a configuration error a solo rerun cannot excuse.
# Here-string, not a pipe: grep -q quitting early would SIGPIPE printf and,
# under pipefail, skip this guard exactly when the pattern is present.
if grep -q '^Stale baseline entries' <<<"$plain"; then
echo "Suite also reported stale baseline entries; not retrying." >&2
exit "$rc"
fi
# Drop the suite-only flags: --scenario replaces --suite, and solo runs are
# judged directly rather than against the baseline.
rerun_args=()
output_dir=""
skip_next=0
expect_output_dir=0
for arg in "$@"; do
if [ "$skip_next" -eq 1 ]; then
if [ "$expect_output_dir" -eq 1 ]; then
output_dir="$arg"
fi
skip_next=0
expect_output_dir=0
continue
fi
case "$arg" in
--output-dir)
skip_next=1
expect_output_dir=1
;;
--suite | --expected-failures) skip_next=1 ;;
--output-dir=*) output_dir="${arg#--output-dir=}" ;;
--suite=* | --expected-failures=*) ;;
*) rerun_args+=("$arg") ;;
esac
done
if [ -n "$output_dir" ]; then
rerun_args+=(--output-dir "${output_dir}-solo")
fi
for scenario in "${scenarios[@]}"; do
passed=0
for attempt in $(seq 1 "$SOLO_ATTEMPTS"); do
echo ""
echo "Re-running '${scenario}' solo (attempt ${attempt}/${SOLO_ATTEMPTS})..."
if npx --yes "$CONFORMANCE_PKG" client --scenario "$scenario" "${rerun_args[@]}"; then
passed=1
break
fi
done
if [ "$passed" -ne 1 ]; then
echo "'${scenario}' still fails when run alone: real failure, not suite contention." >&2
exit 1
fi
done
if [ -n "$output_dir" ]; then
mkdir -p "$output_dir"
printf '%s\n' "${scenarios[@]}" > "$output_dir/FLAKE_RESCUED"
fi
echo "All ${#scenarios[@]} unexpected failure(s) passed when re-run solo; the suite failures were parallel-run contention."
# Surface the rescue on the run summary; the log line above and the
# FLAKE_RESCUED marker in the uploaded results are otherwise easy to miss on a
# green run.
echo "::warning title=Conformance flake rescued::${scenarios[*]} failed under suite concurrency and passed solo${output_dir:+ (see ${output_dir}-solo and FLAKE_RESCUED in the uploaded results)}."
exit 0
```
## /.github/actions/conformance/run-server.sh
```sh path="/.github/actions/conformance/run-server.sh"
#!/bin/bash
set -e
PORT="${PORT:-3001}"
SERVER_URL="http://localhost:${PORT}/mcp"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR/../../.."
# Refuse to start if something is already listening on the port. The readiness
# check below cannot tell our server apart from a stale one, so a leftover
# listener would mean silently running conformance against old code.
if (: > "/dev/tcp/localhost/${PORT}") 2>/dev/null; then
echo "Error: port ${PORT} is already in use." >&2
echo "Stop the stale process first (lsof -ti:${PORT} -sTCP:LISTEN | xargs kill) or set PORT to a free port." >&2
exit 1
fi
echo "Starting mcp-everything-server on port ${PORT}..."
uv run --frozen mcp-everything-server --port "$PORT" &
SERVER_PID=$!
cleanup() {
echo "Stopping server (PID: ${SERVER_PID})..."
kill $SERVER_PID 2>/dev/null || true
wait $SERVER_PID 2>/dev/null || true
}
trap cleanup EXIT
# Wait for server to be ready. --max-time keeps a hung listener from wedging
# the loop, and a dead server process fails fast instead of retrying.
echo "Waiting for server to be ready..."
MAX_RETRIES=30
RETRY_COUNT=0
while ! curl -s --max-time 2 "$SERVER_URL" > /dev/null 2>&1; do
if ! kill -0 $SERVER_PID 2>/dev/null; then
echo "Server process exited unexpectedly" >&2
exit 1
fi
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"
npx --yes "${CONFORMANCE_PKG:?set CONFORMANCE_PKG (pinned in .github/workflows/conformance.yml)}" \
server --url "$SERVER_URL" "$@"
```
## /.github/dependabot.yml
```yml path="/.github/dependabot.yml"
version: 2
updates:
- package-ecosystem: "uv"
directory: "/"
schedule:
interval: monthly
cooldown:
default-days: 14
groups:
python-packages:
patterns:
- "*"
python-security:
applies-to: security-updates
patterns:
- "*"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: monthly
cooldown:
default-days: 14
groups:
github-actions:
patterns:
- "*"
```
## /.github/pull_request_template.md
<!--
Pull requests from outside the maintainer team need to link an open issue that
a maintainer has assigned to you (or one labeled `help wanted`); others are
closed automatically until that's in place. See CONTRIBUTING.md for details.
-->
Fixes #
<!-- Provide a brief summary of your changes -->
## Motivation and Context
<!-- Why is this change needed? What problem does it solve? -->
## How Has This Been Tested?
<!-- Have you tested this in a real application? Which scenarios were tested? -->
## Breaking Changes
<!-- Will users need to update their code or configurations? -->
## Types of changes
<!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
- [ ] Documentation update
## Checklist
<!-- Go over all the following points, and put an `x` in all the boxes that apply. -->
- [ ] I am assigned to the linked issue (or it is labeled `help wanted`, or I'm a maintainer)
- [ ] I have disclosed any AI assistance and can explain the change in my own words
- [ ] I have read the [MCP Documentation](https://modelcontextprotocol.io)
- [ ] My code follows the repository's style guidelines
- [ ] New and existing tests pass locally
- [ ] I have added appropriate error handling
- [ ] I have added or updated documentation as needed
## Additional context
<!-- Add any other context, implementation notes, or design decisions -->
## /.github/scripts/docs_preview.js
```js path="/.github/scripts/docs_preview.js"
// Docs preview gate and PR comment. .github/workflows/docs-preview.yml wires
// these up: `authorize` decides whether a run may build and deploy a preview
// (and for which commit), `comment` reports the outcome on the pull request.
// The security model is described in the workflow's header.
'use strict';
const MARKER = '<!-- docs-preview -->';
const BOT_LOGIN = 'github-actions[bot]';
// Sets the job outputs `authorized`, `pr_number`, `head_sha` and
// `slash_attempt` for a pull_request_target or /preview-docs issue_comment run.
async function authorize({ github, context, core }) {
const { owner, repo } = context.repo;
async function permissionFor(username) {
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username });
return { level: data.permission, role: data.role_name };
}
let authorized = false;
let prNumber = '';
let headSha = '';
let slashAttempt = false;
if (context.eventName === 'pull_request_target') {
const pr = context.payload.pull_request;
prNumber = String(pr.number);
headSha = pr.head.sha;
// No automatic preview for fork PRs: actions/checkout refuses to fetch a
// fork's head in a pull_request_target run. A maintainer can still request
// one with /preview-docs. (head.repo is null once the fork is deleted.)
const headRepo = pr.head.repo;
if (!headRepo || headRepo.id !== context.payload.repository.id) {
core.info(`PR #${prNumber} head is on ${headRepo ? headRepo.full_name : 'a deleted fork'}; fork PRs are previewed via /preview-docs only.`);
} else {
// Gate on the *sender* (whoever caused this run — on synchronize that
// is the pusher), not the PR author, so a non-admin pushing to an
// admin-opened branch does not get an automatic build.
const actor = context.payload.sender.login;
const perm = await permissionFor(actor);
authorized = perm.level === 'admin';
core.info(`pull_request_target by ${actor} (level=${perm.level}, role=${perm.role}) → authorized=${authorized}`);
}
} else {
// issue_comment: the job-level `if:` already guarantees this is a PR
// comment starting with /preview-docs.
slashAttempt = true;
const actor = context.payload.comment.user.login;
prNumber = String(context.payload.issue.number);
const perm = await permissionFor(actor);
authorized = perm.level === 'admin' || perm.role === 'maintain';
if (authorized) {
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: Number(prNumber) });
if (pr.state !== 'open') {
authorized = false;
core.info(`PR #${prNumber} is ${pr.state}; refusing to preview.`);
} else {
headSha = pr.head.sha;
}
}
core.info(`/preview-docs by ${actor} (level=${perm.level}, role=${perm.role}) → authorized=${authorized}`);
}
core.setOutput('authorized', String(authorized));
core.setOutput('pr_number', prNumber);
core.setOutput('head_sha', headSha);
core.setOutput('slash_attempt', String(slashAttempt));
}
// Posts or updates the preview comment on the PR. Reads the outcome of the
// earlier jobs from the step's env: AUTHORIZED, PR_NUMBER, HEAD_SHA,
// DEPLOY_RESULT, DEPLOYMENT_URL, ALIAS_URL, RUN_URL.
async function comment({ github, context }) {
const { owner, repo } = context.repo;
const env = process.env;
const issue_number = Number(env.PR_NUMBER);
async function upsert(body) {
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100 });
const existing = comments.find((c) => c.user?.login === BOT_LOGIN && c.body?.includes(MARKER));
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number, body });
}
}
if (env.AUTHORIZED !== 'true') {
await github.rest.issues.createComment({
owner, repo, issue_number,
body: `@${context.actor} — only repository admins or maintainers can run \`/preview-docs\` (and the PR must be open).`,
});
return;
}
if (env.DEPLOY_RESULT !== 'success') {
await upsert(
`${MARKER}\n### 📚 Documentation preview\n\n` +
`❌ Preview build **failed** for \`${env.HEAD_SHA.slice(0, 7)}\` — [workflow logs](${env.RUN_URL}).`
);
return;
}
const previewUrl = env.ALIAS_URL || env.DEPLOYMENT_URL;
const ts = new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
await upsert(
`${MARKER}\n### 📚 Documentation preview\n\n` +
`| | |\n|---|---|\n` +
`| **Preview** | ${previewUrl} |\n` +
`| **Deployment** | ${env.DEPLOYMENT_URL} |\n` +
`| **Commit** | \`${env.HEAD_SHA.slice(0, 7)}\` |\n` +
`| **Triggered by** | @${context.actor} |\n` +
`| **Updated** | ${ts} |\n`
);
}
module.exports = { authorize, comment };
```
## /.github/scripts/docs_preview.test.js
```js path="/.github/scripts/docs_preview.test.js"
// Scenario tests for docs_preview.js: who gets a preview build (`authorize`)
// and what the pull request shows afterwards (`comment`).
//
// node --test .github/scripts/docs_preview.test.js
//
// No dependencies; the GitHub client is a small fake defined at the bottom.
// CI runs it in the checks job (.github/workflows/shared.yml).
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { authorize, comment } = require('./docs_preview.js');
const REPO = { owner: 'modelcontextprotocol', repo: 'python-sdk' };
const BASE_REPO = { id: 1, full_name: 'modelcontextprotocol/python-sdk' };
const HEAD = 'e4dfda7baa127ab00ebcd1d5324560cbe3cdfe42';
const MARKER = '<!-- docs-preview -->';
// `permission` / `role_name` as the collaborators API reports them.
const PEOPLE = {
admin: { permission: 'admin', role_name: 'admin' },
maintainer: { permission: 'write', role_name: 'maintain' },
writer: { permission: 'write', role_name: 'write' },
outsider: { permission: 'read', role_name: 'read' },
};
// ── authorize ──────────────────────────────────────────────────────────────
// `expect` is the full set of job outputs the step writes.
const authorizeScenarios = [
{
name: 'admin pushes to (or opens) a PR → automatic preview of that head',
event: pushed(7, 'admin'),
expect: { authorized: 'true', pr_number: '7', head_sha: HEAD, slash_attempt: 'false' },
},
{
name: 'someone with write but not admin pushes → no automatic preview',
event: pushed(7, 'writer'),
expect: { authorized: 'false', pr_number: '7', head_sha: HEAD, slash_attempt: 'false' },
},
{
// actions/checkout refuses a fork's head under pull_request_target, so
// the run stops here instead of failing in `build`; /preview-docs still works.
name: 'admin pushes to or reopens a fork PR → no automatic preview',
event: pushed(7, 'admin', { fork: 'someone/python-sdk' }),
expect: { authorized: 'false', pr_number: '7', head_sha: HEAD, slash_attempt: 'false' },
permissionLookups: 0,
},
{
name: 'fork PR whose fork has since been deleted → no automatic preview',
event: pushed(7, 'admin', { fork: null }),
expect: { authorized: 'false', pr_number: '7', head_sha: HEAD, slash_attempt: 'false' },
permissionLookups: 0,
},
{
name: 'maintainer comments /preview-docs on a fork PR → previewed like any other',
pr: { fork: 'someone/python-sdk' },
event: slash(7, 'maintainer'),
expect: { authorized: 'true', pr_number: '7', head_sha: HEAD, slash_attempt: 'true' },
},
{
name: 'maintainer comments /preview-docs on an open PR → preview of its current head',
event: slash(7, 'maintainer'),
expect: { authorized: 'true', pr_number: '7', head_sha: HEAD, slash_attempt: 'true' },
},
{
name: 'admin comments /preview-docs → authorized as well',
event: slash(7, 'admin'),
expect: { authorized: 'true', pr_number: '7', head_sha: HEAD, slash_attempt: 'true' },
},
{
name: 'writer without the maintain role comments /preview-docs → refused, recorded as an attempt',
event: slash(7, 'writer'),
expect: { authorized: 'false', pr_number: '7', head_sha: '', slash_attempt: 'true' },
},
{
name: 'outsider comments /preview-docs → refused, recorded as an attempt',
event: slash(7, 'outsider'),
expect: { authorized: 'false', pr_number: '7', head_sha: '', slash_attempt: 'true' },
},
{
name: '/preview-docs on a closed PR → refused even for a maintainer',
pr: { state: 'closed' },
event: slash(7, 'maintainer'),
expect: { authorized: 'false', pr_number: '7', head_sha: '', slash_attempt: 'true' },
},
];
for (const s of authorizeScenarios) {
test(`authorize: ${s.name}`, async () => {
const world = makeWorld({ pr: { number: 7, ...s.pr } });
assert.deepEqual(await runAuthorize(world, s.event), s.expect);
assert.equal(world.writes.length, 0);
if (s.permissionLookups !== undefined) assert.equal(world.permissionLookups, s.permissionLookups);
});
}
test('authorize: a failing permission lookup fails the step instead of deciding either way', async () => {
const world = makeWorld({ pr: { number: 7 } });
world.failPermissionLookup = true;
await assert.rejects(runAuthorize(world, pushed(7, 'admin')), /boom/);
});
// ── comment ────────────────────────────────────────────────────────────────
const DEPLOYED = {
AUTHORIZED: 'true',
PR_NUMBER: '7',
HEAD_SHA: HEAD,
DEPLOY_RESULT: 'success',
DEPLOYMENT_URL: 'https://1a2b3c.mcp-python-sdk-docs.pages.dev',
ALIAS_URL: 'https://pr-7.mcp-python-sdk-docs.pages.dev',
RUN_URL: 'https://github.com/modelcontextprotocol/python-sdk/actions/runs/1',
};
test('comment: a refused /preview-docs gets a plain reply to the commenter, not a preview comment', async () => {
const world = makeWorld({ pr: { number: 7 } });
await runComment(world, { ...DEPLOYED, AUTHORIZED: 'false', HEAD_SHA: '', DEPLOY_RESULT: 'skipped' }, 'outsider');
assert.equal(world.comments.length, 1);
assert.match(world.comments[0].body, /^@outsider — only repository admins or maintainers can run `\/preview-docs`/);
assert.ok(!world.comments[0].body.includes(MARKER));
});
test('comment: first successful deploy posts one preview comment linking the alias URL and the commit', async () => {
const world = makeWorld({ pr: { number: 7 } });
await runComment(world, DEPLOYED, 'admin');
assert.equal(world.comments.length, 1);
const body = world.comments[0].body;
assert.ok(body.startsWith(`${MARKER}\n### 📚 Documentation preview`));
assert.match(body, /\| \*\*Preview\*\* \| https:\/\/pr-7\.mcp-python-sdk-docs\.pages\.dev \|/);
assert.match(body, /\| \*\*Deployment\*\* \| https:\/\/1a2b3c\.mcp-python-sdk-docs\.pages\.dev \|/);
assert.match(body, /\| \*\*Commit\*\* \| `e4dfda7` \|/);
assert.match(body, /\| \*\*Triggered by\*\* \| @admin \|/);
assert.deepEqual(world.writes, ['comment on #7']);
});
test('comment: a later deploy edits the existing preview comment instead of adding another', async () => {
const world = makeWorld({ pr: { number: 7 }, comments: [{ user: 'someone', body: 'LGTM' }, { user: 'github-actions[bot]', body: `${MARKER}\nold table` }] });
await runComment(world, { ...DEPLOYED, HEAD_SHA: 'f'.repeat(40) }, 'admin');
assert.equal(world.comments.length, 2);
assert.match(world.comments[1].body, /\| \*\*Commit\*\* \| `fffffff` \|/);
assert.deepEqual(world.writes, ['edit comment 101']);
});
test("comment: someone else's comment that happens to contain the marker is left alone", async () => {
const world = makeWorld({ pr: { number: 7 }, comments: [{ user: 'someone', body: `quoting ${MARKER} here` }] });
await runComment(world, DEPLOYED, 'admin');
assert.equal(world.comments.length, 2);
assert.equal(world.comments[0].body, `quoting ${MARKER} here`);
assert.deepEqual(world.writes, ['comment on #7']);
});
test('comment: with no alias URL the preview link falls back to the deployment URL', async () => {
const world = makeWorld({ pr: { number: 7 } });
await runComment(world, { ...DEPLOYED, ALIAS_URL: '' }, 'admin');
assert.match(world.comments[0].body, /\| \*\*Preview\*\* \| https:\/\/1a2b3c\.mcp-python-sdk-docs\.pages\.dev \|/);
});
test('comment: a build or deploy that did not succeed is reported with the short SHA and a link to the run', async () => {
const world = makeWorld({ pr: { number: 7 }, comments: [{ user: 'github-actions[bot]', body: `${MARKER}\nold table` }] });
await runComment(world, { ...DEPLOYED, DEPLOY_RESULT: 'skipped', DEPLOYMENT_URL: '', ALIAS_URL: '' }, 'admin');
assert.equal(world.comments.length, 1);
assert.equal(
world.comments[0].body,
`${MARKER}\n### 📚 Documentation preview\n\n❌ Preview build **failed** for \`e4dfda7\` — [workflow logs](${DEPLOYED.RUN_URL}).`
);
});
// ── Harness ────────────────────────────────────────────────────────────────
// `fork`: full name of the fork the head lives on; null for a deleted fork; omitted for a same-repo branch.
function pushed(number, sender, { fork } = {}) {
const repo = fork === undefined ? BASE_REPO : fork === null ? null : { id: 2, full_name: fork };
return { eventName: 'pull_request_target', actor: sender, payload: { action: 'synchronize', repository: BASE_REPO, pull_request: { number, head: { sha: HEAD, repo } }, sender: { login: sender } } };
}
function slash(number, commenter) {
return { eventName: 'issue_comment', actor: commenter, payload: { action: 'created', issue: { number, pull_request: {} }, comment: { body: '/preview-docs', user: { login: commenter } } } };
}
async function runAuthorize(world, event) {
const outputs = {};
const core = { info: () => {}, setOutput: (k, v) => { outputs[k] = v; } };
await authorize({ github: world.github, context: { repo: REPO, ...event }, core });
return outputs;
}
async function runComment(world, env, actor) {
const saved = {};
for (const [k, v] of Object.entries(env)) { saved[k] = process.env[k]; process.env[k] = v; }
try {
await comment({ github: world.github, context: { repo: REPO, actor }, core: {} });
} finally {
for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
}
}
// ── A tiny in-memory GitHub ────────────────────────────────────────────────
function makeWorld({ pr, comments = [] }) {
const world = { pr: { state: 'open', ...pr }, comments: [], writes: [], failPermissionLookup: false, permissionLookups: 0, nextCommentId: 100 };
for (const c of comments) world.comments.push({ id: world.nextCommentId++, ...c });
const err = (status, message = 'fake error') => Object.assign(new Error(message), { status });
const write = (what) => world.writes.push(what);
const checkPr = (n) => { if (n !== world.pr.number) throw err(404); };
const rest = {
repos: {
getCollaboratorPermissionLevel: async ({ username }) => {
world.permissionLookups++;
if (world.failPermissionLookup) throw err(500, 'boom');
const person = PEOPLE[username];
if (!person) throw err(404, 'not a user');
return { data: { ...person, user: { login: username } } };
},
},
pulls: {
get: async ({ pull_number }) => {
checkPr(pull_number);
const repo = world.pr.fork ? { id: 2, full_name: world.pr.fork } : BASE_REPO;
return { data: { number: pull_number, state: world.pr.state, head: { sha: HEAD, repo } } };
},
},
issues: {
listComments: async ({ issue_number }) => { checkPr(issue_number); return { data: world.comments.map((c) => ({ id: c.id, body: c.body, user: { login: c.user } })) }; },
createComment: async ({ issue_number, body }) => {
checkPr(issue_number);
write(`comment on #${issue_number}`);
world.comments.push({ id: world.nextCommentId++, user: 'github-actions[bot]', body });
},
updateComment: async ({ comment_id, body }) => {
write(`edit comment ${comment_id}`);
const c = world.comments.find((x) => x.id === comment_id);
if (!c) throw err(404);
c.body = body;
},
},
};
world.github = { rest, paginate: async (fn, args) => (await fn(args)).data };
return world;
}
```
## /.github/scripts/pr_intake_gate.js
```js path="/.github/scripts/pr_intake_gate.js"
// PR intake gate. The policy lives in CONTRIBUTING.md ("How pull requests get
// in"); .github/workflows/require-linked-issue.yml wires this up to events.
//
// A pull request from someone without triage rights stays open only if its
// description links (Fixes/Closes/Resolves #N) an open issue in this repo that
// is either assigned to the PR author or labeled `help wanted`. Otherwise the
// gate labels it `missing-issue-link`, leaves one comment, and closes it. It
// re-evaluates — and reopens — the PR when the description is edited or the
// author is assigned to the issue. A triage+ user reopening the PR, removing
// the label, or adding `bypass-issue-check` overrides it, and the override
// sticks. A PR that comes back this way also gets a comment asking the review
// bot for a review, since it doesn't act on `reopened` by itself.
//
// Everything that writes goes through mutate(); when the workflow passes
// ENFORCE=false (its kill switch) the run only logs what it would have done.
'use strict';
const LABEL = 'missing-issue-link'; // marks PRs the gate has closed
const BYPASS_LABEL = 'bypass-issue-check'; // sticky maintainer override
const OPEN_LABEL = 'help wanted'; // issue label that waives assignment
const MARKER = '<!-- require-linked-issue -->';
const BOT_LOGIN = 'github-actions[bot]';
const MAX_ISSUES = 5;
// cubic starts on `opened` and abandons the run when the gate closes the PR
// seconds later; it ignores `reopened`, so a PR the gate lets back in would
// otherwise wait for its next push to be reviewed.
const REVIEW_REQUEST = '@cubic-dev-ai review this PR';
module.exports = async function run({ github, context, core }) {
const { owner, repo } = context.repo;
const enforce = process.env.ENFORCE === 'true';
const contributingUrl = `https://github.com/${owner}/${repo}/blob/main/CONTRIBUTING.md#how-pull-requests-get-in`;
// ── Entry points ─────────────────────────────────────────────────────────
if (context.eventName === 'issues') {
// Someone was assigned an issue: re-evaluate their gate-closed PRs that
// reference it (they may pass now).
const issueNumber = context.payload.issue.number;
const assignee = context.payload.assignee.login;
const closed = await github.paginate(github.rest.issues.listForRepo, {
owner, repo, state: 'closed', creator: assignee, labels: LABEL, per_page: 100,
});
const prs = closed.filter((i) => i.pull_request && closingRefs(i.body).includes(issueNumber));
console.log(`#${issueNumber} assigned to ${assignee}: ${prs.length} gate-closed PR(s) reference it`);
// Evaluate each independently so one transient failure doesn't strand
// the rest (this event won't fire again for the same assignment).
const failures = [];
for (const pr of prs) {
try {
await evaluate(pr.number, 'assigned', context.payload.sender?.login, issueNumber);
} catch (e) {
failures.push(`#${pr.number}: ${e.message}`);
}
}
if (failures.length) throw new Error(`Could not re-evaluate ${failures.join('; ')}`);
return;
}
if (context.eventName === 'workflow_dispatch') {
const n = parseInt(process.env.PR_NUMBER_INPUT, 10);
if (!Number.isInteger(n) || n <= 0) throw new Error(`Bad pr_number input: ${process.env.PR_NUMBER_INPUT}`);
await evaluate(n, 'dispatch', context.payload.sender?.login);
return;
}
await evaluate(context.payload.pull_request.number, context.payload.action, context.payload.sender?.login);
// ── The rules ────────────────────────────────────────────────────────────
async function evaluate(prNumber, action, sender, hintIssue = null) {
// Always read the PR live; the event payload can be stale by the time a
// queued run starts.
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
const labels = pr.labels.map((l) => l.name);
// An `unlabeled` run only fires for LABEL (see the workflow `if:`), so the
// event itself proves the label was there a moment ago.
const gated = action === 'unlabeled' || labels.includes(LABEL);
console.log(`PR #${prNumber} by ${pr.user.login} (${pr.state}${pr.draft ? ', draft' : ''}) — ${action} by ${sender ?? '-'}, enforce=${enforce}`);
// 0. Scope: open PRs, plus closed PRs the gate closed itself. Merged PRs
// and PRs someone closed for other reasons are left alone.
if (pr.merged_at) return log('merged — nothing to do');
if (pr.state === 'closed' && !gated) return log('closed by someone else — not ours');
// 1. Exempt authors: bots and anyone with triage or better. Drafts are
// gated like any other PR.
if (pr.user.type === 'Bot') return log('author is a bot — exempt');
if (await isTrusted(pr.user.login)) return pass('author has triage+ on this repo');
// 2. Overrides: a triage+ user reopening the PR or removing the label wants
// it open. Anyone else doing so just triggers a re-check.
if ((action === 'reopened' || action === 'unlabeled') && sender && (await isTrusted(sender))) {
return pass(`${sender} ${action === 'reopened' ? 'reopened it' : 'removed the label'} — override`, { sticky: true });
}
if (labels.includes(BYPASS_LABEL)) return pass(`carries ${BYPASS_LABEL}`);
// 3. The rule: the description links an open issue in this repo that is
// labeled `help wanted` or assigned to the author. Only the first few
// references are fetched; a just-assigned issue is checked first.
const author = pr.user.login.toLowerCase();
const refs = closingRefs(pr.body);
if (hintIssue && refs.includes(hintIssue)) refs.unshift(...refs.splice(refs.indexOf(hintIssue), 1));
const linked = [];
for (const num of refs.slice(0, MAX_ISSUES)) {
const issue = await getIssue(num);
if (!issue) continue; // missing, a PR, closed, or transferred away
linked.push(num);
if (issue.labels.some((l) => l.name.toLowerCase() === OPEN_LABEL)) return pass(`#${num} is labeled "${OPEN_LABEL}"`);
if (issue.assignees.some((a) => a.login.toLowerCase() === author)) return pass(`author is assigned to #${num}`);
}
return fail(linked);
// ── Outcomes ─────────────────────────────────────────────────────────
async function pass(reason, { sticky = false } = {}) {
console.log(`PASS: ${reason}`);
if (sticky) await addLabel(prNumber, BYPASS_LABEL);
if (pr.state === 'closed' && !(await reopen(pr, reason))) return;
if (gated) {
await removeLabel(prNumber, LABEL);
await deleteGateComment(prNumber);
// Not for drafts: cubic picks those up itself on ready_for_review.
if (!pr.draft) await requestReview(prNumber);
}
}
async function fail(linkedIssues) {
console.log(`FAIL: ${linkedIssues.length ? `not assigned to ${linkedIssues.map((n) => `#${n}`).join(', ')}` : 'no usable issue link'}`);
await addLabel(prNumber, LABEL);
await upsertGateComment(prNumber, closedComment(pr.draft, linkedIssues));
if (pr.state === 'open') {
await mutate(`close PR #${prNumber}`, () => github.rest.pulls.update({ owner, repo, pull_number: prNumber, state: 'closed' }));
}
}
function log(msg) {
console.log(msg);
}
}
// ── Comment text ─────────────────────────────────────────────────────────
function closedComment(draft, linkedIssues) {
const issues = linkedIssues.map((n) => `#${n}`).join(', ');
const opener = draft
? "This PR has been closed automatically. It's still a draft, but we close those early so you don't put in more time only to have it closed the moment you mark it ready.\n\n"
: 'This PR has been closed automatically. ';
const rule = `${opener}This repo only keeps pull requests open when they come from a maintainer, or from a contributor a maintainer has assigned to the linked issue`;
const situation = linkedIssues.length
? [
`${rule}, and you aren't currently assigned to ${issues}.`,
'',
`If a maintainer assigns you to ${issues}, this PR reopens on its own and there's nothing more you need to do here. Assignment is a maintainer call based on capacity; comments that only ask to be assigned don't factor in. What does help is engaging on the issue itself by confirming the repro, explaining why it matters for your use case, or describing the approach you'd take.`,
]
: [
`${rule}, and this PR doesn't link an open issue yet.`,
'',
"- **If you're already assigned to an issue for this**, add `Fixes #<n>` to the description and the PR will reopen on its own.",
`- **If there's no issue yet**, please [open one](https://github.com/${owner}/${repo}/issues/new/choose) instead: what you ran into, why it matters for your use case, and a minimal reproduction. That context is super important to us and is what we use to decide what to prioritise.`,
"- **If there's an issue but you're not assigned**, add `Fixes #<n>` anyway so they're linked, then engage on the issue itself by confirming the repro or describing the approach you'd take. Assignment is a maintainer call based on capacity; comments that only ask to be assigned don't factor in. If you are assigned, this PR reopens automatically.",
];
return [
MARKER,
...situation,
'',
"You're welcome to keep pushing commits here (just avoid force-pushing, since GitHub can't reopen a rewritten branch), but that on its own won't get the PR reviewed or the issue assigned, and realistically most auto-closed PRs stay closed. There's no need to open a new PR either way.",
'',
`[CONTRIBUTING.md](${contributingUrl}) has the full reasoning, but in short:`,
'',
"- We're a small team with very little capacity to review community PRs right now.",
'- Many recent PRs are AI-generated with little human review, and reviewing one carefully still costs a maintainer as much time as it ever did. A well-described issue is usually more useful to us than the code.',
'',
`*Maintainers: reopen, remove \`${LABEL}\`, or add \`${BYPASS_LABEL}\` to override.*`,
].join('\n');
}
function cannotReopenComment(pr, reason) {
return [
MARKER,
`This PR now passes the intake check (${reason}), but GitHub won't let it be reopened — usually because the branch was force-pushed or deleted while the PR was closed, or because another open PR uses the same branch.`,
'',
`If you have another open PR from this branch, please continue there. Otherwise, either push the branch back to \`${pr.head.sha.slice(0, 7)}\` and edit this PR's description to retry, or open a new PR that links the same issue (if a maintainer had waved this one through, mention that so they can do the same there).`,
].join('\n');
}
// ── Helpers ──────────────────────────────────────────────────────────────
async function mutate(description, fn) {
if (!enforce) {
console.log(`[dry-run] would ${description}`);
return undefined;
}
return fn();
}
// Triage-or-better on this repo, from the permission endpoint's capability
// flags (role names can be custom; author_association hides private org
// members). Only a nonexistent user 404s; any other error must throw rather
// than be read as "untrusted", or a maintainer's PR could be closed.
async function isTrusted(username) {
try {
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username });
const p = data.user?.permissions;
if (!p) throw new Error(`permission response for ${username} has no capability flags`);
const trusted = Boolean(p.triage || p.push || p.maintain || p.admin);
console.log(` ${username}: ${trusted ? 'trusted' : 'not trusted'} (role ${data.role_name || '-'})`);
return trusted;
} catch (e) {
if (e.status === 404) return false;
throw new Error(`Permission check failed for ${username} (HTTP ${e.status ?? '?'}): ${e.message}`);
}
}
// Issue numbers referenced with a closing keyword, in the forms GitHub itself
// honors: `Fixes #1`, `closes owner/repo#1`, `Resolved https://github.com/owner/repo/issues/1`.
function closingRefs(body) {
const repoRef = `${owner}/${repo}`.replace(/[.*+?^${}()|[\]\\]/g, '\\{{contextString}}amp;');
const re = new RegExp(
`\\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\s*:?\\s*(?:${repoRef}#|#|https?://github\\.com/${repoRef}/issues/)(\\d+)`,
'gi',
);
return [...new Set([...(body || '').matchAll(re)].map((m) => parseInt(m[1], 10)))];
}
// The linked issue, or null if it doesn't exist, is actually a PR, isn't
// open, or has been transferred to another repository.
async function getIssue(num) {
let issue;
try {
({ data: issue } = await github.rest.issues.get({ owner, repo, issue_number: num }));
} catch (e) {
if (e.status === 404 || e.status === 410) return null;
throw new Error(`Cannot fetch issue #${num} (HTTP ${e.status ?? '?'}): ${e.message}`);
}
if (issue.pull_request || issue.state !== 'open') return null;
if (!issue.repository_url?.endsWith(`/${owner}/${repo}`)) return null;
return issue;
}
// Reopen a gate-closed PR. GitHub refuses (422) if the branch was rewritten
// or deleted while closed, or another open PR uses it. Explain that in the
// comment and make sure the control label is (still) on, so the PR stays
// gate-managed and a later edit or override retries the reopen.
async function reopen(pr, reason) {
try {
await mutate(`reopen PR #${pr.number}`, () => github.rest.pulls.update({ owner, repo, pull_number: pr.number, state: 'open' }));
return true;
} catch (e) {
if (e.status !== 422) throw e;
core.warning(`GitHub refused to reopen PR #${pr.number}: ${e.message}`);
await addLabel(pr.number, LABEL);
await upsertGateComment(pr.number, cannotReopenComment(pr, reason));
return false;
}
}
async function addLabel(prNumber, name) {
await mutate(`add "${name}" to PR #${prNumber}`, async () => {
await ensureLabelExists(name);
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [name] });
});
}
async function removeLabel(prNumber, name) {
await mutate(`remove "${name}" from PR #${prNumber}`, async () => {
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name });
} catch (e) {
if (e.status !== 404) throw e;
}
});
}
async function ensureLabelExists(name) {
const meta = {
[LABEL]: ['b76e79', 'Auto-closed: PR needs a linked issue assigned to its author (see CONTRIBUTING.md)'],
[BYPASS_LABEL]: ['0e8a16', 'Maintainer override for the linked-issue intake gate'],
}[name];
try {
await github.rest.issues.getLabel({ owner, repo, name });
} catch (e) {
if (e.status !== 404) throw e;
try {
await github.rest.issues.createLabel({ owner, repo, name, color: meta[0], description: meta[1] });
} catch (createErr) {
if (createErr.status !== 422) throw createErr; // created concurrently
}
}
}
// The gate keeps at most one comment per PR: authored by the Actions bot and
// carrying MARKER. It's created or updated on failure and deleted on pass.
async function findGateComment(prNumber) {
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: prNumber, per_page: 100 });
return comments.find((c) => c.user?.login === BOT_LOGIN && c.body?.includes(MARKER));
}
async function upsertGateComment(prNumber, body) {
const existing = await findGateComment(prNumber);
if (!existing) {
await mutate(`comment on PR #${prNumber}`, () => github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body }));
} else if (existing.body !== body) {
await mutate(`update the gate comment on PR #${prNumber}`, () => github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }));
}
}
// Runs once per return: the caller only gets here while the PR still counts
// as gate-closed, and it has just removed the label that says so.
async function requestReview(prNumber) {
await mutate(`request a review on PR #${prNumber}`, () => github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body: REVIEW_REQUEST }));
}
async function deleteGateComment(prNumber) {
const existing = await findGateComment(prNumber);
if (!existing) return;
await mutate(`delete the gate comment on PR #${prNumber}`, async () => {
try {
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
} catch (e) {
if (e.status !== 404) throw e; // already deleted by a concurrent run
}
});
}
};
```
## /.github/scripts/pr_intake_gate.test.js
```js path="/.github/scripts/pr_intake_gate.test.js"
// Scenario tests for pr_intake_gate.js. Each case sets up a tiny in-memory
// repository (pull requests, issues, people), fires one event at the gate, and
// checks what the gate left behind: PR state, labels, and its comment.
//
// node --test .github/scripts/pr_intake_gate.test.js
//
// No dependencies; the GitHub client is a small fake defined at the bottom.
// CI runs it in the checks job (.github/workflows/shared.yml).
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const gate = require('./pr_intake_gate.js');
const LABEL = 'missing-issue-link';
const BYPASS = 'bypass-issue-check';
const REVIEW_REQUEST = '@cubic-dev-ai review this PR';
const REPO = { owner: 'modelcontextprotocol', repo: 'python-sdk' };
// People. Only the capability flags matter to the gate.
const PEOPLE = {
maintainer: { type: 'User', perms: { admin: true, maintain: true, push: true, triage: true, pull: true } },
triager: { type: 'User', perms: { triage: true, pull: true } }, // e.g. a trusted-contributors team
outsider: { type: 'User', perms: { pull: true } },
'dependabot[bot]': { type: 'Bot', perms: {} },
'somebot[bot]': { type: 'Bot', perms: {} },
};
// ── Scenarios ──────────────────────────────────────────────────────────────
// `prs` / `issues` describe the world before the event; `expect` describes each
// PR afterwards: state, labels, and comment ('closed' = the "this PR has been
// closed" comment, 'closed-draft' = its draft wording, 'cannot-reopen' = the
// refused-reopen comment, null = none). `reviewRequested`, where given, is
// whether the gate left its comment asking the review bot for a review.
// `writes: 0` additionally asserts the gate touched nothing at all.
const scenarios = [
{
name: 'outsider opens a PR with no issue link → labeled, commented, closed',
prs: [pr(3300, 'outsider')],
event: opened(3300, 'outsider'),
expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'closed' } },
},
{
name: 'outsider links an open issue reported by someone else, not assigned → closed',
prs: [pr(3300, 'outsider', { body: 'Fixes #10' })],
issues: [issue(10)],
event: opened(3300, 'outsider'),
expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'closed' } },
},
{
name: 'outsider links an issue they are assigned to → stays open, untouched',
prs: [pr(3300, 'outsider', { body: 'closes #10' })],
issues: [issue(10, { assignees: ['outsider'] })],
event: opened(3300, 'outsider'),
expect: { 3300: { state: 'open', labels: [], comment: null } },
writes: 0,
},
{
name: 'outsider links a `help wanted` issue → stays open without assignment',
prs: [pr(3300, 'outsider', { body: 'Resolves modelcontextprotocol/python-sdk#10' })],
issues: [issue(10, { labels: ['help wanted'] })],
event: opened(3300, 'outsider'),
expect: { 3300: { state: 'open', labels: [], comment: null } },
writes: 0,
},
{
name: 'links only count when they use a closing keyword and point at an open issue here',
prs: [pr(3300, 'outsider', { body: 'Related to #10, fixes owner/other#10, prefixes #10, Fixes #11, Fixes #12' })],
issues: [
issue(10, { assignees: ['outsider'] }), // referenced, but never with a closing keyword for this repo
issue(11, { assignees: ['outsider'], state: 'closed' }), // closed issues don't count
issue(12, { assignees: ['outsider'], repository_url: 'https://api.github.com/repos/modelcontextprotocol/typescript-sdk' }), // transferred away
],
event: opened(3300, 'outsider'),
expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'closed' } },
},
{
name: 'gate-closed PR: author edits the description to link a qualifying issue → reopened, label and comment removed',
prs: [pr(3300, 'outsider', { state: 'closed', labels: [LABEL], body: 'Fixes #10', gateComment: true })],
issues: [issue(10, { labels: ['help wanted'] })],
event: edited(3300, 'outsider'),
expect: { 3300: { state: 'open', labels: [], comment: null, reviewRequested: true } },
},
{
name: 'gate-closed PR: maintainer assigns the author on the linked issue → reopened',
prs: [
pr(3300, 'outsider', { state: 'closed', labels: [LABEL], body: 'Fixes #10', gateComment: true }),
pr(3301, 'outsider', { state: 'closed', labels: [LABEL], body: 'Fixes #99', gateComment: true }), // different issue: untouched
],
issues: [issue(10, { assignees: ['outsider'] }), issue(99)],
event: assigned(10, 'outsider', 'maintainer'),
expect: {
3300: { state: 'open', labels: [], comment: null, reviewRequested: true },
3301: { state: 'closed', labels: [LABEL], comment: 'closed', reviewRequested: false },
},
},
{
name: 'gate-closed PR: maintainer reopens it → stays open with the sticky bypass label',
prs: [pr(3300, 'outsider', { state: 'open', labels: [LABEL], gateComment: true })], // payload arrives post-reopen
event: reopened(3300, 'maintainer'),
expect: { 3300: { state: 'open', labels: [BYPASS], comment: null, reviewRequested: true } },
},
{
name: 'gate-closed PR: triage-role user removes the label → reopened with the sticky bypass label',
prs: [pr(3300, 'outsider', { state: 'closed', labels: [], gateComment: true })], // payload arrives post-unlabel
event: unlabeled(3300, 'triager'),
expect: { 3300: { state: 'open', labels: [BYPASS], comment: null, reviewRequested: true } },
},
{
name: 'gate-closed PR: some other bot strips the label → re-checked, label restored, still closed',
prs: [pr(3300, 'outsider', { state: 'closed', labels: [], gateComment: true })],
event: unlabeled(3300, 'somebot[bot]'),
expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'closed' } },
},
{
name: 'PR carrying the bypass label is never re-closed, even after edits that would fail',
prs: [pr(3300, 'outsider', { labels: [BYPASS], body: 'no link at all' })],
event: edited(3300, 'outsider'),
expect: { 3300: { state: 'open', labels: [BYPASS], comment: null } },
writes: 0,
},
{
name: 'previously passing PR edited to drop its link → closed again',
prs: [pr(3300, 'outsider', { body: 'see #10' })],
issues: [issue(10, { assignees: ['outsider'] })],
event: edited(3300, 'outsider'),
expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'closed' } },
},
{
name: "maintainer's own PR is exempt (and reopening it adds no labels)",
prs: [pr(3300, 'maintainer')],
event: reopened(3300, 'maintainer'),
expect: { 3300: { state: 'open', labels: [], comment: null } },
writes: 0,
},
{
name: 'triage-role author is exempt',
prs: [pr(3300, 'triager')],
event: opened(3300, 'triager'),
expect: { 3300: { state: 'open', labels: [], comment: null } },
writes: 0,
},
{
name: 'bot-authored PR (Dependabot) is exempt',
prs: [pr(3300, 'dependabot[bot]')],
event: opened(3300, 'dependabot[bot]'),
expect: { 3300: { state: 'open', labels: [], comment: null } },
writes: 0,
},
{
name: 'draft PR with no issue link is closed like any other, with a note about why drafts are closed early',
prs: [pr(3300, 'outsider', { draft: true })],
event: opened(3300, 'outsider'),
expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'closed-draft' } },
},
{
name: 'pre-existing draft marked ready for review with no link → closed',
prs: [pr(3300, 'outsider')],
event: readyForReview(3300, 'outsider'),
expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'closed' } },
},
{
name: 'PR a maintainer closed by hand (no gate label) is left alone when edited',
prs: [pr(3300, 'outsider', { state: 'closed' })],
event: edited(3300, 'outsider'),
expect: { 3300: { state: 'closed', labels: [], comment: null } },
writes: 0,
},
{
name: 'merged PR still carrying the label is left alone',
prs: [pr(3300, 'outsider', { state: 'closed', merged_at: '2026-08-15T00:00:00Z', labels: [LABEL] })],
event: edited(3300, 'outsider'),
expect: { 3300: { state: 'closed', labels: [LABEL], comment: null } },
writes: 0,
},
{
name: 'GitHub refuses to reopen (branch rewritten while closed) → stays closed, comment explains',
prs: [pr(3300, 'outsider', { state: 'closed', labels: [LABEL], body: 'Fixes #10', gateComment: true, refuseReopen: true })],
issues: [issue(10, { assignees: ['outsider'] })],
event: edited(3300, 'outsider'),
expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'cannot-reopen', reviewRequested: false } },
},
{
name: 'gate-closed PR: maintainer adds the bypass label → reopened, and the label sticks',
prs: [pr(3300, 'outsider', { state: 'closed', labels: [LABEL, BYPASS], gateComment: true })], // payload arrives post-label
event: labeled(3300, 'maintainer', BYPASS),
expect: { 3300: { state: 'open', labels: [BYPASS], comment: null, reviewRequested: true } },
},
{
name: 'gate-closed draft comes back → reopened, but no review is requested while it is a draft',
prs: [pr(3300, 'outsider', { state: 'closed', draft: true, labels: [LABEL], body: 'Fixes #10', gateComment: true })],
issues: [issue(10, { assignees: ['outsider'] })],
event: assigned(10, 'outsider', 'maintainer'),
expect: { 3300: { state: 'open', labels: [], comment: null, reviewRequested: false } },
},
{
name: 'refused reopen after a label-removal override → both labels on, so the PR stays gate-managed',
prs: [pr(3300, 'outsider', { state: 'closed', labels: [], gateComment: true, refuseReopen: true })],
event: unlabeled(3300, 'triager'),
expect: { 3300: { state: 'closed', labels: [BYPASS, LABEL], comment: 'cannot-reopen' } },
},
{
name: 'after a refused reopen, once the branch is restored an edit retries and reopens',
prs: [pr(3300, 'outsider', { state: 'closed', labels: [BYPASS, LABEL], comments: [{ user: 'github-actions[bot]', body: "<!-- require-linked-issue -->\n…GitHub won't let it be reopened…" }] })],
event: edited(3300, 'outsider'),
expect: { 3300: { state: 'open', labels: [BYPASS], comment: null } },
},
{
name: 'assignment reopens the PR even when the assigned issue is referenced after several others',
prs: [pr(3300, 'outsider', { state: 'closed', labels: [LABEL], body: 'Fixes #1 fixes #2 fixes #3 fixes #4 fixes #5 fixes #6', gateComment: true })],
issues: [issue(1), issue(2), issue(3), issue(4), issue(5), issue(6, { assignees: ['outsider'] })],
event: assigned(6, 'outsider', 'maintainer'),
expect: { 3300: { state: 'open', labels: [], comment: null } },
},
{
name: 'a comment planted by the author with the marker is ignored; the gate posts its own',
prs: [pr(3300, 'outsider', { comments: [{ user: 'outsider', body: '<!-- require-linked-issue -->\nnice try' }] })],
event: opened(3300, 'outsider'),
expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'closed', foreignComments: 1 } },
},
{
name: 'manual dispatch evaluates an old, never-gated PR like a new one',
prs: [pr(3150, 'outsider')],
event: dispatch(3150, 'maintainer'),
expect: { 3150: { state: 'closed', labels: [LABEL], comment: 'closed' } },
},
];
for (const s of scenarios) {
test(s.name, async () => {
const world = makeWorld(s);
await run(world, s.event, { enforce: true });
assert.deepEqual(observe(world, s.expect), s.expect);
if (s.writes !== undefined) assert.equal(world.writes.length, s.writes, `writes: ${world.writes.join(', ')}`);
});
}
test('kill switch (ENFORCE=false) reaches the same verdicts but writes nothing', async () => {
for (const s of scenarios) {
const world = makeWorld(s);
await run(world, s.event, { enforce: false });
assert.equal(world.writes.length, 0, `${s.name}: ${world.writes.join(', ')}`);
}
});
test('on assignment, one PR failing to re-evaluate does not stop the others (run still fails)', async () => {
const world = makeWorld({
prs: [
pr(3300, 'outsider', { state: 'closed', labels: [LABEL], body: 'Fixes #10', gateComment: true }),
pr(3301, 'outsider', { state: 'closed', labels: [LABEL], body: 'Fixes #10', gateComment: true }),
],
issues: [issue(10, { assignees: ['outsider'] })],
});
world.failPullsGet = 3300;
await assert.rejects(run(world, assigned(10, 'outsider', 'maintainer'), { enforce: true }), /Could not re-evaluate #3300/);
assert.equal(world.prs.get(3301).state, 'open');
});
test('an API error while checking permissions fails the run rather than closing the PR', async () => {
const world = makeWorld({ prs: [pr(3300, 'outsider')] });
world.failPermissionLookup = true;
await assert.rejects(run(world, opened(3300, 'outsider'), { enforce: true }), /Permission check failed/);
assert.equal(world.writes.length, 0);
});
// ── Scenario helpers ───────────────────────────────────────────────────────
function pr(number, author, o = {}) {
return { number, author, state: 'open', draft: false, body: '', labels: [], merged_at: null, comments: [], gateComment: false, refuseReopen: false, ...o };
}
function issue(number, o = {}) {
return { number, state: 'open', labels: [], assignees: [], repository_url: `https://api.github.com/repos/${REPO.owner}/${REPO.repo}`, ...o };
}
function prEvent(action, number, sender, extra = {}) {
return { eventName: 'pull_request_target', payload: { action, pull_request: { number }, sender: { login: sender }, ...extra } };
}
function opened(number, sender) { return prEvent('opened', number, sender); }
function edited(number, sender) { return prEvent('edited', number, sender); }
function reopened(number, sender) { return prEvent('reopened', number, sender); }
function readyForReview(number, sender) { return prEvent('ready_for_review', number, sender); }
function unlabeled(number, sender) { return prEvent('unlabeled', number, sender, { label: { name: LABEL } }); }
function labeled(number, sender, name) { return prEvent('labeled', number, sender, { label: { name } }); }
function assigned(issueNumber, assignee, sender) {
return { eventName: 'issues', payload: { action: 'assigned', issue: { number: issueNumber }, assignee: { login: assignee }, sender: { login: sender } } };
}
function dispatch(number, sender) {
return { eventName: 'workflow_dispatch', prNumber: number, payload: { inputs: { pr_number: String(number) }, sender: { login: sender } } };
}
async function run(world, event, { enforce }) {
process.env.ENFORCE = enforce ? 'true' : 'false';
process.env.PR_NUMBER_INPUT = event.prNumber ? String(event.prNumber) : '';
const core = { warning: () => {}, setFailed: (m) => { throw new Error(`setFailed: ${m}`); } };
const quiet = console.log;
console.log = () => {};
try {
await gate({ github: world.github, context: { repo: REPO, ...event }, core });
} finally {
console.log = quiet;
}
}
// What the gate left behind, in the same shape as a scenario's `expect`.
function observe(world, expect) {
const out = {};
for (const num of Object.keys(expect)) {
const p = world.prs.get(Number(num));
const gateComments = p.comments.filter((c) => c.user === 'github-actions[bot]' && c.body.includes('<!-- require-linked-issue -->'));
assert.ok(gateComments.length <= 1, `PR #${num} has ${gateComments.length} gate comments`);
const body = gateComments[0]?.body;
const kind = !body ? null : body.includes("won't let it be reopened") ? 'cannot-reopen' : body.includes('still a draft') ? 'closed-draft' : 'closed';
out[num] = { state: p.state, labels: [...p.labels].sort(), comment: kind };
if ('foreignComments' in expect[num]) out[num].foreignComments = p.comments.length - gateComments.length;
if ('reviewRequested' in expect[num]) {
const requests = p.comments.filter((c) => c.user === 'github-actions[bot]' && c.body === REVIEW_REQUEST);
assert.ok(requests.length <= 1, `PR #${num} has ${requests.length} review requests`);
out[num].reviewRequested = requests.length === 1;
}
}
return out;
}
// ── A tiny in-memory GitHub ────────────────────────────────────────────────
function makeWorld({ prs = [], issues = [] }) {
const world = { prs: new Map(), issues: new Map(), writes: [], failPermissionLookup: false, failPullsGet: null, nextCommentId: 100 };
for (const p of prs) {
const copy = structuredClone(p);
copy.comments = copy.comments.map((c) => ({ id: world.nextCommentId++, ...c }));
if (copy.gateComment) copy.comments.push({ id: world.nextCommentId++, user: 'github-actions[bot]', body: '<!-- require-linked-issue -->\nThanks for the contribution. …closed for now…' });
world.prs.set(p.number, copy);
}
for (const i of issues) world.issues.set(i.number, structuredClone(i));
const err = (status, message = 'fake error') => Object.assign(new Error(message), { status });
const write = (what) => world.writes.push(what);
const getPr = (n) => { const p = world.prs.get(n); if (!p) throw err(404); return p; };
const apiPr = (p) => ({
number: p.number, state: p.state, draft: p.draft, body: p.body, merged_at: p.merged_at,
labels: p.labels.map((name) => ({ name })),
user: { login: p.author, type: PEOPLE[p.author].type },
head: { sha: 'abc1234def', ref: 'patch-1', repo: { name: REPO.repo, owner: { login: p.author } } },
});
const rest = {
repos: {
getCollaboratorPermissionLevel: async ({ username }) => {
if (world.failPermissionLookup) throw err(500, 'boom');
const person = PEOPLE[username];
if (!person) throw err(404, 'not a user');
return { data: { role_name: 'custom', user: { permissions: { admin: false, maintain: false, push: false, triage: false, pull: false, ...person.perms } } } };
},
},
pulls: {
get: async ({ pull_number }) => { if (world.failPullsGet === pull_number) throw err(502, 'bad gateway'); return { data: apiPr(getPr(pull_number)) }; },
update: async ({ pull_number, state }) => {
const p = getPr(pull_number);
write(`${state === 'open' ? 'reopen' : 'close'} #${pull_number}`);
if (state === 'open' && p.refuseReopen) throw err(422, 'Validation Failed: state cannot be changed');
p.state = state;
return { data: apiPr(p) };
},
},
issues: {
get: async ({ issue_number }) => {
if (world.prs.has(issue_number)) return { data: { ...apiPr(getPr(issue_number)), pull_request: {} } };
const i = world.issues.get(issue_number);
if (!i) throw err(404);
return { data: { ...i, labels: i.labels.map((name) => ({ name })), assignees: i.assignees.map((login) => ({ login })) } };
},
listForRepo: async ({ state, creator, labels }) => ({
data: [...world.prs.values()]
.filter((p) => p.state === state && p.author === creator && p.labels.includes(labels))
.map((p) => ({ number: p.number, body: p.body, pull_request: {} })),
}),
getLabel: async () => ({ data: {} }),
createLabel: async ({ name }) => { write(`create label ${name}`); },
addLabels: async ({ issue_number, labels }) => {
const p = getPr(issue_number);
write(`label #${issue_number} ${labels}`);
for (const l of labels) if (!p.labels.includes(l)) p.labels.push(l);
},
removeLabel: async ({ issue_number, name }) => {
const p = getPr(issue_number);
write(`unlabel #${issue_number} ${name}`);
if (!p.labels.includes(name)) throw err(404);
p.labels = p.labels.filter((l) => l !== name);
},
listComments: async ({ issue_number }) => ({ data: getPr(issue_number).comments.map((c) => ({ id: c.id, body: c.body, user: { login: c.user } })) }),
createComment: async ({ issue_number, body }) => {
write(`comment on #${issue_number}`);
getPr(issue_number).comments.push({ id: world.nextCommentId++, user: 'github-actions[bot]', body });
},
updateComment: async ({ comment_id, body }) => {
write(`edit comment ${comment_id}`);
for (const p of world.prs.values()) for (const c of p.comments) if (c.id === comment_id) c.body = body;
},
deleteComment: async ({ comment_id }) => {
write(`delete comment ${comment_id}`);
for (const p of world.prs.values()) p.comments = p.comments.filter((c) => c.id !== comment_id);
},
},
};
world.github = { rest, paginate: async (fn, args) => (await fn(args)).data };
return world;
}
```
## /.github/workflows/claude.yml
```yml path="/.github/workflows/claude.yml"
# Source: https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude') && !startsWith(github.event.comment.body, '@claude review')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
persist-credentials: false
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@2261fcfc88e7de1b55f179edd588805e12de71f2 # v1.0.228
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # zizmor: ignore[secrets-outside-env]
use_commit_signing: true
additional_permissions: |
actions: read
```
## /.github/workflows/conformance.yml
```yml path="/.github/workflows/conformance.yml"
name: Conformance Tests
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: conformance-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
# Pinned conformance harness package spec (passed verbatim to `npx --yes`).
# Bump deliberately and reconcile the three
# .github/actions/conformance/expected-failures*.yml files in the same change.
CONFORMANCE_PKG: "@modelcontextprotocol/conformance@0.2.0-alpha.11"
# Each job runs three `--suite all` legs, one per baseline file. The two
# `--spec-version <rev>` legs run every scenario the pinned harness marks
# applicable at that spec revision, at that revision's wire (stateful
# handshake for 2025-11-25, stateless per-request _meta for 2026-07-28). Each
# is a superset of the frozen requirement set that `conformance tier-check`
# scores for that revision (`npx $CONFORMANCE_PKG list --requirements <rev>`
# prints it), so an expected-failures.<rev>.yml entry for a scenario in that
# set means tier-check reports the SDK below 100% for <rev>. The bare leg runs
# every shipped scenario at whichever wire the harness defaults it to; it is
# the only leg that reaches extension-tagged scenarios (tasks-*, auth/dpop*,
# ...), which never match a --spec-version filter. For everything else it
# overlaps one of the pinned legs, so a failure it also sees needs its own
# expected-failures.yml entry besides the pinned file's.
jobs:
server-conformance:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: true
version: 0.9.5
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
- run: uv sync --frozen --all-extras --package mcp-everything-server
- name: Run server conformance (all suite, 2025-11-25 wire)
run: >-
./.github/actions/conformance/run-server.sh
--suite all
--spec-version 2025-11-25
--expected-failures ./.github/actions/conformance/expected-failures.2025-11-25.yml
--output-dir conformance-results/server-2025-11-25
- name: Run server conformance (all suite, 2026-07-28 wire)
run: >-
./.github/actions/conformance/run-server.sh
--suite all
--spec-version 2026-07-28
--expected-failures ./.github/actions/conformance/expected-failures.2026-07-28.yml
--output-dir conformance-results/server-2026-07-28
- name: Run server conformance (all suite, default wire)
run: >-
./.github/actions/conformance/run-server.sh
--suite all
--expected-failures ./.github/actions/conformance/expected-failures.yml
--output-dir conformance-results/server-all
- name: Upload conformance results
# The log has only summary counts; per-check data is in checks.json.
# always(), not !cancelled(): a timeout-minutes kill is a cancellation,
# and that is when the partial results matter most.
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: server-conformance-results
path: conformance-results/
if-no-files-found: ignore
client-conformance:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: true
version: 0.9.5
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
# --compile-bytecode: without it, ~40 concurrently spawned interpreters
# race to byte-compile site-packages during the timing-sensitive window.
- run: uv sync --frozen --all-extras --package mcp --compile-bytecode
- name: Pre-compile bytecode (editable sources)
run: uv run --frozen python -m compileall -q src .github/actions/conformance
# The harness runs a leg's scenarios via unbounded Promise.all; under
# that concurrency the slowest one (sse-retry, which has a real-time SSE
# reconnect wait) needs more than the 30s default budget.
# `.venv/bin/python` (not `uv run`) avoids lockfile re-checks in ~40
# concurrent spawns; run-client.sh re-runs unexpected failures solo.
- name: Run client conformance (all suite, 2025-11-25 wire)
run: >-
./.github/actions/conformance/run-client.sh
--command '.venv/bin/python .github/actions/conformance/client.py'
--suite all
--timeout 60000
--spec-version 2025-11-25
--expected-failures ./.github/actions/conformance/expected-failures.2025-11-25.yml
--output-dir conformance-results/client-2025-11-25
- name: Run client conformance (all suite, 2026-07-28 wire)
run: >-
./.github/actions/conformance/run-client.sh
--command '.venv/bin/python .github/actions/conformance/client.py'
--suite all
--timeout 60000
--spec-version 2026-07-28
--expected-failures ./.github/actions/conformance/expected-failures.2026-07-28.yml
--output-dir conformance-results/client-2026-07-28
- name: Run client conformance (all suite, default wire)
run: >-
./.github/actions/conformance/run-client.sh
--command '.venv/bin/python .github/actions/conformance/client.py'
--suite all
--timeout 60000
--expected-failures ./.github/actions/conformance/expected-failures.yml
--output-dir conformance-results/client-all
- name: Upload conformance results
# The log has only summary counts; per-check data is in checks.json.
# always(), not !cancelled(): a timeout-minutes kill is a cancellation,
# and that is when the partial results matter most.
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: client-conformance-results
path: conformance-results/
if-no-files-found: ignore
```
## /.github/workflows/deploy-docs.yml
```yml path="/.github/workflows/deploy-docs.yml"
name: Deploy Docs
on:
push:
branches:
# main is the sole deployer of the combined site (v2 at / and /v2/, v1.x
# at /v1/); the v1.x branch has no deploy workflow. A v1.x docs change is
# published by the next main deploy or a manual workflow_dispatch here.
- main
paths:
- docs/**
# docs pages include their code blocks from these files via `--8<--`, so a
# change here changes the rendered site even when no .md file moves.
- docs_src/**
# translated pages and the language registry feed the site/<code>/ sites
- i18n/**
- mkdocs.yml
- src/mcp/**
- src/mcp-types/**
- scripts/build-docs.sh
- scripts/docs/**
- pyproject.toml
- uv.lock
- .github/workflows/deploy-docs.yml
workflow_dispatch:
concurrency:
group: deploy-docs
# Newest run wins. build-docs.sh builds the tips of main and v1.x, not the
# triggering commit, so a newer run publishes everything an older one would,
# and Pages takes the site as one artifact, so a cancelled run leaves the live
# site as it was. With `false`, a run that never gets a runner holds the group
# and every later deploy waits behind it.
cancel-in-progress: true
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: true
version: 0.9.5
- name: Build combined docs (main at / and /v2/, v1.x at /v1/)
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@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
```
## /.github/workflows/docs-preview-cleanup.yml
```yml path="/.github/workflows/docs-preview-cleanup.yml"
name: Docs Preview Cleanup
# Deletes Cloudflare Pages preview deployments for a PR when it closes.
# Runs as pull_request_target so secrets are available for fork PRs; it never
# checks out PR code, so there is no untrusted-code execution risk.
on:
pull_request_target: # zizmor: ignore[dangerous-triggers] never checks out PR code
types: [closed]
permissions: {}
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Delete preview deployments for this PR
env:
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CF_PROJECT: ${{ vars.CLOUDFLARE_PAGES_PROJECT }}
BRANCH: pr-${{ github.event.pull_request.number }}
run: |
set -euo pipefail
if [ -z "$CF_API_TOKEN" ] || [ -z "$CF_ACCOUNT_ID" ] || [ -z "$CF_PROJECT" ]; then
echo "Cloudflare credentials/project not configured; skipping cleanup."
exit 0
fi
base="https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/pages/projects/$CF_PROJECT/deployments"
# Collect matching ids across all pages first, then delete — deleting
# mid-pagination would shift later pages and skip entries.
ids=""
for page in $(seq 1 200); do
resp=$(curl -fsS -H "Authorization: Bearer $CF_API_TOKEN" "$base?env=preview&per_page=25&page=$page")
ids="$ids $(jq -r --arg b "$BRANCH" '.result[]? | select(.deployment_trigger.metadata.branch == $b) | .id' <<<"$resp")"
[ "$(jq '.result | length' <<<"$resp")" -lt 25 ] && break
done
deleted=0
for id in $ids; do
echo "Deleting deployment $id"
curl -fsS -X DELETE -H "Authorization: Bearer $CF_API_TOKEN" "$base/$id?force=true" > /dev/null
deleted=$((deleted + 1))
done
echo "Deleted $deleted deployment(s) for $BRANCH."
```
## /.github/workflows/docs-preview.yml
```yml path="/.github/workflows/docs-preview.yml"
name: Docs Preview
# Builds the docs site for a PR and deploys it to Cloudflare Pages.
#
# Security: the build executes Python from the PR (mkdocstrings imports
# src/mcp, `!!python/name:` config directives run, and heads may ship their
# own build scripts). The build is gated by `authorize` (admin sender on a
# same-repo branch for auto-preview, admin/maintainer commenter for
# /preview-docs) and isolated from Cloudflare secrets — `build` runs PR code
# with no secrets and hands the static site to `deploy` via an artifact, so
# PR code never shares a runner with the Cloudflare token. Fork PRs get no
# automatic preview: actions/checkout refuses to fetch a fork's head in a
# pull_request_target run, so a maintainer requests one with /preview-docs,
# which runs under issue_comment with the same gating and isolation.
# `authorize` and `comment` run .github/scripts/docs_preview.js, checked out
# from the default branch only; those two checkouts must never take a `ref:`.
#
# Required configuration:
# - secrets.CLOUDFLARE_API_TOKEN (scope: Account → Cloudflare Pages → Edit)
# - secrets.CLOUDFLARE_ACCOUNT_ID
# - vars.CLOUDFLARE_PAGES_PROJECT (existing Pages project, e.g. mcp-python-sdk-docs)
on:
pull_request_target: # zizmor: ignore[dangerous-triggers] build is permission-gated and secret-isolated; see header comment
types: [opened, reopened, synchronize]
paths:
- docs/**
- docs_src/**
- i18n/**
- mkdocs.yml
- scripts/docs/**
- pyproject.toml
issue_comment:
types: [created]
permissions: {}
concurrency:
# Workflow-level concurrency is evaluated when the run is queued — before any
# job-level `if:` — so an unrelated PR comment would otherwise cancel an
# in-flight build. Only runs that actually produce a preview share a group;
# everything else falls through to a unique run_id group.
group: >-
docs-preview-pr-${{
github.event_name == 'pull_request_target' && github.event.pull_request.number
|| (github.event.issue.pull_request && startsWith(github.event.comment.body, '/preview-docs') && github.event.issue.number)
|| github.run_id
}}
cancel-in-progress: true
jobs:
authorize:
if: >-
github.event_name == 'pull_request_target' ||
(github.event.issue.pull_request && startsWith(github.event.comment.body, '/preview-docs'))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
authorized: ${{ steps.check.outputs.authorized }}
pr_number: ${{ steps.check.outputs.pr_number }}
head_sha: ${{ steps.check.outputs.head_sha }}
slash_attempt: ${{ steps.check.outputs.slash_attempt }}
steps:
- name: Check out the scripts (default branch)
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# No `ref:` here, ever: this job trusts what it checks out (see header).
persist-credentials: false
sparse-checkout: .github/scripts
- name: Determine authorization
id: check
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { authorize } = require('./.github/scripts/docs_preview.js');
await authorize({ github, context, core });
build:
needs: authorize
if: needs.authorize.outputs.authorized == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.authorize.outputs.head_sha }}
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
# Keep the untrusted build away from the shared Actions cache. GitHub
# already limits pull_request_target and issue_comment runs to
# read-only access in the default branch's cache scope, so this is
# defence in depth (and avoids a refused save in the post step).
# Mirrors publish-pypi.yml.
enable-cache: false
version: 0.9.5
# Both triggers run this workflow file from the default branch (whatever
# the PR targets), so the whole recipe — dependency sync included — must
# come from the checkout itself: heads that ship scripts/docs/build.sh
# (the Zensical toolchain) build with it; older heads and v1.x heads still
# build with MkDocs. Both arms must write the site to site/. Keep the
# detection in sync with build_site() in scripts/build-docs.sh.
- run: |
if [ -f scripts/docs/build.sh ]; then
bash scripts/docs/build.sh
else
uv sync --frozen --group docs
# The env var silences mkdocs-material's MkDocs 2.0 warning banner.
NO_MKDOCS_2_WARNING=1 uv run --frozen --no-sync mkdocs build
fi
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: site
path: site/
retention-days: 1
# An empty site/ means the build arm broke its output contract; fail
# here instead of surfacing as a confusing download error in deploy.
if-no-files-found: error
deploy:
needs: [authorize, build]
if: needs.authorize.outputs.authorized == 'true'
runs-on: ubuntu-latest
permissions: {}
outputs:
deployment_url: ${{ steps.wrangler.outputs.deployment-url }}
alias_url: ${{ steps.wrangler.outputs.pages-deployment-alias-url }}
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: site
path: site
- name: Deploy to Cloudflare Pages
id: wrangler
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
packageManager: npm
command: >-
pages deploy ./site
--project-name=${{ vars.CLOUDFLARE_PAGES_PROJECT }}
--branch=pr-${{ needs.authorize.outputs.pr_number }}
--commit-hash=${{ needs.authorize.outputs.head_sha }}
--commit-dirty=true
comment:
needs: [authorize, build, deploy]
if: >-
always() &&
needs.deploy.result != 'cancelled' &&
(needs.authorize.outputs.authorized == 'true' || needs.authorize.outputs.slash_attempt == 'true')
runs-on: ubuntu-latest
permissions:
contents: read # check out the script from the default branch
pull-requests: write
steps:
- name: Check out the scripts (default branch)
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# No `ref:` here, ever: this job trusts what it checks out (see header).
persist-credentials: false
sparse-checkout: .github/scripts
- name: Post or update preview comment
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
AUTHORIZED: ${{ needs.authorize.outputs.authorized }}
PR_NUMBER: ${{ needs.authorize.outputs.pr_number }}
HEAD_SHA: ${{ needs.authorize.outputs.head_sha }}
DEPLOY_RESULT: ${{ needs.deploy.result }}
DEPLOYMENT_URL: ${{ needs.deploy.outputs.deployment_url }}
ALIAS_URL: ${{ needs.deploy.outputs.alias_url }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
with:
script: |
const { comment } = require('./.github/scripts/docs_preview.js');
await comment({ github, context, core });
```
## /.github/workflows/main.yml
```yml path="/.github/workflows/main.yml"
name: CI
on:
push:
branches: ["main", "v1.x"]
tags: ["v*.*.*"]
pull_request:
permissions:
contents: read
jobs:
checks:
uses: ./.github/workflows/shared.yml
all-green:
if: always()
needs: [checks]
runs-on: ubuntu-latest
steps:
- uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # v1.3.0
with:
jobs: ${{ toJSON(needs) }}
```
## /.github/workflows/publish-pypi.yml
```yml path="/.github/workflows/publish-pypi.yml"
name: Publishing
on:
release:
types: [published]
permissions:
contents: read
jobs:
release-build:
name: Build distribution
runs-on: ubuntu-latest
needs: [checks]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: false
version: 0.12.5
- name: Set up Python 3.12
run: uv python install 3.12
- name: Build
run: |
uv build --package mcp
uv build --package mcp-types
- name: Upload artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-dists
path: dist/
checks:
uses: ./.github/workflows/shared.yml
pypi-publish:
name: Upload release to PyPI
runs-on: ubuntu-latest
environment: release
needs:
- release-build
permissions:
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
steps:
- name: Retrieve release distributions
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-dists
path: dist/
- name: Publish package distributions to PyPI
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
# Lets a re-run after a partially failed upload publish the remaining
# files instead of erroring on the ones already on PyPI.
skip-existing: true
```
## /.github/workflows/require-linked-issue.yml
```yml path="/.github/workflows/require-linked-issue.yml"
# PR intake gate — see CONTRIBUTING.md ("How pull requests get in") for the
# policy and .github/scripts/pr_intake_gate.js for the rules as applied.
#
# In short: a PR from someone without triage rights stays open only if it links
# an open issue here that is assigned to them (or labeled `help wanted`);
# otherwise it is labeled `missing-issue-link`, gets one comment, and is closed,
# and it reopens automatically once the author is assigned. Drafts are gated
# too; bots are skipped. A triage+ user reopening the PR, removing the label,
# or adding `bypass-issue-check` overrides. A PR that comes back open gets a
# comment asking cubic to review it, because cubic doesn't act on `reopened`.
#
# Operating it:
# - Live by default. To pause it without a revert, set the repository
# variable PR_GATE_ENFORCE to "false": runs then only log their verdicts.
# - PRs below the number in the job `if:` predate the gate and are ignored
# unless evaluated by hand: `gh workflow run require-linked-issue.yml -f pr_number=N`.
#
# Security: pull_request_target runs with a write token in the base repo's
# context, and GitHub takes both this file and the checkout from the default
# branch whatever the PR targets (so PRs against v1.x are covered too). The
# job checks out only that, for the script, and never fetches, builds, or runs
# anything from the pull request.
#
# Adapted from PrefectHQ/fastmcp's require-issue-link.yml (Apache-2.0), itself
# from langchain-ai/langchain (MIT).
name: Require Linked Issue
on:
pull_request_target: # zizmor: ignore[dangerous-triggers] checks out the default branch only and never runs PR code — see header
types: [opened, edited, reopened, ready_for_review, labeled, unlabeled]
issues:
types: [assigned]
workflow_dispatch:
inputs:
pr_number:
description: PR number to evaluate
required: true
type: number
permissions: {}
jobs:
gate:
name: Evaluate
# Routing only; the rules are in the script. PR events run at or above the
# grandfathering floor, or for PRs the gate has already labeled; label
# events only for the two labels the gate cares about.
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'issues' && !github.event.issue.pull_request && github.event.issue.state == 'open') ||
(
github.event_name == 'pull_request_target' &&
(
github.event.pull_request.number >= 3200 ||
contains(github.event.pull_request.labels.*.name, 'missing-issue-link') ||
github.event.action == 'unlabeled'
) &&
(github.event.action != 'unlabeled' || github.event.label.name == 'missing-issue-link') &&
(github.event.action != 'labeled' || github.event.label.name == 'bypass-issue-check')
)
runs-on: ubuntu-latest
timeout-minutes: 10
concurrency:
group: require-linked-issue-${{ github.event.pull_request.number || inputs.pr_number || format('issue-{0}-{1}', github.event.issue.number, github.event.assignee.login) }}
cancel-in-progress: false
permissions:
contents: read # check out the gate script from the default branch
issues: write # read linked issues; label and comment on the PR
pull-requests: write # close and reopen the PR
env:
ENFORCE: ${{ vars.PR_GATE_ENFORCE != 'false' && 'true' || 'false' }} # kill switch: set the variable to "false" to go log-only
PR_NUMBER_INPUT: ${{ inputs.pr_number }}
steps:
- name: Check out the gate script (default branch)
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: .github/scripts
- name: Evaluate
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const run = require('./.github/scripts/pr_intake_gate.js');
await run({ github, context, core });
```
## /.github/workflows/shared.yml
```yml path="/.github/workflows/shared.yml"
name: Shared Checks
on:
workflow_call:
permissions:
contents: read
env:
COLUMNS: 150
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: true
version: 0.9.5
- name: Install dependencies
run: uv sync --frozen --all-extras --python 3.10
- uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1
with:
extra_args: --all-files --verbose
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24 # match actions/github-script's node24 runtime
- name: Workflow script tests
run: node --test '.github/scripts/*.test.js'
- name: Surface types match vendored schema
run: |
uv sync --group codegen --frozen
uv run --frozen --group codegen python scripts/gen_surface_types.py --check
# Resolves only mcp-types' declared dependencies into an empty environment,
# so an import of the SDK or anything from its stack fails here.
- name: mcp-types installs and imports standalone
run: |
uv run --isolated --no-project --with ./src/mcp-types python -c \
"import mcp_types, mcp_types.jsonrpc, mcp_types.methods, mcp_types.version, mcp_types._v2025_11_25, mcp_types._v2026_07_28"
test:
name: test (${{ matrix.python-version }}, ${{ matrix.dep-resolution.name }}, ${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
dep-resolution:
- name: lowest-direct
install-flags: "--upgrade --resolution lowest-direct"
- name: locked
install-flags: "--frozen"
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: true
version: 0.9.5
- name: Install the project
run: uv sync ${{ matrix.dep-resolution.install-flags }} --all-extras --python ${{ matrix.python-version }}
- name: Run pytest with coverage
shell: bash
env:
# tests/examples/test_stories_smoke.py is gated on this var; it spawns real
# stdio + uvicorn subprocesses, so run it on exactly one matrix cell.
MCP_EXAMPLES_SMOKE: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' && matrix.dep-resolution.name == 'locked' && '1' || '' }}
# PEP 597: text I/O without encoding= raises EncodingWarning, which pytest's "error"
# filter makes fatal on every cell. An env var (not -X) so pytest-xdist workers inherit it.
PYTHONWARNDEFAULTENCODING: "1"
run: |
uv run --frozen --no-sync coverage erase
uv run --frozen --no-sync coverage run -m pytest -n auto
uv run --frozen --no-sync coverage combine
uv run --frozen --no-sync coverage report
- name: Check for unnecessary no cover pragmas
if: runner.os != 'Windows'
run: uv run --frozen --no-sync strict-no-cover
readme-snippets:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: true
version: 0.9.5
- 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
# `scripts/docs/build.sh` is the whole gauntlet: build_config.py fails on
# nav entries without a page and pages without a nav entry, `zensical build
# --strict` fails on broken .md links, `pymdownx.snippets: check_paths:
# true` fails on a deleted `docs_src/` include, and the post-build steps
# fail on order-dependent API rendering, unresolved cross-references,
# inventory download failures, and broken non-markdown link targets.
# Until this job existed the docs were only ever built post-merge by
# `deploy-docs.yml`, so those failures went green on the PR and broke the next
# deploy of main. This is the check path; `deploy-docs.yml` stays the deploy
# path.
docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: true
version: 0.9.5
- name: Build the docs in strict mode
run: bash scripts/docs/build.sh
```
## /.github/workflows/zizmor.yml
```yml path="/.github/workflows/zizmor.yml"
name: GitHub Actions Security Analysis
on:
push:
branches: ["main"]
pull_request:
branches: ["**"]
permissions: {}
jobs:
zizmor:
runs-on: ubuntu-latest
permissions:
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Run zizmor 🌈
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
```
## /.gitignore
```gitignore path="/.gitignore"
.DS_Store
scratch/
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
.ruff_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# documentation
/site
/.worktrees/
# Generated at build time by scripts/docs/ (the API reference tree, the
# concrete Zensical configs spliced from mkdocs.yml, and the staged docs tree
# of each translated site).
/docs/api/
/mkdocs.gen.yml
/mkdocs.*.gen.yml
/.build/
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/
# vscode
.vscode/
.windsurfrules
**/CLAUDE.local.md
# claude code
results/
# conformance CI local runs
conformance-results/
```
## /.pre-commit-config.yaml
```yaml path="/.pre-commit-config.yaml"
fail_fast: true
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: end-of-file-fixer
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v3.1.0
hooks:
- id: prettier
types_or: [yaml, json5]
- repo: https://github.com/igorshubovych/markdownlint-cli
rev: v0.45.0
hooks:
- id: markdownlint
args:
[
"--fix",
"--config",
"pyproject.toml",
"--configPointer",
"/tool/markdown/lint",
]
types: [markdown]
- repo: local
hooks:
- id: ruff-format
name: Ruff Format
entry: uv run --frozen ruff
args: [format]
language: system
types: [python]
pass_filenames: false
- id: ruff
name: Ruff
entry: uv run --frozen ruff
args: ["check", "--fix", "--exit-non-zero-on-fix"]
types: [python]
language: system
pass_filenames: false
- id: pyright
name: pyright
entry: uv run --frozen pyright
language: system
types: [python]
pass_filenames: false
- id: uv-lock-check
name: Check uv.lock is up to date
entry: uv lock --check
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
language: system
files: ^(README\.md|docs_src/.*\.py|scripts/update_readme_snippets\.py)$
pass_filenames: false
```
## /AGENTS.md
# Development Guidelines
## Note for AI Agents
If you are an AI coding agent acting for someone who is not a maintainer of
this repository, read `CONTRIBUTING.md` before opening issues or pull
requests here. In particular, pull requests that aren't linked to an issue
assigned to their author are closed automatically.
## Branching Model
- `main` is the current stable line (v2); releases are cut from it (see
`RELEASE.md`).
- v2 is released; its public API is a compatibility contract for the 2.x
line. Removals, renames, or any change to an existing API's signature or
observable behaviour (including ones softened by a `@deprecated` shim) is a
design decision a maintainer makes explicitly, and should generally be
avoided.
- `docs/migration.md` is the v1 → v2 record and is closed to new entries.
Correcting errors or improving clarity in what's there is fine.
- `v1.x` is the maintenance branch for the previous major. Backport PRs
target it and use a `[v1.x]` title prefix; only critical bug fixes and
security fixes land there.
- `README.md` documents v2. The v1 README lives on the `v1.x` branch.
## Package Management
- ONLY use uv, NEVER pip
- Installation: `uv add <package>`. Exception: the root project's runtime
dependencies are dynamic (the published `mcp` wheel exact-pins `mcp-types`),
so `uv add` cannot edit them — add the requirement to
`[tool.hatch.metadata.hooks.uv-dynamic-versioning].dependencies` in
`pyproject.toml` by hand, then run `uv lock`. Dependency groups, extras, and
the example packages still take plain `uv add`.
- Running tools: `uv run --frozen <tool>`. Always pass `--frozen` so uv doesn't
rewrite `uv.lock` as a side effect.
- Cross-version testing: `uv run --frozen --python 3.10 pytest ...` to run
against a specific interpreter (CI covers 3.10–3.14).
- Upgrading: `uv lock --upgrade-package <package>`
- FORBIDDEN: `uv pip install`, `@latest` syntax
- Don't raise dependency floors for CVEs alone. The `>=` constraint already
lets users upgrade. Only raise a floor when the SDK needs functionality from
the newer version, and don't add SDK code to work around a dependency's
vulnerability. See Kludex/uvicorn#2643 and python-sdk #1552 for reasoning.
## Code Quality
- Keep comments brief. Explain only non-obvious reasons or constraints; do not
narrate the code or restate names, types, or assertions.
- Type hints required for all code
- Public APIs must have docstrings. When a public API raises exceptions a
caller would reasonably catch, document them in a `Raises:` section. Don't
list exceptions from argument validation or programmer error.
- `src/mcp/__init__.py` defines the public API surface via `__all__`. Adding a
symbol there is a deliberate API decision, not a convenience re-export.
- IMPORTANT: All imports go at the top of the file — inline imports hide
dependencies and obscure circular-import bugs. Only exception: when a
top-level import genuinely can't work (lazy-loading optional deps, or
tests that re-import a module).
- Always pass `encoding=` to text-mode `open()`, `Path.read_text()`/`write_text()`,
`tempfile` and `subprocess` text pipes — normally `"utf-8"`, or `"locale"` when the
platform encoding is genuinely intended; the default is the process locale, not UTF-8.
CI and `scripts/test` run pytest with `PYTHONWARNDEFAULTENCODING=1` (PEP 597), which
makes any omission an error under the `error` filter.
## Testing
- When writing or reviewing tests, conform to `.claude/skills/test-quality/SKILL.md`
— it defines the bar for naming, abstraction level, assertions, and determinism.
- Framework: `uv run --frozen pytest`
- Async testing: use anyio, not asyncio
- Do not use `Test` prefixed classes — write plain top-level `test_*` functions.
Legacy files still contain `Test*` classes; do NOT follow that pattern for new
tests even when adding to such a file.
- IMPORTANT: Tests should be fast and deterministic. Prefer in-memory async execution;
reach for threads only when necessary, and subprocesses only as a last resort.
- For end-to-end behavior, an in-memory `Client(server)` is usually the
cleanest approach (see `tests/client/test_client.py` for the canonical
pattern). For narrower changes, testing the function directly is fine. Use
judgment.
- Test files mirror the source tree: `src/mcp/client/stdio.py` →
`tests/client/test_stdio.py`. Add tests to the existing file for that module.
- 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
- Pytest is configured with `filterwarnings = ["error"]`, so warnings fail
tests. Don't silence warnings from your own code; fix the underlying cause.
Scoped `ignore::` entries for upstream libraries are acceptable in
`pyproject.toml` with a comment explaining why.
- New features from the 2026-07-28 spec must have a matching test in the
[conformance suite](https://github.com/modelcontextprotocol/conformance)
that passes against this SDK (CI runs it via
`.github/workflows/conformance.yml`). If no matching test exists, stop and
tell the user so they can raise an issue on the conformance repo.
### Coverage
CI requires 100% (`fail_under = 100`, `branch = true`).
- Full check: `./scripts/test` (~23s). Runs coverage + `strict-no-cover` on the
default Python. Not identical to CI: CI runs 3.10–3.14 × {ubuntu, windows}
× {locked, lowest-direct}, and some branch-coverage quirks only surface on
specific matrix entries.
- Targeted check while iterating (~4s, deterministic):
```bash
uv run --frozen coverage erase
uv run --frozen coverage run -m pytest tests/path/test_foo.py
uv run --frozen coverage combine
uv run --frozen coverage report --include='src/mcp/path/foo.py' --fail-under=0
# UV_FROZEN=1 propagates --frozen to the uv subprocess strict-no-cover spawns
UV_FROZEN=1 uv run --frozen strict-no-cover
```
Partial runs can't hit 100% (coverage tracks `tests/` too), so `--fail-under=0`
and `--include` scope the report. `strict-no-cover` has no false positives on
partial runs — if your new test executes a line marked `# pragma: no cover`,
even a single-file run catches it.
Avoid adding new `# pragma: no cover`, `# type: ignore`, or `# noqa` comments.
In tests, use `assert isinstance(x, T)` to narrow types instead of
`# type: ignore`. In library code (`src/`), a `# pragma: no cover` needs very
good reasoning — it usually means a test is missing. Audit before pushing:
```bash
git diff origin/main... | grep -E '^\+.*(pragma|type: ignore|noqa)'
```
What the existing pragmas mean:
- `# pragma: no cover` — line is never executed. CI's `strict-no-cover` (skipped
on Windows runners) fails if it IS executed. When your test starts covering
such a line, remove the pragma.
- `# pragma: lax no cover` — excluded from coverage but not checked by
`strict-no-cover`. Use for lines covered on some platforms/versions but not
others.
- `# pragma: no branch` — excludes branch arcs only. coverage.py misreports the
`->exit` arc for nested `async with` on Python 3.11+ (worse on 3.14/Windows).
## Documentation
When a change affects public API or user-visible behaviour, update the relevant
page(s) under `docs/` in the same PR. Docs are organised by the `nav:` sections
in `mkdocs.yml` (Get started, Servers, Inside your handler, Running your server,
Clients, Advanced), not by the on-disk directory names. Find the page covering
the feature you touched in `mkdocs.yml` rather than adding a new one.
## Formatting & Type Checking
- Format: `uv run --frozen ruff format .`
- Lint: `uv run --frozen ruff check . --fix`
- Type check: `uv run --frozen pyright`
- Pre-commit runs all of the above plus markdownlint, a `uv.lock` consistency
check, and README checks — see `.pre-commit-config.yaml`
## Exception Handling
- **Always use `logger.exception()` instead of `logger.error()` when catching exceptions**
- Don't include the exception in the message: `logger.exception("Failed")` not `logger.exception(f"Failed: {e}")`
- **Catch specific exceptions** where possible:
- File ops: `except (OSError, PermissionError):`
- JSON: `except json.JSONDecodeError:`
- Network: `except (ConnectionError, TimeoutError):`
- **FORBIDDEN** `except Exception:` - unless in top-level handlers
## /CLAUDE.md
@AGENTS.md
## /CODE_OF_CONDUCT.md
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
<mcp-coc@anthropic.com>.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
<https://www.contributor-covenant.org/version/2/0/code_of_conduct.html>.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
<https://www.contributor-covenant.org/faq>. Translations are available at
<https://www.contributor-covenant.org/translations>.
## /CONTRIBUTING.md
# Contributing
Thank you for your interest in contributing to the MCP Python SDK! This document provides guidelines and instructions for contributing.
## Before You Start
> [!IMPORTANT]
> **The most useful contribution is a good issue.** Pull requests from outside the maintainer team are only reviewed when a maintainer has assigned you the linked issue, and others are closed automatically. The rest of this section explains why, and what we'd love help with.
### Why Issues Rather Than Pull Requests
This SDK is looked after by a very small team with limited time for review. Now that coding agents can turn any open issue into a plausible-looking pull request within hours, we receive far more PRs than we could ever read carefully — and reviewing a PR properly still takes as long as it always did. When an issue is well described, it's usually quicker for a maintainer, with tooling that already knows this codebase and its conventions, to write a fix that fits than to review and reshape someone else's.
What we can't produce ourselves is your context: what you were trying to do, what you expected, a minimal reproduction, the environment it breaks in, the constraint we hadn't considered. That's the valuable part, and it's what a good issue carries — so that's what we ask for first.
### How Pull Requests Get In
A PR from someone outside the maintainer team stays open when both of these hold:
1. Its description links an open issue in this repository with a closing keyword (`Fixes #123`, `Closes #123`, `Resolves #123`).
2. A maintainer has assigned that issue to you, or the issue carries the [`help wanted`](https://github.com/modelcontextprotocol/python-sdk/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22) label (which means we'd welcome a PR for it from anyone).
Otherwise a bot labels the PR `missing-issue-link`, leaves a comment explaining this, and closes it. If that happens to yours, there's no need to open a new one: it reopens automatically as soon as a maintainer assigns you the issue, or when you edit the description to link one that qualifies. While it's closed, push updates as new commits rather than force-pushing, since GitHub can't reopen a PR whose branch has been rewritten. This applies to small fixes like typos too — for those, an issue pointing at the problem is all we need.
Whether to assign an issue, and to whom, is a [maintainer](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/MAINTAINERS.md#python-sdk) call, and it depends on our capacity at the time as much as on the change itself. Comments that only ask to be assigned don't factor into it, so please skip those (and don't have an agent post them). What does help is engaging with the issue itself: confirming the reproduction, asking about the intended behaviour, or briefly describing the approach you'd take. If you reported the issue and would like to fix it yourself, mention that in the issue — the reporter has first call if we do take an outside PR for it.
### Who We'd Love to Hear From
- **You've hit a real bug.** If you've run into a bug that affects your use case, that's extremely helpful for us to hear about. Talking through why it's a problem for you — rather than just that it is one — helps us both design the right fix and prioritise it, and a minimal reproduction makes it far more likely we can act quickly.
- **You'd like to learn the codebase or contribute regularly.** You're welcome, with one honest caveat: how much mentoring and review we can offer depends entirely on maintainer capacity, which is very limited at the moment, so replies may be slow and we may not be able to take everything on. The best way to start is by filing and triaging issues well and engaging on existing ones. `good first issue` still needs assignment — we'd like a short conversation first. You can find us in [#python-sdk-dev on the MCP Contributors Discord](https://discord.gg/6CSzBmMkjX).
- **You maintain another MCP SDK or work on the spec.** Say so on the issue or in #python-sdk-dev; a maintainer can reopen a specific PR past the gate.
### AI-Assisted Contributions
We use AI tooling constantly and have no problem with you using it too. What matters is that a person is accountable for the result:
- **Disclose it.** One line in the PR description, issue, or comment.
- **Own it.** You can explain the change and the reasoning in your own words, and when a maintainer asks a question the answer comes from you rather than being pasted from a chat window.
- **Keep a human in the loop.** Issues, PRs, and comments generated by an agent without a person who has actually hit the problem and read the output may be closed without warning. If you have an agent filing PRs against open issues autonomously, please turn it off for this repository.
- **Keep issues short and factual.** What happened, what you expected, and how to reproduce it.
Undisclosed AI contributions may be closed, and repeated cases can lead to a block from the `modelcontextprotocol` org. The org-wide [AI contribution policy](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/AI_POLICY.md) also applies.
### The SDK Is Opinionated
Not every contribution will be accepted, even with a working implementation and an assigned issue. We prioritize maintainability and consistency over adding capabilities. This is at maintainers' discretion.
These always need discussion on an issue before anyone writes code:
- New public APIs or decorators
- Architectural changes or refactoring
- Changes that touch multiple modules
- Features that might require spec changes (these need a [SEP](https://github.com/modelcontextprotocol/modelcontextprotocol) first)
### Issue Labels
| Label | Meaning |
|-------|---------|
| [`help wanted`](https://github.com/modelcontextprotocol/python-sdk/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22) | We'd welcome a PR for this from anyone — no assignment needed |
| [`good first issue`](https://github.com/modelcontextprotocol/python-sdk/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22) | Approachable without deep codebase knowledge; still needs assignment (see above) |
| [`ready for work`](https://github.com/modelcontextprotocol/python-sdk/issues?q=is%3Aopen+is%3Aissue+label%3A%22ready+for+work%22) | Triaged and queued for a maintainer to pick up (not a call for PRs) |
| `needs confirmation`, `needs repro`, `needs decision`, `needs design` | Not actionable yet; more information or a maintainer decision is needed first |
## Issue Triage
Every new issue gets a first look from a maintainer within two business days. That first look is the *triage*: it means labeling the issue and deciding whether it is valid and actionable, not fixing it.
The core labels follow the shared [MCP SDK taxonomy](https://modelcontextprotocol.io/community/sdk-tiers#issue-triage-labels) (the repository also carries workflow labels of its own, such as `needs maintainer` and `needs decision`): one **type** (`bug`, `enhancement`, `question`), one **status** (`needs confirmation`, `needs repro`, `ready for work`, `good first issue`, `help wanted`), and — once actionable — one **priority**:
| Label | Meaning | Commitment |
|-------|---------|------------|
| `P0` | Critical: core functionality failures (connections, message exchange, tools/resources/prompts) or a High/Critical-severity security issue | resolved within 7 days |
| `P1` | Significant bug affecting many users | next release |
| `P2` | Moderate issue or valuable feature request | as capacity allows |
| `P3` | Nice-to-have or rare edge case | opportunistic |
Security reports do not belong in the issue tracker; [SECURITY.md](SECURITY.md) has the private channel.
## Development Setup
1. Make sure you have Python 3.10+ installed
2. Install [uv](https://docs.astral.sh/uv/getting-started/installation/)
3. Fork the repository
4. Clone your fork: `git clone https://github.com/YOUR-USERNAME/python-sdk.git`
5. Install dependencies:
```bash
uv sync --frozen --all-extras --dev
```
6. Set up pre-commit hooks:
```bash
uv tool install pre-commit --with pre-commit-uv --force-reinstall
```
## Development Workflow
1. Choose the correct branch for your changes:
| Change Type | Target Branch | Example |
|-------------|---------------|---------|
| New features and fixes for v2 | `main` | New APIs, refactors |
| Security fixes for v1 | `v1.x` | Critical patches |
| Critical bug fixes for v1 | `v1.x` | Backports of severe bugs |
> **Note:** `main` is the current stable line (v2). The `v1.x` branch is the previous major's maintenance line and receives only security and critical bug fixes.
2. Create a new branch from your chosen base branch
3. Make your changes
4. Ensure tests pass:
```bash
uv run pytest
```
5. Run type checking:
```bash
uv run pyright
```
6. Run linting:
```bash
uv run ruff check .
uv run ruff format .
```
7. Update README snippets if you modified `docs_src/` code embedded in the README:
```bash
uv run scripts/update_readme_snippets.py
```
8. (Optional) Run pre-commit hooks on all files:
```bash
pre-commit run --all-files
```
9. Open a pull request against the branch you started from — see [Pull Requests](#pull-requests); you need to be assigned to the linked issue first
## Code Style
- We use `ruff` for linting and formatting
- Follow PEP 8 style guidelines
- Add type hints to all functions
- Include docstrings for public APIs
## Documentation and Translations
Documentation contributions are English only: the pages under `docs/` are the source of truth, and the translated documentation sites are generated from them, guided by the per-language style guides and glossaries under `i18n/<lang>/`. Never edit the generated pages under `i18n/<lang>/pages/`—the tool can't tell a hand edit from its own output, so the edit persists unchecked, is carried forward into future runs, and hides the real fix. To fix a translation, change that language's `instructions.md` or `glossary.json` (or the English page, if that's where the problem is) and re-run `translate --lang <code> --pages <page> …` for the affected pages; the fix then carries into every future run. See [`i18n/README.md`](i18n/README.md) for the details.
## Pull Requests
By the time you open a PR, you should be assigned to the issue it fixes (see [How Pull Requests Get In](#how-pull-requests-get-in)) and the "what" and "why" should already be settled there. This keeps reviews focused on implementation.
- Put `Fixes #<issue>` in the description — the intake gate looks for it.
- If your PR was auto-closed, there's no need to open another: fix the description or wait to be assigned and it reopens itself. Avoid force-pushing the branch while it's closed.
### Scope
Small PRs get reviewed fast. Large PRs sit in the queue.
A few dozen lines can be reviewed in minutes. Hundreds of lines across many files takes real effort and things slip through. If your change is big, break it into smaller PRs or get alignment from a maintainer first.
### What Gets Rejected
- **No assigned issue**: closed automatically until one is linked, as above
- **Scope creep**: changes that go beyond what was discussed on the issue
- **Misalignment**: even well-implemented features may be rejected if they don't fit the SDK's direction
- **Overengineering**: unnecessary complexity for simple problems
- **Undisclosed or unreviewed AI output**: see [AI-Assisted Contributions](#ai-assisted-contributions)
### Checklist
1. Update documentation as needed
2. Add tests for new functionality
3. Ensure CI passes
4. Address review feedback
5. Dependency bound changes and new runtime dependencies follow the [Dependency Policy](DEPENDENCY_POLICY.md)
## Code of Conduct
Please note that this project is released with a [Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms.
## License
By contributing, you agree that your contributions will be licensed under the MIT License.
## /DEPENDENCY_POLICY.md
# Dependency Policy
`mcp` is a library that lives inside other people's environments, so its requirements are chosen to constrain your resolver as little as possible while still describing what the SDK needs.
## How requirements are declared
Every runtime dependency is a `>=` floor set to the oldest version that provides what the SDK uses, with no upper bound unless a dependency's next major is known to break the SDK. The one exception is `mcp-types`, the wire-types package released in lockstep with `mcp`: each `mcp` release requires exactly its own version of it, so it is the other half of the SDK rather than an independent constraint.
## When a floor moves
A floor is raised only when the SDK starts relying on functionality or a fix that first appeared in that version — not because the dependency published a security advisory. The `>=` bound already lets, and expects, you to run the newest release your other constraints allow, so a higher floor would only shrink the environments the SDK installs into; nor does the SDK add code to work around a dependency's vulnerability, since the fix belongs upstream and in your lockfile ([background](https://github.com/Kludex/uvicorn/discussions/2643), [python-sdk#1552](https://github.com/modelcontextprotocol/python-sdk/issues/1552)). Floor raises may ship in a minor release under the [versioning policy](VERSIONING.md) and are called out in the release notes. Adding a new runtime dependency, or moving one to its next major version, is decided in an issue before the pull request.
## Automated updates
[Dependabot](https://github.com/modelcontextprotocol/python-sdk/blob/main/.github/dependabot.yml) opens monthly, grouped pull requests for the `uv` lockfile and for GitHub Actions. These refresh the versions the SDK is developed and tested against; the requirements published to PyPI move only under the rules above.
## /README.md
# MCP Python SDK
<div align="center">
<strong>Python implementation of the Model Context Protocol (MCP)</strong>
[![PyPI][pypi-badge]][pypi-url]
[![MIT licensed][mit-badge]][mit-url]
[![Python Version][python-badge]][python-url]
[![Documentation][docs-badge]][docs-url]
[![Protocol][protocol-badge]][protocol-url]
[![Specification][spec-badge]][spec-url]
</div>
## Documentation
**The documentation lives at <https://py.sdk.modelcontextprotocol.io/>.**
It has a [Get started guide](https://py.sdk.modelcontextprotocol.io/get-started/), [What's new in v2](https://py.sdk.modelcontextprotocol.io/whats-new/), the [API reference](https://py.sdk.modelcontextprotocol.io/api/mcp/), and the [migration guide](https://py.sdk.modelcontextprotocol.io/migration/).
## What is MCP?
The [Model Context Protocol](https://modelcontextprotocol.io) lets you build servers that expose data and functionality to LLM applications in a secure, standardized way. Think of it like a web API, but designed for LLM interactions. With this SDK you can:
- **Build MCP servers** that expose tools, resources, and prompts to any MCP host
- **Build MCP clients** that connect to any MCP server
- Speak every standard transport: stdio, Streamable HTTP, and SSE
## Requirements
Python 3.10+.
## Installation
```bash
uv add "mcp[cli]" # or: pip install "mcp[cli]"
```
The `cli` extra adds the `mcp` command-line tool (`mcp dev`, `mcp run`, `mcp install`) on top of the SDK; install plain `mcp` if you don't need it. For one-off commands, `uv run --with "mcp[cli]" mcp ...` works without a project.
## A server in 15 lines
Create a `server.py`:
<!-- snippet-source docs_src/index/tutorial001.py -->
```python
from mcp.server import MCPServer
mcp = MCPServer("Demo")
@mcp.tool()
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
@mcp.resource("greeting://{name}")
def greeting(name: str) -> str:
"""Greet someone by name."""
return f"Hello, {name}!"
```
_Full example: [docs_src/index/tutorial001.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/docs_src/index/tutorial001.py)_
<!-- /snippet-source -->
That's a complete MCP server: one tool, one templated resource. Open it in the [MCP Inspector](https://github.com/modelcontextprotocol/inspector):
```bash
uv run mcp dev server.py
```
Call `add` with `a=1`, `b=2` and you get `3` back.
Notice what you did **not** write: no JSON Schema (`a: int, b: int` _is_ the schema), no request parsing, no validation code, no protocol handling. Two type-hinted Python functions and a docstring.
[Get started](https://py.sdk.modelcontextprotocol.io/get-started/) takes it from here.
## A client in 10 lines
The same package is a full MCP **client**. Serve `server.py` over HTTP:
```bash
uv run mcp run server.py --transport streamable-http
```
then point a `Client` at it:
```python
import asyncio
from mcp import Client
async def main() -> None:
async with Client("http://localhost:8000/mcp") as client:
result = await client.call_tool("add", {"a": 1, "b": 2})
print(result.structured_content) # {'result': 3}
asyncio.run(main())
```
A URL means Streamable HTTP, the transport you deploy. `Client` can also launch a local server as a stdio subprocess or take any custom transport; [Clients](https://py.sdk.modelcontextprotocol.io/client/) has the rest.
## Contributing
We are passionate about supporting contributors of all levels of experience and would love to see you get involved in the project. See the [contributing guide](https://github.com/modelcontextprotocol/python-sdk/blob/main/CONTRIBUTING.md) to get started.
## License
This project is licensed under the MIT License. See the [LICENSE](https://github.com/modelcontextprotocol/python-sdk/blob/main/LICENSE) file for details.
[pypi-badge]: https://img.shields.io/pypi/v/mcp.svg
[pypi-url]: https://pypi.org/project/mcp/
[mit-badge]: https://img.shields.io/pypi/l/mcp.svg
[mit-url]: https://github.com/modelcontextprotocol/python-sdk/blob/main/LICENSE
[python-badge]: https://img.shields.io/pypi/pyversions/mcp.svg
[python-url]: https://www.python.org/downloads/
[docs-badge]: https://img.shields.io/badge/docs-python--sdk-blue.svg
[docs-url]: https://py.sdk.modelcontextprotocol.io/
[protocol-badge]: https://img.shields.io/badge/protocol-modelcontextprotocol.io-blue.svg
[protocol-url]: https://modelcontextprotocol.io
[spec-badge]: https://img.shields.io/badge/spec-spec.modelcontextprotocol.io-blue.svg
[spec-url]: https://modelcontextprotocol.io/specification/latest
## /RELEASE.md
# Release Process
## Bumping Dependencies
[`DEPENDENCY_POLICY.md`](DEPENDENCY_POLICY.md) says *when* a bound should
move; this is the mechanics.
1. Change the dependency version in `pyproject.toml`. The root `mcp` project's
runtime dependencies are dynamic and live under
`[tool.hatch.metadata.hooks.uv-dynamic-versioning].dependencies`.
2. Regenerate the lock with `uv lock` (or `uv lock --upgrade-package <package>`
to move just that package's locked version). The committed `uv.lock` is a
normal (default-strategy) resolution; the `lowest-direct` resolution that
proves the floors still work is applied only by its CI matrix leg at test
time and is never committed.
## Release lines
Two branches ship, and the package version comes from the git tag
(`uv-dynamic-versioning`). Publishing a GitHub release runs `publish-pypi.yml`
**from the tagged commit**, so the workflow that fires is the tagged branch's
own: a `main` tag builds and publishes two distributions (`mcp` and
`mcp-types`, lock-stepped via `Requires-Dist: mcp-types=={{ version }}`), and a
`v1.x` tag builds and publishes `mcp` only.
| Line | Branch | Tag | GitHub release flags |
| ---------------------------- | ------ | ------------------------- | ------------------------------------- |
| Current stable | `main` | `v2.X.Y` | not a pre-release; becomes **Latest** |
| Maintenance (previous major) | `v1.x` | `v1.X.Y` | not a pre-release; **not** Latest |
| Pre-releases | `main` | `v2.X.YaN` / `bN` / `rcN` | **Pre-release** ticked, never Latest |
The `Development Status` classifier in both `pyproject.toml` files is
permanently `5 - Production/Stable`; it is not bumped as part of any release.
The `mcp-types` PyPI project carries the same trusted publisher as `mcp` (this
repository, workflow `publish-pypi.yml`, environment `release`). For a release
cut from `main`, if only some of the four files upload, fix the cause and
re-run the publish job — its `skip-existing` setting makes it skip whatever
already landed (the `v1.x` workflow publishes a single distribution and has no
such setting).
## Stable release from `main` (`v2.X.Y`)
The stable line's README and docs carry no version pin (`pip install "mcp[cli]"`
installs the newest stable release), so a routine stable release needs no
pin-flip commit; the exception is the first stable release of a new major,
whose pre-release banner and pins are replaced by that flip. `README.md` at the
tagged commit is the PyPI long description, so any README fix has to merge
before the tag.
1. Check the full test matrix is green on the release commit. The publish
workflow re-runs the same checks and blocks publishing until they pass, so a
red leg there means re-running the failed jobs on the Publishing run — but
verify green before creating the release rather than discovering red after
the tag exists.
2. Freeze `main` from that commit until the tag exists: the release is created
with an explicit `--target`, and nothing else should land in between.
3. Create the release NOT as a pre-release, passing the verified commit as
`--target` (otherwise the tag is created from whatever `main`'s HEAD is by
then). It becomes GitHub "Latest", and PyPI's default `pip install mcp`
version moves to it. The tagged commit determines everything about the
release — the workflows that run and the package metadata (readme,
classifiers) that gets published — so it must contain the current release
tooling, not just pass tests. `--target` is ignored if the tag already
exists: when re-creating a release, delete the old tag first and
double-check where the new tag points.
```shell
gh release create v2.X.Y --title v2.X.Y --target <commit-sha> --notes-file <notes.md>
```
4. Curate the release notes: the highlights, anything known-incomplete, and
links to the docs and migration guide, above a `## What's Changed` list.
Generate that list with the release UI's "Generate release notes" (setting
its **Previous tag** to the previous release on this line by hand — the
auto-picked baseline is the newest tag, which may sit on the other line), or
assemble the whole body in the file passed to `--notes-file`. Use absolute
URLs (relative links don't resolve in GitHub release bodies).
5. If a stable release turns out to be broken, yank it on PyPI and release the
fix as the next patch version. Never delete a release from PyPI — version
numbers cannot be reused. Yank `mcp` and `mcp-types` together (they are one
release), and set the yank reason and the GitHub release notes to point at
the replacement version, since yanking doesn't stop `==` pins from installing
the broken version.
## Maintenance release from `v1.x` (`v1.X.Y`)
Land the `[v1.x]`-prefixed backport PRs (and any README banner update, which is
the README PyPI shows for that version), verify the branch tip green, then
create the release the same way with two differences:
- **The tag's target is the verified commit on the `v1.x` branch.** The UI and
CLI default the target to `main`, which is the v2 codebase — a v1 tag created
there would publish v2 code as a v1 stable release. Pass the exact commit
verified green in the previous step rather than the branch name, for the
same moving-HEAD reason as above.
- **It must not take "Latest" back from the 2.x line.** The UI ticks "Set as
the latest release" by default for the newest non-pre-release; untick it, or
pass `--latest=false`, and afterwards confirm `/releases/latest` still names
the newest v2 tag. If it slipped, `gh release edit v1.X.Y --latest=false`
fixes it — release metadata only, no re-cut.
```shell
gh release create v1.X.Y --title v1.X.Y --target <commit-sha> --latest=false --notes-file <notes.md>
```
When generating notes, set **Previous tag** to the previous `v1.*` release by
hand for the same reason as above. Then ask someone to review the release.
## Pre-releases from `main`
Pre-releases of the next version are cut from `main` with a PEP 440
pre-release tag: `aN` for alphas, later `bN`/`rcN` for betas and release
candidates. The PEP 440 suffix is what keeps `pip install mcp` on the stable
version — installers do not pick a pre-release for a plain `mcp` requirement while a
final release satisfies it; a pre-release is opted into with an exact pin, a
specifier that names a pre-release version, or `--pre`.
1. During a pre-release phase the README and docs pin the exact pre-release
version, so update those examples first (grep the outgoing version — the
pins live in the README Installation section, `docs/index.md`,
`docs/get-started/installation.md`, and `docs/get-started/real-host.md`) so
the tagged commit — and therefore the README PyPI publishes — names the
version being released. When entering a new phase (alpha → beta → rc →
stable), update the banner wording too; the stable phase drops the pins.
2. Check the full test matrix is green on the release commit, as above.
3. Create the release as a pre-release, passing the verified commit as
`--target`. The pre-release flag keeps GitHub's "Latest" badge and
`/releases/latest` on the newest stable release:
```shell
gh release create v2.X.YbN --prerelease --title v2.X.YbN --target <commit-sha>
```
4. Curate the release notes: what changed since the previous pre-release, what
is known-incomplete, the install line (`pip install mcp==2.X.YbN`), and a
link to the migration guide, with absolute URLs.
5. If a pre-release turns out to be broken, yank both `mcp` and `mcp-types` on PyPI
and cut the next one, pointing the yank reason and the GitHub release notes
at the replacement version.
## /ROADMAP.md
# Roadmap
The SDK's work is organized by MCP specification revision, with one GitHub project board per revision; each item is an issue or pull request you can follow.
## The 2026-07-28 revision
v2 implements the [2026-07-28 specification](https://modelcontextprotocol.io/specification/2026-07-28) and negotiates back to every earlier revision. Board: **[python-sdk · 2026-07-28 spec](https://github.com/orgs/modelcontextprotocol/projects/42)**; the cross-SDK view is [2026-07-28 Spec Implementation](https://github.com/orgs/modelcontextprotocol/projects/41). Still open there: making advertised capabilities configurable rather than pre-computed ([#2896](https://github.com/modelcontextprotocol/python-sdk/issues/2896)).
## Not yet implemented
Of the extensions and optional client-auth mechanisms not yet implemented, those the conformance suite already exercises appear in its expected-failures baseline, [`.github/actions/conformance/expected-failures.yml`](.github/actions/conformance/expected-failures.yml), which burns down as each lands. The main ones:
* **Tasks extension** (`io.modelcontextprotocol/tasks`, [SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2663-tasks-extension.md)) — deferred at 2.0 because the 2026-07-28 design is wire-incompatible with the earlier in-core Tasks; tracked in [#2806](https://github.com/modelcontextprotocol/python-sdk/issues/2806).
* **DPoP-bound access tokens** ([SEP-1932](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1932)) in the OAuth client.
* **The workload-identity `jwt-bearer` grant** in the OAuth client.
Everything else — bugs and smaller features — lives in the [issue tracker](https://github.com/modelcontextprotocol/python-sdk/issues), prioritized `P0`–`P3`. The SDK aims to release support for each new specification revision alongside that revision.
## The previous major
`v1.x` is a maintenance line: critical bug fixes and security fixes only. Support terms are in [Versioning and support policy](VERSIONING.md#support-and-announcements); the path off it is the **[Migration Guide](https://py.sdk.modelcontextprotocol.io/migration/)**.
## /SECURITY.md
# Security Policy
Thank you for helping keep the Model Context Protocol and its ecosystem secure.
## Supported Versions
| Version | Line | Support |
| ---------------------------------------- | ----------------------- | ------------------------------------------- |
| 2.x (newest release) | current stable (`main`) | bug fixes, security fixes, new features |
| 1.x newest release (`v1.x` branch) | maintenance | critical bug fixes and security fixes |
| older 1.x releases, and all pre-releases | unsupported | upgrade to the newest 1.x release or to 2.x |
Only the newest release of a supported line receives fixes, so reproduce against
it before reporting. If your project depends on `mcp` and is not yet ready for
2.x, keep a `<2` upper bound on your `mcp` requirement and follow the
[migration guide](https://py.sdk.modelcontextprotocol.io/migration/) when you
migrate.
## Reporting Security Issues
If you discover a security vulnerability in this repository, please report it through
the [GitHub Security Advisory process](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability)
for this repository.
Please **do not** report security vulnerabilities through public GitHub issues, discussions,
or pull requests.
## What to Include
To help us triage and respond quickly, please include:
- A description of the vulnerability
- Steps to reproduce the issue
- The potential impact
- Any suggested fixes (optional)
## /VERSIONING.md
# Versioning and support policy
What a version number of `mcp` promises: which changes can arrive in a minor release, which wait for a major, how deprecations are announced, and which release lines are supported.
## The version number
[Semantic Versioning](https://semver.org/) semantics in [PEP 440](https://peps.python.org/pep-0440/) syntax, taken from the git tag: in `2.X.Y`, **X** (minor) carries new functionality and every non-breaking change, **Y** (patch) carries bug fixes only, and a breaking change to the public API lands only in a new **major**. Pre-releases are cut from `main` as `aN`/`bN`/`rcN`; installers prefer final releases by default, so an unpinned `pip install mcp` stays on a stable release whenever one satisfies your requirement. `mcp` and its wire-types package `mcp-types` release in lockstep, each `mcp` requiring exactly the matching `mcp-types`.
## The public API
The promise covers every name exported by `mcp` and `mcp_types` (their `__all__`), the import paths, signatures, and behavior documented on the [documentation site](https://py.sdk.modelcontextprotocol.io/) and in its [API Reference](https://py.sdk.modelcontextprotocol.io/api/mcp/). It does not cover underscore-prefixed names, undocumented modules, or the wording of log lines, warnings, and exception messages (their types and documented raise conditions are covered). APIs labelled **provisional** (for example the middleware chain) may still change in a minor release; **experimental** APIs are opt-in previews.
## Breaking and non-breaking changes
Held for the next major:
* removing or renaming a public name,
* changing a signature, return type, raised exception type, or documented behavior so that working code stops working,
* removing a documented import path, extra, or CLI command.
Allowed in a minor:
* additions — functions, defaulted parameters, classes, fields, enum members,
* changes to provisional or experimental APIs,
* new deprecation warnings, and retired protocol features ceasing to work on connections that negotiate a revision without them (their Python names stay, deprecated, until a major),
* raising a dependency floor the SDK needs (see the [dependency policy](DEPENDENCY_POLICY.md)) when the dependency's changes don't reach you through the SDK's API, or dropping a Python version after its upstream end-of-life — both called out in the release notes,
* bug fixes, including ones that make the SDK match its documented or specified behavior.
## Deprecations
**SDK APIs** are deprecated before removal: they keep working for at least one minor release, marked with [`typing_extensions.deprecated`](https://typing-extensions.readthedocs.io/en/latest/#typing_extensions.deprecated) wherever Python can carry the marker (docstring and migration guide otherwise), and are removed only in a major. **Protocol features** the specification retires keep their implementation through the spec's deprecation window and warn with `MCPDeprecationWarning`, a `UserWarning` subclass that shows by default; what still functions depends on the revision a connection negotiated — see [Deprecated features](https://py.sdk.modelcontextprotocol.io/deprecated/).
## Support and announcements
Two lines are maintained, and only the newest release of each receives fixes:
* **2.x** (`main`) — bug fixes, security fixes, and features.
* **1.x** ([`v1.x`](https://github.com/modelcontextprotocol/python-sdk/tree/v1.x)) — critical bug fixes and security fixes.
Where changes are announced:
* [SECURITY.md](https://github.com/modelcontextprotocol/python-sdk/blob/main/SECURITY.md) has the vulnerability reporting process.
* Every release publishes notes on [GitHub Releases](https://github.com/modelcontextprotocol/python-sdk/releases).
* Every breaking change between majors is documented in the [Migration Guide](https://py.sdk.modelcontextprotocol.io/migration/) before it merges.
* Pull requests that make a breaking change carry the `breaking change` label.
## /docs/.overrides/.icons/mcp.svg
```svg path="/docs/.overrides/.icons/mcp.svg"
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180"><path fill="none" d="M23.5996 85.2532L86.2021 22.6507C94.8457 14.0071 108.86 14.0071 117.503 22.6507C126.147 31.2942 126.147 45.3083 117.503 53.9519L70.2254 101.23" stroke="currentColor" stroke-width="11.0667" stroke-linecap="round"/><path fill="none" d="M70.8789 100.578L117.504 53.952C126.148 45.3083 140.163 45.3083 148.806 53.952L149.132 54.278C157.776 62.9216 157.776 76.9357 149.132 85.5792L92.5139 142.198C89.6327 145.079 89.6327 149.75 92.5139 152.631L104.14 164.257" stroke="currentColor" stroke-width="11.0667" stroke-linecap="round"/><path fill="none" d="M101.853 38.3013L55.553 84.6011C46.9094 93.2447 46.9094 107.258 55.553 115.902C64.1966 124.546 78.2106 124.546 86.8543 115.902L133.154 69.6025" stroke="currentColor" stroke-width="11.0667" stroke-linecap="round"/></svg>
```
## /docs/advanced/apps.md
# MCP Apps
An **MCP App** is a tool with a face: alongside its data, the tool points at an HTML
document the host renders as an interactive surface.
Two parts, always two parts:
1. **A tool** that does the work and returns data, like any other tool.
2. **A `ui://` resource** containing the HTML the host shows for it.
The tool carries a `_meta.ui.resourceUri` reference to the resource. The host fetches
it with `resources/read`, renders it in a **sandboxed iframe**, and pushes the tool's
result into that iframe via `postMessage`. Your server never sends or receives any
`ui/*` messages: that traffic is between the host and the iframe. You serve a tool
and an HTML document; the host does the theater.
The SDK ships this as the built-in `Apps` extension (`io.modelcontextprotocol/ui`).
If [Extensions](extensions.md) are new to you, skim that page first. One minute,
then come back.
## A clock with a face
```python title="server.py" hl_lines="17 20 28 30"
--8<-- "docs_src/apps/tutorial001.py"
```
Four moves:
* `Apps()`: one instance holds your UI-bound tools and their resources.
* `@apps.tool(resource_uri="ui://clock/app.html")`: a regular tool, plus the
`_meta.ui.resourceUri` stamp. Everything `@mcp.tool()` accepts (name, title,
description, ...) passes through.
* `apps.add_html_resource("ui://clock/app.html", CLOCK_HTML)`: the matching
resource, served as `text/html;profile=mcp-app`. That exact MIME type is what
tells a host "this is an app, render it".
* `MCPServer("clock", extensions=[apps])`: opt in. The server now advertises
`io.modelcontextprotocol/ui` under `capabilities.extensions`.
The HTML itself listens for the host's `postMessage` and shows the result. For real
apps, use the official [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps)
browser SDK inside your HTML. It gives you `ontoolresult`, `callServerTool`,
`getHostContext`, and `onhostcontextchanged` instead of raw message events.
## Graceful degradation
Not every client renders apps. The spec is blunt about what that means for you:
> Tools **MUST** return a meaningful `content` array even when UI is available.
The model reads `content`; the iframe is for humans. A UI-capable host still feeds
the text result to the model, and a text-only client gets *only* that. So the
canonical pattern is one tool, two answers. Look at `get_time` again:
```python title="server.py" hl_lines="21-25"
--8<-- "docs_src/apps/tutorial001.py"
```
`client_supports_apps(ctx)` is `True` only when the client declared the
`io.modelcontextprotocol/ui` extension **and** listed `text/html;profile=mcp-app`
in its `mimeTypes` settings. The field is required, so a client that omits it
does not count. Here is the client half of the negotiation:
```python title="client.py" hl_lines="8 12"
--8<-- "docs_src/apps/tutorial001_client.py"
```
Serve `server.py` over HTTP, then run the client from a second terminal:
```console
uv run mcp run server.py --transport streamable-http
```
```console
python client.py
```
```text
2026-06-26T12:00:00Z
```
The rich answer came back. Drop `extensions=[APPS_SUPPORT]` from the `Client` call
and the same program prints `The time is 2026-06-26T12:00:00Z.` instead, which is
all a text-only client ever sees.
!!! warning
Never return a placeholder like `"[Rendered UI]"` as the only content. If the
fallback text is useless, the tool is useless to every text-only client and to
the model itself. Write the sentence.
## Locking the iframe down
The resource side carries the security metadata: what the iframe may load, which
browser permissions it wants, how it would like to be framed:
```python title="server.py" hl_lines="9 19-22"
--8<-- "docs_src/apps/tutorial002.py"
```
`csp` and `permissions` are **requests to the host**, not server behaviour. The host
builds the iframe's Content-Security-Policy and Permissions-Policy from them, and it
may refuse. Feature-detect in your JS rather than assuming a grant.
`ResourceCsp`, field by field (Python name, wire key, what the host does with it):
| Python | Wire (`_meta.ui.csp`) | Controls |
|---|---|---|
| `connect_domains` | `connectDomains` | `connect-src`: where `fetch`/XHR may go |
| `resource_domains` | `resourceDomains` | `img-src`, `style-src`, ...: static assets |
| `frame_domains` | `frameDomains` | `frame-src`: nested iframes |
| `base_uri_domains` | `baseUriDomains` | `base-uri`: what `<base>` may point at |
`ResourcePermissions`: each field requests a browser permission for the iframe.
| Python | Wire (`_meta.ui.permissions`) |
|---|---|
| `camera` | `camera` |
| `microphone` | `microphone` |
| `geolocation` | `geolocation` |
| `clipboard_write` | `clipboardWrite` |
!!! note
CSP and permissions live on the **resource**, never on the tool. The spec's tool
metadata has no slot for them, and hosts ignore them there. The SDK makes the
mistake unrepresentable: `@apps.tool()` simply has no `csp` parameter.
### Visibility
`visibility=["app"]` on a tool says "this exists for the iframe, not the model":
* `"model"`: the model may call it.
* `"app"`: the iframe may call it (via `callServerTool`).
* Omitted: both, which is the default.
Filtering is the **host's** job. Your server lists app-only tools in `tools/list`
like any other; the host hides them from the model. Don't filter server-side.
## The rules the SDK enforces
All of these fail at startup, not in production:
* A `resource_uri` or resource URI that isn't `ui://...` is a `ValueError` at
decoration/registration time.
* A tool bound to a URI with **no matching registered resource** is a `ValueError`
when `MCPServer(extensions=[apps])` consumes the extension. A tool advertising
HTML that 404s on `resources/read` is a misconfiguration, so it refuses to
construct.
* `meta={"ui": ...}` on `@apps.tool()` is a `ValueError`. The decorator owns
`_meta["ui"]`; say it with `resource_uri=` and `visibility=`. Other `meta=` keys
merge fine alongside.
Neither the TypeScript ext-apps SDK nor FastMCP catches any of these today; we'd
rather you find out before a host does.
## Beyond inline HTML
`add_html_resource` covers the common case: a string of HTML. For anything else,
HTML on disk or generated content, build the resource yourself and hand it over:
```python title="server.py" hl_lines="12 18"
--8<-- "docs_src/apps/tutorial003.py"
```
`add_resource` fills in the `text/html;profile=mcp-app` MIME type when the resource
doesn't set one explicitly, and rejects an explicit mismatch: a `ui://` resource
under any other MIME type is one no host will render.
!!! tip
Targeting a pre-GA host that still reads the deprecated flat
`_meta["ui/resourceUri"]` key? Merge it yourself:
`@apps.tool(resource_uri="ui://x", meta={"ui/resourceUri": "ui://x"})`.
The nested `ui` object is the spec shape; the flat key is on its way out.
## See it run
The `apps` story in `examples/stories/` is this page as a runnable pair: a server
with a UI-bound clock tool and a client that negotiates Apps, reads the tool's
`_meta.ui.resourceUri`, fetches the HTML, and calls the tool.
```bash
uv run python -m stories.apps.client
```
## /docs/advanced/extensions.md
# Extensions
An **extension** is an opt-in bundle of MCP behaviour behind one identifier.
On a server it can contribute tools, resources, and new request methods, and it can wrap
`tools/call`. On a client it can claim extra `tools/call` result shapes and observe vendor
notifications. Each side advertises under its own `capabilities.extensions`, and nothing
changes for anyone who didn't ask for it. That is the contract ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)), and
it has one golden rule: **extensions are off by default**.
## Using an extension
Pass instances at construction:
```python title="server.py"
--8<-- "docs_src/extensions/tutorial001.py"
```
Done. The server now advertises `io.modelcontextprotocol/ui` under
`capabilities.extensions` and serves everything the extension contributes.
`Apps` is the built-in reference extension, and it gets its own page: **[MCP Apps](apps.md)**.
!!! note
Extensions are fixed at construction. There is no `add_extension` to call later:
a server's capability map should not change while clients are connected to it.
The capability map rides `server/discover`, which is a **2026-07-28** path. A legacy
`initialize` handshake has nowhere to put it, so a legacy client simply doesn't see
the extension. Design for that: an extension *augments* a server, it must not be the
only way the server is usable.
## Writing your own
Subclass `Extension` and override only what you need. Every method has a default.
### The identifier
```python
--8<-- "docs_src/extensions/tutorial002.py"
```
The identifier is a `vendor-prefix/name` string following the spec's `_meta` key
grammar: dot-separated labels (each starts with a letter, ends with a letter or
digit), a slash, then the name. It is validated **when the class is defined**, so a
typo doesn't wait for a server to boot:
```text
TypeError: Stamps.identifier must be a `vendor-prefix/name` string
(reverse-DNS prefix required), got 'stamps'
```
Use a domain you control as the prefix. `io.modelcontextprotocol/*` is for extensions
specified by the MCP project itself.
### Contributing tools
The smallest useful extension is one tool and a settings map:
```python title="server.py" hl_lines="16 18-19 21-22 25"
--8<-- "docs_src/extensions/tutorial003.py"
```
* `tools()` returns `ToolBinding`s. The server registers each one exactly as if you
had called `mcp.add_tool(...)` yourself: same schema generation, same `Context`
injection, same everything.
* `settings()` is the value advertised at `capabilities.extensions["com.example/stamps"]`.
Return `{}` (the default) to advertise the extension with no settings.
* The extension never receives the server. It declares contributions as data;
`MCPServer` consumes them. There is no `self.server` to mutate.
Serve it over HTTP, and a client is the proof:
```console
uv run mcp run server.py --transport streamable-http
```
```python title="client.py" hl_lines="7-11"
--8<-- "docs_src/extensions/tutorial003_client.py"
```
Every `server.py` on this page is served with that command, and every `client.py`
runs beside it with `python client.py` from a second terminal.
### Serving your own methods
An extension can register **new request methods**: its own verbs, served next to the
spec's:
```python title="server.py" hl_lines="14-20 24 33-41"
--8<-- "docs_src/extensions/tutorial004.py"
```
* `SearchParams` subclasses `RequestParams`, so the 2026 `_meta` envelope parses
uniformly and your handler gets validated params, never a raw dict. Bound what
the client controls: `Field(ge=1, le=100)` rejects an absurd `limit` before
your code allocates anything for it.
* `require_client_extension(ctx, EXTENSION_ID)` is the gate: a client that did not
declare the extension gets the `-32021` (missing required client capability) error,
with the machine-readable `requiredCapabilities` payload the spec asks for.
* `protocol_versions=frozenset({"2026-07-28"})` pins the method to one wire version.
At any other version the client gets `METHOD_NOT_FOUND`, exactly as if the method
didn't exist there. For that client, it doesn't.
Methods are **strictly additive**. The SDK enforces this at construction, not at
runtime:
* A `MethodBinding` for a spec-defined method (`tools/list`, `completion/complete`, ...)
raises `ValueError` when the binding is constructed. Core verbs belong to the server.
* Two extensions binding the same method raise when the second one registers.
Last-write-wins is how plugins corrupt each other; we don't do that.
* An empty `protocol_versions` set raises too: a method that can never be served
is a bug, not a configuration.
### The client side
The client is its own program, and it carries both halves of the client story:
```python title="client.py" hl_lines="21-23 27-30"
--8<-- "docs_src/extensions/tutorial004_client.py"
```
* `Client(..., extensions=[advertise(EXTENSION_ID)])` declares the extension. The
declarations become `ClientCapabilities.extensions`: on a 2026-07-28 connection
the map travels in the per-request `_meta` envelope, so the server sees it on
**every** request; on a legacy connection it rides the `initialize` handshake.
Server code doesn't care which: `require_client_extension(ctx, ...)` and
`ctx.session.check_client_capability(...)` read the right source on both paths.
* Vendor methods drop one layer to `client.session.send_request(...)`; `Client`
only grows first-class methods for spec verbs. `send_request` accepts any
`Request` subclass, so the vendor request passes as-is.
* `SearchRequest` and the two models it carries are the extension's wire contract,
so the client declares them for itself. A published extension would ship them in
a package that both sides import.
### Intercepting `tools/call`
The one interceptive hook. Override `intercept_tool_call` to observe, short-circuit,
or veto a tool call:
```python title="server.py" hl_lines="17-24"
--8<-- "docs_src/extensions/tutorial005.py"
```
* `params` is the validated `CallToolRequestParams`: you get `params.name` and
`params.arguments` without touching raw JSON. It is also what decides which
tool call runs: passing a rewritten context through `call_next` changes what
the handler observes on `ctx`, not the tool invocation. Wire-level request
rewriting belongs to [Middleware](middleware.md).
* `call_next(ctx)` runs the rest of the chain and returns the handler's result.
Return it unchanged (observe), return something else (replace), or raise an
`MCPError` (refuse). Whatever you return is serialized like any handler
result, including the 2026-era `serverInfo` identity stamp, so a
short-circuiting interceptor never produces an anonymous or off-schema
response.
* With several extensions, interceptors nest in registration order: the first
extension in `extensions=[...]` is outermost.
* The default implementation is a pass-through, and a server whose extensions never
override this hook keeps the bare `tools/call` handler untouched. You don't
pay for what you don't use.
The hook wraps `tools/call` and nothing else. For every-message concerns, use
[Middleware](middleware.md). That is what it is for.
## Using a client extension
A **client extension** is the same contract from the consuming side: a bundle of
client-side behaviour behind one identifier. The server here answers `buy` with a
receipt to redeem instead of the goods, and only for a client that declared the
extension:
```python title="server.py" hl_lines="22-25"
--8<-- "docs_src/extensions/tutorial006.py"
```
On the client, pass instances to `Client(extensions=[...])` and call tools normally:
```python title="client.py" hl_lines="33-35"
--8<-- "docs_src/extensions/tutorial006_client.py"
```
`call_tool("buy", ...)` returns a plain `CallToolResult`, like every other call. What
the extension changed: the server may now answer `buy` with a `receipt` **result
shape** instead of a final result, and `Receipts` finishes it (here by redeeming the
receipt with a follow-up call) before `call_tool` returns. Nothing about the call
site moves.
Drop the extension and none of this exists: the server's gate refuses a client
that did not declare it (error -32021), and a claimed shape from a server that
skips the gate fails validation, exactly as the spec requires for an
unrecognized `resultType`. Off by default, on both ends of the wire.
To advertise an identifier with **no** client-side behaviour (the server gates on
the capability, the client does nothing, as in the search client above), use
`advertise()`:
```python
from mcp.client import advertise
client = Client("http://localhost:8000/mcp", extensions=[advertise("com.example/search")])
```
## Writing a client extension
Subclass `ClientExtension` and override only what you need. Three contribution
kinds, each with a default: `settings()`, `claims()`, and `notifications()`.
```python title="client.py" hl_lines="16-17 25-26 28-29"
--8<-- "docs_src/extensions/tutorial006_client.py"
```
* The identifier follows the same grammar as the server's, validated when the class
is defined.
* `claims()` returns `ResultClaim`s: a wire tag, the model that parses it, and the
resolver that finishes it. The model must pin the tag with
`result_type: Literal["receipt"]` and must not subclass the verb's core result
types; both are enforced when the claim is constructed. Vendor fields like
`receipt_token` ride the wire as-is: a substituted shape reaches the client
verbatim.
* The resolver receives the parsed model and a `ClaimContext`; `ctx.session` is the
same public handle as `client.session`, so follow-ups are ordinary session calls.
It returns the verb's normal `CallToolResult`.
* `settings()` is the value advertised at `ClientCapabilities.extensions[identifier]`,
read once at `Client` construction.
`notifications()` declares vendor server notifications to observe:
```python
def notifications(self) -> Sequence[NotificationBinding[Any]]:
return [NotificationBinding(method="notifications/receipts", params_type=ReceiptEvent, handler=self.on_receipt)]
```
The handler receives validated params one at a time, in dispatch order. It observes; it cannot veto
or reply.
Two quiet rules. Claims are active on 2026-07-28 connections only, and the capability
ad follows them: on a legacy connection the claims dissolve and the identifier drops
out of the ad with them, so the client never advertises an extension whose shapes it
would reject. And when you want the claimed shape yourself instead of the resolver,
call `client.session.call_tool(..., allow_claimed=True)`; without that flag, a
claimed shape reaching a session-tier caller raises `UnexpectedClaimedResult`.
### Extension verbs
An extension's own request methods need no client-side registration. A vendor request
type subclasses `mcp.types.Request` and goes through `client.session.send_request`,
as in [Serving your own methods](#serving-your-own-methods). Take a server whose
extension serves one verb about a named job:
```python title="server.py" hl_lines="12-13 30"
--8<-- "docs_src/extensions/tutorial007.py"
```
One addition on the client: when a params key must ride the `Mcp-Name` header
(extension specs such as tasks require this for their verbs), the request type
declares `name_param`:
```python title="client.py" hl_lines="20-23 28-29"
--8<-- "docs_src/extensions/tutorial007_client.py"
```
The session mirrors `params["jobId"]` into `Mcp-Name` on every send path, and a
missing value fails loudly rather than silently omitting a required header.
## What an extension cannot do
The contribution surface is **closed** on purpose. On the server: settings, tools,
resources, methods, one `tools/call` interceptor. On the client: settings, result
claims, notification bindings. An extension cannot:
* **Reach into the host.** It declares data; it holds no server or client reference.
* **Replace core behaviour.** Spec methods and core result tags are rejected at
construction (`initialize` is reserved by the runner outright); a notification
binding shadowed by core vocabulary goes quiet with a warning instead.
* **Register late.** After `MCPServer(...)` or `Client(...)` returns, the extension
set is what it is.
If you are fighting these walls, you are not writing an extension. You are writing
a fork. The walls are the feature: a user reading `extensions=[Apps(), Stamps()]`
knows *everything* those two can have touched.
## /docs/advanced/header-parameters.md
# Header parameters
Most servers never need this.
A gateway or load balancer in front of your server can only route on what it can read without parsing the body. Mark a tool argument with `x-mcp-header`, and clients on the `2026-07-28` **[protocol version](../protocol-versions.md)** send its value as an HTTP header as well.
## Mark an argument
The mark is one extra key in the argument's JSON Schema. On `MCPServer`, `Field` puts it there:
```python title="server.py" hl_lines="13"
--8<-- "docs_src/header_parameters/tutorial001.py"
```
* Over Streamable HTTP on `2026-07-28`, a client sends `Mcp-Param-Region` alongside the body, and the server rejects a call where the two disagree.
* A client that hasn't listed the tool has never seen the mark: it sends no header, and the call is rejected. This SDK's `Client` then lists the tools and resends the call once, so listing first only saves a round trip.
* Every other connection ignores the annotation.
Your function doesn't change: `region` still arrives as an argument.
## What can be marked
`str`, `int` and `bool` arguments. Anything else is refused when the tool is registered, with `InvalidSignature`.
That includes `str | None`, which has no single type. An optional argument needs its schema spelled out, with pydantic's `WithJsonSchema`:
```python
region: Annotated[str | None, WithJsonSchema({"type": "string", "x-mcp-header": "Region"})] = None
```
## On the low-level `Server`
There you write `input_schema` by hand, so the key goes straight in:
```python title="server.py" hl_lines="18"
--8<-- "docs_src/header_parameters/tutorial002.py"
```
* Nothing checks the annotation for you: an invalid one is served, and `2026-07-28` clients leave the tool out of their listing.
### Schemas by name
To check the header, the SDK needs the tool's input schema before it dispatches the call. Without `get_tool_input_schema` it gets it by running your `on_list_tools` handler on every call that carries arguments, whether or not any tool is marked.
```python title="server.py" hl_lines="26 39-41 48"
--8<-- "docs_src/header_parameters/tutorial003.py"
```
* Pass the function to answer from what you already have.
* Return `None` for a tool with nothing to check.
## Recap
* `x-mcp-header` on a tool argument makes `2026-07-28` clients repeat it as an `Mcp-Param-*` HTTP header.
* The server rejects a call whose header and body disagree.
* Only `str`, `int` and `bool` arguments can be marked. `MCPServer` raises `InvalidSignature` for anything else.
* The low-level `Server` checks nothing, and clients drop a tool whose annotation is invalid.
* `get_tool_input_schema` keeps the low-level `Server` from running `on_list_tools` on every call.
The rest of the hand-written `Server` API is **[The low-level Server](low-level-server.md)**.
## /docs/advanced/index.md
# Advanced
Everything an ordinary server or client needs has a topical home in the sections above.
This section is the escape hatches you reach for when `MCPServer`'s convenience
layer is in the way:
* **[The low-level Server](low-level-server.md)**: the class `MCPServer` is built on.
Hand-written schemas, `on_*` handlers, nothing checked for you, and custom JSON-RPC
methods of your own.
* **[Pagination](pagination.md)** and **[Middleware](middleware.md)**: two things you
can *only* do on the low-level `Server`.
* **[Header parameters](header-parameters.md)**: let a gateway route a tool call on one
of its arguments.
* **[Extensions](extensions.md)** and **[MCP Apps](apps.md)**: the protocol's
extension surface. Compose extension packages into a server, or write your own.
A few things you might reasonably look for here live where you'd actually use them
instead:
* **Authorization** is under **[Running your server](../run/index.md)** because you
protect a server where you deploy it.
* **OAuth**, **identity assertion**, connecting to **multiple servers**, and the
response **cache** are all under **[Clients](../client/index.md)**.
* **Multi-round-trip requests** and **Subscriptions** are under
**[Inside your handler](../handlers/index.md)** because both are things a
handler *does*.
* **URI templates** is under **[Servers](../servers/index.md)**, next to Resources.
* **[Protocol versions](../protocol-versions.md)** and
**[Deprecated features](../deprecated.md)** each have their own top-level page.
If you're not sure whether you need this section, you don't.
## /docs/advanced/low-level-server.md
# The low-level Server
`@mcp.tool()` is a layer. Underneath it is a second server class, `Server`, that speaks raw MCP: you hand it the protocol objects and it puts them on the wire, unchanged.
`MCPServer` is built on top of it. You drop down when the convenience layer is in the way:
* You need to emit an **exact** schema (loaded from a file, generated from a database), not one derived from a Python signature.
* You need full control of the result: `_meta`, `is_error`, every key of `structured_content`.
* You need to handle a method MCP doesn't define.
For everything else, stay on `MCPServer`.
## The same tool, by hand
This is the `search_books` tool that **[Tools](../servers/tools.md)** writes in nine lines of `@mcp.tool()`, with the sugar removed:
```python title="server.py" hl_lines="22 26 32"
--8<-- "docs_src/lowlevel/tutorial001.py"
```
Three things changed, and they are the whole low-level API:
* **Handlers are constructor parameters.** `on_list_tools=` and `on_call_tool=` go into `Server(...)`. There are no decorators down here, and every handler has the same shape: `async (ctx, params) -> result`.
* **You write the input schema.** `Tool.input_schema` is a plain JSON Schema `dict`. Nobody derives it from type hints, because there are no type hints to derive it from.
* **You build the result.** `CallToolResult(content=[TextContent(...)])`, by hand. Nothing is wrapped, converted, or inferred from a return annotation.
`params` is the parsed request: `CallToolRequestParams` gives you `.name` and `.arguments`. `ctx` is a `ServerRequestContext`: `ctx.session` for talking back to the client, `ctx.lifespan_context`, `ctx.request_id`, and `ctx.meta`, the request's inbound `_meta`.
!!! info
If you've used FastAPI, you already know this relationship. `MCPServer` is the decorators-and-type-hints layer; `Server` is the Starlette underneath. They are not rivals: `MCPServer` constructs a `Server` and registers handlers exactly like these on it.
### Try it
`mcp dev` and `mcp run` only accept an `MCPServer`, so you serve this one yourself. The last line of `server.py` builds an ordinary ASGI app from it, and uvicorn runs that:
```console
uvicorn server:app --port 8000
```
Point the Inspector, or any client, at `http://localhost:8000/mcp`:
```python title="client.py"
import asyncio
from mcp import Client
async def main() -> None:
async with Client("http://localhost:8000/mcp") as client:
result = await client.call_tool("search_books", {"query": "dune", "limit": 5})
print(result.content)
asyncio.run(main())
```
```text
[TextContent(type='text', text="Found 3 books matching 'dune' (showing up to 5).", annotations=None, meta=None)]
```
The same text the `@mcp.tool()` version produced. Two honest differences:
* `result.structured_content` is `None`. The high-level server wraps a `-> str` into `{"result": ...}` for you; here nobody builds what you didn't build.
* `list_tools` returns the schema **you** typed, character for character. The high-level version had `"title": "Query"` on every property and a `"title": "search_booksArguments"` at the root: Pydantic artifacts. Down here, if it's on the wire, you put it there.
In a test you skip uvicorn and the port: `Client(server)` takes a low-level `Server` in-process exactly like it takes an `MCPServer`, and **[Testing](../get-started/testing.md)** is that pattern.
## Nothing is checked for you
`MCPServer` rejects a bad argument before your function ever runs, validating the call against the schema it generated (**[Tools](../servers/tools.md)**).
`Server` does not do that. Your `input_schema` is *advertised* to the client; it is never *applied* to `params.arguments`.
!!! check
Call `search_books` without `limit` and your `args["limit"]` raises `KeyError`. The client sees:
```text
MCPError: Internal server error
```
A JSON-RPC error, code `-32603`, with a deliberately generic message: the SDK won't leak your traceback to a remote caller. The model never finds out what it did wrong, so it can't retry. (In a test, `raise_exceptions=True` surfaces the real exception instead; see **[Testing](../get-started/testing.md)**.)
That generalises. An exception raised from a low-level handler is **always** a protocol error, never an `is_error=True` tool result. If you want the model to read the failure and recover, validate `params.arguments` yourself and return `CallToolResult(content=[TextContent(...)], is_error=True)`. The two kinds of failure are the subject of **[Handling errors](../servers/handling-errors.md)**.
## Two tools, one handler
`on_call_tool` is the single entry point for every tool on the server. You route on `params.name`:
```python title="server.py" hl_lines="38-43"
--8<-- "docs_src/lowlevel/tutorial002.py"
```
* `list_tools` advertises both. `call_tool` dispatches on the name.
* The `else` branch matters: `Server` will happily forward a `tools/call` for a name you never listed straight into your handler. Raising there turns the call into the same `-32603` as above.
## Structured output, by hand
Declare `output_schema` on the `Tool` and put `structured_content` on the result. Both are yours:
```python title="server.py" hl_lines="19-23 36"
--8<-- "docs_src/lowlevel/tutorial003.py"
```
Call it and the result carries both representations:
```json
{
"content": [{"type": "text", "text": "Found 3 books matching 'dune'."}],
"structuredContent": {"matches": 3, "query": "dune"},
"isError": false,
"resultType": "complete",
"_meta": {"io.modelcontextprotocol/serverInfo": {"name": "Bookshop", "version": "2.0.0"}}
}
```
The `_meta` block is the server's identity stamp: the SDK adds it to every 2026-era result, with the `version` from the constructor (a server that sets none reports an empty string). A server that must not identify itself can strip the key with a middleware, which owns the results it returns.
The server never compares the two fields. This SDK's `Client` does: return `structured_content` that doesn't satisfy the `output_schema` you declared and `call_tool` raises a `RuntimeError` that starts with `Invalid structured content returned by tool search_books` and goes on to quote the `jsonschema` failure. Promising a schema is cheap; keeping it is on you. The whole ladder of return types and schemas is in **[Structured Output](../servers/structured-output.md)**.
## The dialect is JSON Schema 2020-12
`input_schema` and `output_schema` are JSON Schema, and the [MCP specification](https://modelcontextprotocol.io/specification/latest/basic#json-schema-usage) fixes the dialect: a schema with no `$schema` key is **JSON Schema 2020-12**. The schemas `MCPServer` generates rely on that default (Pydantic writes 2020-12 and omits the key), and a hand-written dict is held to it too, so the full 2020-12 vocabulary is available:
```python title="server.py" hl_lines="8 14-15"
--8<-- "docs_src/lowlevel/tutorial007.py"
```
* The root of `input_schema` must be `"type": "object"`. Beside it, `oneOf`, `additionalProperties`, `anyOf`, `if`/`then`/`else`, `prefixItems`, `$defs` with local `$ref`s and the rest of the 2020-12 keywords reach the client exactly as written.
* No `$schema` key is needed. Add one only to opt into an older draft: this SDK's `Client`, which validates `structured_content` against a tool's `output_schema`, picks its validator from `$schema` and uses 2020-12 when there is none.
## `_meta`: for the application, not the model
`content` is the part of the answer the model reads. `structured_content` is the same answer as typed data. `_meta` is the third channel: data that rides along with the result for the **client application**, without being part of the answer at all.
Use it for record IDs, trace IDs, anything your UI needs and your prompt doesn't:
```python title="server.py" hl_lines="37"
--8<-- "docs_src/lowlevel/tutorial004.py"
```
* You construct it as `_meta=`, the wire name. The client reads it back as `result.meta`.
* Namespace your keys (`bookshop/record_ids`). The `io.modelcontextprotocol/*` keys are reserved by the protocol.
!!! warning
`_meta` is a convention between you and the client application, not a guarantee about what reaches
the model. The host decides what it renders. Never put a secret in any part of a tool result.
## Capabilities follow your handlers
A `Server` advertises exactly the method families you gave it handlers for. The `Bookshop` above passes `on_list_tools` and `on_call_tool` and nothing else, so a client connecting to it sees:
```json
{"tools": {"listChanged": false}}
```
No `resources`, no `prompts`: there is nothing to back them. Pass `on_list_prompts` and `prompts` appears; pass `on_completion` and `completions` appears.
`MCPServer` always advertises tools, resources and prompts, whether you registered any or not, because its managers always exist. Down here the declaration *is* the constructor call.
## The lifespan generic
`Server` is generic in the type its lifespan yields. Annotate it once and the object is typed everywhere it surfaces:
```python title="server.py" hl_lines="24-26 44-45 50"
--8<-- "docs_src/lowlevel/tutorial005.py"
```
* The lifespan is a `Callable[[Server[Catalog]], AbstractAsyncContextManager[Catalog]]`; `@asynccontextmanager` on an `async` generator gives you exactly that.
* Whatever it `yield`s becomes `ctx.lifespan_context`, and because the handlers are annotated `ServerRequestContext[Catalog]`, `.search(...)` autocompletes and type-checks.
* It is entered once when the server starts and exited once when it stops. Startup, teardown, and `MCPServer`'s version of the same idea are in **[Lifespan](../handlers/lifespan.md)**.
Without a `lifespan=`, `ctx.lifespan_context` is an empty `dict`.
## A method of your own
The constructor covers the methods MCP defines. `add_request_handler` covers everything else:
```python title="server.py" hl_lines="35-36 39-40 43-44 48"
--8<-- "docs_src/lowlevel/tutorial006.py"
```
* The first argument is the method string. Notifications have a twin, `add_notification_handler`. Its handlers fire on stdio and on handshake-era HTTP connections; on the `2026-07-28` streamable-HTTP path a client's notification POST is acknowledged `202` and not dispatched, because that revision defines no client-to-server notifications over HTTP.
* `params_type` is the model the incoming `params` are validated against **before** your handler runs, so custom methods *do* get the validation tools don't. Subclass `RequestParams` so the `_meta` field parses like every other method's.
* The handler returns a `BaseModel`, a `dict`, or `None`. The SDK serialises it into the JSON-RPC result.
One honest caveat: the high-level `Client` only has verbs for the methods MCP defines, so there is no `client.reindex()`. A vendor method is for a peer that already knows it exists: a client you also ship, or another service of yours speaking JSON-RPC.
One method you cannot claim:
```text
ValueError: 'initialize' is handled by the server runner and cannot be overridden;
use Server.middleware to observe or wrap initialization
```
The handshake belongs to the runner. `server/discover`, `ping`, and every other built-in are yours to replace.
!!! tip
`Server.middleware`, mentioned in that error, wraps **every** inbound message, including `initialize`. If what you want is to observe or rewrite traffic rather than answer a new method, start at **[Middleware](middleware.md)**.
## The other handlers
Each of these is one idea you now have the vocabulary for; each has its own page.
* `on_call_tool`, `on_get_prompt`, and `on_read_resource` may return an `InputRequiredResult` instead of their normal result to pause the call and ask the client for input; see **[Multi-round-trip requests](../handlers/multi-round-trip.md)**. True to this tier, nothing is installed for you: where `MCPServer` seals `requestState` by default, here the `request_state` you set crosses the wire exactly as written until you opt in with `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))`: one line (both names import from `mcp.server.request_state`) for the identical sealing and verification `MCPServer` performs (**[Protecting `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**).
* `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion` are the same `(ctx, params) -> result` shape for the other primitives.
* `on_subscriptions_listen` serves the 2026-07-28 `subscriptions/listen` stream. Pass a `ListenHandler` built over a `SubscriptionBus` and publish events to the bus from your other handlers; see **[Subscriptions](../handlers/subscriptions.md)** for the full composition.
* `get_tool_input_schema` keeps `on_list_tools` off the call path; see **[Header parameters](header-parameters.md#schemas-by-name)**.
* `server.streamable_http_app()` returns the same Starlette app `MCPServer`'s does; deploy it the way **[Running your server](../run/index.md)** deploys any other ASGI app. There is no `server.run(transport=...)` down here: `server.run(read_stream, write_stream, server.create_initialization_options())` drives one connection over a pair of streams, and that one line is the whole story.
## Recap
* The low-level `Server` takes its handlers as `on_*` **constructor parameters**; every handler is `async (ctx, params) -> result`.
* You write the `input_schema` dict and you build the `CallToolResult`. Nothing is derived, wrapped, or validated for you.
* An exception in a handler is a `-32603` protocol error. A tool error the model can read is a `CallToolResult` with `is_error=True` that **you** return.
* `_meta` on the result is addressed to the client application, not the model.
* `Server[T]` is generic in what its lifespan yields; `ctx.lifespan_context` is a typed `T`.
* `add_request_handler(method, params_type, handler)` serves any method. `initialize` is reserved.
* The capabilities a `Server` advertises are derived from which handlers you registered.
The client treated both servers identically because they *are* the same protocol, which is the whole point. The next layer down isn't a class at all: it's **[Middleware](middleware.md)**.
## /docs/advanced/pagination.md
# Pagination
Most servers never need this.
`MCPServer` answers every `list_*` request with everything it has, in one page, `next_cursor=None`. For a few dozen tools, resources or prompts that is the right answer and there is nothing to configure.
Pagination is for the server whose resource list is really a database: thousands of rows it refuses to serialize in one response. The protocol's answer is a **cursor**: the server returns a page plus an opaque token, and the client sends that token back to get the next page.
`@mcp.resource()` has no hook for any of that. To page, you write the list handler yourself, on the **[low-level Server](low-level-server.md)**.
## A server that pages
```python title="server.py" hl_lines="12 15-16"
--8<-- "docs_src/pagination/tutorial001.py"
```
* On a low-level `Server`, handlers are constructor arguments, not decorators. `on_list_resources` answers every `resources/list` request; that's the whole hookup.
* Every paged handler is typed `params: PaginatedRequestParams | None`, and the example accepts both. Over a connection, though, the SDK never hands you `None` (a request with no `params` member reaches the handler as the model with its defaults), so the signal that matters is `params.cursor is None`: **start from the top**.
* You decide what a cursor *is*. Here it's an offset rendered as a string. A timestamp, a primary key, a base64 blob: anything you can mint on the way out and recognise on the way back in.
* `next_cursor=None` is how you say "that was the last page". There is no count, no total, no `has_more`. `None` is the entire signal.
!!! tip
A `PAGE_SIZE` of 10 makes the example readable. Pick yours per endpoint: a list of
one-line resources can afford a page of 500; a list of fat prompt templates cannot.
The client has no say in it, and that is by design.
### Try it
`mcp run` only accepts an `MCPServer`, so you serve this one yourself. The last line of `server.py` builds an ordinary ASGI app from the `Server`, and uvicorn runs that:
```console
uvicorn server:app --port 8000
```
Point any client (**[The Client](../client/index.md)**, or the Inspector) at `http://localhost:8000/mcp` and call `list_resources()` with no arguments. You get ten resources, `book-1` through `book-10`, and `next_cursor` is the string `"10"`.
Hand it back with `list_resources(cursor="10")` and the first resource is `book-11`, the new `next_cursor` is `"20"`.
The tenth page comes back with `next_cursor` set to `None`. Done.
## The client loop
Every `list_*` method on `Client` (`list_tools`, `list_resources`, `list_resource_templates`, `list_prompts`) takes a `cursor=` keyword. Draining a paged list is one `while True`:
```python title="client.py" hl_lines="9-15"
--8<-- "docs_src/pagination/tutorial002.py"
```
* `cursor` starts as `None`, so the first request carries no cursor.
* Extend **before** you look at `next_cursor`: the last page has resources too.
* `next_cursor is None` is the exit. Anything else goes straight back into `cursor=`, untouched.
With uvicorn still serving `server.py`, run `python client.py` in a second terminal. It prints `100 resources`: ten pages of ten, stitched together by a loop that never knew there were ten pages.
This is the same loop **[The Client](../client/index.md)** shows for every `list_*` verb, and it costs nothing against a server that doesn't page: `next_cursor` is `None` on the first response and the loop runs once.
## The three rules
**Cursors are opaque.** A client must never parse, build, or guess one. The only legal source of a cursor is the previous page's `next_cursor`, verbatim.
**The server picks the page size.** There is no `limit=` in the protocol. If you need a different page size, you change the server.
**A client that ignores paging still works.** It calls `list_resources()` once, gets the first ten, and never notices the `next_cursor` it threw away. Nothing breaks; it sees less.
!!! check
Opaque means opaque. Invent a cursor (`list_resources(cursor="page-2")`) and there is
nothing the protocol can do for you. This server tries `int("page-2")`, the handler raises,
and what comes back to the client is:
```text
MCPError(-32603, 'Internal server error', None)
```
A cursor you didn't get from the server is a bug, not a feature request.
## Recap
* `MCPServer` returns everything in one page. Pagination is opt-in, and you opt in on the low-level `Server`.
* `on_list_resources` (and `on_list_tools`, `on_list_prompts`, `on_list_resource_templates`) receives `PaginatedRequestParams | None`; `params.cursor` is `None` for the first page.
* You return a page plus `next_cursor`: any string you'll recognise later, or `None` when there is nothing left.
* The client loop: pass `cursor=`, accumulate, repeat until `next_cursor is None`.
* Cursors are opaque, the server owns the page size, and a non-paging client still gets page one.
The rest of the hand-written `Server` API (`on_call_tool`, `input_schema` dicts, `_meta`) is **[The low-level Server](low-level-server.md)**.
## /docs/favicon.svg
```svg path="/docs/favicon.svg"
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="180" height="180" rx="24" fill="black"/>
<mask id="mask0_246_1229" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="7" y="7" width="166" height="166">
<path d="M173 7H7V173H173V7Z" fill="white"/>
</mask>
<g mask="url(#mask0_246_1229)">
<path d="M23.5996 85.2532L86.2021 22.6507C94.8457 14.0071 108.86 14.0071 117.503 22.6507C126.147 31.2942 126.147 45.3083 117.503 53.9519L70.2254 101.23" stroke="white" stroke-width="11.0667" stroke-linecap="round"/>
<path d="M70.8789 100.578L117.504 53.952C126.148 45.3083 140.163 45.3083 148.806 53.952L149.132 54.278C157.776 62.9216 157.776 76.9357 149.132 85.5792L92.5139 142.198C89.6327 145.079 89.6327 149.75 92.5139 152.631L104.14 164.257" stroke="white" stroke-width="11.0667" stroke-linecap="round"/>
<path d="M101.853 38.3013L55.553 84.6011C46.9094 93.2447 46.9094 107.258 55.553 115.902C64.1966 124.546 78.2106 124.546 86.8543 115.902L133.154 69.6025" stroke="white" stroke-width="11.0667" stroke-linecap="round"/>
</g>
</svg>
```
## /docs_src/__init__.py
```py path="/docs_src/__init__.py"
"""Complete, runnable source for every code example in `docs/`.
Each `docs/<page>.md` includes its examples from `docs_src/<chapter>/tutorialNNN.py`
via `--8<--`, and `tests/docs_src/test_<chapter>.py` imports the same module and
exercises it through the in-memory `mcp.Client`. The file you read in the docs is
the file CI runs.
"""
```
## /docs_src/apps/__init__.py
```py path="/docs_src/apps/__init__.py"
```
## /docs_src/apps/report.html
```html path="/docs_src/apps/report.html"
<!doctype html>
<title>Report</title>
<p>Quarterly numbers render here.</p>
```
The content has been capped at 50000 tokens. The user could consider applying other filters to refine the result. The better and more specific the context, the better the LLM can follow instructions. If the context seems verbose, the user can refine the filter using uithub. Thank you for using https://uithub.com - Perfect LLM context for any GitHub repo.