Compare commits

...
Author SHA1 Message Date
Max
8c2fa6eaba
[v1.x] Add AuthSettings.validate_token_resource to check a bearer token's resource (#3451)
Some checks failed
Main branch checks / checks (push) Has been cancelled
2026-09-05 03:52:13 +01:00
Max
2106335745
[v1.x] Follow redirects only within the MCP endpoint's origin (#3448) 2026-09-05 02:35:49 +01:00
Max
3eed7ce0a6
[v1.x] Validate the authorization server metadata issuer on every discovery path (#3431) 2026-09-04 18:40:26 +01:00
Max
92120b4b0f
[v1.x] Expire idle Streamable HTTP sessions by default and cap concurrent sessions (#3426) 2026-09-02 16:06:38 +01:00
Max
5ebdfed406
[v1.x] Resolve tool output-schema references within the schema document only (#3396) 2026-08-26 15:11:38 +01:00
Max
b22271305c
[v1.x] Give recursive tool return types an object-rooted output schema (#3377)
Some checks failed
Main branch checks / checks (push) Has been cancelled
2026-08-24 19:12:08 +01:00
Max
4dc224fab2
[v1.x] Apply the request body limit to the SSE and OAuth endpoints (#3344) 2026-08-21 17:02:59 +01:00
Max
ae3338f6b3
[v1.x] Complete the FastMCP Settings model at import time (#3352) 2026-08-21 12:02:46 +01:00
Max
98b7159cb8
[v1.x] Move the v1.x docs to /v1/ and mark v1.x as the maintenance line (#3177)
Some checks failed
Main branch checks / checks (push) Has been cancelled
2026-07-28 13:57:32 +01:00
Max
c0c5a9d642
[v1.x] ci: pick the docs toolchain per worktree in build-docs.sh (#3082) 2026-07-25 20:37:13 +01:00
Max
e8283746d0
[v1.x] fix: reject trailing newline in tool-name validation (#3086)
Co-authored-by: otiscuilei <otiscui@icloud.com>
2026-07-17 14:19:59 +01:00
Marcelo Trylesinski
5f0b6af9f2
[v1.x] Add Streamable HTTP request body limits (#3101) 2026-07-16 09:54:29 +01:00
Max
ba33472033
[v1.x] docs: pin mkdocs<2 (#3074) 2026-07-07 13:51:59 +01:00
Max
0e3a604636
[v1.x] docs: publish llms.txt and markdown renditions of the docs (#3029) 2026-06-30 11:30:59 +01:00
Max
9678a3b347
[v1.x] Route Context.report_progress() to the originating request stream (#2994) 2026-06-26 15:43:15 +02:00
Max
777b8d0671
[v1.x] Support TransportSecuritySettings in the WebSocket server transport (#2992)
Some checks failed
Main branch checks / checks (push) Has been cancelled
2026-06-26 13:31:33 +02:00
Max
47204674fb
[v1.x] Set Development Status classifier to Production/Stable (#2976) 2026-06-25 23:48:24 +02:00
Max
6df3d73426
[v1.x] Buffer per-request StreamableHTTP streams; store priming event before dispatch (#2948) 2026-06-23 14:57:45 +01:00
Max
32d32908fe
[v1.x] Pass a list to parametrize in test_docs_examples (pytest 9.1.0 compat) (#2889)
Some checks failed
Main branch checks / checks (push) Has been cancelled
2026-06-16 21:29:51 +00:00
Max
0dca751056
[v1.x] Deflake the child process cleanup tests (#2839) 2026-06-11 16:48:13 +00:00
Max
52258a9564
[v1.x] Add a v2 status banner to the README (#2835) 2026-06-11 09:39:38 +01:00
Max
b8f491724c
[v1.x] Deprecate the WebSocket transport and the experimental tasks entry points (#2828) 2026-06-11 09:20:19 +01:00
Sheldon
2309e5ef97
fix: omit null optional fields from task result payloads (#2809)
Co-authored-by: Sheldon <neooosky@gmail.com>
2026-06-10 10:37:52 +01:00
Max
494eb11d36
[v1.x] Support Python 3.14 (#2769) 2026-06-03 11:27:55 +01:00
Max
62137874ff
[v1.x] Scope experimental tasks to the session that created them (#2720)
Some checks failed
Main branch checks / checks (push) Has been cancelled
2026-05-29 18:05:58 +01:00
Max
ce267b6fc5
[v1.x] Bind transport sessions to the authenticated principal (#2719) 2026-05-29 16:46:37 +00:00
Max
1abcca2408
[v1.x] Add subject and claims to AccessToken (#2690) 2026-05-26 15:49:44 +01:00
Max
9773a3f75e
[v1.x] ci: deploy docs to py.sdk.modelcontextprotocol.io via Pages artifact (#2635) 2026-05-18 14:29:15 +00:00
Max
77431ebe7d
[v1.x] refactor: import SSEError from httpx_sse public API (#2561)
Some checks failed
Main branch checks / checks (push) Has been cancelled
2026-05-08 12:42:45 -04:00
Max
2034cae340
[v1.x] build: restrict httpx to <1.0.0 (#2559) 2026-05-08 13:18:01 +00:00
Felix Weinberger
73d458baac
[v1.x] fix(auth): coerce empty-string optional URL fields to None in OAuthClientMetadata (#2405) 2026-04-13 18:53:58 +01:00
Max
8d4c2f5834
[v1.x] fix: catch PydanticUserError when generating output schema (pydantic 2.13 compat) (#2435) 2026-04-13 17:09:19 +01:00
Owen Devereaux
6524782667
[v1.x] fix: handle ClosedResourceError when transport closes mid-request (#2334)
Some checks failed
Main branch checks / checks (push) Has been cancelled
Co-authored-by: Owen Devereaux <owendevereaux@users.noreply.github.com>
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
2026-03-24 22:24:47 +00:00
Max Isbey
2e9897e2b9
[v1.x] fix: handle non-UTF-8 bytes in stdio server stdin (#2303) 2026-03-17 18:40:43 +00:00
BabyChrist666
f8d98b63a7
Backport: Add missing TasksCallCapability to v1.x (#2137)
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-02 12:56:24 +00:00
Felix Weinberger
c68e254bad
docs: add server-side tool error handling documentation (#2129) 2026-02-24 11:31:00 +00:00
Felix Weinberger
1ef124e4a8
docs: add snippet verification for docs/ pages (#2115) 2026-02-23 15:38:15 +00:00
Felix Weinberger
cfbbd7d71a
docs: fix GitHub links to point to v1.x branch (#2102) 2026-02-23 15:26:21 +00:00
Felix Weinberger
1f9fb348f3
docs: fix stub pages and improve docs structure (#2101)
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
2026-02-23 15:18:24 +00:00
Felix Weinberger
c86477c7b7
docs: comprehensive feature documentation for SEP-1730 Tier 1 (#2090)
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
2026-02-23 14:57:16 +00:00
Felix Weinberger
a77462b64d
docs: restructure README into docs/ pages (#2091)
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
2026-02-23 14:17:12 +00:00
Felix Weinberger
b1adfcd885
Add VERSIONING.md, ROADMAP.md, and DEPENDENCY_POLICY.md (#2084) 2026-02-19 17:23:58 +00:00
Max Isbey
66aaf93bcd
[v1.x] fix: prevent command injection in example URL opening (#2085) 2026-02-18 19:04:18 +00:00
Felix Weinberger
23a615783e
feat: add idle timeout for StreamableHTTP sessions (#1994) 2026-02-18 10:34:18 +00:00
Felix Weinberger
67458948ae
fix: add RFC 8707 resource validation to OAuth client (#2069) 2026-02-17 14:35:42 +00:00
Felix Weinberger
78ddff022b
ci: backport conformance tests from main to v1.x (#2068) 2026-02-16 19:27:57 +00:00
Max Isbey
bac2789e09
fix: remove unused requests dependency from simple-chatbot example (#1959) 2026-01-26 14:03:06 +00:00
Luca Chang
3d9d34552a
[v1.x] fix: return HTTP 404 for unknown session IDs instead of 400 (#1945)
Some checks failed
Main branch checks / checks (push) Has been cancelled
Co-authored-by: Maxime <67350340+max-rousseau@users.noreply.github.com>
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
2026-01-24 19:09:14 +00:00
Max Isbey
d891525958
Backport: Support for Resource and ResourceTemplate metadata (#1928)
Co-authored-by: Jacem Elwaar <jacem@mcpappsbuilders.com>
2026-01-22 14:02:25 +00:00
Max Isbey
d3787c96b4
ci: add all-green job to pull-request-checks workflow (#1929) 2026-01-22 13:56:22 +00:00
151 changed files with 11494 additions and 3435 deletions

367
.github/actions/conformance/client.py vendored Normal file
View file

@ -0,0 +1,367 @@
"""MCP unified conformance test client.
This client is designed to work with the @modelcontextprotocol/conformance npm package.
It handles all conformance test scenarios via environment variables and CLI arguments.
Contract:
- MCP_CONFORMANCE_SCENARIO env var -> scenario name
- MCP_CONFORMANCE_CONTEXT env var -> optional JSON (for client-credentials scenarios)
- Server URL as last CLI argument (sys.argv[1])
- Must exit 0 within 30 seconds
Scenarios:
initialize - Connect, initialize, list tools, close
tools_call - Connect, call add_numbers(a=5, b=3), close
sse-retry - Connect, call test_reconnection, close
elicitation-sep1034-client-defaults - Elicitation with default accept callback
auth/client-credentials-jwt - Client credentials with private_key_jwt
auth/client-credentials-basic - Client credentials with client_secret_basic
auth/* - Authorization code flow (default for auth scenarios)
"""
import asyncio
import json
import logging
import os
import sys
from collections.abc import Callable, Coroutine
from typing import Any, cast
from urllib.parse import parse_qs, urlparse
import httpx
from pydantic import AnyUrl
from mcp import ClientSession, types
from mcp.client.auth import OAuthClientProvider, TokenStorage
from mcp.client.auth.extensions.client_credentials import (
ClientCredentialsOAuthProvider,
PrivateKeyJWTOAuthProvider,
SignedJWTParameters,
)
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
from mcp.shared.context import RequestContext
# Set up logging to stderr (stdout is for conformance test output)
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
stream=sys.stderr,
)
logger = logging.getLogger(__name__)
# Type for async scenario handler functions
ScenarioHandler = Callable[[str], Coroutine[Any, None, None]]
# Registry of scenario handlers
HANDLERS: dict[str, ScenarioHandler] = {}
def register(name: str) -> Callable[[ScenarioHandler], ScenarioHandler]:
"""Register a scenario handler."""
def decorator(fn: ScenarioHandler) -> ScenarioHandler:
HANDLERS[name] = fn
return fn
return decorator
def get_conformance_context() -> dict[str, Any]:
"""Load conformance test context from MCP_CONFORMANCE_CONTEXT environment variable."""
context_json = os.environ.get("MCP_CONFORMANCE_CONTEXT")
if not context_json:
raise RuntimeError(
"MCP_CONFORMANCE_CONTEXT environment variable not set. "
"Expected JSON with client_id, client_secret, and/or private_key_pem."
)
try:
return json.loads(context_json)
except json.JSONDecodeError as e:
raise RuntimeError(f"Failed to parse MCP_CONFORMANCE_CONTEXT as JSON: {e}") from e
class InMemoryTokenStorage(TokenStorage):
"""Simple in-memory token storage for conformance testing."""
def __init__(self) -> None:
self._tokens: OAuthToken | None = None
self._client_info: OAuthClientInformationFull | None = None
async def get_tokens(self) -> OAuthToken | None:
return self._tokens
async def set_tokens(self, tokens: OAuthToken) -> None:
self._tokens = tokens
async def get_client_info(self) -> OAuthClientInformationFull | None:
return self._client_info
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
self._client_info = client_info
class ConformanceOAuthCallbackHandler:
"""OAuth callback handler that automatically fetches the authorization URL
and extracts the auth code, without requiring user interaction.
"""
def __init__(self) -> None:
self._auth_code: str | None = None
self._state: str | None = None
async def handle_redirect(self, authorization_url: str) -> None:
"""Fetch the authorization URL and extract the auth code from the redirect."""
logger.debug(f"Fetching authorization URL: {authorization_url}")
async with httpx.AsyncClient() as client:
response = await client.get(
authorization_url,
follow_redirects=False,
)
if response.status_code in (301, 302, 303, 307, 308):
location = cast(str, response.headers.get("location"))
if location:
redirect_url = urlparse(location)
query_params: dict[str, list[str]] = parse_qs(redirect_url.query)
if "code" in query_params:
self._auth_code = query_params["code"][0]
state_values = query_params.get("state")
self._state = state_values[0] if state_values else None
logger.debug(f"Got auth code from redirect: {self._auth_code[:10]}...")
return
else:
raise RuntimeError(f"No auth code in redirect URL: {location}")
else:
raise RuntimeError(f"No redirect location received from {authorization_url}")
else:
raise RuntimeError(f"Expected redirect response, got {response.status_code} from {authorization_url}")
async def handle_callback(self) -> tuple[str, str | None]:
"""Return the captured auth code and state."""
if self._auth_code is None:
raise RuntimeError("No authorization code available - was handle_redirect called?")
auth_code = self._auth_code
state = self._state
self._auth_code = None
self._state = None
return auth_code, state
# --- Scenario Handlers ---
@register("initialize")
async def run_initialize(server_url: str) -> None:
"""Connect, initialize, list tools, close."""
async with streamable_http_client(url=server_url) as (read_stream, write_stream, _):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
logger.debug("Initialized successfully")
await session.list_tools()
logger.debug("Listed tools successfully")
@register("tools_call")
async def run_tools_call(server_url: str) -> None:
"""Connect, initialize, list tools, call add_numbers(a=5, b=3), close."""
async with streamable_http_client(url=server_url) as (read_stream, write_stream, _):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
await session.list_tools()
result = await session.call_tool("add_numbers", {"a": 5, "b": 3})
logger.debug(f"add_numbers result: {result}")
@register("sse-retry")
async def run_sse_retry(server_url: str) -> None:
"""Connect, initialize, list tools, call test_reconnection, close."""
async with streamable_http_client(url=server_url) as (read_stream, write_stream, _):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
await session.list_tools()
result = await session.call_tool("test_reconnection", {})
logger.debug(f"test_reconnection result: {result}")
async def default_elicitation_callback(
context: RequestContext["ClientSession", Any],
params: types.ElicitRequestParams,
) -> types.ElicitResult | types.ErrorData:
"""Accept elicitation and apply defaults from the schema (SEP-1034)."""
content: dict[str, str | int | float | bool | list[str] | None] = {}
# For form mode, extract defaults from the requested_schema
if isinstance(params, types.ElicitRequestFormParams):
schema = params.requestedSchema
logger.debug(f"Elicitation schema: {schema}")
properties = schema.get("properties", {})
for prop_name, prop_schema in properties.items():
if "default" in prop_schema:
content[prop_name] = prop_schema["default"]
logger.debug(f"Applied defaults: {content}")
return types.ElicitResult(action="accept", content=content)
@register("elicitation-sep1034-client-defaults")
async def run_elicitation_defaults(server_url: str) -> None:
"""Connect with elicitation callback that applies schema defaults."""
async with streamable_http_client(url=server_url) as (read_stream, write_stream, _):
async with ClientSession(
read_stream, write_stream, elicitation_callback=default_elicitation_callback
) as session:
await session.initialize()
await session.list_tools()
result = await session.call_tool("test_client_elicitation_defaults", {})
logger.debug(f"test_client_elicitation_defaults result: {result}")
@register("auth/client-credentials-jwt")
async def run_client_credentials_jwt(server_url: str) -> None:
"""Client credentials flow with private_key_jwt authentication."""
context = get_conformance_context()
client_id = context.get("client_id")
private_key_pem = context.get("private_key_pem")
signing_algorithm = context.get("signing_algorithm", "ES256")
if not client_id:
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_id'")
if not private_key_pem:
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'private_key_pem'")
jwt_params = SignedJWTParameters(
issuer=client_id,
subject=client_id,
signing_algorithm=signing_algorithm,
signing_key=private_key_pem,
)
oauth_auth = PrivateKeyJWTOAuthProvider(
server_url=server_url,
storage=InMemoryTokenStorage(),
client_id=client_id,
assertion_provider=jwt_params.create_assertion_provider(),
)
await _run_auth_session(server_url, oauth_auth)
@register("auth/client-credentials-basic")
async def run_client_credentials_basic(server_url: str) -> None:
"""Client credentials flow with client_secret_basic authentication."""
context = get_conformance_context()
client_id = context.get("client_id")
client_secret = context.get("client_secret")
if not client_id:
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_id'")
if not client_secret:
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_secret'")
oauth_auth = ClientCredentialsOAuthProvider(
server_url=server_url,
storage=InMemoryTokenStorage(),
client_id=client_id,
client_secret=client_secret,
token_endpoint_auth_method="client_secret_basic",
)
await _run_auth_session(server_url, oauth_auth)
async def run_auth_code_client(server_url: str) -> None:
"""Authorization code flow (default for auth/* scenarios)."""
callback_handler = ConformanceOAuthCallbackHandler()
storage = InMemoryTokenStorage()
# Check for pre-registered client credentials from context
context_json = os.environ.get("MCP_CONFORMANCE_CONTEXT")
if context_json:
try:
context = json.loads(context_json)
client_id = context.get("client_id")
client_secret = context.get("client_secret")
if client_id:
await storage.set_client_info(
OAuthClientInformationFull(
client_id=client_id,
client_secret=client_secret,
redirect_uris=[AnyUrl("http://localhost:3000/callback")],
token_endpoint_auth_method="client_secret_basic" if client_secret else "none",
)
)
logger.debug(f"Pre-loaded client credentials: client_id={client_id}")
except json.JSONDecodeError:
logger.exception("Failed to parse MCP_CONFORMANCE_CONTEXT")
oauth_auth = OAuthClientProvider(
server_url=server_url,
client_metadata=OAuthClientMetadata(
client_name="conformance-client",
redirect_uris=[AnyUrl("http://localhost:3000/callback")],
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
),
storage=storage,
redirect_handler=callback_handler.handle_redirect,
callback_handler=callback_handler.handle_callback,
client_metadata_url="https://conformance-test.local/client-metadata.json",
)
await _run_auth_session(server_url, oauth_auth)
async def _run_auth_session(server_url: str, oauth_auth: OAuthClientProvider) -> None:
"""Common session logic for all OAuth flows."""
client = httpx.AsyncClient(auth=oauth_auth, timeout=30.0)
async with streamable_http_client(url=server_url, http_client=client) as (read_stream, write_stream, _):
async with ClientSession(
read_stream, write_stream, elicitation_callback=default_elicitation_callback
) as session:
await session.initialize()
logger.debug("Initialized successfully")
tools_result = await session.list_tools()
logger.debug(f"Listed tools: {[t.name for t in tools_result.tools]}")
# Call the first available tool (different tests have different tools)
if tools_result.tools:
tool_name = tools_result.tools[0].name
try:
result = await session.call_tool(tool_name, {})
logger.debug(f"Called {tool_name}, result: {result}")
except Exception as e:
logger.debug(f"Tool call result/error: {e}")
logger.debug("Connection closed successfully")
def main() -> None:
"""Main entry point for the conformance client."""
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <server-url>", file=sys.stderr)
sys.exit(1)
server_url = sys.argv[1]
scenario = os.environ.get("MCP_CONFORMANCE_SCENARIO")
if scenario:
logger.debug(f"Running explicit scenario '{scenario}' against {server_url}")
handler = HANDLERS.get(scenario)
if handler:
asyncio.run(handler(server_url))
elif scenario.startswith("auth/"):
asyncio.run(run_auth_code_client(server_url))
else:
print(f"Unknown scenario: {scenario}", file=sys.stderr)
sys.exit(1)
else:
logger.debug(f"Running default auth flow against {server_url}")
asyncio.run(run_auth_code_client(server_url))
if __name__ == "__main__":
main()

View file

@ -0,0 +1,11 @@
# Known conformance test failures for v1.x
# These are tracked and should be removed as they're fixed.
server: []
client:
# The pinned harness (0.1.13) serves authorization server metadata whose `issuer`
# omits the tenant path its resource metadata advertises (`/tenant1`), so a client
# that checks RFC 8414 section 3.3 refuses it. The mock includes the path from
# conformance 0.1.15 (modelcontextprotocol/conformance#152); drop these two entries
# when the pin moves past it.
- auth/metadata-var2
- auth/metadata-var3

30
.github/actions/conformance/run-server.sh vendored Executable file
View file

@ -0,0 +1,30 @@
#!/bin/bash
set -e
PORT="${PORT:-3001}"
SERVER_URL="http://localhost:${PORT}/mcp"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR/../../.."
# Start everything-server
uv run --frozen mcp-everything-server --port "$PORT" &
SERVER_PID=$!
trap "kill $SERVER_PID 2>/dev/null || true; wait $SERVER_PID 2>/dev/null || true" EXIT
# Wait for server to be ready
MAX_RETRIES=30
RETRY_COUNT=0
while ! curl -s "$SERVER_URL" > /dev/null 2>&1; do
RETRY_COUNT=$((RETRY_COUNT + 1))
if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then
echo "Server failed to start after ${MAX_RETRIES} retries" >&2
exit 1
fi
sleep 0.5
done
echo "Server ready at $SERVER_URL"
# Run conformance tests
npx @modelcontextprotocol/conformance@0.1.13 server --url "$SERVER_URL" "$@"

10
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: monthly
groups:
github-actions:
patterns:
- "*"

44
.github/workflows/conformance.yml vendored Normal file
View file

@ -0,0 +1,44 @@
name: Conformance Tests
on:
push:
branches: [v1.x]
pull_request:
branches: [v1.x]
workflow_dispatch:
concurrency:
group: conformance-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
server-conformance:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1
with:
enable-cache: true
version: 0.9.5
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 24
- run: uv sync --frozen --all-extras --package mcp-everything-server
- run: ./.github/actions/conformance/run-server.sh
client-conformance:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1
with:
enable-cache: true
version: 0.9.5
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 24
- run: uv sync --frozen --all-extras --package mcp
- run: npx @modelcontextprotocol/conformance@0.1.13 client --command 'uv run --frozen python .github/actions/conformance/client.py' --suite all --expected-failures .github/actions/conformance/expected-failures.yml

View file

@ -1,33 +0,0 @@
name: Publish Docs manually
on:
workflow_dispatch:
jobs:
docs-publish:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Configure Git Credentials
run: |
git config user.name github-actions[bot]
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
enable-cache: true
version: 0.9.5
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
- uses: actions/cache@v4
with:
key: mkdocs-material-${{ env.cache_id }}
path: .cache
restore-keys: |
mkdocs-material-
- run: uv sync --frozen --group docs
- run: uv run --frozen --no-sync mkdocs gh-deploy --force

View file

@ -51,32 +51,3 @@ jobs:
- name: Publish package distributions to PyPI - name: Publish package distributions to PyPI
uses: pypa/gh-action-pypi-publish@release/v1 uses: pypa/gh-action-pypi-publish@release/v1
docs-publish:
runs-on: ubuntu-latest
needs: ["pypi-publish"]
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Configure Git Credentials
run: |
git config user.name github-actions[bot]
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
enable-cache: true
version: 0.9.5
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
- uses: actions/cache@v4
with:
key: mkdocs-material-${{ env.cache_id }}
path: .cache
restore-keys: |
mkdocs-material-
- run: uv sync --frozen --group docs
- run: uv run --frozen --no-sync mkdocs gh-deploy --force

View file

@ -6,3 +6,12 @@ on:
jobs: jobs:
checks: checks:
uses: ./.github/workflows/shared.yml uses: ./.github/workflows/shared.yml
all-green:
if: always()
needs: [checks]
runs-on: ubuntu-latest
steps:
- uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2
with:
jobs: ${{ toJSON(needs) }}

View file

@ -35,7 +35,7 @@ jobs:
continue-on-error: true continue-on-error: true
strategy: strategy:
matrix: matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"] python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
dep-resolution: dep-resolution:
- name: lowest-direct - name: lowest-direct
install-flags: "--upgrade --resolution lowest-direct" install-flags: "--upgrade --resolution lowest-direct"
@ -61,7 +61,7 @@ jobs:
uv run --frozen --no-sync coverage combine uv run --frozen --no-sync coverage combine
uv run --frozen --no-sync coverage report uv run --frozen --no-sync coverage report
readme-snippets: doc-snippets:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
@ -74,5 +74,22 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: uv sync --frozen --all-extras --python 3.10 run: uv sync --frozen --all-extras --python 3.10
- name: Check README snippets are up to date - name: Check doc snippets are up to date
run: uv run --frozen scripts/update_readme_snippets.py --check run: uv run --frozen scripts/update_doc_snippets.py --check
# The published site is built and deployed from main, which builds this
# branch's docs (via scripts/docs/build.sh) under /v1/; this branch has no
# deploy workflow of its own. This job runs that same script so a change
# that breaks the v1 docs fails here rather than at main's next deploy.
docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: astral-sh/setup-uv@v7
with:
enable-cache: true
version: 0.9.5
- name: Build docs
run: bash scripts/docs/build.sh

View file

@ -0,0 +1,40 @@
name: Weekly Lockfile Update
on:
workflow_dispatch:
schedule:
# Every Thursday at 8:00 UTC
- cron: "0 8 * * 4"
permissions:
contents: write
pull-requests: write
jobs:
update-lockfile:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1
with:
version: 0.9.5
- name: Update lockfile
run: |
echo '## Updated Dependencies' > pr_body.md
echo '' >> pr_body.md
echo '```' >> pr_body.md
uv lock --upgrade 2>&1 | tee -a pr_body.md
echo '```' >> pr_body.md
- name: Create pull request
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v7
with:
commit-message: "chore: update uv.lock with latest dependencies"
title: "chore: weekly dependency update"
body-path: pr_body.md
branch: weekly-lockfile-update-v1x
delete-branch: true
add-paths: uv.lock
labels: dependencies

1
.gitignore vendored
View file

@ -143,6 +143,7 @@ venv.bak/
# mkdocs documentation # mkdocs documentation
/site /site
/.worktrees/
# mypy # mypy
.mypy_cache/ .mypy_cache/

View file

@ -55,9 +55,9 @@ repos:
language: system language: system
files: ^(pyproject\.toml|uv\.lock)$ files: ^(pyproject\.toml|uv\.lock)$
pass_filenames: false pass_filenames: false
- id: readme-snippets - id: doc-snippets
name: Check README snippets are up to date name: Check doc snippets are up to date
entry: uv run --frozen python scripts/update_readme_snippets.py --check entry: uv run --frozen python scripts/update_doc_snippets.py --check
language: system language: system
files: ^(README\.md|examples/.*\.py|scripts/update_readme_snippets\.py)$ files: ^(README\.md|docs/.*\.md|examples/.*\.py|scripts/update_doc_snippets\.py)$
pass_filenames: false pass_filenames: false

View file

@ -24,6 +24,11 @@ This document contains critical information about working with this codebase. Fo
- Coverage: test edge cases and errors - Coverage: test edge cases and errors
- New features require tests - New features require tests
- Bug fixes require regression tests - Bug fixes require regression tests
- Avoid `anyio.sleep()` with a fixed duration to wait for async operations. Instead:
- Use `anyio.Event` — set it in the callback/handler, `await event.wait()` in the test
- For stream messages, use `await stream.receive()` instead of `sleep()` + `receive_nowait()`
- Exception: `sleep()` is appropriate when testing time-based features (e.g., timeouts)
- Wrap indefinite waits (`event.wait()`, `stream.receive()`) in `anyio.fail_after(5)` to prevent hangs
- For commits fixing bugs or adding features based on user reports add: - For commits fixing bugs or adding features based on user reports add:

View file

@ -64,6 +64,28 @@ pre-commit run --all-files
9. Submit a pull request to the same branch you branched from 9. Submit a pull request to the same branch you branched from
## Dependency Update Policy
See [DEPENDENCY_POLICY.md](DEPENDENCY_POLICY.md) for the full dependency update policy.
When bumping a dependency version manually, update the constraint in `pyproject.toml` then run `uv lock --resolution lowest-direct` (see [RELEASE.md](RELEASE.md)).
Security-relevant dependency updates (P0) are applied within 7 days of public disclosure and backported to active release branches.
The SDK currently supports Python 3.10 through 3.14. New CPython releases are supported within one minor SDK release of their stable release date.
## Triage Process
New issues are triaged by a maintainer within 2 business days. Triage means adding an appropriate label and determining whether the issue is valid.
Issues are labeled per the [SDK Tiering System](https://modelcontextprotocol.io/community/sdk-tiers):
- **Type** (pick one): `bug`, `enhancement`, `question`
- **Status** (pick one): `needs confirmation`, `needs repro`, `ready for work`, `good first issue`, `help wanted`
- **Priority** (if actionable): `P0`, `P1`, `P2`, `P3`
P0 issues are security vulnerabilities (CVSS ≥ 7.0) or core functionality failures that prevent basic MCP operations (connection establishment, message exchange, or use of core primitives). P0 issues must be resolved within 7 days.
## Code Style ## Code Style
- We use `ruff` for linting and formatting - We use `ruff` for linting and formatting

30
DEPENDENCY_POLICY.md Normal file
View file

@ -0,0 +1,30 @@
# Dependency Policy
As a library consumed by downstream projects, the MCP Python SDK takes a conservative approach to dependency updates. Dependencies are kept stable unless there is a specific reason to update, such as a security vulnerability, a bug fix, or a need for new functionality.
## Update Triggers
Dependencies are updated when:
- A **security vulnerability** is disclosed (via GitHub security alerts or PyPI advisories) in a dependency that directly affects the SDK's functionality or its consumers.
- A bug in a dependency directly affects the SDK.
- A new dependency feature is needed for SDK development.
- A dependency drops support for a Python version the SDK still targets.
Routine version bumps without a clear motivation are avoided to minimize churn for downstream consumers.
## What We Don't Do
The SDK does not run ad-hoc version bumps for PyPI dependencies. Updating a dependency can force downstream consumers to adopt that update transitively, which can be disruptive for projects with strict dependency policies.
Dependencies are only updated when there is a concrete reason, not simply because a newer version is available.
## Automated Tooling
- **Lockfile refresh**: The lockfile is updated automatically every Thursday at 08:00 UTC by the [`weekly-lockfile-update.yml`](.github/workflows/weekly-lockfile-update.yml) workflow, which runs `uv lock --upgrade` and opens a PR. This does not alter the minimum or maximum versions for dependencies of the `mcp` package itself.
- **GitHub security updates** are enabled at the repository level and automatically open pull requests for packages with known vulnerabilities. This is a GitHub repo setting, separate from the `dependabot.yml` configuration.
- **GitHub Actions versions** are kept up to date via Dependabot on a monthly schedule (see `.github/dependabot.yml`).
## Pinning and Ranges
Production dependencies use compatible-release specifiers (`~=`) or lower-bound constraints (`>=`) to allow compatible updates. Exact versions are pinned only when necessary to work around a specific issue. The lockfile (`uv.lock`) records exact resolved versions for reproducible installs.

2406
README.md

File diff suppressed because it is too large Load diff

22
ROADMAP.md Normal file
View file

@ -0,0 +1,22 @@
# Roadmap
## Spec Implementation Tracking
The SDK tracks implementation of MCP spec components via GitHub Projects, with a dedicated project board for each spec revision. For example, see the [2025-11-25 spec revision board](https://github.com/orgs/modelcontextprotocol/projects/26).
## Current Focus Areas
### Next Spec Revision
The next MCP specification revision is being developed in the [protocol repository](https://github.com/modelcontextprotocol/modelcontextprotocol). Key areas expected in the next revision include extensions and stateless transports.
The SDK has historically implemented spec changes promptly as they are finalized, with dedicated project boards tracking component-level progress for each revision.
### v2
A major version of the SDK is in active development, tracked via [GitHub Project](https://github.com/orgs/modelcontextprotocol/projects/31). Target milestones:
- **Alpha**: ~mid-March 2026
- **Beta**: ~May 2026
The v2 release is planned to align with the next spec release, expected around mid-2026.

40
VERSIONING.md Normal file
View file

@ -0,0 +1,40 @@
# Versioning Policy
The MCP Python SDK (`mcp`) follows [Semantic Versioning 2.0.0](https://semver.org/).
## Version Format
`MAJOR.MINOR.PATCH`
- **MAJOR**: Incremented for breaking changes (see below).
- **MINOR**: Incremented for new features that are backward-compatible.
- **PATCH**: Incremented for backward-compatible bug fixes.
## What Constitutes a Breaking Change
The following changes are considered breaking and require a major version bump:
- Removing or renaming a public API export (class, function, type, or constant).
- Changing the signature of a public function or method in a way that breaks existing callers (removing parameters, changing required/optional status, changing types).
- Removing or renaming a public type or dataclass/TypedDict field.
- Changing the behavior of an existing API in a way that breaks documented contracts.
- Dropping support for a Python version that is still receiving security updates.
- Removing support for a transport type.
- Changes to the MCP protocol version that require client/server code changes.
The following are **not** considered breaking:
- Adding new optional parameters to existing functions.
- Adding new exports, types, or classes.
- Adding new optional fields to existing types.
- Bug fixes that correct behavior to match documented intent.
- Internal refactoring that does not affect the public API.
- Adding support for new MCP spec features.
- Changes to dev dependencies or build tooling.
## How Breaking Changes Are Communicated
1. **Changelog**: All breaking changes are documented in the GitHub release notes with migration instructions.
2. **Deprecation**: When feasible, APIs are deprecated for at least one minor release before removal using `warnings.warn()` with `DeprecationWarning`, which surfaces warnings at runtime and through static analysis tooling.
3. **Migration guide**: Major version releases include a migration guide describing what changed and how to update.
4. **PR labels**: Pull requests containing breaking changes are labeled with `breaking change`.

View file

@ -1,5 +1,184 @@
# Authorization # Authorization
!!! warning "Under Construction" This page covers OAuth 2.1 authentication for both MCP servers and clients.
This page is currently being written. Check back soon for complete documentation. ## Server-Side Authentication
Authentication can be used by servers that want to expose tools accessing protected resources.
`mcp.server.auth` implements OAuth 2.1 resource server functionality, where MCP servers act as Resource Servers (RS) that validate tokens issued by separate Authorization Servers (AS). This follows the [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) and implements RFC 9728 (Protected Resource Metadata) for AS discovery.
MCP servers can use authentication by providing an implementation of the `TokenVerifier` protocol:
<!-- snippet-source examples/snippets/servers/oauth_server.py -->
```python
"""
Run from the repository root:
uv run examples/snippets/servers/oauth_server.py
"""
from pydantic import AnyHttpUrl
from mcp.server.auth.provider import AccessToken, TokenVerifier
from mcp.server.auth.settings import AuthSettings
from mcp.server.fastmcp import FastMCP
class SimpleTokenVerifier(TokenVerifier):
"""Simple token verifier for demonstration."""
async def verify_token(self, token: str) -> AccessToken | None:
pass # This is where you would implement actual token validation
# Create FastMCP instance as a Resource Server
mcp = FastMCP(
"Weather Service",
json_response=True,
# Token verifier for authentication
token_verifier=SimpleTokenVerifier(),
# Auth settings for RFC 9728 Protected Resource Metadata
auth=AuthSettings(
issuer_url=AnyHttpUrl("https://auth.example.com"), # Authorization Server URL
resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"), # This server's URL (mcp.run() default)
required_scopes=["user"],
validate_token_resource=True,
),
)
@mcp.tool()
async def get_weather(city: str = "London") -> dict[str, str]:
"""Get weather data for a city"""
return {
"city": city,
"temperature": "22",
"condition": "Partly cloudy",
"humidity": "65%",
}
if __name__ == "__main__":
mcp.run(transport="streamable-http")
```
_Full example: [examples/snippets/servers/oauth_server.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/oauth_server.py)_
<!-- /snippet-source -->
For a complete example with separate Authorization Server and Resource Server implementations, see [`examples/servers/simple-auth/`](https://github.com/modelcontextprotocol/python-sdk/tree/v1.x/examples/servers/simple-auth).
**Architecture:**
- **Authorization Server (AS)**: Handles OAuth flows, user authentication, and token issuance
- **Resource Server (RS)**: Your MCP server that validates tokens and serves protected resources
- **Client**: Discovers AS through RFC 9728, obtains tokens, and uses them with the MCP server
See [TokenVerifier](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/src/mcp/server/auth/provider.py) for more details on implementing token validation.
A verifier should report who the token was issued for (its `aud`) in `AccessToken.resource`. `AuthSettings(validate_token_resource=True)` then refuses any token whose `resource` is not `resource_server_url`. Leaving it unset while `resource_server_url` is set warns (`DeprecationWarning`) and behaves as `False`; 3.0 makes `True` the default for resource servers.
- Turn it on when your authorization server binds tokens to the `resource` the client requested, which MCP clients always send. Keep `resource_server_url` the exact URL clients connect to.
- Leave it off when your authorization server uses its own audience identifiers (an Auth0 API identifier, an Entra application ID) and check `aud` in your verifier instead, returning `None` for a token that isn't for this server.
- If `aud` is a list, put the entry that equals `resource_server_url` in `resource`.
## Client-Side Authentication
The SDK includes [authorization support](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) for connecting to protected MCP servers:
<!-- snippet-source examples/snippets/clients/oauth_client.py -->
```python
"""
Before running, specify running MCP RS server URL.
To spin up RS server locally, see
examples/servers/simple-auth/README.md
cd to the `examples/snippets` directory and run:
uv run oauth-client
"""
import asyncio
from urllib.parse import parse_qs, urlparse
import httpx
from pydantic import AnyUrl
from mcp import ClientSession
from mcp.client.auth import OAuthClientProvider, TokenStorage
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
class InMemoryTokenStorage(TokenStorage):
"""Demo In-memory token storage implementation."""
def __init__(self):
self.tokens: OAuthToken | None = None
self.client_info: OAuthClientInformationFull | None = None
async def get_tokens(self) -> OAuthToken | None:
"""Get stored tokens."""
return self.tokens
async def set_tokens(self, tokens: OAuthToken) -> None:
"""Store tokens."""
self.tokens = tokens
async def get_client_info(self) -> OAuthClientInformationFull | None:
"""Get stored client information."""
return self.client_info
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
"""Store client information."""
self.client_info = client_info
async def handle_redirect(auth_url: str) -> None:
print(f"Visit: {auth_url}")
async def handle_callback() -> tuple[str, str | None]:
callback_url = input("Paste callback URL: ")
params = parse_qs(urlparse(callback_url).query)
return params["code"][0], params.get("state", [None])[0]
async def main():
"""Run the OAuth client example."""
oauth_auth = OAuthClientProvider(
server_url="http://localhost:8001",
client_metadata=OAuthClientMetadata(
client_name="Example MCP Client",
redirect_uris=[AnyUrl("http://localhost:3000/callback")],
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
scope="user",
),
storage=InMemoryTokenStorage(),
redirect_handler=handle_redirect,
callback_handler=handle_callback,
)
async with httpx.AsyncClient(auth=oauth_auth) as custom_client:
async with streamable_http_client("http://localhost:8001/mcp", http_client=custom_client) as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
print(f"Available tools: {[tool.name for tool in tools.tools]}")
resources = await session.list_resources()
print(f"Available resources: {[r.uri for r in resources.resources]}")
def run():
asyncio.run(main())
if __name__ == "__main__":
run()
```
_Full example: [examples/snippets/clients/oauth_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/clients/oauth_client.py)_
<!-- /snippet-source -->
For a complete working example, see [`examples/clients/simple-auth-client/`](https://github.com/modelcontextprotocol/python-sdk/tree/v1.x/examples/clients/simple-auth-client).

433
docs/client.md Normal file
View file

@ -0,0 +1,433 @@
# Writing MCP Clients
The SDK provides a high-level client interface for connecting to MCP servers using various [transports](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports):
<!-- snippet-source examples/snippets/clients/stdio_client.py -->
```python
"""
cd to the `examples/snippets/clients` directory and run:
uv run client
"""
import asyncio
import os
from pydantic import AnyUrl
from mcp import ClientSession, StdioServerParameters, types
from mcp.client.stdio import stdio_client
from mcp.shared.context import RequestContext
# Create server parameters for stdio connection
server_params = StdioServerParameters(
command="uv", # Using uv to run the server
args=["run", "server", "fastmcp_quickstart", "stdio"], # We're already in snippets dir
env={"UV_INDEX": os.environ.get("UV_INDEX", "")},
)
# Optional: create a sampling callback
async def handle_sampling_message(
context: RequestContext[ClientSession, None], params: types.CreateMessageRequestParams
) -> types.CreateMessageResult:
print(f"Sampling request: {params.messages}")
return types.CreateMessageResult(
role="assistant",
content=types.TextContent(
type="text",
text="Hello, world! from model",
),
model="gpt-3.5-turbo",
stopReason="endTurn",
)
async def run():
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write, sampling_callback=handle_sampling_message) as session:
# Initialize the connection
await session.initialize()
# List available prompts
prompts = await session.list_prompts()
print(f"Available prompts: {[p.name for p in prompts.prompts]}")
# Get a prompt (greet_user prompt from fastmcp_quickstart)
if prompts.prompts:
prompt = await session.get_prompt("greet_user", arguments={"name": "Alice", "style": "friendly"})
print(f"Prompt result: {prompt.messages[0].content}")
# List available resources
resources = await session.list_resources()
print(f"Available resources: {[r.uri for r in resources.resources]}")
# List available tools
tools = await session.list_tools()
print(f"Available tools: {[t.name for t in tools.tools]}")
# Read a resource (greeting resource from fastmcp_quickstart)
resource_content = await session.read_resource(AnyUrl("greeting://World"))
content_block = resource_content.contents[0]
if isinstance(content_block, types.TextResourceContents):
print(f"Resource content: {content_block.text}")
# Call a tool (add tool from fastmcp_quickstart)
result = await session.call_tool("add", arguments={"a": 5, "b": 3})
result_unstructured = result.content[0]
if isinstance(result_unstructured, types.TextContent):
print(f"Tool result: {result_unstructured.text}")
result_structured = result.structuredContent
print(f"Structured tool result: {result_structured}")
def main():
"""Entry point for the client script."""
asyncio.run(run())
if __name__ == "__main__":
main()
```
_Full example: [examples/snippets/clients/stdio_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/clients/stdio_client.py)_
<!-- /snippet-source -->
Clients can also connect using [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http):
<!-- snippet-source examples/snippets/clients/streamable_basic.py -->
```python
"""
Run from the repository root:
uv run examples/snippets/clients/streamable_basic.py
"""
import asyncio
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client
async def main():
# Connect to a streamable HTTP server
async with streamable_http_client("http://localhost:8000/mcp") as (
read_stream,
write_stream,
_,
):
# Create a session using the client streams
async with ClientSession(read_stream, write_stream) as session:
# Initialize the connection
await session.initialize()
# List available tools
tools = await session.list_tools()
print(f"Available tools: {[tool.name for tool in tools.tools]}")
if __name__ == "__main__":
asyncio.run(main())
```
_Full example: [examples/snippets/clients/streamable_basic.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/clients/streamable_basic.py)_
<!-- /snippet-source -->
To configure headers, authentication or timeouts, create an `httpx.AsyncClient` and pass it as `http_client=`.
## HTTP redirects
The transport connects to the URL you gave it, and only that origin.
* A `307`/`308` redirect that stays on the same scheme, host and port is followed, and so is `http://` → `https://` on the same host. That covers the usual `/mcp` → `/mcp/` trailing-slash redirect.
* A redirect anywhere else is **not** followed. Connecting fails with:
```text
httpx.HTTPStatusError: Redirect to https://other.example.com/mcp not followed; use that URL as the endpoint if it is the intended server
```
If that URL is the server you meant, put it in your config. If it isn't, the server or a proxy in front of it is misconfigured.
This holds for any `httpx.AsyncClient` you pass in: its `follow_redirects` setting is not consulted for MCP requests, in either direction. The SDK's OAuth providers apply the same rule to their own requests, and so does `sse_client()`.
!!! tip
`Redirect to http://… not followed: it would downgrade this HTTPS endpoint to plain HTTP` means the
server sits behind a TLS-terminating proxy it doesn't know about and is issuing `http://` redirects.
That is fixed on the server (for uvicorn: `--proxy-headers` and `--forwarded-allow-ips`), or by
using the exact `https://…/` URL the message suggests.
## Client Display Utilities
When building MCP clients, the SDK provides utilities to help display human-readable names for tools, resources, and prompts:
<!-- snippet-source examples/snippets/clients/display_utilities.py -->
```python
"""
cd to the `examples/snippets` directory and run:
uv run display-utilities-client
"""
import asyncio
import os
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from mcp.shared.metadata_utils import get_display_name
# Create server parameters for stdio connection
server_params = StdioServerParameters(
command="uv", # Using uv to run the server
args=["run", "server", "fastmcp_quickstart", "stdio"],
env={"UV_INDEX": os.environ.get("UV_INDEX", "")},
)
async def display_tools(session: ClientSession):
"""Display available tools with human-readable names"""
tools_response = await session.list_tools()
for tool in tools_response.tools:
# get_display_name() returns the title if available, otherwise the name
display_name = get_display_name(tool)
print(f"Tool: {display_name}")
if tool.description:
print(f" {tool.description}")
async def display_resources(session: ClientSession):
"""Display available resources with human-readable names"""
resources_response = await session.list_resources()
for resource in resources_response.resources:
display_name = get_display_name(resource)
print(f"Resource: {display_name} ({resource.uri})")
templates_response = await session.list_resource_templates()
for template in templates_response.resourceTemplates:
display_name = get_display_name(template)
print(f"Resource Template: {display_name}")
async def run():
"""Run the display utilities example."""
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
# Initialize the connection
await session.initialize()
print("=== Available Tools ===")
await display_tools(session)
print("\n=== Available Resources ===")
await display_resources(session)
def main():
"""Entry point for the display utilities client."""
asyncio.run(run())
if __name__ == "__main__":
main()
```
_Full example: [examples/snippets/clients/display_utilities.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/clients/display_utilities.py)_
<!-- /snippet-source -->
The `get_display_name()` function implements the proper precedence rules for displaying names:
- For tools: `title` > `annotations.title` > `name`
- For other objects: `title` > `name`
This ensures your client UI shows the most user-friendly names that servers provide.
## OAuth Authentication
For OAuth 2.1 client authentication, see [Authorization](authorization.md#client-side-authentication).
## Roots
### Listing Roots
Clients can provide a `list_roots_callback` so that servers can discover the client's workspace roots (directories, project folders, etc.):
<!-- snippet-source examples/snippets/clients/roots_example.py -->
```python
from mcp import ClientSession, types
from mcp.shared.context import RequestContext
async def handle_list_roots(
context: RequestContext[ClientSession, None],
) -> types.ListRootsResult:
"""Return the client's workspace roots."""
return types.ListRootsResult(
roots=[
types.Root(uri="file:///home/user/project", name="My Project"),
types.Root(uri="file:///home/user/data", name="Data Folder"),
]
)
# Pass the callback when creating the session
session = ClientSession(
read_stream,
write_stream,
list_roots_callback=handle_list_roots,
)
```
_Full example: [examples/snippets/clients/roots_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/clients/roots_example.py)_
<!-- /snippet-source -->
When a `list_roots_callback` is provided, the client automatically declares the `roots` capability (with `listChanged=True`) during initialization.
### Roots Change Notifications
When the client's workspace roots change (e.g., a folder is added or removed), notify the server:
```python
# After roots change, notify the server
await session.send_roots_list_changed()
```
## SSE Transport (Legacy)
For servers that use the older SSE transport, use `sse_client()` from `mcp.client.sse`:
<!-- snippet-source examples/snippets/clients/sse_client.py -->
```python
import asyncio
from mcp import ClientSession
from mcp.client.sse import sse_client
async def main():
async with sse_client("http://localhost:8000/sse") as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
tools = await session.list_tools()
print(f"Available tools: {[t.name for t in tools.tools]}")
asyncio.run(main())
```
_Full example: [examples/snippets/clients/sse_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/clients/sse_client.py)_
<!-- /snippet-source -->
The `sse_client()` function accepts optional `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters. The SSE transport is considered legacy; prefer [Streamable HTTP](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) for new servers.
## Ping
Send a ping to verify the server is responsive:
```python
# After session.initialize()
result = await session.send_ping()
# Returns EmptyResult on success; raises on timeout
```
## Logging
### Receiving Log Messages
Pass a `logging_callback` to receive log messages from the server:
<!-- snippet-source examples/snippets/clients/logging_client.py -->
```python
from mcp import ClientSession, types
async def handle_log(params: types.LoggingMessageNotificationParams) -> None:
"""Handle log messages from the server."""
print(f"[{params.level}] {params.data}")
session = ClientSession(
read_stream,
write_stream,
logging_callback=handle_log,
)
```
_Full example: [examples/snippets/clients/logging_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/clients/logging_client.py)_
<!-- /snippet-source -->
### Setting the Server Log Level
Request that the server change its minimum log level:
```python
await session.set_logging_level("debug")
```
The `level` parameter is a `LoggingLevel` string: `"debug"`, `"info"`, `"notice"`, `"warning"`, `"error"`, `"critical"`, `"alert"`, or `"emergency"`.
## Parsing Tool Results
When calling tools through MCP, the `CallToolResult` object contains the tool's response in a structured format. Understanding how to parse this result is essential for properly handling tool outputs.
```python
"""examples/snippets/clients/parsing_tool_results.py"""
import asyncio
from mcp import ClientSession, StdioServerParameters, types
from mcp.client.stdio import stdio_client
async def parse_tool_results():
"""Demonstrates how to parse different types of content in CallToolResult."""
server_params = StdioServerParameters(
command="python", args=["path/to/mcp_server.py"]
)
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
# Example 1: Parsing text content
result = await session.call_tool("get_data", {"format": "text"})
for content in result.content:
if isinstance(content, types.TextContent):
print(f"Text: {content.text}")
# Example 2: Parsing structured content from JSON tools
result = await session.call_tool("get_user", {"id": "123"})
if hasattr(result, "structuredContent") and result.structuredContent:
# Access structured data directly
user_data = result.structuredContent
print(f"User: {user_data.get('name')}, Age: {user_data.get('age')}")
# Example 3: Parsing embedded resources
result = await session.call_tool("read_config", {})
for content in result.content:
if isinstance(content, types.EmbeddedResource):
resource = content.resource
if isinstance(resource, types.TextResourceContents):
print(f"Config from {resource.uri}: {resource.text}")
elif isinstance(resource, types.BlobResourceContents):
print(f"Binary data from {resource.uri}")
# Example 4: Parsing image content
result = await session.call_tool("generate_chart", {"data": [1, 2, 3]})
for content in result.content:
if isinstance(content, types.ImageContent):
print(f"Image ({content.mimeType}): {len(content.data)} bytes")
# Example 5: Handling errors
result = await session.call_tool("failing_tool", {})
if result.isError:
print("Tool execution failed!")
for content in result.content:
if isinstance(content, types.TextContent):
print(f"Error: {content.text}")
async def main():
await parse_tool_results()
if __name__ == "__main__":
asyncio.run(main())
```

Some files were not shown because too many files have changed in this diff Show more