chore: import upstream snapshot with attribution
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:10:27 +08:00
commit 49b9bb6724
992 changed files with 161690 additions and 0 deletions
View File
View File
+198
View File
@@ -0,0 +1,198 @@
"""Tests for mcp.cli.claude — Claude Desktop config file generation."""
import importlib.metadata
import json
from pathlib import Path
from typing import Any
import pytest
from mcp.cli.claude import get_uv_path, mcp_requirement, update_claude_config
def _set_mcp_version(monkeypatch: pytest.MonkeyPatch, version: str) -> None:
real_version = importlib.metadata.version
def fake_version(distribution_name: str) -> str:
return version if distribution_name == "mcp" else real_version(distribution_name)
monkeypatch.setattr(importlib.metadata, "version", fake_version)
@pytest.fixture
def config_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Temp Claude config dir with the config path, uv path, and SDK version mocked."""
claude_dir = tmp_path / "Claude"
claude_dir.mkdir()
monkeypatch.setattr("mcp.cli.claude.get_claude_config_path", lambda: claude_dir)
monkeypatch.setattr("mcp.cli.claude.get_uv_path", lambda: "/fake/bin/uv")
# The ambient version is a dev build in the repo venv but varies by
# environment; pin it so the generated --with requirement is stable.
_set_mcp_version(monkeypatch, "1.2.3")
return claude_dir
def test_mcp_requirement_pins_release_versions(monkeypatch: pytest.MonkeyPatch):
"""Release versions produce an exact pin so spawned environments run the installed SDK version."""
_set_mcp_version(monkeypatch, "2.0.0a1")
assert mcp_requirement() == "mcp==2.0.0a1"
assert mcp_requirement("mcp[cli]") == "mcp[cli]==2.0.0a1"
def test_mcp_requirement_leaves_dev_versions_unpinned(monkeypatch: pytest.MonkeyPatch):
"""Dev versions are not published to PyPI, so the requirement falls back to the unpinned package."""
_set_mcp_version(monkeypatch, "2.0.0a2.dev3")
assert mcp_requirement() == "mcp"
assert mcp_requirement("mcp[cli]") == "mcp[cli]"
def test_mcp_requirement_leaves_local_versions_unpinned(monkeypatch: pytest.MonkeyPatch):
"""Local version segments (source builds) are not published to PyPI, so no pin is emitted."""
_set_mcp_version(monkeypatch, "1.2.3+g0123abc")
assert mcp_requirement() == "mcp"
def test_mcp_requirement_falls_back_when_mcp_is_not_installed(monkeypatch: pytest.MonkeyPatch):
"""Without distribution metadata there is no version to pin, so the requirement stays unpinned."""
def raise_not_found(distribution_name: str) -> str:
raise importlib.metadata.PackageNotFoundError(distribution_name)
monkeypatch.setattr(importlib.metadata, "version", raise_not_found)
assert mcp_requirement() == "mcp"
assert mcp_requirement("mcp[cli]") == "mcp[cli]"
def _read_server(config_dir: Path, name: str) -> dict[str, Any]:
config = json.loads((config_dir / "claude_desktop_config.json").read_text())
return config["mcpServers"][name]
def test_generates_uv_run_command(config_dir: Path):
"""Should write a uv run command that invokes mcp run on the resolved file spec."""
assert update_claude_config(file_spec="server.py:app", server_name="my_server")
resolved = Path("server.py").resolve()
assert _read_server(config_dir, "my_server") == {
"command": "/fake/bin/uv",
"args": ["run", "--frozen", "--with", "mcp[cli]==1.2.3", "mcp", "run", f"{resolved}:app"],
}
def test_file_spec_without_object_suffix(config_dir: Path):
"""File specs without :object should still resolve to an absolute path."""
assert update_claude_config(file_spec="server.py", server_name="s")
assert _read_server(config_dir, "s")["args"][-1] == str(Path("server.py").resolve())
def test_with_packages_sorted_and_deduplicated(config_dir: Path):
"""Extra packages should appear as sorted --with flags with duplicates removed."""
assert update_claude_config(file_spec="s.py:app", server_name="s", with_packages=["zebra", "aardvark", "zebra"])
args = _read_server(config_dir, "s")["args"]
assert args[:8] == ["run", "--frozen", "--with", "aardvark", "--with", "mcp[cli]==1.2.3", "--with", "zebra"]
def test_explicit_mcp_cli_kept_alongside_pinned_requirement(config_dir: Path):
"""A user-supplied mcp[cli] no longer collapses into the pinned requirement; uv resolves both to the pin."""
assert update_claude_config(file_spec="s.py:app", server_name="s", with_packages=["mcp[cli]"])
args = _read_server(config_dir, "s")["args"]
assert args[:6] == ["run", "--frozen", "--with", "mcp[cli]", "--with", "mcp[cli]==1.2.3"]
def test_with_editable_adds_flag(config_dir: Path, tmp_path: Path):
"""with_editable should add --with-editable after the --with flags."""
editable = tmp_path / "project"
assert update_claude_config(file_spec="s.py:app", server_name="s", with_editable=editable)
args = _read_server(config_dir, "s")["args"]
assert args[4:6] == ["--with-editable", str(editable)]
def test_env_vars_written(config_dir: Path):
"""env_vars should be written under the server's env key."""
assert update_claude_config(file_spec="s.py:app", server_name="s", env_vars={"KEY": "val"})
assert _read_server(config_dir, "s")["env"] == {"KEY": "val"}
def test_existing_env_vars_merged_new_wins(config_dir: Path):
"""Re-installing should merge env vars, with new values overriding existing ones."""
(config_dir / "claude_desktop_config.json").write_text(
json.dumps({"mcpServers": {"s": {"env": {"OLD": "keep", "KEY": "old"}}}})
)
assert update_claude_config(file_spec="s.py:app", server_name="s", env_vars={"KEY": "new"})
assert _read_server(config_dir, "s")["env"] == {"OLD": "keep", "KEY": "new"}
def test_existing_env_vars_preserved_without_new(config_dir: Path):
"""Re-installing without env_vars should keep the existing env block intact."""
(config_dir / "claude_desktop_config.json").write_text(json.dumps({"mcpServers": {"s": {"env": {"KEEP": "me"}}}}))
assert update_claude_config(file_spec="s.py:app", server_name="s")
assert _read_server(config_dir, "s")["env"] == {"KEEP": "me"}
def test_other_servers_preserved(config_dir: Path):
"""Installing a new server should not clobber existing mcpServers entries."""
(config_dir / "claude_desktop_config.json").write_text(json.dumps({"mcpServers": {"other": {"command": "x"}}}))
assert update_claude_config(file_spec="s.py:app", server_name="s")
config = json.loads((config_dir / "claude_desktop_config.json").read_text())
assert set(config["mcpServers"]) == {"other", "s"}
assert config["mcpServers"]["other"] == {"command": "x"}
def test_raises_when_config_dir_missing(monkeypatch: pytest.MonkeyPatch):
"""Should raise RuntimeError when Claude Desktop config dir can't be found."""
monkeypatch.setattr("mcp.cli.claude.get_claude_config_path", lambda: None)
monkeypatch.setattr("mcp.cli.claude.get_uv_path", lambda: "/fake/bin/uv")
with pytest.raises(RuntimeError, match="Claude Desktop config directory not found"):
update_claude_config(file_spec="s.py:app", server_name="s")
@pytest.mark.parametrize("which_result, expected", [("/usr/local/bin/uv", "/usr/local/bin/uv"), (None, "uv")])
def test_get_uv_path(monkeypatch: pytest.MonkeyPatch, which_result: str | None, expected: str):
"""Should return shutil.which's result, or fall back to bare 'uv' when not on PATH."""
def fake_which(cmd: str) -> str | None:
return which_result
monkeypatch.setattr("shutil.which", fake_which)
assert get_uv_path() == expected
@pytest.mark.parametrize(
"file_spec, expected_last_arg",
[
("C:\\Users\\server.py", "C:\\Users\\server.py"),
("C:\\Users\\server.py:app", "C:\\Users\\server.py:app"),
],
)
def test_windows_drive_letter_not_split(
config_dir: Path, monkeypatch: pytest.MonkeyPatch, file_spec: str, expected_last_arg: str
):
"""Drive-letter paths like 'C:\\server.py' must not be split on the drive colon.
Before the fix, a bare 'C:\\path\\server.py' would hit rsplit(":", 1) and yield
("C", "\\path\\server.py"), calling resolve() on Path("C") instead of the full path.
"""
seen: list[str] = []
def fake_resolve(self: Path) -> Path:
seen.append(str(self))
return self
monkeypatch.setattr(Path, "resolve", fake_resolve)
assert update_claude_config(file_spec=file_spec, server_name="s")
assert seen == ["C:\\Users\\server.py"]
assert _read_server(config_dir, "s")["args"][-1] == expected_last_arg
+120
View File
@@ -0,0 +1,120 @@
import importlib.metadata
import subprocess
import sys
from pathlib import Path
from typing import Any
import pytest
from mcp.cli.cli import _build_uv_command, _get_npx_command, _parse_file_path # type: ignore[reportPrivateUsage]
def _set_mcp_version(monkeypatch: pytest.MonkeyPatch, version: str) -> None:
real_version = importlib.metadata.version
def fake_version(distribution_name: str) -> str:
return version if distribution_name == "mcp" else real_version(distribution_name)
monkeypatch.setattr(importlib.metadata, "version", fake_version)
@pytest.mark.parametrize(
"spec, expected_obj",
[
("server.py", None),
("foo.py:srv_obj", "srv_obj"),
],
)
def test_parse_file_path_accepts_valid_specs(tmp_path: Path, spec: str, expected_obj: str | None):
"""Should accept valid file specs."""
file = tmp_path / spec.split(":")[0]
file.write_text("x = 1")
path, obj = _parse_file_path(f"{file}:{expected_obj}" if ":" in spec else str(file))
assert path == file.resolve()
assert obj == expected_obj
def test_parse_file_path_missing(tmp_path: Path):
"""Should system exit if a file is missing."""
with pytest.raises(SystemExit):
_parse_file_path(str(tmp_path / "missing.py"))
def test_parse_file_exit_on_dir(tmp_path: Path):
"""Should system exit if a directory is passed"""
dir_path = tmp_path / "dir"
dir_path.mkdir()
with pytest.raises(SystemExit):
_parse_file_path(str(dir_path))
def test_build_uv_command_pins_the_running_mcp_version(monkeypatch: pytest.MonkeyPatch):
"""The spawned environment installs the same SDK version that is running, not the latest stable."""
_set_mcp_version(monkeypatch, "1.2.3")
cmd = _build_uv_command("foo.py")
assert cmd == ["uv", "run", "--with", "mcp==1.2.3", "mcp", "run", "foo.py"]
def test_build_uv_command_leaves_source_builds_unpinned(monkeypatch: pytest.MonkeyPatch):
"""Source-build versions are not on PyPI, so the requirement stays unpinned."""
_set_mcp_version(monkeypatch, "2.0.0a2.dev3+g0123abc")
cmd = _build_uv_command("foo.py")
assert cmd == ["uv", "run", "--with", "mcp", "mcp", "run", "foo.py"]
def test_build_uv_command_adds_editable_and_packages(monkeypatch: pytest.MonkeyPatch):
"""Should include --with-editable and every --with pkg in correct order."""
_set_mcp_version(monkeypatch, "1.2.3")
test_path = Path("/pkg")
cmd = _build_uv_command(
"foo.py",
with_editable=test_path,
with_packages=["package1", "package2"],
)
assert cmd == [
"uv",
"run",
"--with",
"mcp==1.2.3",
"--with-editable",
str(test_path), # Use str() to match what the function does
"--with",
"package1",
"--with",
"package2",
"mcp",
"run",
"foo.py",
]
def test_get_npx_unix_like(monkeypatch: pytest.MonkeyPatch):
"""Should return "npx" on unix-like systems."""
monkeypatch.setattr(sys, "platform", "linux")
assert _get_npx_command() == "npx"
def test_get_npx_windows(monkeypatch: pytest.MonkeyPatch):
"""Should return one of the npx candidates on Windows."""
candidates = ["npx.cmd", "npx.exe", "npx"]
def fake_run(cmd: list[str], **kw: Any) -> subprocess.CompletedProcess[bytes]:
if cmd[0] in candidates:
return subprocess.CompletedProcess(cmd, 0)
else: # pragma: no cover
raise subprocess.CalledProcessError(1, cmd[0])
monkeypatch.setattr(sys, "platform", "win32")
monkeypatch.setattr(subprocess, "run", fake_run)
assert _get_npx_command() in candidates
def test_get_npx_returns_none_when_npx_missing(monkeypatch: pytest.MonkeyPatch):
"""Should give None if every candidate fails."""
monkeypatch.setattr(sys, "platform", "win32", raising=False)
def always_fail(*args: Any, **kwargs: Any) -> subprocess.CompletedProcess[bytes]:
raise subprocess.CalledProcessError(1, args[0])
monkeypatch.setattr(subprocess, "run", always_fail)
assert _get_npx_command() is None
View File
@@ -0,0 +1,503 @@
import urllib.parse
import warnings
import jwt
import pytest
from pydantic import AnyHttpUrl, AnyUrl
from mcp.client.auth.extensions.client_credentials import (
ClientCredentialsOAuthProvider,
JWTParameters,
PrivateKeyJWTOAuthProvider,
RFC7523OAuthClientProvider,
SignedJWTParameters,
static_assertion_provider,
)
from mcp.shared.auth import (
AuthorizationCodeResult,
OAuthClientInformationFull,
OAuthClientMetadata,
OAuthMetadata,
OAuthToken,
)
from mcp.shared.exceptions import MCPDeprecationWarning
class MockTokenStorage:
"""Mock token storage for testing."""
def __init__(self):
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: # pragma: no cover
self._tokens = tokens
async def get_client_info(self) -> OAuthClientInformationFull | None: # pragma: no cover
return self._client_info
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: # pragma: no cover
self._client_info = client_info
@pytest.fixture
def mock_storage():
return MockTokenStorage()
@pytest.fixture
def client_metadata():
return OAuthClientMetadata(
client_name="Test Client",
client_uri=AnyHttpUrl("https://example.com"),
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
scope="read write",
)
@pytest.fixture
def rfc7523_oauth_provider(client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage):
async def redirect_handler(url: str) -> None: # pragma: no cover
"""Mock redirect handler."""
pass
async def callback_handler() -> AuthorizationCodeResult: # pragma: no cover
"""Mock callback handler."""
return AuthorizationCodeResult(code="test_auth_code", state="test_state")
with warnings.catch_warnings():
warnings.simplefilter("ignore", MCPDeprecationWarning)
return RFC7523OAuthClientProvider(
server_url="https://api.example.com/v1/mcp",
client_metadata=client_metadata,
storage=mock_storage,
redirect_handler=redirect_handler,
callback_handler=callback_handler,
)
class TestOAuthFlowClientCredentials:
"""Test OAuth flow behavior for client credentials flows."""
@pytest.mark.anyio
async def test_token_exchange_request_jwt_predefined(self, rfc7523_oauth_provider: RFC7523OAuthClientProvider):
"""Test token exchange request building with a predefined JWT assertion."""
# Set up required context
rfc7523_oauth_provider.context.client_info = OAuthClientInformationFull(
grant_types=["urn:ietf:params:oauth:grant-type:jwt-bearer"],
token_endpoint_auth_method="private_key_jwt",
redirect_uris=None,
scope="read write",
)
rfc7523_oauth_provider.context.oauth_metadata = OAuthMetadata(
issuer=AnyHttpUrl("https://api.example.com"),
authorization_endpoint=AnyHttpUrl("https://api.example.com/authorize"),
token_endpoint=AnyHttpUrl("https://api.example.com/token"),
registration_endpoint=AnyHttpUrl("https://api.example.com/register"),
)
rfc7523_oauth_provider.context.client_metadata = rfc7523_oauth_provider.context.client_info
rfc7523_oauth_provider.context.protocol_version = "2025-06-18"
rfc7523_oauth_provider.jwt_parameters = JWTParameters(
# https://www.jwt.io
assertion="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30"
)
request = await rfc7523_oauth_provider._exchange_token_jwt_bearer()
assert request.method == "POST"
assert str(request.url) == "https://api.example.com/token"
assert request.headers["Content-Type"] == "application/x-www-form-urlencoded"
# Check form data
content = urllib.parse.unquote_plus(request.content.decode())
assert "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" in content
assert "scope=read write" in content
assert "resource=https://api.example.com/v1/mcp" in content
assert (
"assertion=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30"
in content
)
@pytest.mark.anyio
async def test_token_exchange_request_jwt(self, rfc7523_oauth_provider: RFC7523OAuthClientProvider):
"""Test token exchange request building wiith a generated JWT assertion."""
# Set up required context
rfc7523_oauth_provider.context.client_info = OAuthClientInformationFull(
grant_types=["urn:ietf:params:oauth:grant-type:jwt-bearer"],
token_endpoint_auth_method="private_key_jwt",
redirect_uris=None,
scope="read write",
)
rfc7523_oauth_provider.context.oauth_metadata = OAuthMetadata(
issuer=AnyHttpUrl("https://api.example.com"),
authorization_endpoint=AnyHttpUrl("https://api.example.com/authorize"),
token_endpoint=AnyHttpUrl("https://api.example.com/token"),
registration_endpoint=AnyHttpUrl("https://api.example.com/register"),
)
rfc7523_oauth_provider.context.client_metadata = rfc7523_oauth_provider.context.client_info
rfc7523_oauth_provider.context.protocol_version = "2025-06-18"
rfc7523_oauth_provider.jwt_parameters = JWTParameters(
issuer="foo",
subject="1234567890",
claims={
"name": "John Doe",
"admin": True,
"iat": 1516239022,
},
jwt_signing_algorithm="HS256",
jwt_signing_key="a-string-secret-at-least-256-bits-long",
jwt_lifetime_seconds=300,
)
request = await rfc7523_oauth_provider._exchange_token_jwt_bearer()
assert request.method == "POST"
assert str(request.url) == "https://api.example.com/token"
assert request.headers["Content-Type"] == "application/x-www-form-urlencoded"
# Check form data
content = urllib.parse.unquote_plus(request.content.decode()).split("&")
assert "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" in content
assert "scope=read write" in content
assert "resource=https://api.example.com/v1/mcp" in content
# Check assertion
assertion = next(param for param in content if param.startswith("assertion="))[len("assertion=") :]
claims = jwt.decode(
assertion,
key="a-string-secret-at-least-256-bits-long",
algorithms=["HS256"],
audience="https://api.example.com/",
subject="1234567890",
issuer="foo",
verify=True,
)
assert claims["name"] == "John Doe"
assert claims["admin"]
assert claims["iat"] == 1516239022
class TestClientCredentialsOAuthProvider:
"""Test ClientCredentialsOAuthProvider."""
@pytest.mark.anyio
async def test_init_sets_client_info(self, mock_storage: MockTokenStorage):
"""Test that _initialize sets client_info."""
provider = ClientCredentialsOAuthProvider(
server_url="https://api.example.com",
storage=mock_storage,
client_id="test-client-id",
client_secret="test-client-secret",
)
# client_info is set during _initialize
await provider._initialize()
assert provider.context.client_info is not None
assert provider.context.client_info.client_id == "test-client-id"
assert provider.context.client_info.client_secret == "test-client-secret"
assert provider.context.client_info.grant_types == ["client_credentials"]
assert provider.context.client_info.token_endpoint_auth_method == "client_secret_basic"
@pytest.mark.anyio
async def test_init_with_scopes(self, mock_storage: MockTokenStorage):
"""Test that constructor accepts scopes."""
provider = ClientCredentialsOAuthProvider(
server_url="https://api.example.com",
storage=mock_storage,
client_id="test-client-id",
client_secret="test-client-secret",
scopes="read write",
)
await provider._initialize()
assert provider.context.client_info is not None
assert provider.context.client_info.scope == "read write"
@pytest.mark.anyio
async def test_init_with_client_secret_post(self, mock_storage: MockTokenStorage):
"""Test that constructor accepts client_secret_post auth method."""
provider = ClientCredentialsOAuthProvider(
server_url="https://api.example.com",
storage=mock_storage,
client_id="test-client-id",
client_secret="test-client-secret",
token_endpoint_auth_method="client_secret_post",
)
await provider._initialize()
assert provider.context.client_info is not None
assert provider.context.client_info.token_endpoint_auth_method == "client_secret_post"
@pytest.mark.anyio
async def test_exchange_token_client_credentials(self, mock_storage: MockTokenStorage):
"""Test token exchange request building."""
provider = ClientCredentialsOAuthProvider(
server_url="https://api.example.com/v1/mcp",
storage=mock_storage,
client_id="test-client-id",
client_secret="test-client-secret",
scopes="read write",
)
provider.context.oauth_metadata = OAuthMetadata(
issuer=AnyHttpUrl("https://api.example.com"),
authorization_endpoint=AnyHttpUrl("https://api.example.com/authorize"),
token_endpoint=AnyHttpUrl("https://api.example.com/token"),
)
provider.context.protocol_version = "2025-06-18"
request = await provider._perform_authorization()
assert request.method == "POST"
assert str(request.url) == "https://api.example.com/token"
content = urllib.parse.unquote_plus(request.content.decode())
assert "grant_type=client_credentials" in content
assert "scope=read write" in content
assert "resource=https://api.example.com/v1/mcp" in content
@pytest.mark.anyio
async def test_exchange_token_client_secret_post_includes_client_id(self, mock_storage: MockTokenStorage):
"""Test that client_secret_post includes both client_id and client_secret in body (RFC 6749 §2.3.1)."""
provider = ClientCredentialsOAuthProvider(
server_url="https://api.example.com/v1/mcp",
storage=mock_storage,
client_id="test-client-id",
client_secret="test-client-secret",
token_endpoint_auth_method="client_secret_post",
scopes="read write",
)
await provider._initialize()
provider.context.oauth_metadata = OAuthMetadata(
issuer=AnyHttpUrl("https://api.example.com"),
authorization_endpoint=AnyHttpUrl("https://api.example.com/authorize"),
token_endpoint=AnyHttpUrl("https://api.example.com/token"),
)
provider.context.protocol_version = "2025-06-18"
request = await provider._perform_authorization()
content = urllib.parse.unquote_plus(request.content.decode())
assert "grant_type=client_credentials" in content
assert "client_id=test-client-id" in content
assert "client_secret=test-client-secret" in content
# Should NOT have Basic auth header
assert "Authorization" not in request.headers
@pytest.mark.anyio
async def test_exchange_token_client_secret_post_without_client_id(self, mock_storage: MockTokenStorage):
"""Test client_secret_post skips body credentials when client_id is None."""
provider = ClientCredentialsOAuthProvider(
server_url="https://api.example.com/v1/mcp",
storage=mock_storage,
client_id="placeholder",
client_secret="test-client-secret",
token_endpoint_auth_method="client_secret_post",
scopes="read write",
)
await provider._initialize()
provider.context.oauth_metadata = OAuthMetadata(
issuer=AnyHttpUrl("https://api.example.com"),
authorization_endpoint=AnyHttpUrl("https://api.example.com/authorize"),
token_endpoint=AnyHttpUrl("https://api.example.com/token"),
)
provider.context.protocol_version = "2025-06-18"
# Override client_info to have client_id=None (edge case)
provider.context.client_info = OAuthClientInformationFull(
redirect_uris=None,
client_id=None,
client_secret="test-client-secret",
grant_types=["client_credentials"],
token_endpoint_auth_method="client_secret_post",
scope="read write",
)
request = await provider._perform_authorization()
content = urllib.parse.unquote_plus(request.content.decode())
assert "grant_type=client_credentials" in content
# Neither client_id nor client_secret should be in body since client_id is None
# (RFC 6749 §2.3.1 requires both for client_secret_post)
assert "client_id=" not in content
assert "client_secret=" not in content
assert "Authorization" not in request.headers
@pytest.mark.anyio
async def test_exchange_token_without_scopes(self, mock_storage: MockTokenStorage):
"""Test token exchange without scopes."""
provider = ClientCredentialsOAuthProvider(
server_url="https://api.example.com/v1/mcp",
storage=mock_storage,
client_id="test-client-id",
client_secret="test-client-secret",
)
provider.context.oauth_metadata = OAuthMetadata(
issuer=AnyHttpUrl("https://api.example.com"),
authorization_endpoint=AnyHttpUrl("https://api.example.com/authorize"),
token_endpoint=AnyHttpUrl("https://api.example.com/token"),
)
provider.context.protocol_version = "2024-11-05" # Old version - no resource param
request = await provider._perform_authorization()
content = urllib.parse.unquote_plus(request.content.decode())
assert "grant_type=client_credentials" in content
assert "scope=" not in content
assert "resource=" not in content
class TestPrivateKeyJWTOAuthProvider:
"""Test PrivateKeyJWTOAuthProvider."""
@pytest.mark.anyio
async def test_init_sets_client_info(self, mock_storage: MockTokenStorage):
"""Test that _initialize sets client_info."""
async def mock_assertion_provider(audience: str) -> str: # pragma: no cover
return "mock-jwt"
provider = PrivateKeyJWTOAuthProvider(
server_url="https://api.example.com",
storage=mock_storage,
client_id="test-client-id",
assertion_provider=mock_assertion_provider,
)
# client_info is set during _initialize
await provider._initialize()
assert provider.context.client_info is not None
assert provider.context.client_info.client_id == "test-client-id"
assert provider.context.client_info.grant_types == ["client_credentials"]
assert provider.context.client_info.token_endpoint_auth_method == "private_key_jwt"
@pytest.mark.anyio
async def test_exchange_token_client_credentials(self, mock_storage: MockTokenStorage):
"""Test token exchange request building with assertion provider."""
async def mock_assertion_provider(audience: str) -> str:
return f"jwt-for-{audience}"
provider = PrivateKeyJWTOAuthProvider(
server_url="https://api.example.com/v1/mcp",
storage=mock_storage,
client_id="test-client-id",
assertion_provider=mock_assertion_provider,
scopes="read write",
)
provider.context.oauth_metadata = OAuthMetadata(
issuer=AnyHttpUrl("https://auth.example.com"),
authorization_endpoint=AnyHttpUrl("https://auth.example.com/authorize"),
token_endpoint=AnyHttpUrl("https://auth.example.com/token"),
)
provider.context.protocol_version = "2025-06-18"
request = await provider._perform_authorization()
assert request.method == "POST"
assert str(request.url) == "https://auth.example.com/token"
content = urllib.parse.unquote_plus(request.content.decode())
assert "grant_type=client_credentials" in content
assert "client_assertion=jwt-for-https://auth.example.com/" in content
assert "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" in content
assert "scope=read write" in content
@pytest.mark.anyio
async def test_exchange_token_without_scopes(self, mock_storage: MockTokenStorage):
"""Test token exchange without scopes."""
async def mock_assertion_provider(audience: str) -> str:
return f"jwt-for-{audience}"
provider = PrivateKeyJWTOAuthProvider(
server_url="https://api.example.com/v1/mcp",
storage=mock_storage,
client_id="test-client-id",
assertion_provider=mock_assertion_provider,
)
provider.context.oauth_metadata = OAuthMetadata(
issuer=AnyHttpUrl("https://auth.example.com"),
authorization_endpoint=AnyHttpUrl("https://auth.example.com/authorize"),
token_endpoint=AnyHttpUrl("https://auth.example.com/token"),
)
provider.context.protocol_version = "2024-11-05" # Old version - no resource param
request = await provider._perform_authorization()
content = urllib.parse.unquote_plus(request.content.decode())
assert "grant_type=client_credentials" in content
assert "scope=" not in content
assert "resource=" not in content
class TestSignedJWTParameters:
"""Test SignedJWTParameters."""
@pytest.mark.anyio
async def test_create_assertion_provider(self):
"""Test that create_assertion_provider creates valid JWTs."""
params = SignedJWTParameters(
issuer="test-issuer",
subject="test-subject",
signing_key="a-string-secret-at-least-256-bits-long",
signing_algorithm="HS256",
lifetime_seconds=300,
)
provider = params.create_assertion_provider()
assertion = await provider("https://auth.example.com")
claims = jwt.decode(
assertion,
key="a-string-secret-at-least-256-bits-long",
algorithms=["HS256"],
audience="https://auth.example.com",
)
assert claims["iss"] == "test-issuer"
assert claims["sub"] == "test-subject"
assert claims["aud"] == "https://auth.example.com"
assert "exp" in claims
assert "iat" in claims
assert "jti" in claims
@pytest.mark.anyio
async def test_create_assertion_provider_with_additional_claims(self):
"""Test that additional_claims are included in the JWT."""
params = SignedJWTParameters(
issuer="test-issuer",
subject="test-subject",
signing_key="a-string-secret-at-least-256-bits-long",
signing_algorithm="HS256",
additional_claims={"custom": "value"},
)
provider = params.create_assertion_provider()
assertion = await provider("https://auth.example.com")
claims = jwt.decode(
assertion,
key="a-string-secret-at-least-256-bits-long",
algorithms=["HS256"],
audience="https://auth.example.com",
)
assert claims["custom"] == "value"
class TestStaticAssertionProvider:
"""Test static_assertion_provider helper."""
@pytest.mark.anyio
async def test_returns_static_token(self):
"""Test that static_assertion_provider returns the same token regardless of audience."""
token = "my-static-jwt-token"
provider = static_assertion_provider(token)
result1 = await provider("https://auth1.example.com")
result2 = await provider("https://auth2.example.com")
assert result1 == token
assert result2 == token
@@ -0,0 +1,411 @@
"""Unit tests for the standalone SEP-990 jwt-bearer `httpx.Auth`.
The provider's authorization server is configuration; these tests assert that authorization-server
metadata is fetched only from the configured issuer, that the resource server is never consulted for
AS selection, and that the ID-JAG and client secret reach only the issuer's token endpoint.
"""
import base64
import json
import urllib.parse
import httpx
import pytest
from mcp.client.auth import OAuthFlowError, OAuthTokenError
from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider, _origin
from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthToken
ISSUER = "https://auth.example.com"
RS = "https://mcp.example.com"
ASM_PATH = "/.well-known/oauth-authorization-server"
OIDC_PATH = "/.well-known/openid-configuration"
class InMemoryStorage:
def __init__(self, tokens: OAuthToken | None = None) -> None:
self.tokens = tokens
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:
raise NotImplementedError
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
raise NotImplementedError
def asm_body(*, issuer: str = ISSUER, token_endpoint: str | None = None) -> bytes:
return json.dumps(
{
"issuer": issuer,
"authorization_endpoint": f"{issuer}/authorize",
"token_endpoint": token_endpoint or f"{issuer}/token",
}
).encode()
def token_body(*, access_token: str = "issued-token", scope: str | None = None) -> bytes:
payload: dict[str, object] = {"access_token": access_token, "token_type": "Bearer", "expires_in": 3600}
if scope is not None:
payload["scope"] = scope
return json.dumps(payload).encode()
def make_provider(
storage: InMemoryStorage | None = None,
*,
scope: str | None = "mcp",
token_endpoint_auth_method: str = "client_secret_post",
record: list[tuple[str, str]] | None = None,
) -> IdentityAssertionOAuthProvider:
async def assertion_provider(audience: str, resource: str) -> str:
if record is not None:
record.append((audience, resource))
return "the-id-jag"
return IdentityAssertionOAuthProvider(
server_url=f"{RS}/mcp",
storage=storage if storage is not None else InMemoryStorage(),
client_id="test-client-id",
client_secret="test-client-secret",
issuer=ISSUER,
assertion_provider=assertion_provider,
scope=scope,
token_endpoint_auth_method=token_endpoint_auth_method, # type: ignore[arg-type]
)
def mock_transport(
requests: list[httpx.Request],
*,
asm: bytes | int = 200,
token: bytes | int = 200,
rs_first_status: int = 401,
rs_first_headers: dict[str, str] | None = None,
) -> httpx.MockTransport:
"""Build a `MockTransport` that records every request and serves the configured ASM and token.
`asm` / `token` are either a body (served as 200 JSON) or an int status (served with no body).
The MCP resource server's first response is `rs_first_status` (default 401) with optional
headers; subsequent RS requests return 200.
"""
rs_hits = 0
def handle(request: httpx.Request) -> httpx.Response:
nonlocal rs_hits
requests.append(request)
host, path = request.url.host, request.url.path
if host == "mcp.example.com":
rs_hits += 1
if rs_hits == 1:
return httpx.Response(rs_first_status, headers=rs_first_headers or {})
return httpx.Response(200, json={"ok": True})
if host == "auth.example.com" and path in (ASM_PATH, OIDC_PATH):
if isinstance(asm, int):
return httpx.Response(asm)
return httpx.Response(200, content=asm, headers={"content-type": "application/json"})
if host == "auth.example.com" and path == "/token":
if isinstance(token, int):
return httpx.Response(token, json={"error": "invalid_grant"})
return httpx.Response(200, content=token, headers={"content-type": "application/json"})
raise AssertionError(f"unexpected request: {request.method} {request.url}") # pragma: no cover
return httpx.MockTransport(handle)
def form(request: httpx.Request) -> dict[str, str]:
return dict(urllib.parse.parse_qsl(request.content.decode()))
@pytest.mark.anyio
async def test_on_401_exchanges_assertion_at_configured_issuer_and_retries() -> None:
"""A 401 fetches ASM from the configured issuer, posts the jwt-bearer grant, and retries."""
requests: list[httpx.Request] = []
record: list[tuple[str, str]] = []
storage = InMemoryStorage()
auth = make_provider(storage, record=record)
async with httpx.AsyncClient(
transport=mock_transport(requests, asm=asm_body(), token=token_body(scope="mcp")), auth=auth
) as http:
response = await http.post(f"{RS}/mcp")
assert [(r.method, str(r.url)) for r in requests] == [
("POST", f"{RS}/mcp"),
("GET", f"{ISSUER}{ASM_PATH}"),
("POST", f"{ISSUER}/token"),
("POST", f"{RS}/mcp"),
]
body = form(requests[2])
assert body == {
"grant_type": JWT_BEARER_GRANT_TYPE,
"assertion": "the-id-jag",
"client_id": "test-client-id",
"resource": f"{RS}/mcp",
"scope": "mcp",
"client_secret": "test-client-secret",
}
assert "Authorization" not in requests[2].headers
assert record == [(ISSUER, f"{RS}/mcp")]
assert response.status_code == 200
assert storage.tokens is not None
assert storage.tokens.access_token == "issued-token"
assert storage.tokens.scope == "mcp"
@pytest.mark.anyio
async def test_resource_server_metadata_is_never_consulted() -> None:
"""No PRM well-known and no RS-origin ASM well-known is ever fetched.
This is the by-construction property: the AS is configuration, so the resource server has no
input into where the ID-JAG or client secret go. Any GET to the RS host fails the test.
"""
requests: list[httpx.Request] = []
auth = make_provider()
async with httpx.AsyncClient(
transport=mock_transport(requests, asm=asm_body(), token=token_body()), auth=auth
) as http:
await http.post(f"{RS}/mcp")
rs_gets = [r for r in requests if r.url.host == "mcp.example.com" and r.method == "GET"]
assert rs_gets == []
assert all(r.url.host == "auth.example.com" for r in requests if r.method == "GET")
# No DCR was attempted anywhere.
assert not any(r.url.path == "/register" for r in requests)
@pytest.mark.anyio
async def test_asm_404_at_configured_issuer_raises_before_minting_assertion() -> None:
"""If the issuer's well-knowns 404, the flow fails closed and the assertion is never minted."""
requests: list[httpx.Request] = []
record: list[tuple[str, str]] = []
auth = make_provider(record=record)
async with httpx.AsyncClient(transport=mock_transport(requests, asm=404), auth=auth) as http:
with pytest.raises(OAuthFlowError, match="No authorization server metadata"):
await http.post(f"{RS}/mcp")
# Both RFC 8414 and OIDC well-knowns were tried at the configured issuer; nothing else.
assert [str(r.url) for r in requests if r.method == "GET"] == [f"{ISSUER}{ASM_PATH}", f"{ISSUER}{OIDC_PATH}"]
assert record == []
assert not any(r.url.path == "/token" for r in requests)
@pytest.mark.anyio
async def test_asm_5xx_stops_discovery_and_raises() -> None:
"""A 5xx at the issuer's well-known stops discovery without trying further URLs."""
requests: list[httpx.Request] = []
auth = make_provider()
async with httpx.AsyncClient(transport=mock_transport(requests, asm=500), auth=auth) as http:
with pytest.raises(OAuthFlowError, match="No authorization server metadata"):
await http.post(f"{RS}/mcp")
assert [str(r.url) for r in requests if r.method == "GET"] == [f"{ISSUER}{ASM_PATH}"]
@pytest.mark.anyio
async def test_asm_with_wrong_issuer_is_rejected_before_minting_assertion() -> None:
"""RFC 8414 section 3.3: metadata whose `issuer` differs from the configured one is rejected."""
requests: list[httpx.Request] = []
record: list[tuple[str, str]] = []
auth = make_provider(record=record)
async with httpx.AsyncClient(
transport=mock_transport(requests, asm=asm_body(issuer="https://other.example")), auth=auth
) as http:
with pytest.raises(OAuthFlowError, match="issuer mismatch"):
await http.post(f"{RS}/mcp")
assert record == []
assert not any(r.url.path == "/token" for r in requests)
@pytest.mark.anyio
async def test_asm_with_off_origin_token_endpoint_is_rejected_before_minting_assertion() -> None:
"""A `token_endpoint` off the configured issuer's origin is refused before any credential is sent."""
requests: list[httpx.Request] = []
record: list[tuple[str, str]] = []
auth = make_provider(record=record)
async with httpx.AsyncClient(
transport=mock_transport(requests, asm=asm_body(token_endpoint="https://other.example/token")), auth=auth
) as http:
with pytest.raises(OAuthFlowError, match="not on the configured issuer origin"):
await http.post(f"{RS}/mcp")
assert record == []
assert not any(r.url.path == "/token" for r in requests)
@pytest.mark.anyio
async def test_403_insufficient_scope_unions_challenged_scope_with_configured() -> None:
"""A 403 `insufficient_scope` re-exchanges with the union of configured and challenged scopes."""
requests: list[httpx.Request] = []
auth = make_provider(scope="mcp")
transport = mock_transport(
requests,
asm=asm_body(),
token=token_body(),
rs_first_status=403,
rs_first_headers={"WWW-Authenticate": 'Bearer error="insufficient_scope", scope="mcp files:write"'},
)
async with httpx.AsyncClient(transport=transport, auth=auth) as http:
response = await http.post(f"{RS}/mcp")
[token_req] = [r for r in requests if r.url.path == "/token"]
assert form(token_req)["scope"] == "mcp files:write"
assert response.status_code == 200
@pytest.mark.anyio
async def test_403_without_insufficient_scope_does_not_reauthorize() -> None:
"""A plain 403 (not `insufficient_scope`) is returned to the caller without re-exchanging."""
requests: list[httpx.Request] = []
record: list[tuple[str, str]] = []
auth = make_provider(record=record)
transport = mock_transport(requests, rs_first_status=403, rs_first_headers={"WWW-Authenticate": "Bearer"})
async with httpx.AsyncClient(transport=transport, auth=auth) as http:
response = await http.post(f"{RS}/mcp")
assert response.status_code == 403
assert record == []
assert [str(r.url) for r in requests] == [f"{RS}/mcp"]
@pytest.mark.anyio
async def test_token_endpoint_error_surfaces_as_oauth_token_error() -> None:
requests: list[httpx.Request] = []
auth = make_provider()
async with httpx.AsyncClient(transport=mock_transport(requests, asm=asm_body(), token=400), auth=auth) as http:
with pytest.raises(OAuthTokenError, match=r"Token exchange failed \(400\).*invalid_grant"):
await http.post(f"{RS}/mcp")
@pytest.mark.anyio
async def test_client_secret_basic_sends_basic_header_not_body_secret() -> None:
requests: list[httpx.Request] = []
auth = make_provider(token_endpoint_auth_method="client_secret_basic")
async with httpx.AsyncClient(
transport=mock_transport(requests, asm=asm_body(), token=token_body()), auth=auth
) as http:
await http.post(f"{RS}/mcp")
[token_req] = [r for r in requests if r.url.path == "/token"]
assert "client_secret" not in form(token_req)
decoded = base64.b64decode(token_req.headers["Authorization"].removeprefix("Basic ")).decode()
assert decoded == "test-client-id:test-client-secret"
@pytest.mark.anyio
async def test_stored_token_is_reused_without_reauthorizing() -> None:
"""A valid stored token is sent on the first request; on success no ASM or /token is fetched."""
requests: list[httpx.Request] = []
storage = InMemoryStorage(tokens=OAuthToken(access_token="cached", token_type="Bearer", expires_in=3600))
auth = make_provider(storage)
transport = mock_transport(requests, rs_first_status=200)
async with httpx.AsyncClient(transport=transport, auth=auth) as http:
response = await http.post(f"{RS}/mcp")
assert response.status_code == 200
assert [str(r.url) for r in requests] == [f"{RS}/mcp"]
assert requests[0].headers["Authorization"] == "Bearer cached"
@pytest.mark.anyio
async def test_second_401_re_exchanges_without_refetching_asm() -> None:
"""ASM is discovered once; a later 401 mints a fresh assertion against the cached token endpoint."""
requests: list[httpx.Request] = []
record: list[tuple[str, str]] = []
auth = make_provider(record=record)
rs_hits = 0
def handle(request: httpx.Request) -> httpx.Response:
nonlocal rs_hits
requests.append(request)
host, path = request.url.host, request.url.path
if host == "mcp.example.com":
rs_hits += 1
# First and third RS hits draw a 401; second and fourth succeed.
return httpx.Response(401 if rs_hits in (1, 3) else 200)
if host == "auth.example.com" and path == ASM_PATH:
return httpx.Response(200, content=asm_body(), headers={"content-type": "application/json"})
assert host == "auth.example.com" and path == "/token"
return httpx.Response(200, content=token_body(), headers={"content-type": "application/json"})
async with httpx.AsyncClient(transport=httpx.MockTransport(handle), auth=auth) as http:
await http.post(f"{RS}/mcp")
await http.post(f"{RS}/mcp")
asm_gets = [r for r in requests if r.url.path == ASM_PATH]
token_posts = [r for r in requests if r.url.path == "/token"]
assert len(asm_gets) == 1
assert len(token_posts) == 2
assert len(record) == 2
@pytest.mark.anyio
async def test_no_configured_scope_omits_scope_and_backfills_from_request() -> None:
"""With no configured scope and no scope in the token response, the stored token records None."""
requests: list[httpx.Request] = []
storage = InMemoryStorage()
auth = make_provider(storage, scope=None)
async with httpx.AsyncClient(
transport=mock_transport(requests, asm=asm_body(), token=token_body()), auth=auth
) as http:
await http.post(f"{RS}/mcp")
[token_req] = [r for r in requests if r.url.path == "/token"]
assert "scope" not in form(token_req)
assert storage.tokens is not None
assert storage.tokens.scope is None
def test_empty_client_secret_is_rejected() -> None:
async def assertion_provider(audience: str, resource: str) -> str:
raise NotImplementedError
with pytest.raises(ValueError, match="client_secret is required"):
IdentityAssertionOAuthProvider(
server_url=f"{RS}/mcp",
storage=InMemoryStorage(),
client_id="c",
client_secret="",
issuer=ISSUER,
assertion_provider=assertion_provider,
)
def test_empty_issuer_is_rejected() -> None:
async def assertion_provider(audience: str, resource: str) -> str:
raise NotImplementedError
with pytest.raises(ValueError, match="issuer is required"):
IdentityAssertionOAuthProvider(
server_url=f"{RS}/mcp",
storage=InMemoryStorage(),
client_id="c",
client_secret="s",
issuer="",
assertion_provider=assertion_provider,
)
def test_origin_normalizes_default_ports() -> None:
"""`_origin` treats an explicit scheme-default port as equal to the port-less form."""
assert _origin("https://host") == _origin("https://host:443")
assert _origin("http://host") == _origin("http://host:80")
assert _origin("https://host") != _origin("https://host:8443")
assert _origin("https://host") != _origin("https://other")
+135
View File
@@ -0,0 +1,135 @@
from collections.abc import Callable, Generator
from contextlib import asynccontextmanager
from typing import Any
from unittest.mock import patch
import pytest
from mcp_types import JSONRPCNotification, JSONRPCRequest
import mcp.shared.memory
from mcp.client._transport import WriteStream
from mcp.shared.message import SessionMessage
class SpyMemoryObjectSendStream:
def __init__(self, original_stream: WriteStream[SessionMessage]):
self.original_stream = original_stream
self.sent_messages: list[SessionMessage] = []
async def send(self, message: SessionMessage):
self.sent_messages.append(message)
await self.original_stream.send(message)
async def aclose(self):
await self.original_stream.aclose()
async def __aenter__(self):
return self
async def __aexit__(self, *args: Any):
await self.aclose()
class StreamSpyCollection:
def __init__(self, client_spy: SpyMemoryObjectSendStream, server_spy: SpyMemoryObjectSendStream):
self.client = client_spy
self.server = server_spy
def clear(self) -> None:
"""Clear all captured messages."""
self.client.sent_messages.clear()
self.server.sent_messages.clear()
def get_client_requests(self, method: str | None = None) -> list[JSONRPCRequest]:
"""Get client-sent requests, optionally filtered by method."""
return [
req.message
for req in self.client.sent_messages
if isinstance(req.message, JSONRPCRequest) and (method is None or req.message.method == method)
]
def get_server_requests(self, method: str | None = None) -> list[JSONRPCRequest]: # pragma: no cover
"""Get server-sent requests, optionally filtered by method."""
return [ # pragma: no cover
req.message
for req in self.server.sent_messages
if isinstance(req.message, JSONRPCRequest) and (method is None or req.message.method == method)
]
def get_client_notifications(self, method: str | None = None) -> list[JSONRPCNotification]: # pragma: no cover
"""Get client-sent notifications, optionally filtered by method."""
return [
notif.message
for notif in self.client.sent_messages
if isinstance(notif.message, JSONRPCNotification) and (method is None or notif.message.method == method)
]
def get_server_notifications(self, method: str | None = None) -> list[JSONRPCNotification]: # pragma: no cover
"""Get server-sent notifications, optionally filtered by method."""
return [
notif.message
for notif in self.server.sent_messages
if isinstance(notif.message, JSONRPCNotification) and (method is None or notif.message.method == method)
]
@pytest.fixture
def stream_spy() -> Generator[Callable[[], StreamSpyCollection], None, None]:
"""Fixture that provides spies for both client and server write streams.
Example:
```python
async def test_something(stream_spy):
# ... set up server and client ...
spies = stream_spy()
# Run some operation that sends messages
await client.some_operation()
# Check the messages
requests = spies.get_client_requests(method="some/method")
assert len(requests) == 1
# Clear for the next operation
spies.clear()
```
"""
client_spy = None
server_spy = None
# Store references to our spy objects
def capture_spies(c_spy: SpyMemoryObjectSendStream, s_spy: SpyMemoryObjectSendStream):
nonlocal client_spy, server_spy
client_spy = c_spy
server_spy = s_spy
# Create patched version of stream creation
original_create_streams = mcp.shared.memory.create_client_server_memory_streams
@asynccontextmanager
async def patched_create_streams():
async with original_create_streams() as (client_streams, server_streams):
client_read, client_write = client_streams
server_read, server_write = server_streams
# Create spy wrappers
spy_client_write = SpyMemoryObjectSendStream(client_write)
spy_server_write = SpyMemoryObjectSendStream(server_write)
# Capture references for the test to use
capture_spies(spy_client_write, spy_server_write)
yield (client_read, spy_client_write), (server_read, spy_server_write)
# Apply the patch for the duration of the test
# Patch both locations since InMemoryTransport imports it directly
with patch("mcp.shared.memory.create_client_server_memory_streams", patched_create_streams):
with patch("mcp.client._memory.create_client_server_memory_streams", patched_create_streams):
# Return a collection with helper methods
def get_spy_collection() -> StreamSpyCollection:
assert client_spy is not None, "client_spy was not initialized"
assert server_spy is not None, "server_spy was not initialized"
return StreamSpyCollection(client_spy, server_spy)
yield get_spy_collection
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+948
View File
@@ -0,0 +1,948 @@
"""Tests for the unified Client class."""
from __future__ import annotations
import contextvars
from collections.abc import AsyncIterator, Iterator
from contextlib import asynccontextmanager, contextmanager
from unittest.mock import patch
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
CallToolResult,
EmptyResult,
GetPromptResult,
ListPromptsResult,
ListResourcesResult,
ListResourceTemplatesResult,
ListToolsResult,
Prompt,
PromptArgument,
PromptMessage,
PromptsCapability,
ReadResourceResult,
Resource,
ResourcesCapability,
ServerCapabilities,
TextContent,
TextResourceContents,
Tool,
ToolsCapability,
)
from mcp_types.version import LATEST_HANDSHAKE_VERSION
from pydantic import FileUrl
from mcp import MCPDeprecationWarning, MCPError
from mcp.client._memory import InMemoryTransport
from mcp.client._transport import TransportStreams
from mcp.client.client import Client
from mcp.client.session import ClientRequestContext
from mcp.client.streamable_http import streamable_http_client
from mcp.server import Server, ServerRequestContext
from mcp.server.mcpserver import Context, MCPServer
from mcp.shared.memory import MessageStream, create_client_server_memory_streams
from mcp.shared.message import SessionMessage
from tests.interaction._connect import BASE_URL, mounted_app
pytestmark = pytest.mark.anyio
@pytest.fixture
def simple_server() -> Server:
"""Create a simple MCP server for testing."""
async def handle_list_resources(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> ListResourcesResult:
return ListResourcesResult(
resources=[Resource(uri="memory://test", name="Test Resource", description="A test resource")]
)
async def handle_subscribe_resource(ctx: ServerRequestContext, params: types.SubscribeRequestParams) -> EmptyResult:
return EmptyResult()
async def handle_unsubscribe_resource(
ctx: ServerRequestContext, params: types.UnsubscribeRequestParams
) -> EmptyResult:
return EmptyResult()
async def handle_set_logging_level(ctx: ServerRequestContext, params: types.SetLevelRequestParams) -> EmptyResult:
return EmptyResult()
async def handle_completion(ctx: ServerRequestContext, params: types.CompleteRequestParams) -> types.CompleteResult:
return types.CompleteResult(completion=types.Completion(values=[]))
return Server( # pyright: ignore[reportDeprecated]
name="test_server",
on_list_resources=handle_list_resources,
on_subscribe_resource=handle_subscribe_resource,
on_unsubscribe_resource=handle_unsubscribe_resource,
on_set_logging_level=handle_set_logging_level,
on_completion=handle_completion,
)
@pytest.fixture
def app() -> MCPServer:
"""Create an MCPServer server for testing."""
server = MCPServer("test")
@server.tool()
def greet(name: str) -> str:
"""Greet someone by name."""
return f"Hello, {name}!"
@server.resource("test://resource")
def test_resource() -> str:
"""A test resource."""
return "Test content"
@server.prompt()
def greeting_prompt(name: str) -> str:
"""A greeting prompt."""
return f"Please greet {name} warmly."
return server
async def test_client_is_initialized(app: MCPServer):
"""Test that the client is initialized after entering context."""
async with Client(app, mode="legacy") as client:
assert client.server_capabilities == snapshot(
ServerCapabilities(
experimental={},
prompts=PromptsCapability(list_changed=False),
resources=ResourcesCapability(subscribe=False, list_changed=False),
tools=ToolsCapability(list_changed=False),
)
)
assert client.server_info.name == "test"
async def test_client_exposes_negotiated_protocol_version(app: MCPServer):
"""The negotiated protocol version is readable after initialization."""
async with Client(app, mode="legacy") as client:
assert client.protocol_version == LATEST_HANDSHAKE_VERSION
async def test_client_with_simple_server(simple_server: Server):
"""Test that from_server works with a basic Server instance."""
async with Client(simple_server) as client:
resources = await client.list_resources()
assert resources == snapshot(
ListResourcesResult(
resources=[Resource(name="Test Resource", uri="memory://test", description="A test resource")]
)
)
async def test_client_send_ping(app: MCPServer):
async with Client(app, mode="legacy") as client:
result = await client.send_ping() # pyright: ignore[reportDeprecated]
assert result == snapshot(EmptyResult())
async def test_client_list_tools(app: MCPServer):
async with Client(app) as client:
result = await client.list_tools()
assert result == snapshot(
ListToolsResult(
tools=[
Tool(
name="greet",
description="Greet someone by name.",
input_schema={
"properties": {"name": {"title": "Name", "type": "string"}},
"required": ["name"],
"title": "greetArguments",
"type": "object",
},
output_schema={
"properties": {"result": {"title": "Result", "type": "string"}},
"required": ["result"],
"title": "greetOutput",
"type": "object",
},
)
]
)
)
async def test_client_call_tool(app: MCPServer):
async with Client(app) as client:
result = await client.call_tool("greet", {"name": "World"})
assert result == snapshot(
CallToolResult(
content=[TextContent(text="Hello, World!")],
structured_content={"result": "Hello, World!"},
)
)
async def test_read_resource(app: MCPServer):
"""Test reading a resource."""
async with Client(app) as client:
result = await client.read_resource("test://resource")
assert result == snapshot(
ReadResourceResult(
contents=[TextResourceContents(uri="test://resource", mime_type="text/plain", text="Test content")]
)
)
async def test_read_resource_error_propagates():
"""MCPError raised by a server handler propagates to the client with its code intact."""
async def handle_read_resource(
ctx: ServerRequestContext, params: types.ReadResourceRequestParams
) -> ReadResourceResult:
raise MCPError(code=404, message="no resource with that URI was found")
server = Server("test", on_read_resource=handle_read_resource)
async with Client(server) as client:
with pytest.raises(MCPError) as exc_info:
await client.read_resource("unknown://example")
assert exc_info.value.error.code == 404
async def test_raise_exceptions_propagates_handler_error_on_modern_inproc_path():
"""`raise_exceptions=True` on the modern in-process path: an unmapped handler
exception reaches the client with its original type chained, instead of being
sanitized to an opaque `INTERNAL_ERROR`."""
async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
raise ValueError("boom")
server = Server("test", on_call_tool=handle_call_tool)
async with Client(server, mode="2026-07-28", raise_exceptions=True) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("explode", {})
# The original exception is chained — not swallowed into a generic "Internal server error".
assert isinstance(exc_info.value.__cause__, ValueError)
assert str(exc_info.value.__cause__) == "boom"
async def test_raise_exceptions_false_sanitizes_handler_error_on_modern_inproc_path():
"""`raise_exceptions=False` (the default) on the modern in-process path: an
unmapped handler exception is sanitized to an opaque `INTERNAL_ERROR` so the
in-process path matches the wire path's leak guard."""
async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
raise ValueError("boom")
server = Server("test", on_call_tool=handle_call_tool)
async with Client(server, mode="2026-07-28", raise_exceptions=False) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("explode", {})
assert exc_info.value.error.code == types.INTERNAL_ERROR
assert exc_info.value.error.message == "Internal server error"
assert exc_info.value.__cause__ is None
async def test_modern_inproc_path_refuses_server_initiated_requests():
"""The in-process modern entry enforces the same prohibition as the other
modern entries: a handler's request-scoped server-initiated request is
refused server-side with the no-back-channel contract, instead of the
protocol-forbidden frame being delivered to the client."""
async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
schema = types.ElicitRequestedSchema(type="object", properties={"x": {"type": "string"}})
await ctx.session.elicit_form("question", schema, related_request_id=ctx.request_id)
raise AssertionError("unreachable: elicit_form must refuse") # pragma: no cover
server = Server("test", on_call_tool=handle_call_tool)
async with Client(server, mode="2026-07-28") as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("asker", {})
assert exc_info.value.error.code == types.INVALID_REQUEST
assert "no back-channel" in exc_info.value.error.message
assert "elicitation/create" in exc_info.value.error.message
async def test_get_prompt(app: MCPServer):
"""Test getting a prompt."""
async with Client(app) as client:
result = await client.get_prompt("greeting_prompt", {"name": "Alice"})
assert result == snapshot(
GetPromptResult(
description="A greeting prompt.",
messages=[PromptMessage(role="user", content=TextContent(text="Please greet Alice warmly."))],
)
)
def test_client_session_property_before_enter(app: MCPServer):
"""Test that accessing session before context manager raises RuntimeError."""
client = Client(app)
with pytest.raises(RuntimeError, match="Client must be used within an async context manager"):
client.session
async def test_client_reentry_raises_runtime_error(app: MCPServer):
"""Test that reentering a client raises RuntimeError."""
async with Client(app) as client:
with pytest.raises(RuntimeError, match="Client is already entered"):
await client.__aenter__()
async def test_client_send_progress_notification():
"""Test sending progress notification."""
received_from_client = None
event = anyio.Event()
async def handle_progress(ctx: ServerRequestContext, params: types.ProgressNotificationParams) -> None:
nonlocal received_from_client
received_from_client = {"progress_token": params.progress_token, "progress": params.progress}
event.set()
server = Server(name="test_server", on_progress=handle_progress) # pyright: ignore[reportDeprecated]
with anyio.fail_after(5):
async with Client(server, mode="legacy") as client:
await client.send_progress_notification(progress_token="token123", progress=50.0) # pyright: ignore[reportDeprecated]
await event.wait()
assert received_from_client == snapshot({"progress_token": "token123", "progress": 50.0})
async def test_client_subscribe_resource(simple_server: Server):
async with Client(simple_server, mode="legacy") as client:
with pytest.warns(MCPDeprecationWarning, match="use Client.listen"):
result = await client.subscribe_resource("memory://test") # pyright: ignore[reportDeprecated]
assert result == snapshot(EmptyResult())
async def test_client_unsubscribe_resource(simple_server: Server):
async with Client(simple_server, mode="legacy") as client:
with pytest.warns(MCPDeprecationWarning, match="use Client.listen"):
result = await client.unsubscribe_resource("memory://test") # pyright: ignore[reportDeprecated]
assert result == snapshot(EmptyResult())
async def test_client_set_logging_level(simple_server: Server):
"""Test setting logging level."""
async with Client(simple_server, mode="legacy") as client:
result = await client.set_logging_level("debug") # pyright: ignore[reportDeprecated]
assert result == snapshot(EmptyResult())
async def test_client_list_resources_with_params(app: MCPServer):
"""Test listing resources with params parameter."""
async with Client(app) as client:
result = await client.list_resources()
assert result == snapshot(
ListResourcesResult(
resources=[
Resource(
name="test_resource",
uri="test://resource",
description="A test resource.",
mime_type="text/plain",
)
]
)
)
async def test_client_list_resource_templates(app: MCPServer):
"""Test listing resource templates with params parameter."""
async with Client(app) as client:
result = await client.list_resource_templates()
assert result == snapshot(ListResourceTemplatesResult(resource_templates=[]))
async def test_list_prompts(app: MCPServer):
"""Test listing prompts with params parameter."""
async with Client(app) as client:
result = await client.list_prompts()
assert result == snapshot(
ListPromptsResult(
prompts=[
Prompt(
name="greeting_prompt",
description="A greeting prompt.",
arguments=[PromptArgument(name="name", required=True)],
)
]
)
)
async def test_complete_with_prompt_reference(simple_server: Server):
"""Test getting completions for a prompt argument."""
async with Client(simple_server) as client:
ref = types.PromptReference(type="ref/prompt", name="test_prompt")
result = await client.complete(ref=ref, argument={"name": "arg", "value": "test"})
assert result == snapshot(types.CompleteResult(completion=types.Completion(values=[])))
def test_client_with_url_initializes_streamable_http_transport():
with patch("mcp.client.client.streamable_http_client") as mock:
_ = Client("http://localhost:8000/mcp")
mock.assert_called_once_with("http://localhost:8000/mcp")
async def test_client_uses_transport_directly(app: MCPServer):
transport = InMemoryTransport(app)
async with Client(transport, mode="legacy") as client:
result = await client.call_tool("greet", {"name": "Transport"})
assert result == snapshot(
CallToolResult(
content=[TextContent(text="Hello, Transport!")],
structured_content={"result": "Hello, Transport!"},
)
)
_TEST_CONTEXTVAR = contextvars.ContextVar("test_var", default="initial")
@contextmanager
def _set_test_contextvar(value: str) -> Iterator[None]:
token = _TEST_CONTEXTVAR.set(value)
try:
yield
finally:
_TEST_CONTEXTVAR.reset(token)
async def test_context_propagation():
"""Sender's contextvars.Context is propagated to the server handler."""
server = MCPServer("test")
@server.tool()
async def check_context() -> str:
"""Return the contextvar value visible to the handler."""
return _TEST_CONTEXTVAR.get()
async with Client(server) as client:
with _set_test_contextvar("client_value"):
result = await client.call_tool("check_context", {})
assert result.content[0].text == "client_value", ( # type: ignore[union-attr]
"Server handler did not see the sender's contextvars.Context"
)
async def test_client_auto_mode_probes_discover_then_adopts(simple_server: Server) -> None:
"""`mode='auto'` over an in-process HTTP transport: the `server/discover` probe
reaches the modern entry and the negotiated protocol version is adopted without
an `initialize` handshake."""
with anyio.fail_after(5):
async with (
mounted_app(simple_server) as (http, _),
Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode="auto") as client,
):
assert client.protocol_version == "2026-07-28"
assert (await client.list_resources()).resources[0].name == "Test Resource"
@asynccontextmanager
async def _stream_loop_transport(server: Server) -> AsyncIterator[TransportStreams]:
"""A Transport whose far end is `Server.run` over crossed memory streams - the stdio shape, in process."""
async with (
create_client_server_memory_streams() as ((client_read, client_write), (server_read, server_write)),
anyio.create_task_group() as tg,
):
tg.start_soon(server.run, server_read, server_write, server.create_initialization_options())
yield client_read, client_write
tg.cancel_scope.cancel()
async def test_client_auto_mode_negotiates_modern_over_a_stream_loop(simple_server: Server) -> None:
"""`mode='auto'` against a real `Server.run` stream loop: the probe reaches the
dual-era driver, the connection locks modern, and feature requests are served
at 2026-07-28 with no `initialize` handshake."""
with anyio.fail_after(5):
async with Client(_stream_loop_transport(simple_server), mode="auto") as client:
assert client.protocol_version == "2026-07-28"
assert (await client.list_resources()).resources[0].name == "Test Resource"
async def test_client_pinned_modern_mode_works_over_a_stream_loop(simple_server: Server) -> None:
"""A pinned-modern client sends no probe: its first envelope-bearing request
locks the stream-loop connection modern and is served."""
with anyio.fail_after(5):
async with Client(_stream_loop_transport(simple_server), mode="2026-07-28") as client:
assert client.protocol_version == "2026-07-28"
assert (await client.list_resources()).resources[0].name == "Test Resource"
async def test_client_legacy_mode_still_handshakes_over_a_stream_loop(simple_server: Server) -> None:
"""`mode='legacy'` against the dual-era stream loop is byte-identical legacy:
the handshake runs and the session lands at a handshake-era version."""
with anyio.fail_after(5):
async with Client(_stream_loop_transport(simple_server), mode="legacy") as client:
assert client.protocol_version == LATEST_HANDSHAKE_VERSION
assert (await client.list_resources()).resources[0].name == "Test Resource"
async def test_client_auto_mode_recovers_from_a_timed_out_probe_over_a_stream_loop(
simple_server: Server, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A probe that outlives the client's discover timeout still succeeds on the
(slow-starting) server and locks the connection modern; the fallback
handshake's -32022 is modern evidence, so one corrective re-probe completes
the connect instead of stranding `mode='auto'`."""
monkeypatch.setattr("mcp.client.session.DISCOVER_TIMEOUT_SECONDS", 0.05)
c2relay_send, c2relay_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
relay2s_send, relay2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
async def relay() -> None:
# Hold the client's first frame (the probe) until its second frame (the
# post-timeout initialize) arrives - the deterministic stand-in for a
# server too slow to answer before the client's discover timeout.
held: SessionMessage | Exception | None = None
first = True
async for item in c2relay_recv:
if first:
held, first = item, False
continue
if held is not None:
await relay2s_send.send(held)
held = None
await relay2s_send.send(item)
@asynccontextmanager
async def transport() -> AsyncIterator[TransportStreams]:
async with c2relay_send, c2relay_recv, relay2s_send, relay2s_recv, s2c_send, s2c_recv:
async with anyio.create_task_group() as tg:
tg.start_soon(simple_server.run, relay2s_recv, s2c_send, simple_server.create_initialization_options())
tg.start_soon(relay)
yield s2c_recv, c2relay_send
tg.cancel_scope.cancel()
with anyio.fail_after(10):
async with Client(transport(), mode="auto") as client:
assert client.protocol_version == "2026-07-28"
assert (await client.list_resources()).resources[0].name == "Test Resource"
@pytest.mark.parametrize("code", [types.METHOD_NOT_FOUND, types.REQUEST_TIMEOUT, types.INTERNAL_ERROR])
async def test_client_auto_mode_falls_back_to_initialize_on_legacy_signal(code: int) -> None:
"""`mode='auto'`: any JSON-RPC error from `server/discover` makes
`Client.__aenter__` run the legacy `initialize()` handshake and land at a
handshake-era protocol version. The denylist policy treats every server-sent
rpc-error as "not modern" — including INTERNAL_ERROR, since a legacy server
may crash on the unknown method before reaching its router. A real `Server`
always implements `server/discover`, so the server side is hand-played."""
methods_seen: list[str] = []
async def scripted_server(streams: MessageStream) -> None:
server_read, server_write = streams
async for message in server_read:
assert isinstance(message, SessionMessage)
frame = message.message
assert isinstance(frame, types.JSONRPCRequest | types.JSONRPCNotification)
methods_seen.append(frame.method)
if isinstance(frame, types.JSONRPCNotification):
continue
if frame.method == "server/discover":
error = types.ErrorData(code=code, message="nope")
await server_write.send(SessionMessage(types.JSONRPCError(jsonrpc="2.0", id=frame.id, error=error)))
elif frame.method == "initialize": # pragma: no branch
result = types.InitializeResult(
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ServerCapabilities(),
server_info=types.Implementation(name="legacy-only", version="0.0.1"),
)
await server_write.send(
SessionMessage(
types.JSONRPCResponse(
jsonrpc="2.0",
id=frame.id,
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
)
@asynccontextmanager
async def scripted_transport() -> AsyncIterator[TransportStreams]:
async with (
create_client_server_memory_streams() as ((client_read, client_write), server_streams),
anyio.create_task_group() as tg,
):
tg.start_soon(scripted_server, server_streams)
yield client_read, client_write
tg.cancel_scope.cancel()
with anyio.fail_after(5):
async with Client(scripted_transport(), mode="auto") as client:
assert client.protocol_version == LATEST_HANDSHAKE_VERSION
assert client.server_info.name == "legacy-only"
assert methods_seen == ["server/discover", "initialize", "notifications/initialized"]
@pytest.mark.anyio
async def test_modern_list_tools_drops_tools_with_invalid_x_mcp_header_but_legacy_does_not() -> None:
"""At 2026-07-28 the spec requires clients to exclude tools whose `x-mcp-header`
annotation is malformed; handshake-era sessions surface them unchanged. Two
tools are advertised — one valid, one with a non-RFC-9110-token header name —
and the modern client sees only the valid one."""
valid = types.Tool(
name="ok",
input_schema={"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "Region"}}},
)
bad = types.Tool(
name="dropme",
input_schema={"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "bad name"}}},
)
async def on_list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[valid, bad])
server = Server("test", on_list_tools=on_list_tools)
with anyio.fail_after(5):
async with Client(server) as client:
result = await client.list_tools()
assert [t.name for t in result.tools] == ["ok"]
async with Client(server, mode="legacy") as client:
result = await client.list_tools()
assert [t.name for t in result.tools] == ["ok", "dropme"]
_RETIRED_TOOL = Tool(
name="retired",
input_schema={"type": "object", "properties": {"region": {"type": "string", "x-mcp-header": "Region"}}},
output_schema={"type": "object"},
)
_SURVIVOR_TOOL = Tool(name="survivor", input_schema={"type": "object"})
def _scripted_listing_server(listings: list[ListToolsResult]) -> Server:
"""Serves the given listings in order, one per tools/list request."""
async def on_list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return listings.pop(0)
return Server("test", on_list_tools=on_list_tools)
async def test_a_complete_listing_prunes_per_tool_state_for_tools_it_no_longer_contains() -> None:
"""SDK-defined: a complete (uncursored, cursorless) listing is the full tool universe, so the
header map and output schema derived from an earlier listing of a now-absent tool are dropped."""
server = _scripted_listing_server(
[
ListToolsResult(tools=[_RETIRED_TOOL, _SURVIVOR_TOOL]),
ListToolsResult(tools=[_SURVIVOR_TOOL]),
]
)
with anyio.fail_after(5):
async with Client(server) as client:
await client.session.list_tools()
assert set(client.session._x_mcp_header_maps) == {"retired", "survivor"}
assert set(client.session._tool_output_schemas) == {"retired", "survivor"}
await client.session.list_tools()
assert set(client.session._x_mcp_header_maps) == {"survivor"}
assert set(client.session._tool_output_schemas) == {"survivor"}
async def test_a_complete_listing_prunes_output_schemas_on_a_legacy_session_too() -> None:
"""SDK-defined: the prune is era-independent -- legacy sessions cache output schemas the same
way (their header-map dict just stays empty, since the x-mcp-header filter is 2026-only)."""
server = _scripted_listing_server(
[
ListToolsResult(tools=[_RETIRED_TOOL, _SURVIVOR_TOOL]),
ListToolsResult(tools=[_SURVIVOR_TOOL]),
]
)
with anyio.fail_after(5):
async with Client(server, mode="legacy") as client:
await client.session.list_tools()
assert set(client.session._tool_output_schemas) == {"retired", "survivor"}
assert client.session._x_mcp_header_maps == {}
await client.session.list_tools()
assert set(client.session._tool_output_schemas) == {"survivor"}
async def test_a_listing_with_a_next_cursor_prunes_no_per_tool_state() -> None:
"""SDK-defined: a first page carrying next_cursor is not the full universe -- state for tools
expected on later pages must survive it."""
server = _scripted_listing_server(
[
ListToolsResult(tools=[_RETIRED_TOOL, _SURVIVOR_TOOL]),
ListToolsResult(tools=[_SURVIVOR_TOOL], next_cursor="2"),
]
)
with anyio.fail_after(5):
async with Client(server) as client:
await client.session.list_tools()
await client.session.list_tools()
assert set(client.session._x_mcp_header_maps) == {"retired", "survivor"}
assert set(client.session._tool_output_schemas) == {"retired", "survivor"}
async def test_a_cursor_page_fetch_prunes_no_per_tool_state() -> None:
"""SDK-defined: a continuation page is partial even when it ends the pagination (no
next_cursor) -- only an uncursored single-page listing prunes."""
server = _scripted_listing_server(
[
ListToolsResult(tools=[_RETIRED_TOOL, _SURVIVOR_TOOL]),
ListToolsResult(tools=[_SURVIVOR_TOOL]),
]
)
with anyio.fail_after(5):
async with Client(server) as client:
await client.session.list_tools()
await client.session.list_tools(params=types.PaginatedRequestParams(cursor="2"))
assert set(client.session._x_mcp_header_maps) == {"retired", "survivor"}
assert set(client.session._tool_output_schemas) == {"retired", "survivor"}
def test_client_rejects_handshake_era_mode_at_construction() -> None:
"""A handshake-era protocol-version string passed as `mode=` is rejected by
`__post_init__` with a hint to use `mode='legacy'` — the version-pin path is
modern-only."""
server = MCPServer("test")
with pytest.raises(ValueError, match=r"handshake-era version; use mode='legacy'"):
Client(server, mode="2025-06-18")
with pytest.raises(ValueError, match=r"mode must be 'legacy', 'auto', or one of"):
Client(server, mode="not-a-version")
# ── SEP-2322 multi-round-trip auto-loop ────────────────────────────────────────
_NAME_SCHEMA = {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}
def _name_elicitation(message: str = "What is your name?") -> types.ElicitRequest:
return types.ElicitRequest(params=types.ElicitRequestFormParams(message=message, requested_schema=_NAME_SCHEMA))
async def test_call_tool_auto_loop_dispatches_elicitation_then_returns_final_result() -> None:
"""When the server returns `InputRequiredResult` carrying an elicitation,
`Client.call_tool` routes it to `elicitation_callback` and retries
automatically — the caller sees only the terminal `CallToolResult`."""
server = MCPServer("test")
@server.tool()
async def greet(ctx: Context) -> str | types.InputRequiredResult:
responses = ctx.input_responses
if responses and "user_name" in responses:
answer = responses["user_name"]
assert isinstance(answer, types.ElicitResult)
assert answer.content is not None
return f"Hello, {answer.content['name']}!"
return types.InputRequiredResult(input_requests={"user_name": _name_elicitation()})
callback_params: list[types.ElicitRequestParams] = []
async def elicitation_callback(
context: ClientRequestContext, params: types.ElicitRequestParams
) -> types.ElicitResult | types.ErrorData:
callback_params.append(params)
assert context.request_id == "user_name" # the inputRequests key is the request id
return types.ElicitResult(action="accept", content={"name": "Ada"})
with anyio.fail_after(5):
async with Client(server, elicitation_callback=elicitation_callback) as client:
result = await client.call_tool("greet")
assert result == snapshot(
CallToolResult(content=[TextContent(text="Hello, Ada!")], structured_content={"result": "Hello, Ada!"})
)
assert len(callback_params) == 1
assert isinstance(callback_params[0], types.ElicitRequestFormParams)
assert callback_params[0].message == "What is your name?"
assert callback_params[0].requested_schema == _NAME_SCHEMA
async def test_call_tool_auto_loop_dispatches_sampling_then_returns_final_result() -> None:
"""`InputRequiredResult` with an embedded `CreateMessageRequest` is routed
to `sampling_callback` and the call retried with the model's reply."""
server = MCPServer("test")
@server.tool()
async def ask(ctx: Context) -> str | types.InputRequiredResult:
responses = ctx.input_responses
if responses and "q" in responses:
answer = responses["q"]
assert isinstance(answer, types.CreateMessageResult)
assert answer.content.type == "text"
return f"Model said: {answer.content.text}"
return types.InputRequiredResult(
input_requests={
"q": types.CreateMessageRequest(
params=types.CreateMessageRequestParams(
messages=[types.SamplingMessage(role="user", content=TextContent(text="Capital of France?"))],
max_tokens=10,
)
)
}
)
callback_params: list[types.CreateMessageRequestParams] = []
async def sampling_callback(
context: ClientRequestContext, params: types.CreateMessageRequestParams
) -> types.CreateMessageResult | types.ErrorData:
callback_params.append(params)
return types.CreateMessageResult(role="assistant", content=TextContent(text="Paris"), model="echo")
with anyio.fail_after(5):
async with Client(server, sampling_callback=sampling_callback) as client:
result = await client.call_tool("ask")
assert result == snapshot(
CallToolResult(
content=[TextContent(text="Model said: Paris")], structured_content={"result": "Model said: Paris"}
)
)
assert len(callback_params) == 1
assert callback_params[0].messages[0].content == TextContent(text="Capital of France?")
async def test_call_tool_auto_loop_dispatches_list_roots_then_returns_final_result() -> None:
"""`InputRequiredResult` with an embedded `ListRootsRequest` is routed to
`list_roots_callback` and the call retried with the returned roots."""
server = MCPServer("test")
@server.tool()
async def count_roots(ctx: Context) -> str | types.InputRequiredResult:
responses = ctx.input_responses
if responses and "roots" in responses:
answer = responses["roots"]
assert isinstance(answer, types.ListRootsResult)
return f"Client exposed {len(answer.roots)} root(s)."
return types.InputRequiredResult(input_requests={"roots": types.ListRootsRequest()})
callback_called: list[ClientRequestContext] = []
async def list_roots_callback(context: ClientRequestContext) -> types.ListRootsResult | types.ErrorData:
callback_called.append(context)
return types.ListRootsResult(roots=[types.Root(uri=FileUrl("file:///workspace"))])
with anyio.fail_after(5):
async with Client(server, list_roots_callback=list_roots_callback) as client:
result = await client.call_tool("count_roots")
assert result == snapshot(
CallToolResult(
content=[TextContent(text="Client exposed 1 root(s).")],
structured_content={"result": "Client exposed 1 root(s)."},
)
)
assert len(callback_called) == 1
assert callback_called[0].request_id == "roots"
async def test_call_tool_auto_loop_round_trips_evolving_request_state_across_three_rounds() -> None:
"""A three-round flow where each `InputRequiredResult.request_state`
encodes the round number: the driver echoes it back byte-exact, the server
advances per round, and the elicitation callback runs once per round."""
server = MCPServer("test")
@server.tool()
async def multi(ctx: Context) -> str | types.InputRequiredResult:
# Round number is the integer the server stashed in `request_state` last leg.
round_num = int(ctx.request_state) if ctx.request_state else 0
if round_num == 3:
return "done after 3 rounds"
next_round = round_num + 1
return types.InputRequiredResult(
input_requests={f"step{next_round}": _name_elicitation(f"Round {next_round}?")},
request_state=str(next_round),
)
messages: list[str] = []
async def elicitation_callback(
context: ClientRequestContext, params: types.ElicitRequestParams
) -> types.ElicitResult | types.ErrorData:
assert isinstance(params, types.ElicitRequestFormParams)
messages.append(params.message)
return types.ElicitResult(action="accept", content={"name": "x"})
with anyio.fail_after(5):
async with Client(server, elicitation_callback=elicitation_callback) as client:
result = await client.call_tool("multi")
assert result.content == [TextContent(text="done after 3 rounds")]
assert messages == ["Round 1?", "Round 2?", "Round 3?"]
async def test_call_tool_auto_loop_raises_mcp_error_when_no_callback_registered() -> None:
"""SDK-defined: with no `elicitation_callback`, the default returns
`ErrorData(INVALID_REQUEST, ...)` and the driver raises it as `MCPError`
rather than retrying."""
server = MCPServer("test")
@server.tool()
async def needs_input(ctx: Context) -> str | types.InputRequiredResult:
if ctx.input_responses:
raise NotImplementedError # unreachable: client errors before retrying
return types.InputRequiredResult(input_requests={"ask": _name_elicitation()})
async with Client(server) as client:
with anyio.fail_after(5), pytest.raises(MCPError) as exc:
await client.call_tool("needs_input")
assert exc.value.error.code == types.INVALID_REQUEST
async def test_get_prompt_auto_loop_resolves_input_required_via_callbacks() -> None:
"""`Client.get_prompt` runs the same driver as `call_tool`: an
`InputRequiredResult` from `prompts/get` is fulfilled and retried."""
async def handler(
ctx: ServerRequestContext, params: types.GetPromptRequestParams
) -> types.GetPromptResult | types.InputRequiredResult:
assert params.name == "summary"
if params.input_responses and "ask" in params.input_responses:
return GetPromptResult(messages=[PromptMessage(role="user", content=TextContent(text="ok"))])
return types.InputRequiredResult(input_requests={"ask": _name_elicitation()})
server = Server("test")
server.add_request_handler("prompts/get", types.GetPromptRequestParams, handler)
async def elicitation_callback(
context: ClientRequestContext, params: types.ElicitRequestParams
) -> types.ElicitResult | types.ErrorData:
return types.ElicitResult(action="accept", content={"name": "x"})
with anyio.fail_after(5):
async with Client(server, mode="2026-07-28", elicitation_callback=elicitation_callback) as client:
result = await client.get_prompt("summary")
assert result == snapshot(GetPromptResult(messages=[PromptMessage(role="user", content=TextContent(text="ok"))]))
async def test_read_resource_auto_loop_resolves_input_required_via_callbacks() -> None:
"""`Client.read_resource` runs the same driver as `call_tool`: an
`InputRequiredResult` from `resources/read` is fulfilled and retried."""
async def handler(
ctx: ServerRequestContext, params: types.ReadResourceRequestParams
) -> types.ReadResourceResult | types.InputRequiredResult:
assert params.uri == "memory://gated"
if params.input_responses and "ask" in params.input_responses:
return ReadResourceResult(contents=[TextResourceContents(uri="memory://gated", text="unlocked")])
return types.InputRequiredResult(input_requests={"ask": _name_elicitation()})
server = Server("test")
server.add_request_handler("resources/read", types.ReadResourceRequestParams, handler)
async def elicitation_callback(
context: ClientRequestContext, params: types.ElicitRequestParams
) -> types.ElicitResult | types.ErrorData:
return types.ElicitResult(action="accept", content={"name": "x"})
with anyio.fail_after(5):
async with Client(server, mode="2026-07-28", elicitation_callback=elicitation_callback) as client:
result = await client.read_resource("memory://gated")
assert result == snapshot(
ReadResourceResult(contents=[TextResourceContents(uri="memory://gated", text="unlocked")])
)
File diff suppressed because it is too large Load Diff
+573
View File
@@ -0,0 +1,573 @@
"""`Client` + `ClientExtension` integration: extension declarations fold into the session at
construction, and `call_tool` drives claim resolvers transparently against real `MCPServer`s.
"""
import logging
from collections.abc import Awaitable, Callable, Sequence
from typing import Any, Literal, cast
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import CallToolResult, Result, TextContent
from mcp_types.version import LATEST_MODERN_VERSION
from pydantic import BaseModel
from typing_extensions import assert_type
from mcp.client import ClaimContext, ClientExtension, NotificationBinding, ResultClaim, advertise
from mcp.client.client import Client
from mcp.client.session import ClientRequestContext, _CallToolResultAdapter
from mcp.server import Server, ServerRequestContext
from mcp.server.context import CallNext, HandlerResult
from mcp.server.extension import Extension
from mcp.server.mcpserver import Context, MCPServer
pytestmark = pytest.mark.anyio
_VOUCHER_EXT = "com.example/voucher"
_RIVAL_EXT = "com.example/rival"
_NAME_SCHEMA = {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}
def _name_elicitation() -> types.ElicitRequest:
return types.ElicitRequest(
params=types.ElicitRequestFormParams(message="What is your name?", requested_schema=_NAME_SCHEMA)
)
class VoucherResult(Result):
"""The claimed `tools/call` shape, tagged `voucher`, carrying a vendor top-level field."""
result_type: Literal["voucher"] = "voucher"
voucher_code: str | None = None
_Resolver = Callable[[VoucherResult, ClaimContext], Awaitable[CallToolResult]]
class _VoucherExtension(ClientExtension):
"""Client half: claims the `voucher` tag with the supplied resolver."""
identifier = _VOUCHER_EXT
def __init__(self, resolve: _Resolver) -> None:
self._resolve = resolve
def claims(self) -> Sequence[ResultClaim[Any]]:
return [ResultClaim(result_type="voucher", model=VoucherResult, resolve=self._resolve)]
class _VoucherIssuer(Extension):
"""Server half: rewrites every `tools/call` result into the vendor-claimed shape."""
identifier = _VOUCHER_EXT
async def intercept_tool_call(
self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
) -> HandlerResult:
return {"resultType": "voucher", "voucherCode": "v-42"}
class _TwoRoundVoucherIssuer(Extension):
"""Server half: demands input on the first round, then issues the claimed shape."""
identifier = _VOUCHER_EXT
async def intercept_tool_call(
self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
) -> HandlerResult:
if params.input_responses is None:
return types.InputRequiredResult(input_requests={"user_name": _name_elicitation()})
return {"resultType": "voucher", "voucherCode": "after-input"}
def _voucher_server(issuer: Extension | None = None) -> MCPServer:
"""An `MCPServer` whose `issue` tool the server extension rewrites into the claimed shape."""
server = MCPServer("vouchers", extensions=[issuer if issuer is not None else _VoucherIssuer()])
@server.tool()
def issue() -> CallToolResult:
"""Issue a voucher."""
raise NotImplementedError # the server extension short-circuits before the tool runs
return server
def _structured_voucher_server() -> MCPServer:
"""Like `_voucher_server`, but `issue` declares an output schema (`-> str`)."""
server = MCPServer("vouchers", extensions=[_VoucherIssuer()])
@server.tool()
def issue() -> str:
"""Issue a voucher."""
raise NotImplementedError # the server extension short-circuits before the tool runs
return server
def _add_server() -> MCPServer:
"""A plain claim-less server with one ordinary tool."""
server = MCPServer("plain")
@server.tool()
def add(a: int, b: int) -> int:
"""Add two integers."""
return a + b
return server
# Construction-time validation
class _CouponResult(Result):
result_type: Literal["coupon"] = "coupon"
async def _unreachable_coupon_resolve(claimed: _CouponResult, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError # the wrong resolver for a voucher; must never run
class _CouponExtension(ClientExtension):
identifier = "com.example/coupons"
def claims(self) -> Sequence[ResultClaim[Any]]:
return [ResultClaim(result_type="coupon", model=_CouponResult, resolve=_unreachable_coupon_resolve)]
class _SelfConflictingClaims(ClientExtension):
identifier = "com.example/twice"
def claims(self) -> Sequence[ResultClaim[Any]]:
return [
ResultClaim(result_type="twice", model=_TwiceResult, resolve=_unreachable_twice_resolve),
ResultClaim(result_type="twice", model=_TwiceResult, resolve=_unreachable_twice_resolve),
]
class _TwiceResult(Result):
result_type: Literal["twice"] = "twice"
async def _unreachable_twice_resolve(claimed: _TwiceResult, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError
def test_mapping_extensions_get_the_migration_error() -> None:
"""SDK-defined: the replaced dict form fails with a message naming the new shape."""
with pytest.raises(TypeError) as exc_info:
Client(_add_server(), extensions=cast("Sequence[ClientExtension]", {"com.example/ui": {}}))
assert str(exc_info.value) == snapshot(
"extensions= takes a sequence of ClientExtension instances. The mapping form was "
"replaced: use advertise(identifier, settings) for advertise-only entries"
)
def test_one_extension_claiming_a_tag_twice_reads_as_one_owner() -> None:
"""SDK-defined: a self-conflict names the one extension once, not as a pair."""
with pytest.raises(ValueError) as exc_info:
Client(_add_server(), extensions=[_SelfConflictingClaims()])
assert str(exc_info.value) == snapshot(
"extension 'com.example/twice' claims resultType 'twice'; a wire tag can have only one resolver"
)
def test_bare_extension_instance_is_rejected_with_the_fix_named() -> None:
"""SDK-defined: an instance whose class never set `identifier` fails construction naming the type and the fix."""
with pytest.raises(ValueError) as exc_info:
Client(_add_server(), extensions=[ClientExtension()])
assert str(exc_info.value) == snapshot(
"ClientExtension has no `identifier`; a ClientExtension must set the `identifier` "
"class attribute (or assign one in `__init__`) before it can be used"
)
class _SelfAssignedBadId(ClientExtension):
"""Assigns a malformed identifier in `__init__`, invisible at class definition."""
def __init__(self) -> None:
self.identifier = "not-prefixed"
def test_invalid_per_instance_identifier_raises_the_validators_error() -> None:
"""SDK-defined: per-instance identifiers are validated when the Client consumes the extension."""
with pytest.raises(TypeError) as exc_info:
Client(_add_server(), extensions=[_SelfAssignedBadId()])
assert str(exc_info.value) == snapshot(
"_SelfAssignedBadId.identifier must be a `vendor-prefix/name` string "
"(reverse-DNS prefix required), got 'not-prefixed'"
)
def test_duplicate_extension_identifiers_are_rejected_naming_the_identifier() -> None:
"""SDK-defined: one identifier cannot appear twice across the extensions sequence."""
with pytest.raises(ValueError) as exc_info:
Client(_add_server(), extensions=[advertise(_VOUCHER_EXT), advertise(_VOUCHER_EXT, {"a": 1})])
assert str(exc_info.value) == snapshot("extension identifier 'com.example/voucher' is passed more than once")
async def _unreachable_resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError
class _RivalVoucherExtension(ClientExtension):
identifier = _RIVAL_EXT
def claims(self) -> Sequence[ResultClaim[Any]]:
return [ResultClaim(result_type="voucher", model=VoucherResult, resolve=_unreachable_resolve)]
def test_conflicting_claims_across_extensions_name_both_owners() -> None:
"""SDK-defined: two extensions claiming the same tag fail at construction with both owners named."""
with pytest.raises(ValueError) as exc_info:
Client(_add_server(), extensions=[_VoucherExtension(_unreachable_resolve), _RivalVoucherExtension()])
assert str(exc_info.value) == snapshot(
"extensions 'com.example/voucher' and 'com.example/rival' both claim resultType "
"'voucher'; a wire tag can have only one resolver"
)
class _EventParams(BaseModel):
seq: int
async def _unreachable_handler(params: _EventParams) -> None:
raise NotImplementedError
class _ObserverA(ClientExtension):
identifier = "com.example/observer-a"
def notifications(self) -> Sequence[NotificationBinding[Any]]:
return [
NotificationBinding(
method="notifications/vendor/event", params_type=_EventParams, handler=_unreachable_handler
)
]
class _ObserverB(ClientExtension):
identifier = "com.example/observer-b"
def notifications(self) -> Sequence[NotificationBinding[Any]]:
return [
NotificationBinding(
method="notifications/vendor/event", params_type=_EventParams, handler=_unreachable_handler
)
]
def test_conflicting_notification_bindings_name_both_owners() -> None:
"""SDK-defined: two extensions binding the same notification method fail with both owners named."""
with pytest.raises(ValueError) as exc_info:
Client(_add_server(), extensions=[_ObserverA(), _ObserverB()])
assert str(exc_info.value) == snapshot(
"extensions 'com.example/observer-a' and 'com.example/observer-b' both bind "
"notification method 'notifications/vendor/event'; a method can have only one observer"
)
# settings() consumption
class _CountedResult(Result):
result_type: Literal["counted"] = "counted"
async def _unreachable_counted_resolve(claimed: _CountedResult, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError
class _CountingSettings(ClientExtension):
identifier = "com.example/counted"
def __init__(self) -> None:
self.reads = 0
self.claims_reads = 0
self.notifications_reads = 0
def settings(self) -> dict[str, Any]:
self.reads += 1
return {"read": self.reads}
def claims(self) -> Sequence[ResultClaim[Any]]:
self.claims_reads += 1
return [ResultClaim(result_type="counted", model=_CountedResult, resolve=_unreachable_counted_resolve)]
def notifications(self) -> Sequence[NotificationBinding[Any]]:
self.notifications_reads += 1
return [
NotificationBinding(method="notifications/counted", params_type=_EventParams, handler=_unreachable_handler)
]
async def test_declarations_are_read_exactly_once_at_construction() -> None:
"""SDK-defined: each declaration method is read exactly once, at Client construction, never again."""
extension = _CountingSettings()
client = Client(_add_server(), extensions=[extension])
assert (extension.reads, extension.claims_reads, extension.notifications_reads) == (1, 1, 1)
with anyio.fail_after(5):
async with client:
await client.call_tool("add", {"a": 1, "b": 2})
await client.call_tool("add", {"a": 3, "b": 4})
assert (extension.reads, extension.claims_reads, extension.notifications_reads) == (1, 1, 1)
async def test_settings_dict_is_held_by_reference_not_copied() -> None:
"""SDK-defined: the settings dict is held by reference, so mutating it before connect changes the ad."""
observed: list[dict[str, dict[str, Any]] | None] = []
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "probe"
assert ctx.session.client_params is not None
observed.append(ctx.session.client_params.capabilities.extensions)
return CallToolResult(content=[])
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})])
server = Server("probe", on_call_tool=call_tool, on_list_tools=list_tools)
settings = {"tier": "bronze"}
client = Client(server, extensions=[advertise("com.example/loyalty", settings)])
settings["tier"] = "gold"
with anyio.fail_after(5):
async with client:
await client.call_tool("probe", {})
assert observed == [{"com.example/loyalty": {"tier": "gold"}}]
# extensions=None stays byte-identical
@pytest.mark.parametrize("extensions", [None, ()], ids=["none", "empty"])
async def test_no_extensions_keeps_tools_call_parsing_byte_identical(
extensions: Sequence[ClientExtension] | None,
) -> None:
"""SDK-defined: `extensions=None` and an empty sequence leave the session exactly as a claim-less client's."""
with anyio.fail_after(5):
async with Client(_add_server(), extensions=extensions) as client:
assert client.session._call_tool_adapter is _CallToolResultAdapter
result = await client.call_tool("add", {"a": 1, "b": 2})
assert result.structured_content == {"result": 3}
# The transparent claim path
async def test_claimed_result_resolves_transparently_to_the_resolvers_result() -> None:
"""A claimed shape never surfaces: the resolver gets the parsed model and `call_tool` returns its product."""
received: list[VoucherResult] = []
produced: list[CallToolResult] = []
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
received.append(claimed)
product = CallToolResult(content=[TextContent(text=f"honored {claimed.voucher_code}")])
produced.append(product)
return product
with anyio.fail_after(5):
async with Client(_voucher_server(), extensions=[_VoucherExtension(resolve)]) as client:
result = await client.call_tool("issue", {})
assert_type(result, CallToolResult)
assert [claimed.voucher_code for claimed in received] == ["v-42"]
assert result is produced[0]
assert result.content == [TextContent(text="honored v-42")]
async def test_claimed_shape_routes_to_its_owning_extensions_resolver() -> None:
"""With two claim-bearing extensions registered, the parsed shape runs its owner's resolver only."""
received: list[VoucherResult] = []
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
received.append(claimed)
return CallToolResult(content=[TextContent(text="routed")])
extensions = [_CouponExtension(), _VoucherExtension(resolve)]
with anyio.fail_after(5):
async with Client(_voucher_server(), extensions=extensions) as client:
result = await client.call_tool("issue", {})
assert [claimed.voucher_code for claimed in received] == ["v-42"]
assert result.content == [TextContent(text="routed")]
async def test_resolver_product_gets_the_direct_paths_output_schema_revalidation() -> None:
"""The resolver's product is revalidated against the tool's output schema exactly like a direct result."""
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
return CallToolResult(content=[TextContent(text="unstructured")])
async with Client(_structured_voucher_server(), extensions=[_VoucherExtension(resolve)]) as client:
with anyio.fail_after(5), pytest.raises(RuntimeError) as exc_info:
await client.call_tool("issue", {})
assert str(exc_info.value) == snapshot("Tool issue has an output schema but did not return structured content")
async def test_resolver_error_result_is_returned_not_raised() -> None:
"""An `isError` resolver product skips output-schema revalidation and comes back as-is."""
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
return CallToolResult(content=[TextContent(text="voucher printer on fire")], is_error=True)
with anyio.fail_after(5):
async with Client(_structured_voucher_server(), extensions=[_VoucherExtension(resolve)]) as client:
result = await client.call_tool("issue", {})
assert result.is_error
assert result.content == [TextContent(text="voucher printer on fire")]
async def test_resolver_receives_the_calls_claim_context() -> None:
"""`ClaimContext` carries the client's own session object, the tool name, and the per-call read timeout."""
contexts: list[ClaimContext] = []
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
contexts.append(ctx)
return CallToolResult(content=[])
with anyio.fail_after(5):
async with Client(_voucher_server(), extensions=[_VoucherExtension(resolve)]) as client:
await client.call_tool("issue", {}, read_timeout_seconds=7.0)
[ctx] = contexts
assert ctx.session is client.session
assert ctx.tool_name == "issue"
assert ctx.read_timeout_seconds == 7.0
class _VoucherRefused(Exception):
"""Extension-owned error vocabulary."""
async def test_resolver_exception_propagates_untouched() -> None:
"""A resolver exception reaches the `call_tool` caller as the very object raised, unwrapped."""
refusal = _VoucherRefused("the voucher is refused")
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
raise refusal
async with Client(_voucher_server(), extensions=[_VoucherExtension(resolve)]) as client:
with anyio.fail_after(5), pytest.raises(_VoucherRefused) as exc_info:
await client.call_tool("issue", {})
assert exc_info.value is refusal
# Unclaimed results with extensions present
async def test_unclaimed_result_flows_through_unchanged_with_extensions_present() -> None:
"""An ordinary `CallToolResult` is untouched by the claim machinery; the resolver never runs."""
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError # this server never produces a claimed shape
with anyio.fail_after(5):
async with Client(_add_server(), extensions=[_VoucherExtension(resolve)]) as client:
result = await client.call_tool("add", {"a": 1, "b": 2})
assert result.structured_content == {"result": 3}
async def test_input_required_then_plain_result_keeps_the_auto_loop_working() -> None:
"""With a claim-bearing extension present, the input_required auto loop on an unclaimed tool is unchanged."""
server = MCPServer("mrtr")
@server.tool()
async def greet(ctx: Context) -> str | types.InputRequiredResult:
responses = ctx.input_responses
if responses and "user_name" in responses:
answer = responses["user_name"]
assert isinstance(answer, types.ElicitResult)
assert answer.content is not None
return f"Hello, {answer.content['name']}!"
return types.InputRequiredResult(input_requests={"user_name": _name_elicitation()})
async def elicitation_callback(
context: ClientRequestContext, params: types.ElicitRequestParams
) -> types.ElicitResult | types.ErrorData:
return types.ElicitResult(action="accept", content={"name": "Ada"})
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError # this server never produces a claimed shape
with anyio.fail_after(5):
async with Client(
server, elicitation_callback=elicitation_callback, extensions=[_VoucherExtension(resolve)]
) as client:
result = await client.call_tool("greet")
assert result.content == [TextContent(text="Hello, Ada!")]
# The multi-round-trip + claimed interplay
async def test_input_required_then_claimed_result_on_retry_resolves_transparently() -> None:
"""A call that demands input first and returns a claimed shape on the retry still resolves transparently."""
prompted: list[str] = []
received: list[VoucherResult] = []
async def elicitation_callback(
context: ClientRequestContext, params: types.ElicitRequestParams
) -> types.ElicitResult | types.ErrorData:
assert isinstance(params, types.ElicitRequestFormParams)
prompted.append(params.message)
return types.ElicitResult(action="accept", content={"name": "Ada"})
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
received.append(claimed)
return CallToolResult(content=[TextContent(text=f"honored {claimed.voucher_code}")])
server = _voucher_server(issuer=_TwoRoundVoucherIssuer())
with anyio.fail_after(5):
async with Client(
server, elicitation_callback=elicitation_callback, extensions=[_VoucherExtension(resolve)]
) as client:
result = await client.call_tool("issue", {})
assert prompted == ["What is your name?"]
assert [claimed.voucher_code for claimed in received] == ["after-input"]
assert result.content == [TextContent(text="honored after-input")]
# Notification bindings fold into the session
class _CoreMethodObserver(ClientExtension):
"""Binds a method the modern core tables already define."""
identifier = "com.example/observer"
def notifications(self) -> Sequence[NotificationBinding[Any]]:
return [
NotificationBinding(method="notifications/message", params_type=_EventParams, handler=_unreachable_handler)
]
async def test_notification_bindings_fold_into_the_session(caplog: pytest.LogCaptureFixture) -> None:
"""The Client threads extension bindings into its session; a core-known binding draws the one-time warning."""
with caplog.at_level(logging.WARNING, logger="client"):
async with Client(_add_server(), extensions=[_CoreMethodObserver()]):
pass
expected = f"notification binding for 'notifications/message' will never fire at {LATEST_MODERN_VERSION}"
assert caplog.text.count(expected) == 1
+379
View File
@@ -0,0 +1,379 @@
"""Construction-time tests for `mcp.client.extension`; no session is ever opened."""
from dataclasses import FrozenInstanceError
from typing import Any, Literal, cast
import pytest
from inline_snapshot import snapshot
from mcp_types import CallToolResult, InputRequiredResult, Result
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
from pydantic import AliasChoices, AliasPath, BaseModel, Field
from pydantic.fields import FieldInfo
from mcp.client.extension import (
ClaimContext,
ClientExtension,
NotificationBinding,
ResultClaim,
_wire_keys,
advertise,
)
class _TaskResult(Result):
result_type: Literal["task"] = "task"
task_id: str = "t-1"
class _UntaggedResult(Result):
"""No `result_type` field at all."""
class _PlainStringTagResult(Result):
result_type: str = "task"
class _OtherTagResult(Result):
result_type: Literal["other"] = "other"
class _ClaimedCallToolResult(CallToolResult):
"""A core-result subclass; rejected as a claim model regardless of its tag."""
class _ClaimedInputRequiredResult(InputRequiredResult):
"""A core-result subclass; rejected as a claim model regardless of its tag."""
async def _resolve(result: Result, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError
def _claim(model: type[Result] = _TaskResult, **kwargs: Any) -> ResultClaim[Result]:
return ResultClaim(result_type="task", model=model, resolve=_resolve, **kwargs)
def test_claim_with_literal_discriminated_model_constructs() -> None:
"""SDK-defined: a model tagged with the claimed Literal constructs, defaulting to `tools/call` everywhere."""
claim = ResultClaim(result_type="task", model=_TaskResult, resolve=_resolve)
assert claim.result_type == "task"
assert claim.model is _TaskResult
assert claim.resolve is _resolve
assert claim.method == "tools/call"
assert claim.protocol_versions is None
def test_claim_accepts_modern_protocol_versions() -> None:
"""SDK-defined: a non-None `protocol_versions` subset of the modern revisions is accepted."""
versions = frozenset(MODERN_PROTOCOL_VERSIONS)
claim = _claim(protocol_versions=versions)
assert claim.protocol_versions == versions
def test_claim_rejects_core_result_type_vocabulary() -> None:
"""SDK-defined: a claim cannot re-key the core tags 'complete' and 'input_required'."""
messages: dict[str, str] = {}
for result_type in ("complete", "input_required"):
with pytest.raises(ValueError) as exc_info:
ResultClaim(result_type=result_type, model=_TaskResult, resolve=_resolve)
messages[result_type] = str(exc_info.value)
assert messages == snapshot(
{
"complete": "resultType 'complete' is core protocol vocabulary",
"input_required": "resultType 'input_required' is core protocol vocabulary",
}
)
@pytest.mark.parametrize("model", [_ClaimedCallToolResult, _ClaimedInputRequiredResult])
def test_claim_rejects_model_subclassing_core_result_types(model: type[Result]) -> None:
"""SDK-defined: a claim model subclassing a core result type is rejected; it would bypass claim routing."""
with pytest.raises(ValueError) as exc_info:
_claim(model=model)
assert str(exc_info.value) == snapshot("claim models must not subclass core result types")
def test_claim_rejects_model_without_result_type_field() -> None:
"""SDK-defined: the claim model must declare the discriminating `result_type` field."""
with pytest.raises(ValueError) as exc_info:
_claim(model=_UntaggedResult)
assert str(exc_info.value) == snapshot("_UntaggedResult.result_type must be Literal['task']")
def test_claim_rejects_plain_str_result_type_field() -> None:
"""SDK-defined: the model's `result_type` must be a Literal of the claimed tag, not a plain `str`."""
with pytest.raises(ValueError) as exc_info:
_claim(model=_PlainStringTagResult)
assert str(exc_info.value) == snapshot("_PlainStringTagResult.result_type must be Literal['task']")
def test_claim_rejects_mismatched_result_type_literal() -> None:
"""SDK-defined: the model's Literal tag must equal the claim's `result_type`."""
with pytest.raises(ValueError) as exc_info:
_claim(model=_OtherTagResult)
assert str(exc_info.value) == snapshot("_OtherTagResult.result_type must be Literal['task']")
class _NotAResult(BaseModel):
result_type: Literal["plain"] = "plain"
class _ReservedAliasResult(Result):
result_type: Literal["clash"] = "clash"
request_state: dict[str, Any] = {}
def test_claim_rejects_model_not_subclassing_result() -> None:
"""SDK-defined: a plain BaseModel cannot be a claim model; the session returns `Result` values."""
with pytest.raises(ValueError) as exc_info:
ResultClaim(result_type="plain", model=cast("type[Result]", _NotAResult), resolve=_resolve)
assert str(exc_info.value) == snapshot("_NotAResult must subclass mcp_types.Result")
def test_claim_rejects_model_aliasing_core_surface_fields() -> None:
"""SDK-defined: a field aliasing requestState or inputRequests would fail core pre-validation."""
with pytest.raises(ValueError) as exc_info:
ResultClaim(result_type="clash", model=_ReservedAliasResult, resolve=_resolve)
assert str(exc_info.value) == snapshot(
"_ReservedAliasResult.request_state aliases 'requestState', a typed field of the core "
"result surface; a colliding value would fail core validation before the claim adapter runs"
)
class _ValidationAliasResult(Result):
result_type: Literal["va"] = "va"
vendor_state: dict[str, Any] | None = Field(default=None, validation_alias="requestState")
class _SerializationAliasResult(Result):
result_type: Literal["sa"] = "sa"
vendor_state: dict[str, Any] | None = Field(default=None, serialization_alias="inputRequests")
class _AliasChoicesResult(Result):
result_type: Literal["ac"] = "ac"
vendor_state: dict[str, Any] | None = Field(
default=None, validation_alias=AliasChoices("vendorKey", "requestState")
)
class _AliasPathResult(Result):
result_type: Literal["ap"] = "ap"
vendor_state: dict[str, Any] | None = Field(
default=None, validation_alias=AliasChoices(AliasPath("requestState", "nested"))
)
def test_wire_keys_for_a_bare_field_is_just_its_name() -> None:
"""SDK-defined: a field with no aliases reads and writes only its own name."""
assert _wire_keys("plain", FieldInfo(annotation=str)) == frozenset({"plain"})
def test_claim_rejects_reserved_aliases_in_every_alias_form() -> None:
"""SDK-defined: validation_alias, serialization_alias, and AliasChoices routes to a reserved key are all caught."""
messages: dict[str, str] = {}
for model in (_ValidationAliasResult, _SerializationAliasResult, _AliasChoicesResult, _AliasPathResult):
with pytest.raises(ValueError) as exc_info:
ResultClaim(result_type=model.model_fields["result_type"].default, model=model, resolve=_resolve)
messages[model.__name__] = str(exc_info.value)
assert messages == snapshot(
{
"_ValidationAliasResult": "_ValidationAliasResult.vendor_state aliases "
"'requestState', a typed field of the core result surface; a colliding value would fail "
"core validation before the claim adapter runs",
"_SerializationAliasResult": "_SerializationAliasResult.vendor_state aliases "
"'inputRequests', a typed field of the core result surface; a colliding value would fail "
"core validation before the claim adapter runs",
"_AliasChoicesResult": "_AliasChoicesResult.vendor_state aliases 'requestState', a typed field of the core "
"result surface; a colliding value would fail core validation before the claim adapter runs",
"_AliasPathResult": "_AliasPathResult.vendor_state aliases "
"'requestState', a typed field of the core result surface; a colliding value would fail "
"core validation before the claim adapter runs",
}
)
def test_claim_rejects_method_outside_the_closed_verb_set() -> None:
"""SDK-defined: claims attach to `tools/call` only, even for values that dodge the static Literal gate."""
with pytest.raises(ValueError) as exc_info:
_claim(method=cast("Literal['tools/call']", "prompts/get"))
assert str(exc_info.value) == snapshot("claims attach to ['tools/call'] only; got method 'prompts/get'")
def test_claim_rejects_empty_protocol_versions() -> None:
"""SDK-defined: an empty version set is rejected; `None` is the spelling for every modern version."""
with pytest.raises(ValueError) as exc_info:
_claim(protocol_versions=frozenset())
assert str(exc_info.value) == snapshot("empty protocol_versions could never activate; use None for all")
def test_claim_rejects_non_modern_protocol_versions() -> None:
"""SDK-defined: a non-None version set must be a subset of the modern protocol revisions."""
messages: list[str] = []
for versions in (
frozenset({"2025-11-25"}),
frozenset({"2026-07-28", "2025-11-25"}),
frozenset({"never-a-version"}),
):
with pytest.raises(ValueError) as exc_info:
_claim(protocol_versions=versions)
messages.append(str(exc_info.value))
assert messages == snapshot(
[
"protocol_versions ['2025-11-25'] are not modern protocol revisions; claimed shapes "
"cannot be delivered on a legacy wire (None means every modern version)",
"protocol_versions ['2025-11-25'] are not modern protocol revisions; claimed shapes "
"cannot be delivered on a legacy wire (None means every modern version)",
"protocol_versions ['never-a-version'] are not modern protocol revisions; claimed shapes "
"cannot be delivered on a legacy wire (None means every modern version)",
]
)
def test_result_claim_is_frozen() -> None:
"""SDK-defined: claims are immutable; mutating one after construction raises."""
claim = _claim()
with pytest.raises(FrozenInstanceError):
setattr(claim, "result_type", "other") # direct assignment is also a type error
class _TaskNotificationParams(BaseModel):
task_id: str
async def _on_task(params: _TaskNotificationParams) -> None:
raise NotImplementedError
def test_notification_binding_constructs() -> None:
"""SDK-defined: a binding is a bare declaration with no construction-time validation."""
binding = NotificationBinding(method="notifications/tasks", params_type=_TaskNotificationParams, handler=_on_task)
assert binding.method == "notifications/tasks"
assert binding.params_type is _TaskNotificationParams
assert binding.handler is _on_task
def test_notification_binding_accepts_core_known_method() -> None:
"""SDK-defined: deliberately no spec-table check at construction, so packages survive core adopting a method."""
binding = NotificationBinding(
method="notifications/progress", params_type=_TaskNotificationParams, handler=_on_task
)
assert binding.method == "notifications/progress"
def test_notification_binding_is_frozen() -> None:
"""SDK-defined: bindings are immutable; mutating one after construction raises."""
binding = NotificationBinding(method="notifications/tasks", params_type=_TaskNotificationParams, handler=_on_task)
with pytest.raises(FrozenInstanceError):
setattr(binding, "method", "notifications/other") # direct assignment is also a type error
def test_extension_defaults_advertise_nothing() -> None:
"""SDK-defined: a minimal subclass advertises empty settings, no claims, and no bindings."""
class _MinimalExt(ClientExtension):
identifier = "com.example/minimal"
ext = _MinimalExt()
assert ext.settings() == {}
assert ext.claims() == ()
assert ext.notifications() == ()
@pytest.mark.parametrize(
"identifier",
[
"io.modelcontextprotocol/ui",
"com.example/my_ext",
"com.x-y.z2/n.a-b_c",
"example/x",
"a/b",
"com.example/9start",
],
)
def test_grammar_conformant_identifiers_accepted_at_class_definition(identifier: str) -> None:
"""Spec `_meta` key grammar: conformant `vendor-prefix/name` identifiers are accepted."""
cls = type("_GoodExt", (ClientExtension,), {"identifier": identifier})
assert cls.identifier == identifier
@pytest.mark.parametrize(
"identifier",
[
"noprefix",
"-foo/bar",
".leading/x",
"a..b/x",
"foo-/x",
"9foo/x",
"foo/-bar",
"foo/bar-",
"foo/",
"/bar",
"foo/ba r",
"io.modelcontextprotocol/ui\n",
"",
42,
],
)
def test_malformed_identifier_rejected_at_class_definition(identifier: Any) -> None:
"""SDK-defined: the SEP-2133 `vendor-prefix/name` grammar is enforced the moment the subclass is defined."""
with pytest.raises(TypeError):
type("_BadExt", (ClientExtension,), {"identifier": identifier})
def test_subclass_without_identifier_allowed_at_definition() -> None:
"""SDK-defined: a subclass with no class-level `identifier` is allowed; validation waits for consumption."""
class _AbstractishExt(ClientExtension):
"""Intermediate base; concrete subclasses supply the identifier."""
class _ConcreteExt(_AbstractishExt):
identifier = "com.example/concrete"
assert _ConcreteExt.identifier == "com.example/concrete"
def test_advertise_serves_captured_settings() -> None:
"""SDK-defined: `advertise()` returns an ad-only extension serving the captured settings."""
ext = advertise("com.example/flags", {"enabled": True})
assert isinstance(ext, ClientExtension)
assert ext.identifier == "com.example/flags"
assert ext.settings() == {"enabled": True}
assert ext.claims() == ()
assert ext.notifications() == ()
def test_advertise_defaults_to_empty_settings() -> None:
"""SDK-defined: omitting settings advertises the extension with an empty map."""
ext = advertise("com.example/flags")
assert ext.settings() == {}
@pytest.mark.parametrize("identifier", ["noprefix", "foo/", ""])
def test_advertise_validates_identifier_eagerly(identifier: str) -> None:
"""SDK-defined: `advertise()` validates the identifier eagerly, at the call site."""
with pytest.raises(TypeError):
advertise(identifier)
+173
View File
@@ -0,0 +1,173 @@
"""Tests for Unicode handling in streamable HTTP transport.
Verifies that Unicode text is correctly transmitted and received in both directions
(server→client and client→server) using the streamable HTTP transport.
"""
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
import httpx
import mcp_types as types
import pytest
from mcp_types import TextContent, Tool
from starlette.applications import Starlette
from starlette.routing import Mount
from mcp.client.session import ClientSession
from mcp.client.streamable_http import streamable_http_client
from mcp.server import Server, ServerRequestContext
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from tests.interaction.transports import StreamingASGITransport
# The in-process app is mounted at this origin purely so URLs are well-formed; nothing listens here.
BASE_URL = "http://127.0.0.1:8000"
# Test constants with various Unicode characters
UNICODE_TEST_STRINGS = {
"cyrillic": "Слой хранилища, где располагаются",
"cyrillic_short": "Привет мир",
"chinese": "你好世界 - 这是一个测试",
"japanese": "こんにちは世界 - これはテストです",
"korean": "안녕하세요 세계 - 이것은 테스트입니다",
"arabic": "مرحبا بالعالم - هذا اختبار",
"hebrew": "שלום עולם - זה מבחן",
"greek": "Γεια σου κόσμε - αυτό είναι δοκιμή",
"emoji": "Hello 👋 World 🌍 - Testing 🧪 Unicode ✨",
"math": "∑ ∫ √ ∞ ≠ ≤ ≥ ∈ ∉ ⊆ ⊇",
"accented": "Café, naïve, résumé, piñata, Zürich",
"mixed": "Hello世界🌍Привет안녕مرحباשלום",
"special": "Line\nbreak\ttab\r\nCRLF",
"quotes": '«French» „German" "English" 「Japanese」',
"currency": "€100 £50 ¥1000 ₹500 ₽200 ¢99",
}
async def handle_list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[
Tool(
name="echo_unicode",
description="🔤 Echo Unicode text - Hello 👋 World 🌍 - Testing 🧪 Unicode ✨",
input_schema={
"type": "object",
"properties": {
"text": {"type": "string", "description": "Text to echo back"},
},
"required": ["text"],
},
),
]
)
async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult:
assert params.name == "echo_unicode"
assert params.arguments is not None
return types.CallToolResult(content=[TextContent(type="text", text=f"Echo: {params.arguments['text']}")])
async def handle_list_prompts(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListPromptsResult:
return types.ListPromptsResult(
prompts=[
types.Prompt(
name="unicode_prompt",
description="Unicode prompt - Слой хранилища, где располагаются",
arguments=[],
)
]
)
async def handle_get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> types.GetPromptResult:
assert params.name == "unicode_prompt"
return types.GetPromptResult(
messages=[
types.PromptMessage(
role="user",
content=types.TextContent(type="text", text="Hello世界🌍Привет안녕مرحباשלום"),
)
]
)
@asynccontextmanager
async def unicode_session() -> AsyncIterator[ClientSession]:
"""Yield an initialized ClientSession speaking streamable HTTP (SSE responses) to the
Unicode test server, entirely in process."""
server = Server(
name="unicode_test_server",
on_list_tools=handle_list_tools,
on_call_tool=handle_call_tool,
on_list_prompts=handle_list_prompts,
on_get_prompt=handle_get_prompt,
)
# SSE response mode, so Unicode rides the SSE event encoding rather than a plain JSON body.
session_manager = StreamableHTTPSessionManager(app=server, json_response=False)
app = Starlette(routes=[Mount("/mcp", app=session_manager.handle_request)])
async with (
session_manager.run(),
# follow_redirects matches the SDK's own client factory; Starlette's Mount 307-redirects
# the bare /mcp path to /mcp/.
httpx.AsyncClient(
transport=StreamingASGITransport(app), base_url=BASE_URL, follow_redirects=True
) as http_client,
streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client) as (read_stream, write_stream),
ClientSession(read_stream, write_stream) as session,
):
await session.initialize()
yield session
@pytest.mark.anyio
async def test_streamable_http_client_unicode_tool_call() -> None:
"""Test that Unicode text is correctly handled in tool calls via streamable HTTP."""
async with unicode_session() as session:
# Test 1: List tools (server→client Unicode in descriptions)
tools = await session.list_tools()
assert len(tools.tools) == 1
# Check Unicode in tool descriptions
echo_tool = tools.tools[0]
assert echo_tool.name == "echo_unicode"
assert echo_tool.description is not None
assert "🔤" in echo_tool.description
assert "👋" in echo_tool.description
# Test 2: Send Unicode text in tool call (client→server→client)
for test_name, test_string in UNICODE_TEST_STRINGS.items():
result = await session.call_tool("echo_unicode", arguments={"text": test_string})
# Verify server correctly received and echoed back Unicode
assert len(result.content) == 1
content = result.content[0]
assert content.type == "text"
assert f"Echo: {test_string}" == content.text, f"Failed for {test_name}"
@pytest.mark.anyio
async def test_streamable_http_client_unicode_prompts() -> None:
"""Test that Unicode text is correctly handled in prompts via streamable HTTP."""
async with unicode_session() as session:
# Test 1: List prompts (server→client Unicode in descriptions)
prompts = await session.list_prompts()
assert len(prompts.prompts) == 1
prompt = prompts.prompts[0]
assert prompt.name == "unicode_prompt"
assert prompt.description is not None
assert "Слой хранилища, где располагаются" in prompt.description
# Test 2: Get prompt with Unicode content (server→client)
result = await session.get_prompt("unicode_prompt", arguments={})
assert len(result.messages) == 1
message = result.messages[0]
assert message.role == "user"
assert message.content.type == "text"
assert message.content.text == "Hello世界🌍Привет안녕مرحباשלום"
+271
View File
@@ -0,0 +1,271 @@
"""Unit tests for the SEP-2322 client-side multi-round-trip driver.
`run_input_required_driver` is pure: it takes the first `InputRequiredResult`
plus `dispatch` / `retry` closures and loops until a terminal result. These
tests build those closures by hand (scripted lists, recording lists) so the
driver is exercised without a `ClientSession`. Integration against a real
server lives in `test_client.py`.
"""
import anyio
import pytest
from inline_snapshot import snapshot
from mcp_types import (
INVALID_REQUEST,
CallToolResult,
ElicitRequest,
ElicitRequestFormParams,
ElicitResult,
ErrorData,
InputRequest,
InputRequiredResult,
InputResponse,
InputResponses,
TextContent,
)
from trio.testing import MockClock
from mcp import MCPError
from mcp.client._input_required import (
_STATE_ONLY_BACKOFF_CAP_SECONDS,
_STATE_ONLY_BACKOFF_INITIAL_SECONDS,
DEFAULT_INPUT_REQUIRED_MAX_ROUNDS,
InputRequiredRoundsExceededError,
run_input_required_driver,
)
pytestmark = pytest.mark.anyio
@pytest.fixture(autouse=True)
def _module_runner_lease() -> None:
"""Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`."""
def _elicit(message: str = "What is your name?") -> ElicitRequest:
schema = {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}
return ElicitRequest(params=ElicitRequestFormParams(message=message, requested_schema=schema))
async def _never_dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData:
"""Dispatch closure for tests whose script never carries `input_requests`."""
raise NotImplementedError
async def test_single_round_dispatches_then_retries_to_terminal_result() -> None:
"""One `InputRequiredResult` with one elicit request: dispatch runs once,
retry runs once with the collected response, and the terminal result is returned."""
first = InputRequiredResult(input_requests={"ask": _elicit()})
terminal = CallToolResult(content=[TextContent(text="done")])
dispatched: list[tuple[str, InputRequest]] = []
retried: list[tuple[InputResponses | None, str | None]] = []
async def dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData:
dispatched.append((key, req))
return ElicitResult(action="accept", content={"name": "Ada"})
async def retry(responses: InputResponses | None, state: str | None) -> CallToolResult | InputRequiredResult:
retried.append((responses, state))
return terminal
with anyio.fail_after(5):
result = await run_input_required_driver(first, dispatch=dispatch, retry=retry, max_rounds=3)
assert result is terminal
assert first.input_requests is not None
assert dispatched == [("ask", first.input_requests["ask"])]
assert retried == [({"ask": ElicitResult(action="accept", content={"name": "Ada"})}, None)]
async def test_multi_round_loops_until_retry_returns_non_input_required() -> None:
"""Two consecutive `InputRequiredResult` legs followed by a terminal result:
the driver dispatches and retries each leg in order."""
terminal = CallToolResult(content=[TextContent(text="done")])
script: list[CallToolResult | InputRequiredResult] = [
InputRequiredResult(input_requests={"b": _elicit("second?")}),
terminal,
]
retried: list[tuple[InputResponses | None, str | None]] = []
dispatched_keys: list[str] = []
async def dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData:
dispatched_keys.append(key)
return ElicitResult(action="decline")
async def retry(responses: InputResponses | None, state: str | None) -> CallToolResult | InputRequiredResult:
retried.append((responses, state))
return script.pop(0)
first = InputRequiredResult(input_requests={"a": _elicit("first?")})
with anyio.fail_after(5):
result = await run_input_required_driver(first, dispatch=dispatch, retry=retry, max_rounds=5)
assert result is terminal
assert dispatched_keys == ["a", "b"]
assert retried == snapshot(
[
({"a": ElicitResult(action="decline")}, None),
({"b": ElicitResult(action="decline")}, None),
]
)
async def test_exceeding_max_rounds_raises_with_the_configured_cap() -> None:
"""When every retry returns another `InputRequiredResult`, the driver gives
up after `max_rounds` retries with `InputRequiredRoundsExceededError`."""
rounds: list[int] = []
async def dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData:
return ElicitResult(action="decline")
async def retry(responses: InputResponses | None, state: str | None) -> CallToolResult | InputRequiredResult:
rounds.append(len(rounds))
return InputRequiredResult(input_requests={"again": _elicit()})
first = InputRequiredResult(input_requests={"again": _elicit()})
with anyio.fail_after(5):
with pytest.raises(InputRequiredRoundsExceededError) as exc:
await run_input_required_driver(first, dispatch=dispatch, retry=retry, max_rounds=3)
assert exc.value.max_rounds == 3
# `first` counts as round 1; rounds 1-3 each retry, round 4 trips the cap before dispatching.
assert len(rounds) == 3
async def test_dispatch_returning_error_data_aborts_the_loop_as_mcp_error() -> None:
"""SDK-defined: a callback that refuses an embedded request returns
`ErrorData`; the driver surfaces it as `MCPError` rather than retrying."""
async def dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData:
return ErrorData(code=INVALID_REQUEST, message="not supported")
async def retry(responses: InputResponses | None, state: str | None) -> CallToolResult | InputRequiredResult:
raise NotImplementedError # unreachable: dispatch errored before any retry
first = InputRequiredResult(input_requests={"ask": _elicit()})
with anyio.fail_after(5):
with pytest.raises(MCPError) as exc:
await run_input_required_driver(first, dispatch=dispatch, retry=retry, max_rounds=3)
assert exc.value.error.code == INVALID_REQUEST
async def test_request_state_passes_through_byte_identical() -> None:
"""`request_state` is opaque to the driver: each leg's value reaches `retry`
as the same object the server sent, never parsed or rebuilt."""
states = ['{"round": 1, "tag": "héllo"}', '{"round": 2, "tag": "wörld"}']
received_states: list[str | None] = []
async def dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData:
return ElicitResult(action="decline")
async def retry(responses: InputResponses | None, state: str | None) -> CallToolResult | InputRequiredResult:
received_states.append(state)
if len(received_states) < 2:
return InputRequiredResult(input_requests={"k": _elicit()}, request_state=states[1])
return CallToolResult(content=[])
first = InputRequiredResult(input_requests={"k": _elicit()}, request_state=states[0])
with anyio.fail_after(5):
await run_input_required_driver(first, dispatch=dispatch, retry=retry, max_rounds=3)
assert received_states[0] is states[0]
assert received_states[1] is states[1]
# Runs on trio's autojumping virtual clock so the backoff sleeps add zero
# wall-clock and the recorded deltas are exact: `anyio.sleep` advances the
# MockClock by precisely the requested duration once every task is idle.
@pytest.mark.parametrize(
"anyio_backend",
[pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")],
)
async def test_state_only_legs_back_off_exponentially_to_the_cap() -> None:
"""SDK-defined pacing: state-only legs sleep 50ms, 100ms, 200ms, then cap at
250ms. Six state-only rounds → deltas `[0.05, 0.1, 0.2, 0.25, 0.25, 0.25]`."""
retry_times: list[float] = []
async def retry(responses: InputResponses | None, state: str | None) -> CallToolResult | InputRequiredResult:
retry_times.append(anyio.current_time())
assert responses is None
if len(retry_times) == 6:
return CallToolResult(content=[])
return InputRequiredResult(request_state="poll")
start = anyio.current_time()
first = InputRequiredResult(request_state="poll")
await run_input_required_driver(first, dispatch=_never_dispatch, retry=retry, max_rounds=10)
deltas = [round(retry_times[0] - start, 9)] + [
round(retry_times[i] - retry_times[i - 1], 9) for i in range(1, len(retry_times))
]
assert deltas == snapshot([0.05, 0.1, 0.2, 0.25, 0.25, 0.25])
assert _STATE_ONLY_BACKOFF_INITIAL_SECONDS == 0.05
assert _STATE_ONLY_BACKOFF_CAP_SECONDS == 0.25
@pytest.mark.parametrize(
"anyio_backend",
[pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")],
)
async def test_backoff_counter_resets_after_a_leg_with_input_requests() -> None:
"""A leg carrying `input_requests` resets `consecutive_state_only`: the
next state-only leg sleeps the initial 50ms again, not the prior position."""
# state-only, state-only, dispatch leg (no sleep), state-only, terminal.
script: list[CallToolResult | InputRequiredResult] = [
InputRequiredResult(request_state="s"),
InputRequiredResult(input_requests={"k": _elicit()}),
InputRequiredResult(request_state="s"),
CallToolResult(content=[]),
]
retry_times: list[float] = []
async def dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData:
return ElicitResult(action="decline")
async def retry(responses: InputResponses | None, state: str | None) -> CallToolResult | InputRequiredResult:
retry_times.append(anyio.current_time())
return script.pop(0)
start = anyio.current_time()
first = InputRequiredResult(request_state="s")
await run_input_required_driver(first, dispatch=dispatch, retry=retry, max_rounds=10)
deltas = [round(retry_times[0] - start, 9)] + [
round(retry_times[i] - retry_times[i - 1], 9) for i in range(1, len(retry_times))
]
# 0.05, 0.1 (two state-only), 0.0 (dispatch leg has no sleep), 0.05 (reset).
assert deltas == snapshot([0.05, 0.1, 0.0, 0.05])
async def test_input_requests_are_dispatched_concurrently() -> None:
"""All `input_requests` in a round are dispatched together: each dispatch
blocks on a shared gate that only opens once every key has started, so a
sequential implementation would deadlock under the `fail_after`."""
keys = ["a", "b", "c"]
started: set[str] = set()
all_started = anyio.Event()
async def dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData:
started.add(key)
if started == set(keys):
all_started.set()
await all_started.wait() # blocks until every sibling is in-flight
return ElicitResult(action="accept", content={"name": key})
received: list[InputResponses | None] = []
async def retry(responses: InputResponses | None, state: str | None) -> CallToolResult | InputRequiredResult:
received.append(responses)
return CallToolResult(content=[])
first = InputRequiredResult(input_requests={k: _elicit() for k in keys})
with anyio.fail_after(5):
await run_input_required_driver(first, dispatch=dispatch, retry=retry, max_rounds=2)
assert received[0] is not None
assert received[0] == {k: ElicitResult(action="accept", content={"name": k}) for k in keys}
def test_default_max_rounds_constant() -> None:
"""SDK-defined default; matches the typescript-sdk."""
assert DEFAULT_INPUT_REQUIRED_MAX_ROUNDS == 10
+126
View File
@@ -0,0 +1,126 @@
from collections.abc import Callable
import mcp_types as types
import pytest
from mcp_types import ListToolsResult
from mcp import Client
from mcp.server import Server, ServerRequestContext
from mcp.server.mcpserver import MCPServer
from .conftest import StreamSpyCollection
pytestmark = pytest.mark.anyio
@pytest.fixture
async def full_featured_server():
"""Create a server with tools, resources, prompts, and templates."""
server = MCPServer("test")
# pragma: no cover on handlers below - these exist only to register items with the
# server so list_* methods return results. The handlers themselves are never called
# because these tests only verify pagination/cursor behavior, not tool/resource invocation.
@server.tool()
def greet(name: str) -> str: # pragma: no cover
"""Greet someone by name."""
return f"Hello, {name}!"
@server.resource("test://resource")
def test_resource() -> str: # pragma: no cover
"""A test resource."""
return "Test content"
@server.resource("test://template/{id}")
def test_template(id: str) -> str: # pragma: no cover
"""A test resource template."""
return f"Template content for {id}"
@server.prompt()
def greeting_prompt(name: str) -> str: # pragma: no cover
"""A greeting prompt."""
return f"Please greet {name}."
return server
@pytest.mark.parametrize(
"method_name,request_method",
[
("list_tools", "tools/list"),
("list_resources", "resources/list"),
("list_prompts", "prompts/list"),
("list_resource_templates", "resources/templates/list"),
],
)
async def test_list_methods_params_parameter(
stream_spy: Callable[[], StreamSpyCollection],
full_featured_server: MCPServer,
method_name: str,
request_method: str,
):
"""Test that the params parameter is accepted and correctly passed to the server.
Covers: list_tools, list_resources, list_prompts, list_resource_templates
See: https://modelcontextprotocol.io/specification/2025-03-26/server/utilities/pagination#request-format
"""
async with Client(full_featured_server, mode="legacy") as client:
spies = stream_spy()
# Test without params (omitted)
method = getattr(client, method_name)
_ = await method()
requests = spies.get_client_requests(method=request_method)
assert len(requests) == 1
assert requests[0].params is None or "cursor" not in requests[0].params
spies.clear()
# Test with params containing cursor
_ = await method(cursor="from_params")
requests = spies.get_client_requests(method=request_method)
assert len(requests) == 1
assert requests[0].params is not None
assert requests[0].params["cursor"] == "from_params"
spies.clear()
# Test with empty params
_ = await method()
requests = spies.get_client_requests(method=request_method)
assert len(requests) == 1
# Empty params means no cursor
assert requests[0].params is None or "cursor" not in requests[0].params
async def test_list_tools_with_strict_server_validation(
full_featured_server: MCPServer,
):
"""Test pagination with a server that validates request format strictly."""
async with Client(full_featured_server) as client:
result = await client.list_tools()
assert isinstance(result, ListToolsResult)
assert len(result.tools) > 0
async def test_list_tools_with_lowlevel_server():
"""Test that list_tools works with a lowlevel Server using params."""
async def handle_list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> ListToolsResult:
# Echo back what cursor we received in the tool description
cursor = params.cursor if params else None
return ListToolsResult(
tools=[types.Tool(name="test_tool", description=f"cursor={cursor}", input_schema={"type": "object"})]
)
server = Server("test-lowlevel", on_list_tools=handle_list_tools)
async with Client(server) as client:
result = await client.list_tools()
assert result.tools[0].description == "cursor=None"
result = await client.list_tools(cursor="page2")
assert result.tools[0].description == "cursor=page2"
+47
View File
@@ -0,0 +1,47 @@
import pytest
from mcp_types import INVALID_REQUEST, ListRootsResult, Root, TextContent
from pydantic import FileUrl
from mcp import Client
from mcp.client import ClientRequestContext
from mcp.server.mcpserver import Context, MCPServer
from mcp.shared.exceptions import MCPError
@pytest.mark.anyio
async def test_list_roots_callback():
server = MCPServer("test")
callback_return = ListRootsResult(
roots=[
Root(uri=FileUrl("file://users/fake/test"), name="Test Root 1"),
Root(uri=FileUrl("file://users/fake/test/2"), name="Test Root 2"),
]
)
async def list_roots_callback(
context: ClientRequestContext,
) -> ListRootsResult:
return callback_return
@server.tool("test_list_roots")
async def test_list_roots(context: Context, message: str):
roots = await context.session.list_roots() # pyright: ignore[reportDeprecated]
assert roots == callback_return
return True
# Test with list_roots callback
async with Client(server, list_roots_callback=list_roots_callback, mode="legacy") as client:
# Make a request to trigger sampling callback
result = await client.call_tool("test_list_roots", {"message": "test message"})
assert result.is_error is False
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "true"
# Without a list_roots callback the client responds with an MCPError, which the
# tool body doesn't catch — the wrapper re-raises it as a top-level JSON-RPC
# error rather than wrapping it as an isError result.
async with Client(server, mode="legacy") as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("test_list_roots", {"message": "test message"})
assert exc_info.value.error.code == INVALID_REQUEST
+108
View File
@@ -0,0 +1,108 @@
from typing import Literal
import mcp_types as types
import pytest
from mcp_types import (
LoggingMessageNotificationParams,
TextContent,
)
from mcp import Client
from mcp.server.mcpserver import Context, MCPServer
from mcp.shared.session import RequestResponder
class LoggingCollector:
def __init__(self):
self.log_messages: list[LoggingMessageNotificationParams] = []
async def __call__(self, params: LoggingMessageNotificationParams) -> None:
self.log_messages.append(params)
@pytest.mark.anyio
async def test_logging_callback():
server = MCPServer("test")
logging_collector = LoggingCollector()
# Create a simple test tool
@server.tool("test_tool")
async def test_tool() -> bool:
# The actual tool is very simple and just returns True
return True
# Create a function that can send a log notification
@server.tool("test_tool_with_log")
async def test_tool_with_log(
message: str, level: Literal["debug", "info", "warning", "error"], logger: str, ctx: Context
) -> bool:
"""Send a log notification to the client."""
await ctx.log(level=level, data=message, logger_name=logger) # pyright: ignore[reportDeprecated]
return True
@server.tool("test_tool_with_log_dict")
async def test_tool_with_log_dict(
level: Literal["debug", "info", "warning", "error"],
logger: str,
ctx: Context,
) -> bool:
"""Send a log notification with a dict payload."""
await ctx.log( # pyright: ignore[reportDeprecated]
level=level,
data={"message": "Test log message", "extra_string": "example", "extra_dict": {"a": 1, "b": 2, "c": 3}},
logger_name=logger,
)
return True
# Create a message handler to catch exceptions
async def message_handler(
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
) -> None:
if isinstance(message, Exception): # pragma: no cover
raise message
async with Client(
server,
logging_callback=logging_collector,
message_handler=message_handler,
mode="legacy",
) as client:
# First verify our test tool works
result = await client.call_tool("test_tool", {})
assert result.is_error is False
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "true"
# Now send a log message via our tool
log_result = await client.call_tool(
"test_tool_with_log",
{
"message": "Test log message",
"level": "info",
"logger": "test_logger",
},
)
log_result_with_dict = await client.call_tool(
"test_tool_with_log_dict",
{
"level": "info",
"logger": "test_logger",
},
)
assert log_result.is_error is False
assert log_result_with_dict.is_error is False
assert len(logging_collector.log_messages) == 2
# Create meta object with related_request_id added dynamically
log = logging_collector.log_messages[0]
assert log.level == "info"
assert log.logger == "test_logger"
assert log.data == "Test log message"
log_with_dict = logging_collector.log_messages[1]
assert log_with_dict.level == "info"
assert log_with_dict.logger == "test_logger"
assert log_with_dict.data == {
"message": "Test log message",
"extra_string": "example",
"extra_dict": {"a": 1, "b": 2, "c": 3},
}
+270
View File
@@ -0,0 +1,270 @@
"""Tests for StreamableHTTP client transport with non-SDK servers.
These tests verify client behavior when interacting with servers
that don't follow SDK conventions.
"""
import json
import httpx
import mcp_types as types
import pytest
from mcp_types import RootsListChangedNotification
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from starlette.routing import Route
from mcp import ClientSession, MCPError
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.session import RequestResponder
pytestmark = pytest.mark.anyio
INIT_RESPONSE = {
"serverInfo": {"name": "test-non-sdk-server", "version": "1.0.0"},
"protocolVersion": "2024-11-05",
"capabilities": {},
}
def _init_json_response(data: dict[str, object]) -> JSONResponse:
return JSONResponse({"jsonrpc": "2.0", "id": data["id"], "result": INIT_RESPONSE})
def _create_non_sdk_server_app() -> Starlette:
"""Create a minimal server that doesn't follow SDK conventions."""
async def handle_mcp_request(request: Request) -> Response:
body = await request.body()
data = json.loads(body)
if data.get("method") == "initialize":
return _init_json_response(data)
# For notifications, return 204 No Content (non-SDK behavior)
if "id" not in data:
return Response(status_code=204, headers={"Content-Type": "application/json"})
return JSONResponse( # pragma: no cover
{"jsonrpc": "2.0", "id": data.get("id"), "error": {"code": -32601, "message": "Method not found"}}
)
return Starlette(debug=True, routes=[Route("/mcp", handle_mcp_request, methods=["POST"])])
def _create_unexpected_content_type_app() -> Starlette:
"""Create a server that returns an unexpected content type for requests."""
async def handle_mcp_request(request: Request) -> Response:
body = await request.body()
data = json.loads(body)
if data.get("method") == "initialize":
return _init_json_response(data)
if "id" not in data:
return Response(status_code=202)
# Return text/plain for all other requests — an unexpected content type.
return Response(content="this is plain text, not json or sse", status_code=200, media_type="text/plain")
return Starlette(debug=True, routes=[Route("/mcp", handle_mcp_request, methods=["POST"])])
async def test_non_compliant_notification_response() -> None:
"""Verify the client ignores unexpected responses to notifications.
The spec states notifications should get either 202 + no response body, or 4xx + optional error body
(https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#sending-messages-to-the-server),
but some servers wrongly return other 2xx codes (e.g. 204). For now we simply ignore unexpected responses
(aligning behaviour w/ the TS SDK).
"""
returned_exception = None
async def message_handler( # pragma: no cover
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
) -> None:
nonlocal returned_exception
if isinstance(message, Exception):
returned_exception = message
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=_create_non_sdk_server_app())) as client:
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session:
await session.initialize()
# The test server returns a 204 instead of the expected 202
await session.send_notification(RootsListChangedNotification(method="notifications/roots/list_changed"))
if returned_exception: # pragma: no cover
pytest.fail(f"Server encountered an exception: {returned_exception}")
async def test_unexpected_content_type_sends_jsonrpc_error() -> None:
"""Verify unexpected content types unblock the pending request with an MCPError.
When a server returns a content type that is neither application/json nor text/event-stream,
the client should send a JSONRPCError so the pending request resolves immediately
instead of hanging until timeout.
"""
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=_create_unexpected_content_type_app())) as client:
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
await session.initialize()
with pytest.raises(MCPError, match="Unexpected content type: text/plain"): # pragma: no branch
await session.list_tools()
def _create_http_error_app(error_status: int, *, error_on_notifications: bool = False) -> Starlette:
"""Create a server that returns an HTTP error for non-init requests."""
async def handle_mcp_request(request: Request) -> Response:
body = await request.body()
data = json.loads(body)
if data.get("method") == "initialize":
return _init_json_response(data)
if "id" not in data:
if error_on_notifications:
return Response(status_code=error_status)
return Response(status_code=202)
return Response(status_code=error_status)
return Starlette(debug=True, routes=[Route("/mcp", handle_mcp_request, methods=["POST"])])
async def test_http_error_status_sends_jsonrpc_error() -> None:
"""Verify HTTP 5xx errors unblock the pending request with an MCPError.
When a server returns a non-2xx status code (e.g. 500), the client should
send a JSONRPCError so the pending request resolves immediately instead of
raising an unhandled httpx.HTTPStatusError that causes the caller to hang.
"""
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=_create_http_error_app(500))) as client:
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
await session.initialize()
with pytest.raises(MCPError, match="Server returned an error response"): # pragma: no branch
await session.list_tools()
async def test_http_error_on_notification_does_not_hang() -> None:
"""Verify HTTP errors on notifications are silently ignored.
When a notification gets an HTTP error, there is no pending request to
unblock, so the client should just return without sending a JSONRPCError.
"""
app = _create_http_error_app(500, error_on_notifications=True)
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client:
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
await session.initialize()
# Should not raise or hang — the error is silently ignored for notifications
await session.send_notification(RootsListChangedNotification(method="notifications/roots/list_changed"))
def _create_invalid_json_response_app() -> Starlette:
"""Create a server that returns invalid JSON for requests."""
async def handle_mcp_request(request: Request) -> Response:
body = await request.body()
data = json.loads(body)
if data.get("method") == "initialize":
return _init_json_response(data)
if "id" not in data:
return Response(status_code=202)
# Return application/json content type but with invalid JSON body.
return Response(content="not valid json{{{", status_code=200, media_type="application/json")
return Starlette(debug=True, routes=[Route("/mcp", handle_mcp_request, methods=["POST"])])
async def test_invalid_json_response_sends_jsonrpc_error() -> None:
"""Verify invalid JSON responses unblock the pending request with an MCPError.
When a server returns application/json with an unparseable body, the client
should send a JSONRPCError so the pending request resolves immediately
instead of hanging until timeout.
"""
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=_create_invalid_json_response_app())) as client:
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
await session.initialize()
with pytest.raises(MCPError, match="Failed to parse JSON response"): # pragma: no branch
await session.list_tools()
def _create_non_2xx_json_body_app(status: int, body: bytes) -> Starlette:
"""Server that returns a fixed non-2xx status + ``application/json`` body for non-init requests.
The initialize response carries an ``mcp-session-id`` so the client treats subsequent
requests as part of an established session (needed for the 404 → session-terminated mapping).
"""
async def handle_mcp_request(request: Request) -> Response:
data = json.loads(await request.body())
if data.get("method") == "initialize":
return JSONResponse(
{"jsonrpc": "2.0", "id": data["id"], "result": INIT_RESPONSE},
headers={"mcp-session-id": "test-session"},
)
if "id" not in data:
return Response(status_code=202)
return Response(content=body, status_code=status, media_type="application/json")
return Starlette(debug=True, routes=[Route("/mcp", handle_mcp_request, methods=["POST"])])
async def test_client_surfaces_jsonrpc_error_from_non_2xx_body_with_correlated_id() -> None:
"""SDK-defined: a JSON-RPC error in a non-2xx body is surfaced verbatim even when the
server set ``id: null`` — the client rewraps it under the pending request's id, so
the awaiting call resolves with the server's error code instead of the generic fallback."""
body = json.dumps(
{"jsonrpc": "2.0", "id": None, "error": {"code": types.METHOD_NOT_FOUND, "message": "nope"}}
).encode()
app = _create_non_2xx_json_body_app(400, body)
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client:
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
await session.initialize()
with pytest.raises(MCPError) as exc:
await session.list_tools()
assert exc.value.error.code == types.METHOD_NOT_FOUND
async def test_client_falls_back_to_generic_error_when_non_2xx_body_is_a_jsonrpc_result() -> None:
"""SDK-defined: a non-2xx response whose JSON body parses as a JSON-RPC *result* (not an
error) falls through to the generic ``INTERNAL_ERROR`` fallback rather than being
treated as the request's reply."""
app = _create_non_2xx_json_body_app(400, b'{"jsonrpc":"2.0","id":1,"result":{}}')
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client:
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
await session.initialize()
with pytest.raises(MCPError) as exc:
await session.list_tools()
assert exc.value.error.code == types.INTERNAL_ERROR
async def test_client_falls_back_to_session_terminated_when_404_body_is_malformed_json() -> None:
"""SDK-defined: an unparseable ``application/json`` body on a 404 response is swallowed
and the status-derived ``INVALID_REQUEST`` (session-terminated) fallback resolves the
pending request — the parse failure never propagates."""
app = _create_non_2xx_json_body_app(404, b"not valid json{{{")
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client:
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
await session.initialize()
with pytest.raises(MCPError) as exc:
await session.list_tools()
assert exc.value.error.code == types.INVALID_REQUEST
@@ -0,0 +1,165 @@
import logging
from typing import Any
import pytest
from mcp_types import (
CallToolRequestParams,
CallToolResult,
ListToolsResult,
PaginatedRequestParams,
TextContent,
Tool,
)
from mcp import Client
from mcp.server import Server, ServerRequestContext
def _make_server(
tools: list[Tool],
structured_content: dict[str, Any],
) -> Server:
"""Create a low-level server that returns the given structured_content for any tool call."""
async def on_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=tools)
async def on_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
return CallToolResult(
content=[TextContent(type="text", text="result")],
structured_content=structured_content,
)
return Server("test-server", on_list_tools=on_list_tools, on_call_tool=on_call_tool)
@pytest.mark.anyio
async def test_tool_structured_output_client_side_validation_basemodel():
"""Test that client validates structured content against schema for BaseModel outputs"""
output_schema = {
"type": "object",
"properties": {"name": {"type": "string", "title": "Name"}, "age": {"type": "integer", "title": "Age"}},
"required": ["name", "age"],
"title": "UserOutput",
}
server = _make_server(
tools=[
Tool(
name="get_user",
description="Get user data",
input_schema={"type": "object"},
output_schema=output_schema,
)
],
structured_content={"name": "John", "age": "invalid"}, # Invalid: age should be int
)
async with Client(server) as client:
with pytest.raises(RuntimeError) as exc_info:
await client.call_tool("get_user", {})
assert "Invalid structured content returned by tool get_user" in str(exc_info.value)
@pytest.mark.anyio
async def test_tool_structured_output_client_side_validation_primitive():
"""Test that client validates structured content for primitive outputs"""
output_schema = {
"type": "object",
"properties": {"result": {"type": "integer", "title": "Result"}},
"required": ["result"],
"title": "calculate_Output",
}
server = _make_server(
tools=[
Tool(
name="calculate",
description="Calculate something",
input_schema={"type": "object"},
output_schema=output_schema,
)
],
structured_content={"result": "not_a_number"}, # Invalid: should be int
)
async with Client(server) as client:
with pytest.raises(RuntimeError) as exc_info:
await client.call_tool("calculate", {})
assert "Invalid structured content returned by tool calculate" in str(exc_info.value)
@pytest.mark.anyio
async def test_tool_structured_output_client_side_validation_dict_typed():
"""Test that client validates dict[str, T] structured content"""
output_schema = {"type": "object", "additionalProperties": {"type": "integer"}, "title": "get_scores_Output"}
server = _make_server(
tools=[
Tool(
name="get_scores",
description="Get scores",
input_schema={"type": "object"},
output_schema=output_schema,
)
],
structured_content={"alice": "100", "bob": "85"}, # Invalid: values should be int
)
async with Client(server) as client:
with pytest.raises(RuntimeError) as exc_info:
await client.call_tool("get_scores", {})
assert "Invalid structured content returned by tool get_scores" in str(exc_info.value)
@pytest.mark.anyio
async def test_tool_structured_output_client_side_validation_missing_required():
"""Test that client validates missing required fields"""
output_schema = {
"type": "object",
"properties": {"name": {"type": "string"}, "age": {"type": "integer"}, "email": {"type": "string"}},
"required": ["name", "age", "email"],
"title": "PersonOutput",
}
server = _make_server(
tools=[
Tool(
name="get_person",
description="Get person data",
input_schema={"type": "object"},
output_schema=output_schema,
)
],
structured_content={"name": "John", "age": 30}, # Missing required 'email'
)
async with Client(server) as client:
with pytest.raises(RuntimeError) as exc_info:
await client.call_tool("get_person", {})
assert "Invalid structured content returned by tool get_person" in str(exc_info.value)
@pytest.mark.anyio
async def test_tool_not_listed_warning(caplog: pytest.LogCaptureFixture):
"""Test that client logs warning when tool is not in list_tools but has output_schema"""
async def on_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[])
async def on_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
return CallToolResult(
content=[TextContent(type="text", text="result")],
structured_content={"result": 42},
)
server = Server("test-server", on_list_tools=on_list_tools, on_call_tool=on_call_tool)
caplog.set_level(logging.WARNING)
async with Client(server) as client:
result = await client.call_tool("mystery_tool", {})
assert result.structured_content == {"result": 42}
assert result.is_error is False
assert "Tool mystery_tool not listed" in caplog.text
+321
View File
@@ -0,0 +1,321 @@
"""Unit tests for the connect-time auto-negotiation policy (`mcp.client._probe.negotiate_auto`).
`negotiate_auto` is a small policy function that drives a `ClientSession` through the
``server/discover`` probe and decides between ``adopt()`` (modern), ``initialize()`` (legacy
fallback), or letting the probe's exception propagate. The policy is a *denylist*: every
``MCPError`` falls back to ``initialize()``, the sole exception being -32022 with a disjoint
modern-only ``supported`` list. Any non-``MCPError`` exception (network errors, anyio
resource errors) propagates untouched — an outage is never an era verdict.
These tests pin the classifier in isolation with a stub session; the end-to-end wire shape is
covered by ``tests/interaction/lowlevel/test_client_connect.py``.
"""
from __future__ import annotations
from typing import Any, cast
import anyio
import httpx
import mcp_types as types
import pytest
from mcp_types import (
INTERNAL_ERROR,
INVALID_REQUEST,
METHOD_NOT_FOUND,
PARSE_ERROR,
REQUEST_TIMEOUT,
UNSUPPORTED_PROTOCOL_VERSION,
Implementation,
ServerCapabilities,
)
from mcp_types.version import (
HANDSHAKE_PROTOCOL_VERSIONS,
LATEST_MODERN_VERSION,
MODERN_PROTOCOL_VERSIONS,
)
from mcp.client._probe import _parse_supported, negotiate_auto
from mcp.client.session import ClientSession
from mcp.shared.exceptions import MCPError
pytestmark = pytest.mark.anyio
class _StubSession:
"""Minimal stand-in for `ClientSession` exposing only what `negotiate_auto` touches.
`send_discover` plays back a script (raise an exception, or return a dict);
`initialize` raises the next entry of an optional `handshake` exception
script (succeeding once it is exhausted) and records its calls; `adopt`
just records.
"""
def __init__(self, *script: dict[str, Any] | Exception, handshake: list[Exception] | None = None) -> None:
self._script: list[dict[str, Any] | Exception] = list(script)
self._handshake: list[Exception] = list(handshake or [])
self.probed_at: list[str] = []
self.initialize_calls: int = 0
self.initialized: bool = False
self.adopted: types.DiscoverResult | None = None
async def send_discover(self, version: str) -> dict[str, Any]:
self.probed_at.append(version)
step = self._script.pop(0)
if isinstance(step, Exception):
raise step
return step
async def initialize(self) -> None:
self.initialize_calls += 1
if self._handshake:
raise self._handshake.pop(0)
self.initialized = True
def adopt(self, result: types.DiscoverResult) -> None:
self.adopted = result
async def _negotiate(session: _StubSession) -> None:
"""Drive `negotiate_auto` against the stub; cast at one seam so the tests stay suppression-free."""
await negotiate_auto(cast("ClientSession", session))
def _discover_dict(versions: list[str] | None = None) -> dict[str, Any]:
return types.DiscoverResult(
supported_versions=versions or list(MODERN_PROTOCOL_VERSIONS),
capabilities=ServerCapabilities(),
server_info=Implementation(name="stub", version="0"),
).model_dump(by_alias=True, mode="json", exclude_none=True)
def _err_32022(supported: Any) -> MCPError:
return MCPError(
code=UNSUPPORTED_PROTOCOL_VERSION,
message="unsupported protocol version",
data={"supported": supported, "requested": LATEST_MODERN_VERSION},
)
# --- happy path: modern server ---
async def test_a_valid_discover_result_is_adopted_without_initializing() -> None:
"""A parseable `DiscoverResult` from the probe is adopted; `initialize()` is never called."""
session = _StubSession(_discover_dict())
await _negotiate(session)
assert session.adopted is not None
assert session.adopted.server_info.name == "stub"
assert not session.initialized
assert session.probed_at == [LATEST_MODERN_VERSION]
async def test_an_unparseable_discover_result_falls_back_to_initialize() -> None:
"""A probe response that does not validate as `DiscoverResult` is not modern evidence,
so the policy falls back to the legacy handshake instead of adopting garbage."""
session = _StubSession({"not": "a discover result"})
await _negotiate(session)
assert session.initialized
assert session.adopted is None
# --- the denylist: every JSON-RPC error code falls back ---
@pytest.mark.parametrize(
"code",
[
pytest.param(METHOD_NOT_FOUND, id="method-not-found-32601"),
pytest.param(INVALID_REQUEST, id="invalid-request-32600"),
pytest.param(INTERNAL_ERROR, id="internal-error-32603"),
pytest.param(PARSE_ERROR, id="parse-error-32700"),
],
)
async def test_any_jsonrpc_error_from_the_probe_falls_back_to_initialize(code: int) -> None:
"""The denylist: every server-sent JSON-RPC error code is treated as "not modern" and
triggers the legacy `initialize()` handshake. Legacy servers reject the unknown
``server/discover`` method with various codes (-32601, -32600, -32603, -32700) depending
on where in their pipeline the request bounces."""
session = _StubSession(MCPError(code=code, message="nope"))
await _negotiate(session)
assert session.initialized
assert session.adopted is None
assert session.probed_at == [LATEST_MODERN_VERSION]
# --- -32022 corrective retry ---
async def test_unsupported_version_with_a_mutual_modern_version_retries_once_then_adopts() -> None:
"""-32022 with a `supported` list naming a modern version we speak: re-probe once at
the highest mutual version, then adopt the second response."""
session = _StubSession(_err_32022(list(MODERN_PROTOCOL_VERSIONS)), _discover_dict())
await _negotiate(session)
assert session.probed_at == [LATEST_MODERN_VERSION, MODERN_PROTOCOL_VERSIONS[-1]]
assert session.adopted is not None
assert not session.initialized
async def test_unsupported_version_naming_only_handshake_versions_falls_back_to_initialize() -> None:
"""-32022 with `supported` naming only handshake-era versions: the server is reachable
via the legacy handshake, so fall back rather than raise."""
session = _StubSession(_err_32022(list(HANDSHAKE_PROTOCOL_VERSIONS)))
await _negotiate(session)
assert session.initialized
assert session.adopted is None
assert session.probed_at == [LATEST_MODERN_VERSION]
async def test_unsupported_version_with_disjoint_modern_only_supported_reraises() -> None:
"""-32022 with `supported` naming only modern versions we *don't* speak: this is the
one denylist exception — the server is modern-only and there is no mutual version, so
falling back to `initialize()` would also fail. The original `MCPError` re-raises."""
session = _StubSession(_err_32022(["2099-01-01"]))
with pytest.raises(MCPError) as exc_info:
await _negotiate(session)
assert exc_info.value.code == UNSUPPORTED_PROTOCOL_VERSION
assert not session.initialized
assert session.adopted is None
@pytest.mark.parametrize(
"data",
[
pytest.param(None, id="no-data"),
pytest.param({"supported": "not-a-list"}, id="malformed-supported"),
pytest.param({"requested": LATEST_MODERN_VERSION}, id="missing-supported"),
],
)
async def test_unsupported_version_with_unparseable_data_falls_back_to_initialize(data: Any) -> None:
"""-32022 with no/malformed `error.data`: nothing actionable, so fall through to the
denylist's `initialize()` fallback rather than guess or raise."""
session = _StubSession(MCPError(code=UNSUPPORTED_PROTOCOL_VERSION, message="bad version", data=data))
await _negotiate(session)
assert session.initialized
assert session.adopted is None
assert session.probed_at == [LATEST_MODERN_VERSION]
async def test_a_second_unsupported_version_after_the_corrective_retry_does_not_loop() -> None:
"""The corrective -32022 retry happens at most once; a second -32022 naming a
modern-only `supported` list re-raises rather than re-probing forever (the loop
guard makes this the disjoint-modern case on attempt two)."""
session = _StubSession(_err_32022(list(MODERN_PROTOCOL_VERSIONS)), _err_32022(list(MODERN_PROTOCOL_VERSIONS)))
with pytest.raises(MCPError) as exc_info:
await _negotiate(session)
assert exc_info.value.code == UNSUPPORTED_PROTOCOL_VERSION
assert session.probed_at == [LATEST_MODERN_VERSION, MODERN_PROTOCOL_VERSIONS[-1]]
assert not session.initialized
assert session.adopted is None
# --- -32022 from the fallback handshake: modern evidence, one re-probe ---
async def test_handshake_unsupported_after_a_timed_out_probe_reprobes_and_adopts() -> None:
"""A probe that times out client-side but succeeds on a slow-starting
server locks the connection modern, so the fallback handshake answers
-32022. That code is itself modern evidence: re-probe once at a version
the server names and adopt - the connect must not fail."""
session = _StubSession(
MCPError(code=REQUEST_TIMEOUT, message="Request 'server/discover' timed out"),
_discover_dict(),
handshake=[_err_32022(list(MODERN_PROTOCOL_VERSIONS))],
)
await _negotiate(session)
assert session.probed_at == [LATEST_MODERN_VERSION, MODERN_PROTOCOL_VERSIONS[-1]]
assert session.adopted is not None
assert session.initialize_calls == 1
assert not session.initialized
@pytest.mark.parametrize(
"data",
[
pytest.param({"supported": ["2099-01-01"], "requested": LATEST_MODERN_VERSION}, id="disjoint"),
pytest.param(None, id="no-data"),
],
)
async def test_handshake_unsupported_without_a_mutual_version_reraises(data: Any) -> None:
"""-32022 from the handshake naming no version we speak (or nothing
parseable) leaves nothing to retry with - the error propagates."""
session = _StubSession(
MCPError(code=METHOD_NOT_FOUND, message="nope"),
handshake=[MCPError(code=UNSUPPORTED_PROTOCOL_VERSION, message="already modern", data=data)],
)
with pytest.raises(MCPError) as exc_info:
await _negotiate(session)
assert exc_info.value.code == UNSUPPORTED_PROTOCOL_VERSION
assert session.adopted is None
assert not session.initialized
async def test_handshake_unsupported_reprobes_at_most_once() -> None:
"""The handshake-driven re-probe is bounded: if the second attempt also
ends in a timed-out probe and a -32022 handshake, the -32022 propagates
instead of looping."""
timeout = MCPError(code=REQUEST_TIMEOUT, message="Request 'server/discover' timed out")
session = _StubSession(
timeout,
timeout,
handshake=[_err_32022(list(MODERN_PROTOCOL_VERSIONS)), _err_32022(list(MODERN_PROTOCOL_VERSIONS))],
)
with pytest.raises(MCPError) as exc_info:
await _negotiate(session)
assert exc_info.value.code == UNSUPPORTED_PROTOCOL_VERSION
assert session.probed_at == [LATEST_MODERN_VERSION, MODERN_PROTOCOL_VERSIONS[-1]]
assert session.initialize_calls == 2
async def test_any_other_handshake_error_propagates_unchanged() -> None:
"""A non--32022 error from the fallback handshake is a real handshake
failure, not era evidence - it propagates without a re-probe."""
session = _StubSession(
MCPError(code=METHOD_NOT_FOUND, message="nope"),
handshake=[MCPError(code=INTERNAL_ERROR, message="handshake broke")],
)
with pytest.raises(MCPError) as exc_info:
await _negotiate(session)
assert exc_info.value.code == INTERNAL_ERROR
assert session.probed_at == [LATEST_MODERN_VERSION]
# --- non-MCP errors propagate ---
@pytest.mark.parametrize(
"exc",
[
pytest.param(httpx.ConnectError("connection refused"), id="httpx-connect-error"),
pytest.param(anyio.ClosedResourceError(), id="anyio-closed-resource"),
],
)
async def test_a_network_or_resource_error_from_the_probe_propagates_unchanged(exc: Exception) -> None:
"""Anything that is not an `MCPError` propagates as-is; an outage or in-process bug
is never an era verdict, and `initialize()` is not called."""
session = _StubSession(exc)
with pytest.raises(type(exc)):
await _negotiate(session)
assert not session.initialized
assert session.adopted is None
# --- helper ---
@pytest.mark.parametrize(
("data", "expected"),
[
({"supported": ["2026-07-28"], "requested": "x"}, ["2026-07-28"]),
({"supported": [], "requested": "x"}, []),
(None, None),
({"supported": 123, "requested": "x"}, None),
("not a dict", None),
],
)
def test_parse_supported_returns_none_for_anything_not_shaped_like_the_spec_error_data(
data: Any, expected: list[str] | None
) -> None:
"""`_parse_supported` returns the `supported` list when `error.data` validates as
`UnsupportedProtocolVersionErrorData`, and `None` otherwise — never raises."""
assert _parse_supported(data) == expected
+132
View File
@@ -0,0 +1,132 @@
import pytest
from mcp_types import (
INVALID_REQUEST,
CreateMessageRequestParams,
CreateMessageResult,
CreateMessageResultWithTools,
SamplingMessage,
TextContent,
ToolUseContent,
)
from mcp import Client
from mcp.client import ClientRequestContext
from mcp.server.mcpserver import Context, MCPServer
from mcp.shared.exceptions import MCPError
@pytest.mark.anyio
async def test_sampling_callback():
server = MCPServer("test")
callback_return = CreateMessageResult(
role="assistant",
content=TextContent(type="text", text="This is a response from the sampling callback"),
model="test-model",
stop_reason="endTurn",
)
async def sampling_callback(
context: ClientRequestContext,
params: CreateMessageRequestParams,
) -> CreateMessageResult:
return callback_return
@server.tool("test_sampling")
async def test_sampling_tool(message: str, ctx: Context) -> bool:
value = await ctx.session.create_message( # pyright: ignore[reportDeprecated]
messages=[SamplingMessage(role="user", content=TextContent(type="text", text=message))],
max_tokens=100,
)
assert value == callback_return
return True
# Test with sampling callback
async with Client(server, sampling_callback=sampling_callback, mode="legacy") as client:
# Make a request to trigger sampling callback
result = await client.call_tool("test_sampling", {"message": "Test message for sampling"})
assert result.is_error is False
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "true"
# Without a sampling callback the client responds with an MCPError, which the
# tool body doesn't catch — the wrapper re-raises it as a top-level JSON-RPC
# error rather than wrapping it as an isError result.
async with Client(server, mode="legacy") as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("test_sampling", {"message": "Test message for sampling"})
assert exc_info.value.error.code == INVALID_REQUEST
@pytest.mark.anyio
async def test_create_message_backwards_compat_single_content():
"""Test backwards compatibility: create_message without tools returns single content."""
server = MCPServer("test")
# Callback returns single content (text)
callback_return = CreateMessageResult(
role="assistant",
content=TextContent(type="text", text="Hello from LLM"),
model="test-model",
stop_reason="endTurn",
)
async def sampling_callback(
context: ClientRequestContext,
params: CreateMessageRequestParams,
) -> CreateMessageResult:
return callback_return
@server.tool("test_backwards_compat")
async def test_tool(message: str, ctx: Context) -> bool:
# Call create_message WITHOUT tools
result = await ctx.session.create_message( # pyright: ignore[reportDeprecated]
messages=[SamplingMessage(role="user", content=TextContent(type="text", text=message))],
max_tokens=100,
)
# Backwards compat: result should be CreateMessageResult
assert isinstance(result, CreateMessageResult)
# Content should be single (not a list) - this is the key backwards compat check
assert isinstance(result.content, TextContent)
assert result.content.text == "Hello from LLM"
# CreateMessageResult should NOT have content_as_list (that's on WithTools)
assert not hasattr(result, "content_as_list") or not callable(getattr(result, "content_as_list", None))
return True
async with Client(server, sampling_callback=sampling_callback, mode="legacy") as client:
result = await client.call_tool("test_backwards_compat", {"message": "Test"})
assert result.is_error is False
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "true"
@pytest.mark.anyio
async def test_create_message_result_with_tools_type():
"""Test that CreateMessageResultWithTools supports content_as_list."""
# Test the type itself, not the overload (overload requires client capability setup)
result = CreateMessageResultWithTools(
role="assistant",
content=ToolUseContent(type="tool_use", id="call_123", name="get_weather", input={"city": "SF"}),
model="test-model",
stop_reason="toolUse",
)
# CreateMessageResultWithTools should have content_as_list
content_list = result.content_as_list
assert len(content_list) == 1
assert content_list[0].type == "tool_use"
# It should also work with array content
result_array = CreateMessageResultWithTools(
role="assistant",
content=[
TextContent(type="text", text="Let me check the weather"),
ToolUseContent(type="tool_use", id="call_456", name="get_weather", input={"city": "NYC"}),
],
model="test-model",
stop_reason="toolUse",
)
content_list_array = result_array.content_as_list
assert len(content_list_array) == 2
assert content_list_array[0].type == "text"
assert content_list_array[1].type == "tool_use"
+169
View File
@@ -0,0 +1,169 @@
"""Regression test for issue #1630: OAuth2 scope incorrectly set to resource_metadata URL.
This test verifies that when a 401 response contains both resource_metadata and scope
in the WWW-Authenticate header, the actual scope is used (not the resource_metadata URL).
"""
from unittest import mock
import httpx
import pytest
from pydantic import AnyUrl
from mcp.client.auth import OAuthClientProvider
from mcp.shared.auth import (
AuthorizationCodeResult,
OAuthClientInformationFull,
OAuthClientMetadata,
OAuthToken,
)
class MockTokenStorage:
"""Mock token storage for 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 # pragma: no cover
async def set_tokens(self, tokens: OAuthToken) -> None:
self._tokens = tokens
async def get_client_info(self) -> OAuthClientInformationFull | None:
return self._client_info # pragma: no cover
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
self._client_info = client_info # pragma: no cover
@pytest.mark.anyio
async def test_401_uses_www_auth_scope_not_resource_metadata_url():
"""Regression test for #1630: Ensure scope is extracted from WWW-Authenticate header,
not the resource_metadata URL.
When a 401 response contains:
WWW-Authenticate: Bearer resource_metadata="https://...", scope="read write"
The client should use "read write" as the scope, NOT the resource_metadata URL.
"""
async def redirect_handler(url: str) -> None:
pass # pragma: no cover
async def callback_handler() -> AuthorizationCodeResult:
return AuthorizationCodeResult(code="test_auth_code", state="test_state") # pragma: no cover
client_metadata = OAuthClientMetadata(
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
client_name="Test Client",
)
provider = OAuthClientProvider(
server_url="https://api.example.com/mcp",
client_metadata=client_metadata,
storage=MockTokenStorage(),
redirect_handler=redirect_handler,
callback_handler=callback_handler,
)
provider.context.current_tokens = None
provider.context.token_expiry_time = None
provider._initialized = True
# Pre-set client info to skip DCR
provider.context.client_info = OAuthClientInformationFull(
client_id="test_client",
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
)
test_request = httpx.Request("GET", "https://api.example.com/mcp")
auth_flow = provider.async_auth_flow(test_request)
# First request (no auth header yet)
await auth_flow.__anext__()
# 401 response with BOTH resource_metadata URL and scope in WWW-Authenticate
# This is the key: the bug would use the URL as scope instead of "read write"
resource_metadata_url = "https://api.example.com/.well-known/oauth-protected-resource"
expected_scope = "read write"
response_401 = httpx.Response(
401,
headers={"WWW-Authenticate": (f'Bearer resource_metadata="{resource_metadata_url}", scope="{expected_scope}"')},
request=test_request,
)
# Send 401, expect PRM discovery request
prm_request = await auth_flow.asend(response_401)
assert ".well-known/oauth-protected-resource" in str(prm_request.url)
# PRM response with scopes_supported (these should be overridden by WWW-Auth scope)
prm_response = httpx.Response(
200,
content=(
b'{"resource": "https://api.example.com/mcp", '
b'"authorization_servers": ["https://auth.example.com"], '
b'"scopes_supported": ["fallback:scope1", "fallback:scope2"]}'
),
request=prm_request,
)
# Send PRM response, expect OAuth metadata discovery
oauth_metadata_request = await auth_flow.asend(prm_response)
assert ".well-known/oauth-authorization-server" in str(oauth_metadata_request.url)
# OAuth metadata response
oauth_metadata_response = httpx.Response(
200,
content=(
b'{"issuer": "https://auth.example.com", '
b'"authorization_endpoint": "https://auth.example.com/authorize", '
b'"token_endpoint": "https://auth.example.com/token"}'
),
request=oauth_metadata_request,
)
# Mock authorization to skip interactive flow
provider._perform_authorization_code_grant = mock.AsyncMock(return_value=("test_auth_code", "test_code_verifier"))
# Send OAuth metadata response, expect token request
token_request = await auth_flow.asend(oauth_metadata_response)
assert "token" in str(token_request.url)
# NOW CHECK: The scope should be the WWW-Authenticate scope, NOT the URL
# This is where the bug manifested - scope was set to resource_metadata_url
actual_scope = provider.context.client_metadata.scope
# This assertion would FAIL on main (scope would be the URL)
# but PASS on the fix branch (scope is "read write")
assert actual_scope == expected_scope, (
f"Expected scope to be '{expected_scope}' from WWW-Authenticate header, "
f"but got '{actual_scope}'. "
f"If scope is '{resource_metadata_url}', the bug from #1630 is present."
)
# Verify it's definitely not the URL (explicit check for the bug)
assert actual_scope != resource_metadata_url, (
f"BUG #1630: Scope was incorrectly set to resource_metadata URL '{resource_metadata_url}' "
f"instead of the actual scope '{expected_scope}'"
)
# Complete the flow to properly release the lock
token_response = httpx.Response(
200,
content=b'{"access_token": "test_token", "token_type": "Bearer", "expires_in": 3600}',
request=token_request,
)
final_request = await auth_flow.asend(token_response)
assert final_request.headers["Authorization"] == "Bearer test_token"
# Finish the flow
final_response = httpx.Response(200, request=final_request)
try:
await auth_flow.asend(final_response)
except StopAsyncIteration:
pass
+252
View File
@@ -0,0 +1,252 @@
"""`ClientSession.send_request` mirrors `Request.name_param` into the `Mcp-Name`
header on send paths the core `NAME_BEARING_METHODS` table does not cover. The
vendor sends also pin the widened `send_request` typing (no cast needed)."""
from collections.abc import Mapping
from typing import Any, Literal
import anyio
import anyio.abc
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
CallToolResult,
Implementation,
ListToolsResult,
Request,
ServerCapabilities,
TextContent,
Tool,
)
from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION
from mcp.client.session import ClientSession
from mcp.shared.dispatcher import CallOptions, OnNotify, OnNotifyIntercept, OnRequest
from mcp.shared.inbound import MCP_NAME_HEADER, MCP_PROTOCOL_VERSION_HEADER, encode_header_value
class _RecordingDispatcher:
"""Records `send_raw_request` opts and answers with canned per-method results."""
def __init__(self) -> None:
self.calls: list[tuple[str, CallOptions]] = []
async def run(
self,
on_request: OnRequest,
on_notify: OnNotify,
on_notify_intercept: OnNotifyIntercept | None = None,
*,
task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
) -> None:
task_status.started()
await anyio.sleep_forever()
async def send_raw_request(
self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None
) -> dict[str, Any]:
self.calls.append((method, opts or {}))
if method == "tools/call":
return CallToolResult(content=[TextContent(type="text", text="ok")]).model_dump(
by_alias=True, mode="json", exclude_none=True
)
if method == "tools/list":
return ListToolsResult(tools=[Tool(name="my-tool", input_schema={"type": "object"})]).model_dump(
by_alias=True, mode="json", exclude_none=True
)
return {}
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
raise NotImplementedError
class _GetWidgetParams(types.RequestParams):
widget_id: str
class _GetWidgetRequest(Request[_GetWidgetParams, Literal["vendor/widgets/get"]]):
method: Literal["vendor/widgets/get"] = "vendor/widgets/get"
name_param = "widgetId"
class _RawWidgetRequest(Request[dict[str, Any], Literal["vendor/widgets/get"]]):
"""Same wire shape with untyped params, so tests can omit or mistype the name value."""
method: Literal["vendor/widgets/get"] = "vendor/widgets/get"
name_param = "widgetId"
class _ShadowCallToolRequest(Request[dict[str, Any], Literal["tools/call"]]):
"""A vendor type declaring `name_param` for a method the core table already covers."""
method: Literal["tools/call"] = "tools/call"
name_param = "customKey"
class _PlainVendorRequest(Request[dict[str, Any], Literal["vendor/widgets/list"]]):
method: Literal["vendor/widgets/list"] = "vendor/widgets/list"
class _OptionalParamsWidgetRequest(Request[dict[str, Any] | None, Literal["vendor/widgets/get"]]):
"""Optional params, so a send can carry no params key at all."""
method: Literal["vendor/widgets/get"] = "vendor/widgets/get"
params: dict[str, Any] | None = None
name_param = "widgetId"
def _adopt_modern(session: ClientSession) -> None:
session.adopt(
types.DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
server_info=Implementation(name="stub", version="0"),
)
)
def _adopt_handshake(session: ClientSession) -> None:
session.adopt(
types.InitializeResult(
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ServerCapabilities(),
server_info=Implementation(name="stub", version="0"),
)
)
def _headers(opts: CallOptions) -> dict[str, str]:
return opts.get("headers") or {}
@pytest.mark.anyio
async def test_vendor_name_param_emits_mcp_name_on_the_modern_path() -> None:
"""A vendor `name_param` emits `Mcp-Name` on a modern wire even outside `NAME_BEARING_METHODS`."""
dispatcher = _RecordingDispatcher()
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
_adopt_modern(session)
await session.send_request(_GetWidgetRequest(params=_GetWidgetParams(widget_id="w-1")), types.EmptyResult)
[(_, opts)] = dispatcher.calls
assert _headers(opts)[MCP_NAME_HEADER] == "w-1"
@pytest.mark.anyio
async def test_vendor_name_param_emits_mcp_name_on_the_handshake_path() -> None:
"""The handshake stamp sets no `Mcp-Name`, so on a legacy wire the delta is the emitter."""
dispatcher = _RecordingDispatcher()
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
_adopt_handshake(session)
await session.send_request(_GetWidgetRequest(params=_GetWidgetParams(widget_id="w-1")), types.EmptyResult)
[(_, opts)] = dispatcher.calls
assert _headers(opts)[MCP_NAME_HEADER] == "w-1"
# The stamp's own headers survive the delta.
assert _headers(opts)[MCP_PROTOCOL_VERSION_HEADER] == LATEST_HANDSHAKE_VERSION
@pytest.mark.anyio
async def test_name_value_passes_through_encode_header_value() -> None:
"""A non-ASCII name is base64-sentinel encoded, a spec MUST for `Mcp-Name`."""
name = "wídget ✨"
dispatcher = _RecordingDispatcher()
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
_adopt_handshake(session)
await session.send_request(_GetWidgetRequest(params=_GetWidgetParams(widget_id=name)), types.EmptyResult)
[(_, opts)] = dispatcher.calls
assert _headers(opts)[MCP_NAME_HEADER] == encode_header_value(name)
assert _headers(opts)[MCP_NAME_HEADER].startswith("=?base64?")
@pytest.mark.anyio
async def test_core_tools_call_header_comes_from_the_stamp_alone() -> None:
"""Core `tools/call` is unchanged: the modern stamp emits the header; legacy stays headerless."""
dispatcher = _RecordingDispatcher()
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
_adopt_modern(session)
await session.call_tool("my-tool", {})
_adopt_handshake(session)
await session.call_tool("my-tool", {})
(_, modern_opts), (_, legacy_opts) = (call for call in dispatcher.calls if call[0] == "tools/call")
assert _headers(modern_opts)[MCP_NAME_HEADER] == "my-tool"
assert MCP_NAME_HEADER not in _headers(legacy_opts)
@pytest.mark.anyio
async def test_stamp_table_rows_win_over_name_param_by_ordering() -> None:
"""A stamp-emitted `Mcp-Name` wins; `name_param` never overwrites an existing header."""
dispatcher = _RecordingDispatcher()
request = _ShadowCallToolRequest(params={"name": "real-tool", "customKey": "other-value"})
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
_adopt_modern(session)
await session.send_request(request, types.CallToolResult)
[(_, opts)] = dispatcher.calls
assert _headers(opts)[MCP_NAME_HEADER] == "real-tool"
@pytest.mark.anyio
async def test_vendor_name_param_emits_mcp_name_on_the_preconnect_path() -> None:
"""Emission is era-unconditional: a session that never adopts still emits `Mcp-Name`."""
dispatcher = _RecordingDispatcher()
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
await session.send_request(_GetWidgetRequest(params=_GetWidgetParams(widget_id="w-1")), types.EmptyResult)
[(_, opts)] = dispatcher.calls
assert _headers(opts) == {MCP_NAME_HEADER: "w-1"} # and no era headers: nothing adopted
@pytest.mark.anyio
async def test_missing_name_value_fails_loud_naming_method_and_key() -> None:
"""A missing name value raises ValueError naming the method and key, before the wire."""
dispatcher = _RecordingDispatcher()
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
_adopt_handshake(session)
with pytest.raises(ValueError) as exc_info:
await session.send_request(_RawWidgetRequest(params={}), types.EmptyResult)
assert dispatcher.calls == [] # raised before reaching the wire
assert str(exc_info.value) == snapshot("vendor/widgets/get requires params['widgetId'] for Mcp-Name")
@pytest.mark.anyio
async def test_non_string_name_value_fails_loud() -> None:
"""A non-string name value raises the same ValueError as a missing one."""
dispatcher = _RecordingDispatcher()
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
_adopt_handshake(session)
with pytest.raises(ValueError) as exc_info:
await session.send_request(_RawWidgetRequest(params={"widgetId": 7}), types.EmptyResult)
assert dispatcher.calls == []
assert str(exc_info.value) == snapshot("vendor/widgets/get requires params['widgetId'] for Mcp-Name")
@pytest.mark.anyio
async def test_absent_params_fails_loud_not_attribute_error() -> None:
"""Absent params still raise the documented ValueError, not an AttributeError."""
dispatcher = _RecordingDispatcher()
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
_adopt_handshake(session)
with pytest.raises(ValueError) as exc_info:
await session.send_request(_OptionalParamsWidgetRequest(), types.EmptyResult)
assert dispatcher.calls == []
assert str(exc_info.value) == snapshot("vendor/widgets/get requires params['widgetId'] for Mcp-Name")
@pytest.mark.anyio
async def test_request_without_name_param_sends_no_mcp_name() -> None:
"""No `name_param` and a method outside the core table emits no `Mcp-Name` on either era."""
dispatcher = _RecordingDispatcher()
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
_adopt_modern(session)
await session.send_request(_PlainVendorRequest(params={}), types.EmptyResult)
_adopt_handshake(session)
await session.send_ping()
for _, opts in dispatcher.calls:
assert MCP_NAME_HEADER not in _headers(opts)
File diff suppressed because it is too large Load Diff
+469
View File
@@ -0,0 +1,469 @@
"""`ClientSession` result claims: construction validation, activation at modern
adopts only, claimed-result routing, the version-aware capability ad, and the
`allow_claimed` escape hatch."""
from collections.abc import Mapping
from typing import Any, Literal
import anyio
import anyio.abc
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
CLIENT_CAPABILITIES_META_KEY,
CallToolResult,
Implementation,
InputRequiredResult,
ListToolsResult,
Result,
ServerCapabilities,
TextContent,
Tool,
)
from mcp_types.methods import validate_server_result
from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION
from pydantic import ValidationError
from typing_extensions import assert_type
from mcp.client.extension import ClaimContext, ResultClaim, UnexpectedClaimedResult
from mcp.client.session import ClientSession, _CallToolResultAdapter
from mcp.shared.dispatcher import CallOptions, OnNotify, OnNotifyIntercept, OnRequest
_TASKS_EXT = "com.example/tasks"
_AD_ONLY_EXT = "com.example/flags"
class _TaskResult(Result):
"""A claimed result shape, tagged `task`."""
result_type: Literal["task"] = "task"
task_id: str
async def _resolve_task(result: _TaskResult, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError # session-tier tests never drive a resolver; that is the Client's job
def _task_claim(**kwargs: Any) -> ResultClaim[_TaskResult]:
return ResultClaim(result_type="task", model=_TaskResult, resolve=_resolve_task, **kwargs)
_COMPLETE_TOOL_RESULT = CallToolResult(content=[TextContent(type="text", text="ok")]).model_dump(
by_alias=True, mode="json", exclude_none=True
)
_CLAIMED_TASK_RESULT = {"resultType": "task", "taskId": "t-1"}
_TOOL_LISTING = ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"})]).model_dump(
by_alias=True, mode="json", exclude_none=True
)
_INITIALIZE_RESULT = types.InitializeResult(
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ServerCapabilities(),
server_info=Implementation(name="stub", version="0"),
).model_dump(by_alias=True, mode="json", exclude_none=True)
class _RecordingDispatcher:
"""Records every send and answers each method with a canned result."""
def __init__(self, tool_result: dict[str, Any] | None = None) -> None:
self.calls: list[tuple[str, Mapping[str, Any] | None, CallOptions]] = []
self.notifications: list[str] = []
self._tool_result = tool_result if tool_result is not None else _COMPLETE_TOOL_RESULT
async def run(
self,
on_request: OnRequest,
on_notify: OnNotify,
on_notify_intercept: OnNotifyIntercept | None = None,
*,
task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
) -> None:
task_status.started()
await anyio.sleep_forever()
async def send_raw_request(
self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None
) -> dict[str, Any]:
self.calls.append((method, params, opts or {}))
if method == "tools/call":
return self._tool_result
if method == "tools/list":
return _TOOL_LISTING
if method == "initialize":
return _INITIALIZE_RESULT
return {}
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
self.notifications.append(method)
def _claims_session(dispatcher: _RecordingDispatcher, *claims: ResultClaim[Any]) -> ClientSession:
return ClientSession(dispatcher=dispatcher, extensions={_TASKS_EXT: {}}, result_claims={_TASKS_EXT: list(claims)})
def _adopt_modern(session: ClientSession) -> None:
session.adopt(
types.DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
server_info=Implementation(name="stub", version="0"),
)
)
def _adopt_handshake(session: ClientSession) -> None:
session.adopt(
types.InitializeResult(
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ServerCapabilities(),
server_info=Implementation(name="stub", version="0"),
)
)
def test_duplicate_claim_tag_across_extensions_rejected() -> None:
"""SDK-defined: two claims on the same resultType cannot be routed apart, so construction fails."""
with pytest.raises(ValueError) as exc_info:
ClientSession(
dispatcher=_RecordingDispatcher(),
extensions={_TASKS_EXT: {}, _AD_ONLY_EXT: {}},
result_claims={_TASKS_EXT: [_task_claim()], _AD_ONLY_EXT: [_task_claim()]},
)
assert str(exc_info.value) == snapshot("duplicate result claim for resultType 'task'")
def test_claims_keyed_to_unadvertised_extension_rejected() -> None:
"""SDK-defined: a `result_claims` key with no `extensions` entry advertises nothing, so construction fails."""
messages: list[str] = []
for extensions in (None, {_AD_ONLY_EXT: {"flag": True}}):
with pytest.raises(ValueError) as exc_info:
ClientSession(
dispatcher=_RecordingDispatcher(),
extensions=extensions,
result_claims={_TASKS_EXT: [_task_claim()]},
)
messages.append(str(exc_info.value))
assert messages == snapshot(
[
"result_claims key 'com.example/tasks' has no extensions entry; a claim is only "
"advertised through its extension's capability ad",
"result_claims key 'com.example/tasks' has no extensions entry; a claim is only "
"advertised through its extension's capability ad",
]
)
def test_empty_claim_sequence_rejected() -> None:
"""SDK-defined: an empty claim list is rejected at construction; a claim-less extension omits the key."""
with pytest.raises(ValueError) as exc_info:
ClientSession(dispatcher=_RecordingDispatcher(), extensions={_TASKS_EXT: {}}, result_claims={_TASKS_EXT: []})
assert str(exc_info.value) == snapshot(
"result_claims['com.example/tasks'] is empty and would drop the extension from "
"the capability ad at every version. Omit the key instead"
)
def test_empty_settings_count_as_an_advertised_extension() -> None:
"""SDK-defined: empty settings ({}) still count as an ad, so claims keyed to the extension construct."""
session = _claims_session(_RecordingDispatcher(), _task_claim())
assert isinstance(session, ClientSession)
def test_without_claims_the_call_tool_adapter_is_the_module_constant() -> None:
"""SDK-defined: with zero active claims the session holds the module-level adapter by identity."""
session = ClientSession(dispatcher=_RecordingDispatcher())
assert session._call_tool_adapter is _CallToolResultAdapter
_adopt_modern(session)
assert session._call_tool_adapter is _CallToolResultAdapter
_adopt_handshake(session)
assert session._call_tool_adapter is _CallToolResultAdapter
@pytest.mark.anyio
@pytest.mark.parametrize("protocol_versions", [None, frozenset({LATEST_MODERN_VERSION})])
async def test_modern_adopt_activates_claims_and_routes_claimed_results(
protocol_versions: frozenset[str] | None,
) -> None:
"""SDK-defined: at a modern adopt, a claim active at the negotiated version routes
the claimed raw to the claim model."""
dispatcher = _RecordingDispatcher(tool_result=_CLAIMED_TASK_RESULT)
session = _claims_session(dispatcher, _task_claim(protocol_versions=protocol_versions))
with anyio.fail_after(5):
async with session:
_adopt_modern(session)
result = await session.call_tool("t", {}, allow_claimed=True)
assert isinstance(result, _TaskResult)
assert result.task_id == "t-1"
@pytest.mark.anyio
async def test_legacy_adopt_clears_active_claims() -> None:
"""SDK-defined: a legacy adopt clears active claims and restores the module-level adapter."""
dispatcher = _RecordingDispatcher(tool_result=_CLAIMED_TASK_RESULT)
session = _claims_session(dispatcher, _task_claim())
with anyio.fail_after(5):
async with session:
_adopt_modern(session)
assert isinstance(await session.call_tool("t", {}, allow_claimed=True), _TaskResult)
_adopt_handshake(session)
assert session._call_tool_adapter is _CallToolResultAdapter
with pytest.raises(ValidationError):
await session.call_tool("t", {}, allow_claimed=True)
# Rejected at response parsing; the request did reach the wire.
assert dispatcher.calls[-1][0] == "tools/call"
@pytest.mark.anyio
async def test_modern_readopt_after_legacy_reactivates_claims() -> None:
"""SDK-defined: a modern re-adopt after legacy reactivates the claims."""
dispatcher = _RecordingDispatcher(tool_result=_CLAIMED_TASK_RESULT)
session = _claims_session(dispatcher, _task_claim())
with anyio.fail_after(5):
async with session:
_adopt_modern(session)
_adopt_handshake(session)
assert session._call_tool_adapter is _CallToolResultAdapter
_adopt_modern(session)
result = await session.call_tool("t", {}, allow_claimed=True)
assert isinstance(result, _TaskResult)
assert session._call_tool_adapter is not _CallToolResultAdapter
@pytest.mark.anyio
async def test_legacy_initialize_ad_drops_claim_bearing_identifiers() -> None:
"""SDK-defined: the legacy initialize ad drops claim-bearing identifiers; ad-only ones ride along."""
dispatcher = _RecordingDispatcher()
session = ClientSession(
dispatcher=dispatcher,
extensions={_TASKS_EXT: {}, _AD_ONLY_EXT: {"flag": True}},
result_claims={_TASKS_EXT: [_task_claim()]},
)
with anyio.fail_after(5):
async with session:
await session.initialize()
[(_, params, _)] = [call for call in dispatcher.calls if call[0] == "initialize"]
assert params is not None
assert params["capabilities"]["extensions"] == {_AD_ONLY_EXT: {"flag": True}}
@pytest.mark.anyio
async def test_legacy_ad_omits_extensions_entirely_when_every_identifier_drops() -> None:
"""SDK-defined: when every identifier drops, the ad omits the `extensions` key entirely."""
dispatcher = _RecordingDispatcher()
session = _claims_session(dispatcher, _task_claim())
with anyio.fail_after(5):
async with session:
await session.initialize()
[(_, params, _)] = [call for call in dispatcher.calls if call[0] == "initialize"]
assert params is not None
assert "extensions" not in params["capabilities"]
@pytest.mark.anyio
async def test_modern_adopt_ad_includes_active_claim_identifiers() -> None:
"""SDK-defined: the modern per-request `_meta` ad includes identifiers whose claims are active."""
dispatcher = _RecordingDispatcher()
session = ClientSession(
dispatcher=dispatcher,
extensions={_TASKS_EXT: {}, _AD_ONLY_EXT: {"flag": True}},
result_claims={_TASKS_EXT: [_task_claim()]},
)
with anyio.fail_after(5):
async with session:
_adopt_modern(session)
await session.send_ping()
[(_, params, _)] = dispatcher.calls
assert params is not None
capabilities = params["_meta"][CLIENT_CAPABILITIES_META_KEY]
assert capabilities["extensions"] == {_TASKS_EXT: {}, _AD_ONLY_EXT: {"flag": True}}
@pytest.mark.anyio
async def test_discover_probe_ad_includes_claim_identifiers_at_the_probe_version() -> None:
"""SDK-defined: `send_discover` builds its `_meta` ad at the probe version, where claims are active."""
dispatcher = _RecordingDispatcher()
session = _claims_session(dispatcher, _task_claim())
with anyio.fail_after(5):
async with session:
await session.send_discover(LATEST_MODERN_VERSION)
[(_, params, _)] = dispatcher.calls
assert params is not None
capabilities = params["_meta"][CLIENT_CAPABILITIES_META_KEY]
assert capabilities["extensions"] == {_TASKS_EXT: {}}
@pytest.mark.anyio
async def test_discover_probe_ad_drops_claim_identifiers_at_a_legacy_probe_version() -> None:
"""SDK-defined: at a legacy probe version no claim can be active, so the identifier drops."""
dispatcher = _RecordingDispatcher()
session = _claims_session(dispatcher, _task_claim())
with anyio.fail_after(5):
async with session:
await session.send_discover(LATEST_HANDSHAKE_VERSION)
[(_, params, _)] = dispatcher.calls
assert params is not None
capabilities = params["_meta"][CLIENT_CAPABILITIES_META_KEY]
assert "extensions" not in capabilities
class _CoreTaggedResult(Result):
"""A claim whose wire tag collides with the adapter's internal routing sentinel."""
result_type: Literal["core"] = "core"
payload: str = ""
async def _resolve_core_tagged(result: _CoreTaggedResult, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError
@pytest.mark.anyio
async def test_claim_tagged_core_cannot_hijack_core_parsing() -> None:
"""SDK-defined: a claim may use "core" as its wire tag without colliding with core parsing."""
claim = ResultClaim(result_type="core", model=_CoreTaggedResult, resolve=_resolve_core_tagged)
dispatcher = _RecordingDispatcher(tool_result={"resultType": "core", "payload": "p-1"})
session = ClientSession(dispatcher=dispatcher, extensions={_TASKS_EXT: {}}, result_claims={_TASKS_EXT: [claim]})
with anyio.fail_after(5):
async with session:
_adopt_modern(session)
ordinary = session._call_tool_adapter.validate_python(_COMPLETE_TOOL_RESULT)
claimed = await session.call_tool("t", {}, allow_claimed=True)
assert isinstance(ordinary, CallToolResult)
assert isinstance(claimed, _CoreTaggedResult)
@pytest.mark.anyio
@pytest.mark.parametrize("with_claims", [True, False])
async def test_unknown_result_type_fails_validation_with_and_without_claims(with_claims: bool) -> None:
"""SDK-defined: a resultType outside the active claim set fails core validation, claims or not."""
raw = {"resultType": "weird", "taskId": "t-1"}
dispatcher = _RecordingDispatcher(tool_result=raw)
session = _claims_session(dispatcher, _task_claim()) if with_claims else ClientSession(dispatcher=dispatcher)
with anyio.fail_after(5):
async with session:
_adopt_modern(session)
with pytest.raises(ValidationError):
await session.call_tool("t", {}, allow_claimed=True)
# Rejected at response parsing; the request did reach the wire.
assert dispatcher.calls[-1][0] == "tools/call"
@pytest.mark.anyio
async def test_non_string_result_type_fails_core_validation_not_discrimination() -> None:
"""SDK-defined: a non-string resultType stays on the core arm and fails as ValidationError, not TypeError."""
raw: dict[str, Any] = {"resultType": {"nested": True}}
dispatcher = _RecordingDispatcher(tool_result=raw)
session = _claims_session(dispatcher, _task_claim())
with anyio.fail_after(5):
async with session:
_adopt_modern(session)
with pytest.raises(ValidationError):
await session.call_tool("t", {}, allow_claimed=True)
# Rejected at response parsing; the request did reach the wire.
assert dispatcher.calls[-1][0] == "tools/call"
def test_adopt_built_adapter_revalidates_model_instances() -> None:
"""SDK-defined: the adopt-built adapter routes already-built model instances as well as raw dicts."""
session = _claims_session(_RecordingDispatcher(), _task_claim())
_adopt_modern(session)
adapter = session._call_tool_adapter
claimed = adapter.validate_python(_TaskResult(task_id="t-2"))
assert isinstance(claimed, _TaskResult)
core = adapter.validate_python(CallToolResult(content=[]))
assert isinstance(core, CallToolResult)
@pytest.mark.anyio
async def test_input_required_routes_to_core_arm_with_claims_active() -> None:
"""Spec-mandated: `input_required` is core vocabulary; active claims leave that arm untouched."""
raw = {"resultType": "input_required", "requestState": "s-1"}
session = _claims_session(_RecordingDispatcher(tool_result=raw), _task_claim())
with anyio.fail_after(5):
async with session:
_adopt_modern(session)
result = await session.call_tool("t", {}, allow_input_required=True, allow_claimed=True)
assert isinstance(result, InputRequiredResult)
assert result.request_state == "s-1"
@pytest.mark.anyio
async def test_claimed_result_raises_unexpected_claimed_result_by_default() -> None:
"""SDK-defined: without `allow_claimed` a claimed shape raises, carrying the parsed
result so the caller can clean up any server-side state it references."""
dispatcher = _RecordingDispatcher(tool_result=_CLAIMED_TASK_RESULT)
session = _claims_session(dispatcher, _task_claim())
with anyio.fail_after(5):
async with session:
_adopt_modern(session)
with pytest.raises(UnexpectedClaimedResult) as exc_info:
await session.call_tool("t", {})
# The shape parsed and then raised; the request did reach the wire.
assert dispatcher.calls[-1][0] == "tools/call"
assert isinstance(exc_info.value.result, _TaskResult)
assert exc_info.value.result.task_id == "t-1"
assert str(exc_info.value) == snapshot(
"Server returned a claimed result (_TaskResult); pass the owning extension to "
"Client(extensions=[...]) for transparent resolution, or call with allow_claimed=True "
"and handle the shape. The carried result may reference server-side state needing cleanup."
)
@pytest.mark.anyio
async def test_call_tool_result_path_identical_under_both_allow_claimed_values() -> None:
"""SDK-defined: `allow_claimed` only affects claimed shapes; ordinary results come back identical."""
dispatcher = _RecordingDispatcher()
session = _claims_session(dispatcher, _task_claim())
with anyio.fail_after(5):
async with session:
_adopt_modern(session)
r_default = await session.call_tool("t", {})
r_opted = await session.call_tool("t", {}, allow_claimed=True)
assert isinstance(r_opted, CallToolResult)
assert r_opted == r_default
@pytest.mark.anyio
async def test_call_tool_overload_matrix_narrows_statically() -> None:
"""SDK-defined: each flag combination narrows `call_tool` to its documented return union under pyright."""
dispatcher = _RecordingDispatcher()
session = _claims_session(dispatcher, _task_claim())
with anyio.fail_after(5):
async with session:
_adopt_modern(session)
r1 = await session.call_tool("t", {})
assert_type(r1, CallToolResult)
r2 = await session.call_tool("t", {}, allow_input_required=True)
assert_type(r2, CallToolResult | InputRequiredResult)
r3 = await session.call_tool("t", {}, allow_claimed=True)
assert_type(r3, CallToolResult | Result)
r4 = await session.call_tool("t", {}, allow_input_required=True, allow_claimed=True)
assert_type(r4, CallToolResult | InputRequiredResult | Result)
assert [type(r) for r in (r1, r2, r3, r4)] == [CallToolResult] * 4
def test_claimed_raw_passes_v2026_tools_call_surface_validation() -> None:
"""Pins the claim path's dependency: an unknown resultType passes `validate_server_result`
at 2026-07-28; this failing is the signal that mcp-types tightened the surface."""
validate_server_result("tools/call", LATEST_MODERN_VERSION, {"resultType": "task", "taskId": "t-1"})
+141
View File
@@ -0,0 +1,141 @@
"""Concurrency over a single client session: multiple requests in flight at once, in both directions."""
import anyio
import pytest
from inline_snapshot import snapshot
from mcp_types import (
CallToolResult,
CreateMessageRequestParams,
CreateMessageResult,
SamplingMessage,
TextContent,
)
from mcp import Client
from mcp.client import ClientRequestContext
from mcp.server.mcpserver import Context, MCPServer
pytestmark = pytest.mark.anyio
async def test_concurrent_tool_calls_resolve_out_of_order_to_their_own_callers() -> None:
"""Three tool calls in flight at once on one session each receive their own result, even though
the responses come back in the reverse of the order the requests were sent.
SDK-defined contract: pins the client request machinery's support for concurrent in-flight
calls with out-of-order response correlation. Each handler parks on its own release event
after signalling it started; a session that serialized requests would never start the later
handlers and the test would time out instead.
"""
send_order = ["a", "b", "c"]
started = {tag: anyio.Event() for tag in send_order}
release = {tag: anyio.Event() for tag in send_order}
done = {tag: anyio.Event() for tag in send_order}
completion_order: list[str] = []
results: dict[str, CallToolResult] = {}
server = MCPServer("parking")
@server.tool()
async def park(tag: str) -> str:
started[tag].set()
await release[tag].wait()
return f"result:{tag}"
async with Client(server) as client:
async def call_and_record(tag: str) -> None:
results[tag] = await client.call_tool("park", {"tag": tag})
completion_order.append(tag)
done[tag].set()
with anyio.fail_after(5):
async with anyio.create_task_group() as task_group: # pragma: no branch
# Waiting for each handler to start before issuing the next call fixes the send
# order, and leaves all three parked in flight together once the loop finishes.
for tag in send_order:
task_group.start_soon(call_and_record, tag)
await started[tag].wait()
# Nothing completed yet: all three calls are genuinely concurrent.
assert completion_order == []
# Release in reverse, awaiting each completion so the finish order is forced.
for tag in reversed(send_order):
release[tag].set()
await done[tag].wait()
assert completion_order == ["c", "b", "a"]
assert results == snapshot(
{
"c": CallToolResult(content=[TextContent(text="result:c")], structured_content={"result": "result:c"}),
"b": CallToolResult(content=[TextContent(text="result:b")], structured_content={"result": "result:b"}),
"a": CallToolResult(content=[TextContent(text="result:a")], structured_content={"result": "result:a"}),
}
)
async def test_overlapping_sampling_requests_are_serviced_concurrently_by_the_client() -> None:
"""A server tool that fans out two sampling requests at once gets both echoes back: the client
runs overlapping inbound `create_message` requests concurrently instead of serializing them in
its receive loop.
Regression pin for https://github.com/modelcontextprotocol/python-sdk/issues/2489 -- v1's
`BaseSession` awaited each inbound request handler inline, so the second sampling callback
could not start until the first returned; here both rendezvous before either is released.
"""
sampling_started = {"x": anyio.Event(), "y": anyio.Event()}
sampling_release = anyio.Event()
tool_results: list[CallToolResult] = []
server = MCPServer("fan_out_server")
@server.tool()
async def fan_out(ctx: Context) -> str:
echoes: dict[str, str] = {}
async def sample(tag: str) -> None:
result = await ctx.session.create_message( # pyright: ignore[reportDeprecated]
messages=[SamplingMessage(role="user", content=TextContent(text=tag))],
max_tokens=10,
)
assert isinstance(result.content, TextContent)
echoes[tag] = result.content.text
async with anyio.create_task_group() as sampler_group:
sampler_group.start_soon(sample, "x")
sampler_group.start_soon(sample, "y")
return f"{echoes['x']} {echoes['y']}"
async def sampling_callback(
context: ClientRequestContext, params: CreateMessageRequestParams
) -> CreateMessageResult:
content = params.messages[0].content
assert isinstance(content, TextContent)
sampling_started[content.text].set()
await sampling_release.wait()
return CreateMessageResult(
role="assistant",
content=TextContent(text=f"echo:{content.text}"),
model="test-model",
stop_reason="endTurn",
)
async with Client(server, sampling_callback=sampling_callback, mode="legacy") as client:
with anyio.fail_after(5):
async with anyio.create_task_group() as task_group: # pragma: no branch
async def invoke_fan_out() -> None:
tool_results.append(await client.call_tool("fan_out", {}))
task_group.start_soon(invoke_fan_out)
# Both sampling callbacks are mid-flight before either may answer -- a client that
# serialized inbound requests would never start the second one.
await sampling_started["x"].wait()
await sampling_started["y"].wait()
sampling_release.set()
assert tool_results == snapshot(
[CallToolResult(content=[TextContent(text="echo:x echo:y")], structured_content={"result": "echo:x echo:y"})]
)
+404
View File
@@ -0,0 +1,404 @@
import contextlib
from unittest import mock
import httpx
import mcp_types as types
import pytest
import mcp
from mcp.client.session_group import (
ClientSessionGroup,
ClientSessionParameters,
SseServerParameters,
StreamableHttpParameters,
)
from mcp.client.stdio import StdioServerParameters
from mcp.shared.exceptions import MCPError
@pytest.fixture
def mock_exit_stack():
"""Fixture for a mocked AsyncExitStack."""
# Use unittest.mock.Mock directly if needed, or just a plain object
# if only attribute access/existence is needed.
# For AsyncExitStack, Mock or MagicMock is usually fine.
return mock.MagicMock(spec=contextlib.AsyncExitStack)
def test_client_session_group_init():
mcp_session_group = ClientSessionGroup()
assert not mcp_session_group._tools
assert not mcp_session_group._resources
assert not mcp_session_group._prompts
assert not mcp_session_group._tool_to_session
def test_client_session_group_component_properties():
# --- Mock Dependencies ---
mock_prompt = mock.Mock()
mock_resource = mock.Mock()
mock_tool = mock.Mock()
# --- Prepare Session Group ---
mcp_session_group = ClientSessionGroup()
mcp_session_group._prompts = {"my_prompt": mock_prompt}
mcp_session_group._resources = {"my_resource": mock_resource}
mcp_session_group._tools = {"my_tool": mock_tool}
# --- Assertions ---
assert mcp_session_group.prompts == {"my_prompt": mock_prompt}
assert mcp_session_group.resources == {"my_resource": mock_resource}
assert mcp_session_group.tools == {"my_tool": mock_tool}
@pytest.mark.anyio
async def test_client_session_group_call_tool():
# --- Mock Dependencies ---
mock_session = mock.AsyncMock()
# --- Prepare Session Group ---
def hook(name: str, server_info: types.Implementation) -> str: # pragma: no cover
return f"{(server_info.name)}-{name}"
mcp_session_group = ClientSessionGroup(component_name_hook=hook)
mcp_session_group._tools = {"server1-my_tool": types.Tool(name="my_tool", input_schema={})}
mcp_session_group._tool_to_session = {"server1-my_tool": mock_session}
text_content = types.TextContent(type="text", text="OK")
mock_session.call_tool.return_value = types.CallToolResult(content=[text_content])
# --- Test Execution ---
result = await mcp_session_group.call_tool(
name="server1-my_tool",
arguments={
"name": "value1",
"args": {},
},
)
# --- Assertions ---
assert result.content == [text_content]
mock_session.call_tool.assert_called_once_with(
"my_tool",
arguments={"name": "value1", "args": {}},
read_timeout_seconds=None,
progress_callback=None,
input_responses=None,
request_state=None,
meta=None,
allow_input_required=False,
)
@pytest.mark.anyio
async def test_client_session_group_call_tool_forwards_allow_input_required():
mock_session = mock.AsyncMock()
mcp_session_group = ClientSessionGroup()
mcp_session_group._tools = {"my_tool": types.Tool(name="my_tool", input_schema={})}
mcp_session_group._tool_to_session = {"my_tool": mock_session}
mock_session.call_tool.return_value = types.InputRequiredResult(request_state="s")
result = await mcp_session_group.call_tool(name="my_tool", arguments={}, allow_input_required=True)
assert isinstance(result, types.InputRequiredResult)
assert result.request_state == "s"
assert mock_session.call_tool.call_args.kwargs["allow_input_required"] is True
@pytest.mark.anyio
async def test_client_session_group_connect_to_server(mock_exit_stack: contextlib.AsyncExitStack):
"""Test connecting to a server and aggregating components."""
# --- Mock Dependencies ---
mock_server_info = mock.Mock(spec=types.Implementation)
mock_server_info.name = "TestServer1"
mock_session = mock.AsyncMock(spec=mcp.ClientSession)
mock_tool1 = mock.Mock(spec=types.Tool)
mock_tool1.name = "tool_a"
mock_resource1 = mock.Mock(spec=types.Resource)
mock_resource1.name = "resource_b"
mock_prompt1 = mock.Mock(spec=types.Prompt)
mock_prompt1.name = "prompt_c"
mock_session.list_tools.return_value = mock.AsyncMock(tools=[mock_tool1])
mock_session.list_resources.return_value = mock.AsyncMock(resources=[mock_resource1])
mock_session.list_prompts.return_value = mock.AsyncMock(prompts=[mock_prompt1])
# --- Test Execution ---
group = ClientSessionGroup(exit_stack=mock_exit_stack)
with mock.patch.object(group, "_establish_session", return_value=(mock_server_info, mock_session)):
await group.connect_to_server(StdioServerParameters(command="test"))
# --- Assertions ---
assert mock_session in group._sessions
assert len(group.tools) == 1
assert "tool_a" in group.tools
assert group.tools["tool_a"] == mock_tool1
assert group._tool_to_session["tool_a"] == mock_session
assert len(group.resources) == 1
assert "resource_b" in group.resources
assert group.resources["resource_b"] == mock_resource1
assert len(group.prompts) == 1
assert "prompt_c" in group.prompts
assert group.prompts["prompt_c"] == mock_prompt1
mock_session.list_tools.assert_awaited_once()
mock_session.list_resources.assert_awaited_once()
mock_session.list_prompts.assert_awaited_once()
@pytest.mark.anyio
async def test_client_session_group_connect_to_server_with_name_hook(mock_exit_stack: contextlib.AsyncExitStack):
"""Test connecting with a component name hook."""
# --- Mock Dependencies ---
mock_server_info = mock.Mock(spec=types.Implementation)
mock_server_info.name = "HookServer"
mock_session = mock.AsyncMock(spec=mcp.ClientSession)
mock_tool = mock.Mock(spec=types.Tool)
mock_tool.name = "base_tool"
mock_session.list_tools.return_value = mock.AsyncMock(tools=[mock_tool])
mock_session.list_resources.return_value = mock.AsyncMock(resources=[])
mock_session.list_prompts.return_value = mock.AsyncMock(prompts=[])
# --- Test Setup ---
def name_hook(name: str, server_info: types.Implementation) -> str:
return f"{server_info.name}.{name}"
# --- Test Execution ---
group = ClientSessionGroup(exit_stack=mock_exit_stack, component_name_hook=name_hook)
with mock.patch.object(group, "_establish_session", return_value=(mock_server_info, mock_session)):
await group.connect_to_server(StdioServerParameters(command="test"))
# --- Assertions ---
assert mock_session in group._sessions
assert len(group.tools) == 1
expected_tool_name = "HookServer.base_tool"
assert expected_tool_name in group.tools
assert group.tools[expected_tool_name] == mock_tool
assert group._tool_to_session[expected_tool_name] == mock_session
@pytest.mark.anyio
async def test_client_session_group_disconnect_from_server():
"""Test disconnecting from a server."""
# --- Test Setup ---
group = ClientSessionGroup()
server_name = "ServerToDisconnect"
# Manually populate state using standard mocks
mock_session1 = mock.MagicMock(spec=mcp.ClientSession)
mock_session2 = mock.MagicMock(spec=mcp.ClientSession)
mock_tool1 = mock.Mock(spec=types.Tool)
mock_tool1.name = "tool1"
mock_resource1 = mock.Mock(spec=types.Resource)
mock_resource1.name = "res1"
mock_prompt1 = mock.Mock(spec=types.Prompt)
mock_prompt1.name = "prm1"
mock_tool2 = mock.Mock(spec=types.Tool)
mock_tool2.name = "tool2"
mock_component_named_like_server = mock.Mock()
mock_session = mock.Mock(spec=mcp.ClientSession)
group._tools = {
"tool1": mock_tool1,
"tool2": mock_tool2,
server_name: mock_component_named_like_server,
}
group._tool_to_session = {
"tool1": mock_session1,
"tool2": mock_session2,
server_name: mock_session1,
}
group._resources = {
"res1": mock_resource1,
server_name: mock_component_named_like_server,
}
group._prompts = {
"prm1": mock_prompt1,
server_name: mock_component_named_like_server,
}
group._sessions = {
mock_session: ClientSessionGroup._ComponentNames(
prompts=set({"prm1"}),
resources=set({"res1"}),
tools=set({"tool1", "tool2"}),
)
}
# --- Assertions ---
assert mock_session in group._sessions
assert "tool1" in group._tools
assert "tool2" in group._tools
assert "res1" in group._resources
assert "prm1" in group._prompts
# --- Test Execution ---
await group.disconnect_from_server(mock_session)
# --- Assertions ---
assert mock_session not in group._sessions
assert "tool1" not in group._tools
assert "tool2" not in group._tools
assert "res1" not in group._resources
assert "prm1" not in group._prompts
@pytest.mark.anyio
async def test_client_session_group_connect_to_server_duplicate_tool_raises_error(
mock_exit_stack: contextlib.AsyncExitStack,
):
"""Test MCPError raised when connecting a server with a dup name."""
# --- Setup Pre-existing State ---
group = ClientSessionGroup(exit_stack=mock_exit_stack)
existing_tool_name = "shared_tool"
# Manually add a tool to simulate a previous connection
group._tools[existing_tool_name] = mock.Mock(spec=types.Tool)
group._tools[existing_tool_name].name = existing_tool_name
# Need a dummy session associated with the existing tool
mock_session = mock.MagicMock(spec=mcp.ClientSession)
group._tool_to_session[existing_tool_name] = mock_session
group._session_exit_stacks[mock_session] = mock.Mock(spec=contextlib.AsyncExitStack)
# --- Mock New Connection Attempt ---
mock_server_info_new = mock.Mock(spec=types.Implementation)
mock_server_info_new.name = "ServerWithDuplicate"
mock_session_new = mock.AsyncMock(spec=mcp.ClientSession)
# Configure the new session to return a tool with the *same name*
duplicate_tool = mock.Mock(spec=types.Tool)
duplicate_tool.name = existing_tool_name
mock_session_new.list_tools.return_value = mock.AsyncMock(tools=[duplicate_tool])
# Keep other lists empty for simplicity
mock_session_new.list_resources.return_value = mock.AsyncMock(resources=[])
mock_session_new.list_prompts.return_value = mock.AsyncMock(prompts=[])
# --- Test Execution and Assertion ---
with pytest.raises(MCPError) as excinfo:
with mock.patch.object(
group,
"_establish_session",
return_value=(mock_server_info_new, mock_session_new),
):
await group.connect_to_server(StdioServerParameters(command="test"))
# Assert details about the raised error
assert excinfo.value.error.code == types.INVALID_PARAMS
assert existing_tool_name in excinfo.value.error.message
assert "already exist " in excinfo.value.error.message
# Verify the duplicate tool was *not* added again (state should be unchanged)
assert len(group._tools) == 1 # Should still only have the original
assert group._tools[existing_tool_name] is not duplicate_tool # Ensure it's the original mock
@pytest.mark.anyio
async def test_client_session_group_disconnect_non_existent_server():
"""Test disconnecting a server that isn't connected."""
session = mock.Mock(spec=mcp.ClientSession)
group = ClientSessionGroup()
with pytest.raises(MCPError):
await group.disconnect_from_server(session)
# TODO(Marcelo): This is horrible. We should drop this test.
@pytest.mark.anyio
@pytest.mark.parametrize(
"server_params_instance, client_type_name, patch_target_for_client_func",
[
(
StdioServerParameters(command="test_stdio_cmd"),
"stdio",
"mcp.client.session_group.mcp.stdio_client",
),
(
SseServerParameters(url="http://test.com/sse", timeout=10.0),
"sse",
"mcp.client.session_group.sse_client",
), # url, headers, timeout, sse_read_timeout
(
StreamableHttpParameters(url="http://test.com/stream", terminate_on_close=False),
"streamablehttp",
"mcp.client.session_group.streamable_http_client",
), # url, headers, timeout, sse_read_timeout, terminate_on_close
],
)
async def test_client_session_group_establish_session_parameterized(
server_params_instance: StdioServerParameters | SseServerParameters | StreamableHttpParameters,
client_type_name: str, # Just for clarity or conditional logic if needed
patch_target_for_client_func: str,
):
with mock.patch("mcp.client.session_group.mcp.ClientSession") as mock_ClientSession_class:
with mock.patch(patch_target_for_client_func) as mock_specific_client_func:
mock_client_cm_instance = mock.AsyncMock(name=f"{client_type_name}ClientCM")
mock_read_stream = mock.AsyncMock(name=f"{client_type_name}Read")
mock_write_stream = mock.AsyncMock(name=f"{client_type_name}Write")
# All client context managers return (read_stream, write_stream)
mock_client_cm_instance.__aenter__.return_value = (mock_read_stream, mock_write_stream)
mock_client_cm_instance.__aexit__ = mock.AsyncMock(return_value=None)
mock_specific_client_func.return_value = mock_client_cm_instance
# --- Mock mcp.ClientSession (class) ---
# mock_ClientSession_class is already provided by the outer patch
mock_raw_session_cm = mock.AsyncMock(name="RawSessionCM")
mock_ClientSession_class.return_value = mock_raw_session_cm
mock_entered_session = mock.AsyncMock(name="EnteredSessionInstance")
mock_raw_session_cm.__aenter__.return_value = mock_entered_session
mock_raw_session_cm.__aexit__ = mock.AsyncMock(return_value=None)
# Mock session.initialize()
mock_initialize_result = mock.AsyncMock(name="InitializeResult")
mock_initialize_result.server_info = types.Implementation(name="foo", version="1")
mock_entered_session.initialize.return_value = mock_initialize_result
# --- Test Execution ---
group = ClientSessionGroup()
returned_server_info = None
returned_session = None
async with contextlib.AsyncExitStack() as stack:
group._exit_stack = stack
(
returned_server_info,
returned_session,
) = await group._establish_session(server_params_instance, ClientSessionParameters())
# --- Assertions ---
# 1. Assert the correct specific client function was called
if client_type_name == "stdio":
assert isinstance(server_params_instance, StdioServerParameters)
mock_specific_client_func.assert_called_once_with(server_params_instance)
elif client_type_name == "sse":
assert isinstance(server_params_instance, SseServerParameters)
mock_specific_client_func.assert_called_once_with(
url=server_params_instance.url,
headers=server_params_instance.headers,
timeout=server_params_instance.timeout,
sse_read_timeout=server_params_instance.sse_read_timeout,
)
elif client_type_name == "streamablehttp": # pragma: no branch
assert isinstance(server_params_instance, StreamableHttpParameters)
# Verify streamable_http_client was called with url, httpx_client, and terminate_on_close
# The http_client is created by the real create_mcp_http_client
call_args = mock_specific_client_func.call_args
assert call_args.kwargs["url"] == server_params_instance.url
assert call_args.kwargs["terminate_on_close"] == server_params_instance.terminate_on_close
assert isinstance(call_args.kwargs["http_client"], httpx.AsyncClient)
mock_client_cm_instance.__aenter__.assert_awaited_once()
# 2. Assert ClientSession was called correctly
mock_ClientSession_class.assert_called_once_with(
mock_read_stream,
mock_write_stream,
read_timeout_seconds=None,
sampling_callback=None,
elicitation_callback=None,
list_roots_callback=None,
logging_callback=None,
message_handler=None,
client_info=None,
)
mock_raw_session_cm.__aenter__.assert_awaited_once()
mock_entered_session.initialize.assert_awaited_once()
# 3. Assert returned values
assert returned_server_info is mock_initialize_result.server_info
assert returned_session is mock_entered_session
@@ -0,0 +1,287 @@
"""`ClientSession` notification bindings: serialized per-binding delivery through a
bounded FIFO, consulted only for methods the negotiated version's core tables do
not know."""
import logging
import anyio
import mcp_types as types
import pytest
from mcp_types import EmptyResult, Implementation, ServerCapabilities
from mcp_types.version import LATEST_MODERN_VERSION
from pydantic import BaseModel
from mcp.client.extension import NotificationBinding
from mcp.client.session import _NOTIFICATION_QUEUE_SIZE, ClientSession
from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair
from mcp.shared.dispatcher import DispatchContext
from mcp.shared.transport_context import TransportContext
_VENDOR_METHOD = "notifications/vendor/task_done"
class _EventParams(BaseModel):
seq: int
async def _server_on_request(
ctx: DispatchContext[TransportContext], method: str, params: dict[str, object] | None
) -> dict[str, object]:
assert method == "ping"
return {}
async def _server_on_notify(
ctx: DispatchContext[TransportContext], method: str, params: dict[str, object] | None
) -> None:
raise NotImplementedError
def _adopt_modern(session: ClientSession) -> None:
session.adopt(
types.DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
server_info=Implementation(name="stub", version="0"),
)
)
async def _noop_handler(params: _EventParams) -> None:
raise NotImplementedError # construction-only tests never deliver
def test_duplicate_binding_method_rejected() -> None:
"""SDK-defined: two bindings on one wire method cannot be routed apart, so construction fails."""
client_side, _ = create_direct_dispatcher_pair()
binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=_noop_handler)
with pytest.raises(ValueError) as exc_info:
ClientSession(dispatcher=client_side, notification_bindings=[binding, binding])
assert str(exc_info.value) == "duplicate notification binding for method 'notifications/vendor/task_done'"
@pytest.mark.anyio
async def test_bound_vendor_notifications_are_delivered_in_order() -> None:
"""SDK-defined: one consumer per binding delivers events in the order the server sent them."""
delivered: list[int] = []
done = anyio.Event()
async def on_event(params: _EventParams) -> None:
delivered.append(params.seq)
if params.seq == 3:
done.set()
client_side, server_side = create_direct_dispatcher_pair()
binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=on_event)
session = ClientSession(dispatcher=client_side, notification_bindings=[binding])
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, _server_on_request, _server_on_notify)
async with session:
_adopt_modern(session)
for seq in (1, 2, 3):
await server_side.notify(_VENDOR_METHOD, {"seq": seq})
await done.wait()
server_side.close()
assert delivered == [1, 2, 3]
@pytest.mark.anyio
async def test_binding_handler_may_do_session_io_without_deadlock() -> None:
"""SDK-defined: delivery is spawn-decoupled, so a handler may await session I/O without deadlock."""
pongs: list[EmptyResult] = []
done = anyio.Event()
client_side, server_side = create_direct_dispatcher_pair()
async def on_event(params: _EventParams) -> None:
pongs.append(await session.send_ping())
done.set()
binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=on_event)
session = ClientSession(dispatcher=client_side, notification_bindings=[binding])
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, _server_on_request, _server_on_notify)
async with session:
_adopt_modern(session)
await server_side.notify(_VENDOR_METHOD, {"seq": 1})
await done.wait()
server_side.close()
assert pongs == [EmptyResult()]
@pytest.mark.anyio
async def test_overflow_drops_oldest_event_with_a_warning(caplog: pytest.LogCaptureFixture) -> None:
"""SDK-defined: on overflow the bounded FIFO drops the oldest queued event with a
warning; everything still queued delivers in order."""
delivered: list[int] = []
consumer_blocked = anyio.Event()
gate = anyio.Event()
done = anyio.Event()
last_seq = _NOTIFICATION_QUEUE_SIZE + 1
async def on_event(params: _EventParams) -> None:
delivered.append(params.seq)
if params.seq == 0:
consumer_blocked.set()
await gate.wait()
if params.seq == last_seq:
done.set()
client_side, server_side = create_direct_dispatcher_pair()
binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=on_event)
session = ClientSession(dispatcher=client_side, notification_bindings=[binding])
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, _server_on_request, _server_on_notify)
async with session:
_adopt_modern(session)
await server_side.notify(_VENDOR_METHOD, {"seq": 0})
await consumer_blocked.wait()
for seq in range(1, last_seq + 1):
await server_side.notify(_VENDOR_METHOD, {"seq": seq})
gate.set()
await done.wait()
server_side.close()
assert delivered == [0, *range(2, last_seq + 1)]
assert caplog.text.count(f"notification queue for {_VENDOR_METHOD!r} is full") == 1
@pytest.mark.anyio
async def test_invalid_params_are_warned_and_dropped_without_reaching_handler(
caplog: pytest.LogCaptureFixture,
) -> None:
"""SDK-defined: params failing the binding's model are warned and dropped; later valid events deliver."""
delivered: list[int] = []
done = anyio.Event()
async def on_event(params: _EventParams) -> None:
delivered.append(params.seq)
done.set()
client_side, server_side = create_direct_dispatcher_pair()
binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=on_event)
session = ClientSession(dispatcher=client_side, notification_bindings=[binding])
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, _server_on_request, _server_on_notify)
async with session:
_adopt_modern(session)
await server_side.notify(_VENDOR_METHOD, {"bogus": "no seq"})
await server_side.notify(_VENDOR_METHOD, {"seq": 1})
await done.wait()
server_side.close()
assert delivered == [1]
assert f"Failed to validate notification: {_VENDOR_METHOD}" in caplog.text
@pytest.mark.anyio
async def test_unbound_vendor_notification_keeps_the_debug_drop(caplog: pytest.LogCaptureFixture) -> None:
"""SDK-defined: a vendor method with no binding keeps the debug-log-and-drop behaviour."""
caplog.set_level(logging.DEBUG, logger="client")
client_side, server_side = create_direct_dispatcher_pair()
binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=_noop_handler)
session = ClientSession(dispatcher=client_side, notification_bindings=[binding])
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, _server_on_request, _server_on_notify)
async with session:
_adopt_modern(session)
await server_side.notify("notifications/vendor/unbound", {"seq": 1})
server_side.close()
assert f"dropped 'notifications/vendor/unbound': not defined at {LATEST_MODERN_VERSION}" in caplog.text
@pytest.mark.anyio
async def test_core_known_method_never_reaches_binding_and_warns_once_at_adopt(
caplog: pytest.LogCaptureFixture,
) -> None:
"""SDK-defined: a binding for a core-known method never fires and warns once at
adopt(); the typed callback still runs."""
logged: list[types.LoggingMessageNotificationParams] = []
async def logging_callback(params: types.LoggingMessageNotificationParams) -> None:
logged.append(params)
async def on_message(params: BaseModel) -> None:
raise NotImplementedError # structurally unreachable: core parses the method first
client_side, server_side = create_direct_dispatcher_pair()
binding = NotificationBinding(method="notifications/message", params_type=BaseModel, handler=on_message)
session = ClientSession(dispatcher=client_side, logging_callback=logging_callback, notification_bindings=[binding])
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, _server_on_request, _server_on_notify)
async with session:
_adopt_modern(session)
# In-process notify() awaits _on_notify inline, so the typed callback has already run.
await server_side.notify("notifications/message", {"level": "info", "data": "hello"})
server_side.close()
assert [params.data for params in logged] == ["hello"]
# The bound handler never ran; a delivery would have logged its NotImplementedError.
assert "notification binding handler" not in caplog.text
expected = f"notification binding for 'notifications/message' will never fire at {LATEST_MODERN_VERSION}"
assert caplog.text.count(expected) == 1
@pytest.mark.anyio
async def test_handler_exception_is_contained_and_later_events_deliver(caplog: pytest.LogCaptureFixture) -> None:
"""SDK-defined: a raising handler costs only that delivery; later events still deliver."""
delivered: list[int] = []
done = anyio.Event()
async def on_event(params: _EventParams) -> None:
if params.seq == 1:
raise ValueError("handler boom")
delivered.append(params.seq)
done.set()
client_side, server_side = create_direct_dispatcher_pair()
binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=on_event)
session = ClientSession(dispatcher=client_side, notification_bindings=[binding])
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, _server_on_request, _server_on_notify)
async with session:
_adopt_modern(session)
await server_side.notify(_VENDOR_METHOD, {"seq": 1})
await server_side.notify(_VENDOR_METHOD, {"seq": 2})
await done.wait()
server_side.close()
assert delivered == [2]
assert f"notification binding handler for {_VENDOR_METHOD!r} raised" in caplog.text
@pytest.mark.anyio
async def test_binding_delivery_works_without_adopt() -> None:
"""SDK-defined: bindings deliver pre-handshake, under the default version tables."""
delivered: list[int] = []
done = anyio.Event()
async def on_event(params: _EventParams) -> None:
delivered.append(params.seq)
done.set()
client_side, server_side = create_direct_dispatcher_pair()
binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=on_event)
session = ClientSession(dispatcher=client_side, notification_bindings=[binding])
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, _server_on_request, _server_on_notify)
async with session:
await server_side.notify(_VENDOR_METHOD, {"seq": 7})
await done.wait()
server_side.close()
assert delivered == [7]
+66
View File
@@ -0,0 +1,66 @@
"""`dispatch_input_request` and `validate_tool_result` are public `ClientSession` API."""
import mcp_types as types
import pytest
from mcp_types import (
CallToolResult,
ErrorData,
ListRootsResult,
ListToolsResult,
PaginatedRequestParams,
Tool,
)
from mcp.client.client import Client
from mcp.client.session import ClientRequestContext, ClientSession
from mcp.server import Server, ServerRequestContext
from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair
@pytest.mark.anyio
async def test_dispatch_input_request_routes_through_the_callback_table() -> None:
expected = ListRootsResult(roots=[])
async def list_roots(context: ClientRequestContext) -> ListRootsResult:
return expected
client_side, _server_side = create_direct_dispatcher_pair()
session = ClientSession(dispatcher=client_side, list_roots_callback=list_roots)
ctx = ClientRequestContext(session=session, request_id="r-1")
response = await session.dispatch_input_request(ctx, types.ListRootsRequest())
assert response is expected
@pytest.mark.anyio
async def test_dispatch_input_request_returns_error_data_on_refusal() -> None:
"""With no callback registered, refusal comes back as `ErrorData`, not a raise."""
client_side, _server_side = create_direct_dispatcher_pair()
session = ClientSession(dispatcher=client_side)
ctx = ClientRequestContext(session=session, request_id="r-1")
response = await session.dispatch_input_request(ctx, types.ListRootsRequest())
assert isinstance(response, ErrorData)
assert response.code == types.INVALID_REQUEST
def _make_server(output_schema: dict[str, object]) -> Server:
async def on_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"}, output_schema=output_schema)])
return Server("test-server", on_list_tools=on_list_tools)
@pytest.mark.anyio
async def test_validate_tool_result_passes_a_conforming_result() -> None:
server = _make_server({"type": "object", "properties": {"x": {"type": "integer"}}, "required": ["x"]})
async with Client(server) as client:
# The session fetches the listing itself when the tool isn't cached yet.
await client.session.validate_tool_result("t", CallToolResult(content=[], structured_content={"x": 1}))
@pytest.mark.anyio
async def test_validate_tool_result_raises_on_schema_mismatch() -> None:
server = _make_server({"type": "object", "properties": {"x": {"type": "integer"}}, "required": ["x"]})
async with Client(server) as client:
# Stable SDK prefix only: the message tail is jsonschema text that shifts with the dependency.
with pytest.raises(RuntimeError, match="Invalid structured content returned by tool t"):
await client.session.validate_tool_result("t", CallToolResult(content=[], structured_content={"x": "no"}))
File diff suppressed because it is too large Load Diff
+750
View File
@@ -0,0 +1,750 @@
"""Unit tests for the streamable-HTTP client transport.
The full client<->server round trip is pinned by the interaction suite under
tests/interaction/transports/; these tests cover the transport's header encoding and the
per-message metadata-headers merge directly because the headers are an HTTP-seam observation
the public client never exposes.
"""
import base64
import json
from collections.abc import AsyncIterator, Callable, Mapping
from typing import Any
import anyio
import httpx
import pytest
from inline_snapshot import snapshot
from mcp_types import (
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
CONNECTION_CLOSED,
INVALID_REQUEST,
METHOD_NOT_FOUND,
PROTOCOL_VERSION_META_KEY,
JSONRPCError,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
)
from mcp_types.version import LATEST_MODERN_VERSION
from starlette.types import Receive, Scope, Send
from mcp.client.streamable_http import (
MAX_RECONNECTION_ATTEMPTS,
RequestContext,
StreamableHTTPTransport,
streamable_http_client,
)
from mcp.server import Server
from mcp.server._streamable_http_modern import handle_modern_request
from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, ServerEvent
from mcp.shared._context_streams import ContextSendStream, create_context_streams
from mcp.shared.dispatcher import CallOptions, DispatchContext
from mcp.shared.inbound import MCP_METHOD_HEADER, MCP_PROTOCOL_VERSION_HEADER, encode_header_value
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
from mcp.shared.message import ClientMessageMetadata, ServerMessageMetadata, SessionMessage
from mcp.shared.transport_context import TransportContext
from tests.interaction.transports import StreamingASGITransport
from tests.shared.test_dispatcher import Recorder, echo_handlers
@pytest.mark.parametrize(
("raw", "expected", "wrapped"),
[
("add", snapshot("add"), False),
("", snapshot(""), False),
("tool with spaces", snapshot("tool with spaces"), False),
(" add", snapshot("=?base64?IGFkZA==?="), True),
("add ", snapshot("=?base64?YWRkIA==?="), True),
("résumé", snapshot("=?base64?csOpc3Vtw6k=?="), True),
("a\r\nb", snapshot("=?base64?YQ0KYg==?="), True),
("=?base64?Zm9v?=", snapshot("=?base64?PT9iYXNlNjQ/Wm05dj89?="), True),
],
)
def test_mcp_name_header_values_are_base64_wrapped_when_unsafe_for_an_http_field(
raw: str, expected: str, wrapped: bool
) -> None:
"""Printable-ASCII names pass verbatim; CR/LF, non-ASCII, edge-whitespace, and sentinel-shaped names are wrapped.
The ``=?base64?...?=`` sentinel is the spec's RFC 7230 safety gate for the ``Mcp-Name`` header.
Wrapped values round-trip through base64 so the server can recover the original name. A leading
or trailing space is wrapped because RFC 7230 forbids it in field-values (h11 rejects on real
transports); an empty value is allowed and passes verbatim.
"""
encoded = encode_header_value(raw)
assert encoded == expected
if wrapped:
assert encoded.startswith("=?base64?") and encoded.endswith("?=")
assert base64.b64decode(encoded.removeprefix("=?base64?").removesuffix("?=")).decode() == raw
else:
assert encoded == raw
@pytest.mark.anyio
async def test_post_request_merges_per_message_metadata_headers() -> None:
"""`ClientMessageMetadata.headers` on a `SessionMessage` are merged into the outgoing POST headers
(SDK-defined: the headers sidecar is the path the session uses to reach the transport)."""
recorded: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
recorded.append(request)
body = json.loads(request.content)
return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}})
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
):
await write.send(
SessionMessage(
message=JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/list", params={}),
metadata=ClientMessageMetadata(headers={"x-test": "v"}),
)
)
reply = await read.receive()
assert isinstance(reply, SessionMessage)
assert [r.method for r in recorded] == ["POST"]
assert recorded[0].headers["x-test"] == "v"
@pytest.mark.anyio
async def test_pre_session_bare_404_maps_to_method_not_found() -> None:
"""A bare HTTP 404 (no JSON-RPC body) before any session-id is held maps to METHOD_NOT_FOUND.
Gateways and legacy servers 404 at the HTTP layer for unknown methods; with no session yet,
"Session terminated" is meaningless, and the discover→initialize fallback ladder keys on -32601.
"""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(404)
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
):
await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="server/discover", params={})))
reply = await read.receive()
assert isinstance(reply, SessionMessage)
assert isinstance(reply.message, JSONRPCError)
assert reply.message.error.code == METHOD_NOT_FOUND
@pytest.mark.anyio
async def test_initialize_post_clears_cached_pv_header_and_unstamped_posts_read_it() -> None:
"""``initialize`` discards the cached protocol-version header; every other POST reads it.
Steps:
1. A stamped probe POST caches its ``MCP-Protocol-Version`` header.
2. An ``initialize`` POST clears that cache before building headers, so the fallback
handshake never carries a probe-stamped value.
3. A subsequent stamped POST re-seeds the cache with the negotiated version.
4. An unstamped POST (a JSON-RPC response written by the dispatcher, which never
passes through the session's stamp) then reads the cache and carries the
negotiated version — the spec MUST for all post-initialization HTTP requests.
"""
recorded: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
recorded.append(request)
body = json.loads(request.content)
if "id" not in body or "result" in body:
return httpx.Response(202)
return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}})
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
):
await write.send(
SessionMessage(
message=JSONRPCRequest(jsonrpc="2.0", id=1, method="server/discover", params={}),
metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: "2026-07-28"}),
)
)
await read.receive()
await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=2, method="initialize", params={})))
await read.receive()
await write.send(
SessionMessage(
message=JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized"),
metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: "2025-11-25"}),
)
)
# An unstamped JSON-RPC response — what the dispatcher writes when answering
# a server-initiated request (sampling/elicitation/roots).
await write.send(SessionMessage(JSONRPCResponse(jsonrpc="2.0", id=99, result={})))
assert [r.method for r in recorded] == ["POST", "POST", "POST", "POST"]
assert recorded[0].headers[MCP_PROTOCOL_VERSION_HEADER] == "2026-07-28"
assert MCP_PROTOCOL_VERSION_HEADER not in recorded[1].headers
assert recorded[2].headers[MCP_PROTOCOL_VERSION_HEADER] == "2025-11-25"
assert recorded[3].headers[MCP_PROTOCOL_VERSION_HEADER] == "2025-11-25"
class _ParkedSSEStream(httpx.AsyncByteStream):
"""An SSE response body that emits one comment line, then parks until closed.
`opened` fires once the transport is iterating the body (the POST is truly in
flight); `closed` fires when httpx tears the body down — the observable proof
that an abort, not a response, ended the stream.
"""
def __init__(self) -> None:
self.opened = anyio.Event()
self.closed = anyio.Event()
self._release = anyio.Event()
async def __aiter__(self) -> AsyncIterator[bytes]:
self.opened.set()
yield b": parked\n\n"
await self._release.wait()
async def aclose(self) -> None:
self.closed.set()
self._release.set()
def _sse_or_ack_handler(
parked: _ParkedSSEStream, posted: list[dict[str, Any]], frame_posted: anyio.Event
) -> Callable[[httpx.Request], httpx.Response]:
"""Requests get the parked SSE body; notifications get 202 and set `frame_posted`."""
def handler(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content)
posted.append(body)
if "id" in body:
return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=parked)
frame_posted.set()
return httpx.Response(202)
return handler
@pytest.mark.anyio
async def test_modern_cancelled_frame_aborts_the_matching_in_flight_post() -> None:
"""At 2026 an outbound `notifications/cancelled` never POSTs — closing the named
request's response stream IS the wire's cancellation signal — so the transport
aborts the in-flight POST and swallows the frame."""
parked = _ParkedSSEStream()
posted: list[dict[str, Any]] = []
def handler(request: httpx.Request) -> httpx.Response:
posted.append(json.loads(request.content))
return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=parked)
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (_read, write),
):
await write.send(
SessionMessage(
message=JSONRPCRequest(jsonrpc="2.0", id="listen-1", method="subscriptions/listen", params={}),
metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION}),
)
)
await parked.opened.wait()
await write.send(
SessionMessage(
JSONRPCNotification(
jsonrpc="2.0", method="notifications/cancelled", params={"requestId": "listen-1"}
)
)
)
await parked.closed.wait()
assert [body["method"] for body in posted] == ["subscriptions/listen"]
@pytest.mark.anyio
@pytest.mark.parametrize("stamped_version", [None, "2025-11-25"], ids=["no-version-yet", "2025-11-25"])
async def test_legacy_cancelled_frame_posts_and_leaves_the_stream_open(stamped_version: str | None) -> None:
"""Below 2026 — or before any stamped POST has revealed the version — the frame is
the spec's cancellation signal: it POSTs, and the request's stream stays open
(a 2025 disconnect is explicitly not a cancel)."""
parked = _ParkedSSEStream()
posted: list[dict[str, Any]] = []
frame_posted = anyio.Event()
handler = _sse_or_ack_handler(parked, posted, frame_posted)
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (_read, write),
):
metadata = (
ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: stamped_version})
if stamped_version is not None
else None
)
await write.send(
SessionMessage(
message=JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={}),
metadata=metadata,
)
)
await parked.opened.wait()
await write.send(
SessionMessage(
JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled", params={"requestId": 1})
)
)
await frame_posted.wait()
# Checked before teardown: exiting the transport cancels the parked POST.
assert not parked.closed.is_set()
assert [body["method"] for body in posted] == ["tools/call", "notifications/cancelled"]
@pytest.mark.anyio
@pytest.mark.parametrize(
"params",
[
pytest.param({"requestId": 999}, id="unknown-id"),
pytest.param({"requestId": True}, id="bool-must-not-alias-request-id-1"),
pytest.param({"requestId": "1"}, id="string-1-must-not-match-int-1"),
pytest.param({}, id="no-request-id"),
pytest.param(None, id="no-params"),
],
)
async def test_modern_cancelled_frames_matching_no_post_are_swallowed(params: dict[str, Any] | None) -> None:
"""At 2026 the frame is swallowed even when it aborts nothing — the wire defines no
client-to-server notifications, so a late cancel racing the response must not leak
a POST — and a mismatched id must not abort someone else's stream."""
parked = _ParkedSSEStream()
posted: list[dict[str, Any]] = []
def handler(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content)
posted.append(body)
if body.get("id") == 1:
return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=parked)
return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}})
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
):
await write.send(
SessionMessage(
message=JSONRPCRequest(jsonrpc="2.0", id=1, method="subscriptions/listen", params={}),
metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION}),
)
)
await parked.opened.wait()
await write.send(
SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled", params=params))
)
# A follow-up request completing proves the loop moved past the swallowed frame.
await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=2, method="ping", params={})))
reply = await read.receive()
# Checked before teardown: exiting the transport cancels the parked POST.
assert not parked.closed.is_set()
assert isinstance(reply, SessionMessage)
assert isinstance(reply.message, JSONRPCResponse)
assert reply.message.id == 2
assert [body["method"] for body in posted] == ["subscriptions/listen", "ping"]
@pytest.mark.anyio
async def test_handler_scoped_cancelled_frames_are_translated_at_modern_too() -> None:
"""A cancel carrying `ServerMessageMetadata` (a handler abandoning its own
back-channel request) still names one of OUR outbound ids — every spec-legal
cancel names a request its sender issued — so at 2026 it aborts that POST and
stays off the wire like any other."""
parked = _ParkedSSEStream()
posted: list[dict[str, Any]] = []
frame_posted = anyio.Event()
handler = _sse_or_ack_handler(parked, posted, frame_posted)
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (_read, write),
):
await write.send(
SessionMessage(
message=JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={}),
metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION}),
)
)
await parked.opened.wait()
await write.send(
SessionMessage(
message=JSONRPCNotification(
jsonrpc="2.0", method="notifications/cancelled", params={"requestId": 1}
),
metadata=ServerMessageMetadata(related_request_id=99),
)
)
await parked.closed.wait()
assert [body["method"] for body in posted] == ["tools/call"]
assert not frame_posted.is_set()
@pytest.mark.anyio
async def test_cancel_for_a_request_sent_under_2025_still_posts_after_modern_adoption() -> None:
"""The translation follows the era the NAMED request was sent under, not the
cache at cancel time: a request POSTed under 2025 keeps 2025 cancellation
semantics (frame on the wire, stream left open) even after a later message
flips the negotiated version to 2026."""
parked = _ParkedSSEStream()
posted: list[dict[str, Any]] = []
frame_posted = anyio.Event()
def handler(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content)
posted.append(body)
if body.get("id") == 1:
return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=parked)
if "id" in body:
return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}})
frame_posted.set()
return httpx.Response(202)
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
):
await write.send(
SessionMessage(
message=JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={}),
metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: "2025-11-25"}),
)
)
await parked.opened.wait()
# A modern-stamped request flips the cached negotiated version.
await write.send(
SessionMessage(
message=JSONRPCRequest(jsonrpc="2.0", id=2, method="ping", params={}),
metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION}),
)
)
reply = await read.receive()
assert isinstance(reply, SessionMessage)
assert isinstance(reply.message, JSONRPCResponse)
await write.send(
SessionMessage(
JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled", params={"requestId": 1})
)
)
await frame_posted.wait()
# Checked before teardown: exiting the transport cancels the parked POST.
assert not parked.closed.is_set()
assert [body["method"] for body in posted] == ["tools/call", "ping", "notifications/cancelled"]
class _SignalingBus(InMemorySubscriptionBus):
"""Signals subscribe/unsubscribe so a test observes the stream lifecycle through
the bus Protocol (the public seam) instead of polling handler internals."""
def __init__(self) -> None:
super().__init__()
self.subscribed = anyio.Event()
self.unsubscribed = anyio.Event()
def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]:
unsubscribe = super().subscribe(listener)
self.subscribed.set()
def unsubscribe_and_signal() -> None:
unsubscribe()
self.unsubscribed.set()
return unsubscribe_and_signal
@pytest.mark.anyio
async def test_scope_cancel_aborts_a_modern_listen_post_end_to_end() -> None:
"""Over a real ASGI bridge: cancelling the caller of a parked `subscriptions/listen`
closes the POST's response stream — the server treats the disconnect as the cancel
and releases the subscription — and no `notifications/cancelled` crosses the wire."""
bus = _SignalingBus()
server = Server("test", on_subscriptions_listen=ListenHandler(bus))
async def app(scope: Scope, receive: Receive, send: Send) -> None:
async with server.lifespan(server) as lifespan_state:
await handle_modern_request(server, None, False, lifespan_state, scope, receive, send)
posted_methods: list[str] = []
async def record_request(request: httpx.Request) -> None:
posted_methods.append(json.loads(request.content)["method"])
acked = anyio.Event()
async def on_notify(dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None) -> None:
assert method == "notifications/subscriptions/acknowledged"
acked.set()
on_request, _ = echo_handlers(Recorder())
with anyio.fail_after(15):
async with (
httpx.AsyncClient(
transport=StreamingASGITransport(app),
base_url="http://testserver",
event_hooks={"request": [record_request]},
) as http,
streamable_http_client("http://testserver/mcp", http_client=http) as (read, write),
):
dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(read, write)
async with anyio.create_task_group() as tg: # pragma: no branch
await tg.start(dispatcher.run, on_request, on_notify)
listen_scope = anyio.CancelScope()
async def send_listen() -> None:
params: dict[str, Any] = {
"_meta": {
PROTOCOL_VERSION_META_KEY: LATEST_MODERN_VERSION,
CLIENT_INFO_META_KEY: {"name": "test-client", "version": "0"},
CLIENT_CAPABILITIES_META_KEY: {},
},
"notifications": {"toolsListChanged": True},
}
opts: CallOptions = {
"request_id": "listen-1",
"headers": {
MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION,
MCP_METHOD_HEADER: "subscriptions/listen",
},
}
with listen_scope:
await dispatcher.send_raw_request("subscriptions/listen", params, opts)
tg.start_soon(send_listen)
await acked.wait()
assert bus.subscribed.is_set()
assert not bus.unsubscribed.is_set()
listen_scope.cancel()
await bus.unsubscribed.wait()
tg.cancel_scope.cancel()
assert posted_methods == ["subscriptions/listen"]
class _CompletingSSEStream(httpx.AsyncByteStream):
"""An SSE body that delivers one JSON-RPC response, then parks in `aclose`.
Holding `aclose` keeps the finished POST task alive past its response, so a
test can re-register the same request id underneath it before releasing.
"""
def __init__(self, response_body: dict[str, Any]) -> None:
self._event = f"data: {json.dumps(response_body)}\n\n".encode()
self.release = anyio.Event()
async def __aiter__(self) -> AsyncIterator[bytes]:
yield self._event
async def aclose(self) -> None:
await self.release.wait()
@pytest.mark.anyio
async def test_a_finished_post_task_does_not_evict_a_reused_ids_new_registration() -> None:
"""Request ids are reusable once resolved; a finished POST task unwinding late
must not pop the successor's registration, or a cancel for the reused id would
find nothing to abort and the live POST would leak past the cancellation."""
completing = _CompletingSSEStream({"jsonrpc": "2.0", "id": "dup-1", "result": {}})
parked = _ParkedSSEStream()
posted: list[dict[str, Any]] = []
streams = [completing, parked]
def handler(request: httpx.Request) -> httpx.Response:
posted.append(json.loads(request.content))
return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=streams.pop(0))
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
):
modern = ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION})
await write.send(
SessionMessage(
message=JSONRPCRequest(jsonrpc="2.0", id="dup-1", method="tools/call", params={}),
metadata=modern,
)
)
reply = await read.receive()
assert isinstance(reply, SessionMessage)
assert isinstance(reply.message, JSONRPCResponse)
# The first task is now parked in `aclose`; reuse its id underneath it.
await write.send(
SessionMessage(
message=JSONRPCRequest(jsonrpc="2.0", id="dup-1", method="subscriptions/listen", params={}),
metadata=modern,
)
)
await parked.opened.wait()
completing.release.set()
await anyio.wait_all_tasks_blocked()
# The successor's registration survived: a cancel still aborts it.
await write.send(
SessionMessage(
JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled", params={"requestId": "dup-1"})
)
)
await parked.closed.wait()
assert [body["method"] for body in posted] == ["tools/call", "subscriptions/listen"]
class _DyingSSEStream(httpx.AsyncByteStream):
"""Emits one id-less comment then breaks - a non-resumable stream dropping."""
def __init__(self) -> None:
self.opened = anyio.Event()
async def __aiter__(self) -> AsyncIterator[bytes]:
self.opened.set()
yield b": hello\n\n"
raise httpx.ReadError("connection reset")
async def aclose(self) -> None:
pass
@pytest.mark.anyio
async def test_a_non_resumable_sse_drop_resolves_the_request_with_an_error() -> None:
"""A per-request SSE stream that dies having carried no event ids can never deliver its
response; the transport resolves the waiter with CONNECTION_CLOSED instead of hanging forever."""
dying = _DyingSSEStream()
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=dying)
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
):
await write.send(
SessionMessage(JSONRPCRequest(jsonrpc="2.0", id="listen-1", method="subscriptions/listen", params={}))
)
reply = await read.receive()
assert isinstance(reply, SessionMessage)
assert isinstance(reply.message, JSONRPCError)
assert reply.message.id == "listen-1"
assert reply.message.error.code == CONNECTION_CLOSED
class _DeliverOnCommandSSEStream(httpx.AsyncByteStream):
"""Parks after opening, then delivers one JSON-RPC response when told."""
def __init__(self, response_body: dict[str, Any]) -> None:
self._event = f"data: {json.dumps(response_body)}\n\n".encode()
self.opened = anyio.Event()
self.deliver = anyio.Event()
async def __aiter__(self) -> AsyncIterator[bytes]:
self.opened.set()
await self.deliver.wait()
yield self._event
async def aclose(self) -> None:
pass
@pytest.mark.anyio
async def test_a_superseded_posts_late_real_response_cannot_answer_the_successor() -> None:
"""SDK-defined: re-issuing an id severs the superseded POST, so nothing from its
stream (a late real response, or a synthesized error for its death) can resolve
the reused id's waiter; only the successor's own response arrives."""
stale = _DeliverOnCommandSSEStream({"jsonrpc": "2.0", "id": "dup-1", "result": {"origin": "stale"}})
succeeding = _DeliverOnCommandSSEStream({"jsonrpc": "2.0", "id": "dup-1", "result": {"origin": "fresh"}})
streams: list[httpx.AsyncByteStream] = [stale, succeeding]
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=streams.pop(0))
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
):
await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id="dup-1", method="tools/call", params={})))
await stale.opened.wait()
await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id="dup-1", method="tools/call", params={})))
await succeeding.opened.wait()
stale.deliver.set()
await anyio.wait_all_tasks_blocked()
succeeding.deliver.set()
reply = await read.receive()
assert isinstance(reply, SessionMessage)
assert isinstance(reply.message, JSONRPCResponse), reply.message
assert reply.message.result == {"origin": "fresh"}
@pytest.mark.anyio
async def test_a_202_to_a_request_resolves_the_waiter_with_an_error() -> None:
"""SDK-defined: a server that answers a request with 202 Accepted has declared no
response will follow (the spec requires SSE or JSON for requests); the transport
resolves the waiter with INVALID_REQUEST instead of parking the caller forever."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(202)
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
):
await write.send(
SessionMessage(JSONRPCRequest(jsonrpc="2.0", id="listen-1", method="subscriptions/listen", params={}))
)
reply = await read.receive()
assert isinstance(reply, SessionMessage)
assert isinstance(reply.message, JSONRPCError)
assert reply.message.id == "listen-1"
assert reply.message.error.code == INVALID_REQUEST
def _abandoned_request_context(
http: httpx.AsyncClient, send: ContextSendStream[SessionMessage | Exception]
) -> RequestContext:
return RequestContext(
client=http,
session_id=None,
session_message=SessionMessage(
JSONRPCRequest(jsonrpc="2.0", id="listen-1", method="subscriptions/listen", params={})
),
metadata=None,
read_stream_writer=send,
)
@pytest.mark.anyio
async def test_exhausted_reconnection_attempts_resolve_the_request_with_an_error() -> None:
"""An id-bearing stream that exhausts its reconnection budget also resolves the waiter with CONNECTION_CLOSED."""
transport = StreamableHTTPTransport("http://test/mcp")
send, receive = create_context_streams[SessionMessage | Exception](1)
async with httpx.AsyncClient() as http:
with anyio.fail_after(5):
await transport._handle_reconnection( # pyright: ignore[reportPrivateUsage]
_abandoned_request_context(http, send), "evt-7", None, MAX_RECONNECTION_ATTEMPTS
)
reply = await receive.receive()
assert isinstance(reply, SessionMessage)
assert isinstance(reply.message, JSONRPCError)
assert reply.message.id == "listen-1"
assert reply.message.error.code == CONNECTION_CLOSED
send.close()
receive.close()
@pytest.mark.anyio
async def test_resolving_an_abandoned_request_after_the_reader_closed_is_contained() -> None:
"""Teardown race: a stream dying after the reader closed resolves best-effort and must not crash."""
transport = StreamableHTTPTransport("http://test/mcp")
send, receive = create_context_streams[SessionMessage | Exception](1)
receive.close()
async with httpx.AsyncClient() as http:
with anyio.fail_after(5):
await transport._handle_reconnection( # pyright: ignore[reportPrivateUsage]
_abandoned_request_context(http, send), "evt-7", None, MAX_RECONNECTION_ATTEMPTS
)
send.close()
+666
View File
@@ -0,0 +1,666 @@
"""Behavioral tests for the client-side `subscriptions/listen` driver (SDK-defined contract).
Public API only, against in-process servers; wire-shape assertions live in the interaction suite.
"""
from itertools import count
from typing import Any
import anyio
import mcp_types as types
import pytest
from mcp_types import SubscriptionFilter
import mcp.client.subscriptions as subscriptions_module
from mcp import Client, MCPError
from mcp.client.session import ClientSession
from mcp.client.subscriptions import (
ListenNotSupportedError,
ListenRoute,
PromptsListChanged,
ResourcesListChanged,
ResourceUpdated,
ServerEvent,
Subscription,
SubscriptionLost,
ToolsListChanged,
listen,
)
from mcp.server import Server, ServerRequestContext
from mcp.server.subscriptions import (
SUBSCRIPTION_ID_META_KEY,
InMemorySubscriptionBus,
ListenHandler,
)
from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair
from mcp.shared.dispatcher import CallOptions
pytestmark = pytest.mark.anyio
def _bus_server(bus: InMemorySubscriptionBus, *, max_subscriptions: int | None = None) -> Server[Any]:
"""A lowlevel server whose only feature is serving listen streams from `bus`."""
handler = (
ListenHandler(bus) if max_subscriptions is None else ListenHandler(bus, max_subscriptions=max_subscriptions)
)
return Server("subs", on_subscriptions_listen=handler)
async def _ack(ctx: ServerRequestContext[Any, Any], honored: SubscriptionFilter) -> dict[str, Any]:
"""Send a hand-rolled ack for a scripted listen handler; returns the stamped meta."""
assert ctx.request_id is not None
meta: dict[str, Any] = {SUBSCRIPTION_ID_META_KEY: ctx.request_id}
await ctx.session.send_notification(
types.SubscriptionsAcknowledgedNotification(
params=types.SubscriptionsAcknowledgedNotificationParams(notifications=honored, _meta=meta)
),
related_request_id=ctx.request_id,
)
return meta
async def test_listen_surfaces_the_honored_filter_and_subscription_id():
"""Entering waits for the server ack and surfaces the honored filter and subscription id."""
bus = InMemorySubscriptionBus()
async with Client(_bus_server(bus)) as client:
with anyio.fail_after(5):
async with client.listen( # pragma: no branch
tools_list_changed=True, resource_subscriptions=["note://todo"]
) as sub:
assert isinstance(sub, Subscription)
assert sub.honored.tools_list_changed is True
assert sub.honored.resource_subscriptions == ["note://todo"]
assert isinstance(sub.subscription_id, str)
assert sub.subscription_id.startswith("listen-")
async def test_listen_delivers_all_four_typed_event_kinds():
"""Bus publishes come back as the same typed event values, in order."""
bus = InMemorySubscriptionBus()
async with Client(_bus_server(bus)) as client:
with anyio.fail_after(5):
async with client.listen( # pragma: no branch
tools_list_changed=True,
prompts_list_changed=True,
resources_list_changed=True,
resource_subscriptions=["note://todo"],
) as sub:
for event in (
ToolsListChanged(),
PromptsListChanged(),
ResourcesListChanged(),
ResourceUpdated(uri="note://todo"),
):
await bus.publish(event)
assert await anext(sub) == event
async def test_unconsumed_duplicate_events_coalesce():
"""Events are level triggers: duplicates pending consumption collapse to one."""
bus = InMemorySubscriptionBus()
async with Client(_bus_server(bus)) as client:
with anyio.fail_after(5):
async with client.listen( # pragma: no branch
tools_list_changed=True, resource_subscriptions=["note://todo"]
) as sub:
for _ in range(3):
await bus.publish(ToolsListChanged())
await bus.publish(ResourceUpdated(uri="note://todo"))
await anyio.wait_all_tasks_blocked()
assert await anext(sub) == ToolsListChanged()
assert await anext(sub) == ResourceUpdated(uri="note://todo")
async def test_graceful_server_close_ends_the_loop_cleanly():
"""The server's deliberate close ends iteration cleanly, after draining prior events."""
bus = InMemorySubscriptionBus()
handler = ListenHandler(bus)
server = Server("subs", on_subscriptions_listen=handler)
events: list[object] = []
async with Client(server) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
await bus.publish(ToolsListChanged())
handler.close()
events.extend([event async for event in sub])
assert events == [ToolsListChanged()]
async def test_abrupt_stream_end_raises_subscription_lost():
"""A stream dying without the graceful result raises `SubscriptionLost` with the cause chained."""
proceed = anyio.Event()
async def dropping_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
await _ack(ctx, params.notifications)
await proceed.wait()
raise MCPError(types.INTERNAL_ERROR, "stream torn down")
server = Server("subs", on_subscriptions_listen=dropping_listen)
async with Client(server) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
proceed.set()
with pytest.raises(SubscriptionLost) as exc_info: # pragma: no branch
await anext(sub)
assert isinstance(exc_info.value.__cause__, MCPError)
assert exc_info.value.__cause__.error.message == "stream torn down"
async def test_listen_on_a_legacy_connection_raises_the_typed_steer():
"""On a 2025 connection `listen` fails fast with the typed error steering to the legacy verbs."""
bus = InMemorySubscriptionBus()
async with Client(_bus_server(bus), mode="legacy") as client:
with anyio.fail_after(5):
# Entering is where the guard fires; __aenter__ directly avoids an unreachable with-body.
with pytest.raises(ListenNotSupportedError) as exc_info: # pragma: no branch
await client.listen(tools_list_changed=True).__aenter__()
assert exc_info.value.negotiated_version == "2025-11-25"
assert "subscribe_resource" in str(exc_info.value)
async def test_server_rejection_raises_from_enter_not_from_iteration():
"""A server without the listen handler fails the open from entering the context."""
server = Server("no-listen")
async with Client(server) as client:
with anyio.fail_after(5):
with pytest.raises(MCPError) as exc_info: # pragma: no branch
await client.listen(tools_list_changed=True).__aenter__()
assert exc_info.value.error.code == types.METHOD_NOT_FOUND
async def test_immediate_result_without_ack_opens_already_closed():
"""A bare result with no ack yields a subscription already gracefully over: no filter, no events."""
async def degenerate_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
assert ctx.request_id is not None
return types.SubscriptionsListenResult(_meta={SUBSCRIPTION_ID_META_KEY: ctx.request_id})
server = Server("subs", on_subscriptions_listen=degenerate_listen)
async with Client(server) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
assert sub.honored == SubscriptionFilter()
with pytest.raises(StopAsyncIteration): # pragma: no branch
await anext(sub)
async def test_server_sent_cancelled_for_the_listen_id_raises_subscription_lost():
"""Server-sent notifications/cancelled for the listen id surfaces as a lost subscription."""
proceed = anyio.Event()
async def cancelling_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
assert ctx.request_id is not None
await _ack(ctx, params.notifications)
await proceed.wait()
await ctx.session.send_notification(
types.CancelledNotification(params=types.CancelledNotificationParams(request_id=ctx.request_id)),
related_request_id=ctx.request_id,
)
await anyio.sleep_forever()
raise AssertionError("unreachable") # pragma: no cover
server = Server("subs", on_subscriptions_listen=cancelling_listen)
async with Client(server) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
proceed.set()
with pytest.raises(SubscriptionLost): # pragma: no branch
await anext(sub)
async def test_exiting_the_context_frees_the_server_slot():
"""Leaving the block ends the subscription server-side: a one-slot handler admits a second listen."""
bus = InMemorySubscriptionBus()
async with Client(_bus_server(bus, max_subscriptions=1)) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as first:
assert first.honored.tools_list_changed is True
async with client.listen(tools_list_changed=True) as second: # pragma: no branch
assert second.honored.tools_list_changed is True
assert second.subscription_id != first.subscription_id
async def test_concurrent_subscriptions_demux_independently():
"""Two open subscriptions each receive only their own filter's events."""
bus = InMemorySubscriptionBus()
async with Client(_bus_server(bus)) as client:
with anyio.fail_after(5):
async with ( # pragma: no branch
client.listen(tools_list_changed=True) as tools_sub,
client.listen(resource_subscriptions=["note://todo"]) as notes_sub,
):
await bus.publish(ToolsListChanged())
await bus.publish(ResourceUpdated(uri="note://todo"))
assert await anext(tools_sub) == ToolsListChanged()
assert await anext(notes_sub) == ResourceUpdated(uri="note://todo")
# Neither stream received the other's event.
await bus.publish(ToolsListChanged())
assert await anext(tools_sub) == ToolsListChanged()
async def test_change_notifications_still_reach_message_handler():
"""The demux tees: a delivered event's notification still reaches message_handler; the ack never does."""
bus = InMemorySubscriptionBus()
seen: list[str] = []
async def on_message(message: object) -> None:
assert not isinstance(message, types.SubscriptionsAcknowledgedNotification)
if isinstance(message, types.ToolListChangedNotification): # pragma: no branch
seen.append("tools-changed")
async with Client(_bus_server(bus), message_handler=on_message) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
await bus.publish(ToolsListChanged())
assert await anext(sub) == ToolsListChanged()
await anyio.wait_all_tasks_blocked()
assert seen == ["tools-changed"]
async def test_enter_times_out_when_the_ack_never_arrives():
"""The ack wait rides the session's read timeout, so a wedged server cannot hang the open."""
async def silent_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
await anyio.sleep_forever()
raise AssertionError("unreachable") # pragma: no cover
server = Server("subs", on_subscriptions_listen=silent_listen)
async with Client(server, read_timeout_seconds=0.05) as client:
with anyio.fail_after(5):
with pytest.raises(TimeoutError): # pragma: no branch
await client.listen(tools_list_changed=True).__aenter__()
async def test_an_open_stream_outlives_the_session_read_timeout():
"""The listen request is exempt from the read timeout: the stream delivers after the deadline."""
bus = InMemorySubscriptionBus()
async with Client(_bus_server(bus), read_timeout_seconds=0.05) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
# Real clock on purpose: this pins a timeout feature.
await anyio.sleep(0.2)
await bus.publish(ToolsListChanged())
assert await anext(sub) == ToolsListChanged()
async def test_a_duplicate_ack_does_not_overwrite_the_honored_filter():
"""The first ack wins; a later conflicting ack is a no-op."""
proceed = anyio.Event()
async def double_acking_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
assert ctx.request_id is not None
await _ack(ctx, params.notifications)
await _ack(ctx, SubscriptionFilter())
await proceed.wait()
return types.SubscriptionsListenResult(_meta={SUBSCRIPTION_ID_META_KEY: ctx.request_id})
server = Server("subs", on_subscriptions_listen=double_acking_listen)
async with Client(server) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
assert sub.honored.tools_list_changed is True
proceed.set()
async def test_a_non_event_frame_with_the_subscription_id_is_teed_not_delivered():
"""A stamped non-event notification never surfaces as an event; it flows to message_handler."""
proceed = anyio.Event()
async def logging_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
assert ctx.request_id is not None
meta = await _ack(ctx, params.notifications)
await ctx.session.send_notification(
types.LoggingMessageNotification(
params=types.LoggingMessageNotificationParams(level="info", data="not an event", _meta=meta)
),
related_request_id=ctx.request_id,
)
await proceed.wait()
return types.SubscriptionsListenResult(_meta=meta)
logged: list[str] = []
async def on_message(message: object) -> None:
if isinstance(message, types.LoggingMessageNotification): # pragma: no branch
logged.append(str(message.params.data))
server = Server("subs", on_subscriptions_listen=logging_listen)
async with Client(server, message_handler=on_message) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
await anyio.wait_all_tasks_blocked()
proceed.set()
with pytest.raises(StopAsyncIteration): # pragma: no branch
await anext(sub)
assert logged == ["not an event"]
async def test_session_teardown_unblocks_a_sibling_consumer_with_subscription_lost():
"""Session teardown settles every open route as lost, unblocking parked consumers."""
bus = InMemorySubscriptionBus()
outcome: list[str] = []
entered = anyio.Event()
async def consume(client: Client) -> None:
with pytest.raises(SubscriptionLost):
async with client.listen(tools_list_changed=True) as sub:
entered.set()
await anext(sub)
outcome.append("lost")
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
async with Client(_bus_server(bus)) as client: # pragma: no branch
tg.start_soon(consume, client)
await entered.wait()
assert outcome == ["lost"]
async def test_server_cancel_before_the_ack_raises_subscription_lost_from_enter():
"""A stream torn down before it was ever acknowledged is a failed open: enter raises."""
async def cancel_first_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
assert ctx.request_id is not None
await ctx.session.send_notification(
types.CancelledNotification(params=types.CancelledNotificationParams(request_id=ctx.request_id)),
related_request_id=ctx.request_id,
)
await anyio.sleep_forever()
raise AssertionError("unreachable") # pragma: no cover
server = Server("subs", on_subscriptions_listen=cancel_first_listen)
async with Client(server) as client:
with anyio.fail_after(5):
with pytest.raises(SubscriptionLost, match="before it was acknowledged"): # pragma: no branch
await client.listen(tools_list_changed=True).__aenter__()
async def test_listen_on_an_exited_session_raises_and_leaks_no_route():
"""Opening on an exited session fails loudly and leaves no demux registration behind."""
bus = InMemorySubscriptionBus()
client = Client(_bus_server(bus))
async with client:
session = client.session
with pytest.raises(RuntimeError):
await listen(session, tools_list_changed=True).__aenter__()
assert session._listen_routes == {} # pyright: ignore[reportPrivateUsage]
async def test_listen_on_a_never_entered_session_raises_runtime_error():
"""An adopted-but-never-entered session has no task group to drive the stream."""
dispatcher, _peer = create_direct_dispatcher_pair()
session = ClientSession(dispatcher=dispatcher)
session.adopt(
types.DiscoverResult(
supported_versions=["2026-07-28"],
capabilities=types.ServerCapabilities(),
server_info=types.Implementation(name="stub", version="0"),
)
)
with pytest.raises(RuntimeError, match="entered session"):
await listen(session, tools_list_changed=True).__aenter__()
assert session._listen_routes == {} # pyright: ignore[reportPrivateUsage]
async def test_a_retained_handle_after_exit_does_not_serve_stale_events():
"""Leaving the block abandons the backlog: a stashed handle must not replay buffered events."""
bus = InMemorySubscriptionBus()
async with Client(_bus_server(bus)) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as sub:
await bus.publish(ToolsListChanged())
await anyio.wait_all_tasks_blocked()
with pytest.raises(StopAsyncIteration): # pragma: no branch
await anext(sub)
async def test_a_stray_ack_outside_the_driver_namespace_still_reaches_message_handler():
"""Acks for ids the driver never minted flow to message_handler (the raw-listen escape hatch)."""
proceed = anyio.Event()
async def stray_acking_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
assert ctx.request_id is not None
await _ack(ctx, params.notifications)
await ctx.session.send_notification(
types.SubscriptionsAcknowledgedNotification(
params=types.SubscriptionsAcknowledgedNotificationParams(
notifications=SubscriptionFilter(), _meta={SUBSCRIPTION_ID_META_KEY: 424242}
)
),
related_request_id=ctx.request_id,
)
await proceed.wait()
return types.SubscriptionsListenResult(_meta={SUBSCRIPTION_ID_META_KEY: ctx.request_id})
handled: list[str] = []
async def on_message(message: object) -> None:
handled.append(type(message).__name__)
server = Server("subs", on_subscriptions_listen=stray_acking_listen)
async with Client(server, message_handler=on_message) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
await anyio.wait_all_tasks_blocked()
proceed.set()
with pytest.raises(StopAsyncIteration): # pragma: no branch
await anext(sub)
assert "SubscriptionsAcknowledgedNotification" in handled
async def test_a_bare_string_for_resource_subscriptions_is_rejected():
"""A bare string would explode into per-character URIs; it is rejected before touching the wire."""
bus = InMemorySubscriptionBus()
async with Client(_bus_server(bus)) as client:
with pytest.raises(TypeError, match="sequence of URIs"):
await client.listen(resource_subscriptions="note://todo").__aenter__() # pyright: ignore[reportArgumentType]
def test_the_route_admits_only_honored_events_and_only_while_live():
"""Route admission: nothing before the ack, only honored events while live, nothing after the end."""
route = ListenRoute()
route.deliver(ToolsListChanged())
assert route._pending == {} # pyright: ignore[reportPrivateUsage]
route.set_acked(SubscriptionFilter(tools_list_changed=True, resource_subscriptions=["note://todo"]))
route.deliver(PromptsListChanged()) # kind not honored
route.deliver(ResourceUpdated(uri="note://todo/draft")) # sub-resource of a subscribed URI: spec says admit
route.deliver(ResourceUpdated(uri="note://todo"))
route.deliver(ToolsListChanged())
route.deliver(ToolsListChanged()) # duplicate pending consumption collapses
assert list(route._pending) == [ # pyright: ignore[reportPrivateUsage]
ResourceUpdated(uri="note://todo/draft"),
ResourceUpdated(uri="note://todo"),
ToolsListChanged(),
]
route.settle("graceful")
route.deliver(ResourceUpdated(uri="note://todo")) # post-close noise is refused
assert len(route._pending) == 3 # pyright: ignore[reportPrivateUsage]
def test_a_peer_flooding_distinct_uris_costs_the_subscription_not_client_memory():
"""A peer flooding distinct URIs trips the `_MAX_PENDING_EVENTS` backstop: the route
settles lost instead of growing client memory without bound."""
route = ListenRoute()
route.set_acked(SubscriptionFilter(resource_subscriptions=["note://todo"]))
for n in range(subscriptions_module._MAX_PENDING_EVENTS): # pyright: ignore[reportPrivateUsage]
route.deliver(ResourceUpdated(uri=f"note://todo/{n}"))
assert route.end is None
route.deliver(ResourceUpdated(uri="note://todo/one-too-many"))
assert route.end == "lost"
assert route.error is not None
assert "backlog" in route.error.error.message
# The overflowing event was not queued.
assert len(route._pending) == subscriptions_module._MAX_PENDING_EVENTS # pyright: ignore[reportPrivateUsage]
async def test_a_cancelled_on_event_barrier_does_not_lose_the_event():
"""Cancelling `anext` mid-barrier leaves the event queued; the next `anext` re-runs the
idempotent barrier and returns it."""
bus = InMemorySubscriptionBus()
entered = anyio.Event()
release = anyio.Event()
async def parked_barrier(event: ServerEvent) -> None:
entered.set()
await release.wait()
async with Client(_bus_server(bus)) as client:
with anyio.fail_after(5):
async with listen(
client.session, tools_list_changed=True, on_event=parked_barrier
) as sub: # pragma: no branch
await bus.publish(ToolsListChanged())
async with anyio.create_task_group() as tg:
cancel_scope = anyio.CancelScope()
async def first_attempt() -> None:
with cancel_scope:
await anext(sub)
raise AssertionError("must be cancelled mid-barrier") # pragma: no cover
tg.start_soon(first_attempt)
await entered.wait()
cancel_scope.cancel()
release.set()
assert await anext(sub) == ToolsListChanged()
async def test_events_outside_the_honored_filter_are_never_delivered():
"""A server violating its acknowledged filter cannot reach the consumer or grow the backlog."""
proceed = anyio.Event()
async def overreaching_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
meta = await _ack(ctx, params.notifications) # honors exactly what was requested: tools only
await ctx.session.send_notification(
types.ResourceUpdatedNotification(
params=types.ResourceUpdatedNotificationParams(uri="note://uninvited", _meta=meta)
),
related_request_id=ctx.request_id,
)
await ctx.session.send_notification(
types.ToolListChangedNotification(params=types.NotificationParams(_meta=meta)),
related_request_id=ctx.request_id,
)
await proceed.wait()
return types.SubscriptionsListenResult(_meta=meta)
server = Server("subs", on_subscriptions_listen=overreaching_listen)
async with Client(server) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
assert await anext(sub) == ToolsListChanged()
proceed.set()
with pytest.raises(StopAsyncIteration): # pragma: no branch
await anext(sub)
async def test_the_on_event_barrier_completes_before_each_event_is_returned():
"""`on_event` is awaited before the iterator returns each event (the Client wires cache eviction here)."""
bus = InMemorySubscriptionBus()
order: list[str] = []
async def barrier(event: ServerEvent) -> None:
order.append(f"barrier:{type(event).__name__}")
async with Client(_bus_server(bus)) as client:
with anyio.fail_after(5):
async with listen(client.session, tools_list_changed=True, on_event=barrier) as sub: # pragma: no branch
await bus.publish(ToolsListChanged())
event = await anext(sub)
order.append(f"returned:{type(event).__name__}")
assert order == ["barrier:ToolsListChanged", "returned:ToolsListChanged"]
async def test_client_listen_installs_the_cache_eviction_barrier_exactly_when_a_cache_exists():
"""`Client.listen` wires the response-cache evictor as the barrier only when a cache exists."""
bus = InMemorySubscriptionBus()
async with Client(_bus_server(bus)) as cached_client:
with anyio.fail_after(5):
async with cached_client.listen(tools_list_changed=True) as sub: # pragma: no branch
assert sub._on_event == cached_client._evict_for_listen_event # pyright: ignore[reportPrivateUsage]
async with Client(_bus_server(bus), cache=False) as uncached_client:
with anyio.fail_after(5):
async with uncached_client.listen(tools_list_changed=True) as sub: # pragma: no branch
assert sub._on_event is None # pyright: ignore[reportPrivateUsage]
async def test_the_cache_eviction_barrier_maps_events_and_contains_store_faults(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The barrier evicts through the same notification mapping as the message_handler wrapper;
a raising store costs a log line, not the delivery."""
client = Client(_bus_server(InMemorySubscriptionBus()))
cache = client._response_cache # pyright: ignore[reportPrivateUsage]
assert cache is not None
evicted: list[types.ServerNotification] = []
async def record(notification: types.ServerNotification) -> None:
evicted.append(notification)
monkeypatch.setattr(cache, "evict_for_notification", record)
await client._evict_for_listen_event(ResourceUpdated(uri="note://x")) # pyright: ignore[reportPrivateUsage]
assert isinstance(evicted[0], types.ResourceUpdatedNotification)
assert evicted[0].params.uri == "note://x"
async def broken(notification: types.ServerNotification) -> None:
raise RuntimeError("store down")
monkeypatch.setattr(cache, "evict_for_notification", broken)
# Contained: a cache fault must not block delivery.
await client._evict_for_listen_event(ToolsListChanged()) # pyright: ignore[reportPrivateUsage]
async def test_a_raw_request_id_collision_fails_the_subscription_not_the_session(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A raw caller occupying the driver's next minted id fails that one listen from enter;
the session survives and the next listen opens normally."""
monkeypatch.setattr(subscriptions_module, "_listen_ids", count(7000))
bus = InMemorySubscriptionBus()
async with Client(_bus_server(bus)) as client:
with anyio.fail_after(5):
async with anyio.create_task_group() as tg: # pragma: no branch
raw_scope = anyio.CancelScope()
async def raw_listen() -> None:
request = types.SubscriptionsListenRequest(
params=types.SubscriptionsListenRequestParams(
notifications=SubscriptionFilter(tools_list_changed=True)
)
)
data = request.model_dump(by_alias=True, mode="json", exclude_none=True)
opts: CallOptions = {"request_id": "listen-7000"}
client.session._stamp(data, opts) # pyright: ignore[reportPrivateUsage]
with raw_scope:
await client.session._dispatcher.send_raw_request( # pyright: ignore[reportPrivateUsage]
data["method"], data.get("params"), opts
)
tg.start_soon(raw_listen)
await anyio.wait_all_tasks_blocked()
with pytest.raises(MCPError) as exc_info:
await client.listen(tools_list_changed=True).__aenter__()
assert "already in flight" in exc_info.value.error.message
# The failed open released the colliding id's demux registration.
assert client.session._listen_routes == {} # pyright: ignore[reportPrivateUsage]
raw_scope.cancel()
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
assert sub.subscription_id == "listen-7001"
@@ -0,0 +1,105 @@
"""Regression tests for memory stream leaks in client transports.
When a connection error occurs (404, 403, ConnectError), transport context
managers must close ALL 4 memory stream ends they created. anyio memory streams
are paired but independent — closing the writer does NOT close the reader.
Unclosed stream ends emit ResourceWarning on GC, which pytest promotes to a
test failure in whatever test happens to be running when GC triggers.
These tests force GC after the transport context exits, so any leaked stream
triggers a ResourceWarning immediately and deterministically here, rather than
nondeterministically in an unrelated later test.
"""
import gc
import sys
from collections.abc import Iterator
from contextlib import contextmanager
import httpx
import pytest
from mcp.client.sse import sse_client
from mcp.client.streamable_http import streamable_http_client
@contextmanager
def _assert_no_memory_stream_leak() -> Iterator[None]:
"""Fail if any anyio MemoryObject stream emits ResourceWarning during the block.
Uses a custom sys.unraisablehook to capture ONLY MemoryObject stream leaks,
ignoring unrelated resources (e.g. PipeHandle from flaky stdio tests on the
same xdist worker). gc.collect() is forced after the block to make leaks
deterministic.
"""
leaked: list[str] = []
old_hook = sys.unraisablehook
def hook(args: "sys.UnraisableHookArgs") -> None: # pragma: no cover
# Only executes if a leak occurs (i.e. the bug is present).
# args.object is the __del__ function (not the stream instance) when
# unraisablehook fires from a finalizer, so check exc_value — the
# actual ResourceWarning("Unclosed <MemoryObjectSendStream at ...>").
# Non-MemoryObject unraisables (e.g. PipeHandle leaked by an earlier
# flaky test on the same xdist worker) are deliberately ignored —
# this test should not fail for another test's resource leak.
if "MemoryObject" in str(args.exc_value):
leaked.append(str(args.exc_value))
sys.unraisablehook = hook
try:
yield
gc.collect()
assert not leaked, f"Memory streams leaked: {leaked}"
finally:
sys.unraisablehook = old_hook
@pytest.mark.anyio
async def test_sse_client_closes_all_streams_on_connection_error(free_tcp_port: int) -> None:
"""sse_client creates streams only after the SSE connection succeeds, so a
ConnectError propagates directly with nothing to leak.
Before the fix, streams were created before connecting and only 2 of 4 were
closed in the finally block.
"""
with _assert_no_memory_stream_leak():
with pytest.raises(httpx.ConnectError):
async with sse_client(f"http://127.0.0.1:{free_tcp_port}/sse"):
pytest.fail("should not reach here") # pragma: no cover
@pytest.mark.anyio
async def test_sse_client_closes_all_streams_on_http_error() -> None:
"""sse_client creates streams only after raise_for_status() passes, so an
HTTPStatusError from a 4xx/5xx response propagates bare (not wrapped in an
ExceptionGroup) with nothing to leak — the task group is never entered.
"""
def return_403(request: httpx.Request) -> httpx.Response:
return httpx.Response(403)
def mock_factory(
headers: dict[str, str] | None = None,
timeout: httpx.Timeout | None = None,
auth: httpx.Auth | None = None,
) -> httpx.AsyncClient:
return httpx.AsyncClient(transport=httpx.MockTransport(return_403))
with _assert_no_memory_stream_leak():
with pytest.raises(httpx.HTTPStatusError):
async with sse_client("http://test/sse", httpx_client_factory=mock_factory):
pytest.fail("should not reach here") # pragma: no cover
@pytest.mark.anyio
async def test_streamable_http_client_closes_all_streams_on_exit() -> None:
"""streamable_http_client must close all 4 stream ends on exit.
Before the fix, read_stream was never closed — not even on the happy path.
This test enters and exits the context without sending any messages, so no
network connection is ever attempted (streamable_http connects lazily).
"""
with _assert_no_memory_stream_leak():
async with streamable_http_client("http://127.0.0.1:1/mcp"):
pass
View File
+149
View File
@@ -0,0 +1,149 @@
"""Tests for InMemoryTransport."""
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any
import anyio
import anyio.lowlevel
import mcp_types as types
import pytest
from mcp_types import ListResourcesResult, Resource
from mcp import Client
from mcp.client import _memory
from mcp.client._memory import InMemoryTransport
from mcp.server import Server, ServerRequestContext
from mcp.server.mcpserver import MCPServer
@pytest.fixture
def simple_server() -> Server:
"""Create a simple MCP server for testing."""
async def handle_list_resources(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> ListResourcesResult: # pragma: no cover
return ListResourcesResult(
resources=[
Resource(
uri="memory://test",
name="Test Resource",
description="A test resource",
)
]
)
return Server(name="test_server", on_list_resources=handle_list_resources)
@pytest.fixture
def mcpserver_server() -> MCPServer:
"""Create an MCPServer server for testing."""
server = MCPServer("test")
@server.tool()
def greet(name: str) -> str:
"""Greet someone by name."""
return f"Hello, {name}!"
@server.resource("test://resource")
def test_resource() -> str: # pragma: no cover
"""A test resource."""
return "Test content"
return server
pytestmark = pytest.mark.anyio
async def test_with_server(simple_server: Server):
"""Test creating transport with a Server instance."""
transport = InMemoryTransport(simple_server)
async with transport as (read_stream, write_stream):
assert read_stream is not None
assert write_stream is not None
async def test_with_mcpserver(mcpserver_server: MCPServer):
"""Test creating transport with an MCPServer instance."""
transport = InMemoryTransport(mcpserver_server)
async with transport as (read_stream, write_stream):
assert read_stream is not None
assert write_stream is not None
async def test_server_is_running(mcpserver_server: MCPServer):
"""Test that the server is running and responding to requests."""
async with Client(mcpserver_server, mode="legacy") as client:
assert client.server_capabilities.tools is not None
async def test_list_tools(mcpserver_server: MCPServer):
"""Test listing tools through the transport."""
async with Client(mcpserver_server, mode="legacy") as client:
tools_result = await client.list_tools()
assert len(tools_result.tools) > 0
tool_names = [t.name for t in tools_result.tools]
assert "greet" in tool_names
async def test_call_tool(mcpserver_server: MCPServer):
"""Test calling a tool through the transport."""
async with Client(mcpserver_server, mode="legacy") as client:
result = await client.call_tool("greet", {"name": "World"})
assert result is not None
assert len(result.content) > 0
assert "Hello, World!" in str(result.content[0])
async def test_raise_exceptions(mcpserver_server: MCPServer):
"""Test that raise_exceptions parameter is passed through."""
transport = InMemoryTransport(mcpserver_server, raise_exceptions=True)
async with transport as (read_stream, _write_stream):
assert read_stream is not None
async def test_aexit_with_well_behaved_lifespan_runs_teardown_without_cancel():
"""A lifespan that finishes promptly on EOF should run to completion.
The transport closes the streams first and waits for the server to exit
naturally, so teardown observes no cancellation.
"""
teardown_ran = anyio.Event()
@asynccontextmanager
async def lifespan(_: Server[Any]) -> AsyncIterator[dict[str, Any]]:
yield {}
await anyio.lowlevel.checkpoint()
teardown_ran.set()
server = Server(name="test_server", lifespan=lifespan)
with anyio.fail_after(5):
async with InMemoryTransport(server):
pass
assert teardown_ran.is_set()
async def test_aexit_with_blocking_lifespan_is_bounded(monkeypatch: pytest.MonkeyPatch):
"""A lifespan that never returns must not hang `__aexit__` forever.
After EOFing the server the transport waits `SERVER_SHUTDOWN_GRACE` for a
natural exit, then cancels the server task as a backstop so the
task-group join completes.
"""
monkeypatch.setattr(_memory, "SERVER_SHUTDOWN_GRACE", 0.05)
teardown_started = anyio.Event()
@asynccontextmanager
async def blocking_lifespan(_: Server[Any]) -> AsyncIterator[dict[str, Any]]:
yield {}
teardown_started.set()
await anyio.Event().wait()
server = Server(name="test_server", lifespan=blocking_lifespan)
with anyio.fail_after(5):
async with InMemoryTransport(server):
pass
assert teardown_started.is_set()
+68
View File
@@ -0,0 +1,68 @@
import os
from collections.abc import AsyncIterator, Iterator
import pytest
# OpenTelemetry's `set_tracer_provider` is set-once per process, so the suite
# uses a single span-capture mechanism: logfire's `capfire` fixture (its
# `configure()` swaps span processors on repeat calls rather than re-setting
# the provider). Logfire's default `distributed_tracing=None` emits a
# RuntimeWarning + diagnostic span when incoming W3C trace context is
# extracted; several tests exercise that propagation deliberately, so opt in
# suite-wide. Set before logfire is imported anywhere.
os.environ.setdefault("LOGFIRE_DISTRIBUTED_TRACING", "true")
import opentelemetry.trace # noqa: E402 (env var must be set before logfire import below)
from logfire.testing import CaptureLogfire # noqa: E402
import mcp.shared._otel # noqa: E402
@pytest.fixture(scope="session")
def anyio_backend() -> str:
return "asyncio"
@pytest.fixture(scope="module", autouse=True)
async def _module_runner_lease(anyio_backend: str) -> AsyncIterator[None]:
"""Share one event loop across each module's tests instead of one per test.
anyio's pytest plugin tears its runner down whenever the last lease is
released, so with only function-scoped async fixtures every async test
creates and destroys its own event loop. On Windows each loop's self-pipe
is an emulated loopback-TCP socketpair, and churning thousands of those per
run can transiently exhaust kernel socket buffers — surfacing in CI as
`OSError: [WinError 10055]` raised from `asyncio.new_event_loop()` before
an arbitrary test's body even starts. Holding a module-scoped lease caps
the churn at one loop per module per xdist worker.
Modules that parametrize `anyio_backend` or call `trio.run(...)` directly
must shadow this fixture with a sync no-op: a module-scoped lease cannot
depend on the function-scoped parameter (pytest raises ScopeMismatch at
setup), and the lease's live asyncio loop lingers over direct trio runs,
whose signal handling collides with the loop's wakeup fd on Windows. The
lease also makes sniffio report asyncio to the module's sync tests, so a
sync test must not call `anyio.run()` itself.
"""
yield
@pytest.fixture(name="capfire")
def _capfire_isolated(capfire: CaptureLogfire) -> Iterator[CaptureLogfire]:
"""Override of logfire's `capfire` that scopes the MCP tracer to the test.
`capfire` installs a real tracer provider, and logfire's proxy machinery
mutates the cached `mcp.shared._otel._tracer` to delegate to it for the
rest of the process. Without isolation, every subsequent test in the same
worker would emit real spans, and `send_raw_request` would inject a real
`traceparent` into outbound `_meta`, breaking the interaction-suite
snapshots that pin `_meta={}` under a no-op tracer.
Setup points `_tracer` at the now-live provider so MCP spans record;
teardown replaces it with a `NoOpTracer`.
"""
mcp.shared._otel._tracer = opentelemetry.trace.get_tracer_provider().get_tracer("mcp-python-sdk")
try:
yield capfire
finally:
mcp.shared._otel._tracer = opentelemetry.trace.NoOpTracer()
View File
+103
View File
@@ -0,0 +1,103 @@
"""`docs/advanced/apps.md`: every claim the page makes, proved against the real SDK."""
from typing import Any
import pytest
from mcp_types import TextContent, TextResourceContents
from docs_src.apps import tutorial001, tutorial002, tutorial003
from mcp import Client
from mcp.client import advertise
from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_the_tool_carries_the_ui_resource_reference() -> None:
"""tutorial001: `@apps.tool(resource_uri=...)` stamps `_meta.ui.resourceUri` on the tool."""
async with Client(tutorial001.mcp) as client:
listed = await client.list_tools()
assert listed.tools[0].meta == {"ui": {"resourceUri": "ui://clock/app.html"}}
async def test_the_ui_resource_is_served_as_the_app_mime_type() -> None:
"""tutorial001: `add_html_resource` serves the HTML at `text/html;profile=mcp-app`,
the MIME type that tells a host "this is an app, render it"."""
async with Client(tutorial001.mcp) as client:
result = await client.read_resource("ui://clock/app.html")
contents = result.contents[0]
assert isinstance(contents, TextResourceContents)
assert contents.mime_type == APP_MIME_TYPE
assert contents.text == tutorial001.CLOCK_HTML
async def test_one_tool_two_answers() -> None:
"""tutorial001: the canonical degradation pattern: raw data for a client that
negotiated Apps, a human sentence for one that did not."""
async with Client(
tutorial001.mcp, extensions=[advertise(EXTENSION_ID, {"mimeTypes": [APP_MIME_TYPE]})]
) as ui_client:
rich = await ui_client.call_tool("get_time", {})
async with Client(tutorial001.mcp) as text_client:
plain = await text_client.call_tool("get_time", {})
assert rich.content == [TextContent(type="text", text="2026-06-26T12:00:00Z")]
assert plain.content == [TextContent(type="text", text="The time is 2026-06-26T12:00:00Z.")]
async def test_the_clock_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None:
"""tutorial001: `main()` declares Apps support with the required `mimeTypes` and
receives the rich answer the page promises."""
await tutorial001.main()
assert "2026-06-26T12:00:00Z" in capsys.readouterr().out
async def test_capability_advertised_under_server_extensions() -> None:
"""tutorial001: passing `extensions=[apps]` advertises `io.modelcontextprotocol/ui`."""
async with Client(tutorial001.mcp) as client:
assert client.server_capabilities.extensions == {EXTENSION_ID: {}}
async def test_csp_permissions_domain_and_border_ride_the_resource_meta() -> None:
"""tutorial002: the iframe lockdown fields land under `_meta.ui` on both the list
entry and the read content item, with the spec's camelCase wire keys."""
expected: dict[str, Any] = {
"ui": {
"csp": {"connectDomains": ["https://api.example.com"]},
"permissions": {"clipboardWrite": {}},
"domain": "dashboard.example.com",
"prefersBorder": True,
}
}
async with Client(tutorial002.mcp) as client:
listed = await client.list_resources()
result = await client.read_resource("ui://dashboard/app.html")
assert listed.resources[0].meta == expected
contents = result.contents[0]
assert isinstance(contents, TextResourceContents)
assert contents.meta == expected
async def test_an_app_only_tool_is_still_listed_and_callable() -> None:
"""tutorial002: `visibility=["app"]` is metadata for the host; the server lists the
tool like any other and serves its calls. Filtering is the host's job."""
async with Client(tutorial002.mcp) as client:
listed = await client.list_tools()
result = await client.call_tool("refresh_dashboard", {})
assert listed.tools[0].meta == {"ui": {"resourceUri": "ui://dashboard/app.html", "visibility": ["app"]}}
assert result.content == [TextContent(type="text", text="refreshed")]
async def test_a_file_resource_is_served_with_the_app_mime_type_filled_in() -> None:
"""tutorial003: `add_resource` accepts a pre-built `FileResource` and fills in the
`text/html;profile=mcp-app` MIME type the resource didn't set explicitly."""
async with Client(tutorial003.mcp) as client:
listed = await client.list_tools()
called = await client.call_tool("refresh_report", {})
result = await client.read_resource("ui://report/app.html")
assert listed.tools[0].meta == {"ui": {"resourceUri": "ui://report/app.html"}}
assert called.content == [TextContent(type="text", text="report refreshed")]
contents = result.contents[0]
assert isinstance(contents, TextResourceContents)
assert contents.mime_type == APP_MIME_TYPE
assert contents.text == tutorial003.REPORT_HTML.read_text()
+213
View File
@@ -0,0 +1,213 @@
"""`docs/run/asgi.md`: every claim the page makes, proved against the real SDK."""
import inspect
import httpx
import pytest
from mcp_types import TextContent
from starlette.applications import Starlette
from starlette.middleware.cors import CORSMiddleware
from starlette.requests import Request
from starlette.responses import PlainTextResponse, Response
from starlette.routing import Mount, Route
from docs_src.asgi import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005, tutorial006
from mcp import Client
from mcp.server import MCPServer
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_streamable_http_app_is_a_starlette_app_with_one_route() -> None:
"""tutorial001: the factory returns a Starlette application with a single route at `/mcp`."""
(route,) = tutorial001.app.routes
assert isinstance(route, Route)
assert route.path == "/mcp"
async def test_the_server_behind_the_app_is_unchanged() -> None:
"""tutorial001: wrapping the server in an ASGI app changes nothing about its tools."""
async with Client(tutorial001.mcp) as client:
result = await client.call_tool("add_note", {"text": "milk"})
assert result.content == [TextContent(type="text", text="Saved: milk")]
assert result.structured_content == {"result": "Saved: milk"}
async def test_streamable_http_app_takes_runs_options_except_port() -> None:
"""The tip: every `run("streamable-http", ...)` option is here except `port`. `host` is one of them."""
parameters = set(inspect.signature(MCPServer.streamable_http_app).parameters) - {"self"}
assert parameters == {
"streamable_http_path",
"json_response",
"stateless_http",
"event_store",
"retry_interval",
"transport_security",
"host",
}
async def test_a_request_before_the_session_manager_runs_is_rejected() -> None:
"""The `!!! check`: nothing starts the session manager except its lifespan."""
transport = httpx.ASGITransport(app=tutorial001.app)
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
with pytest.raises(RuntimeError, match=r"Task group is not initialized\. Make sure to use run\(\)\."):
await http.post("/mcp")
async def test_mounting_at_the_root_keeps_the_default_path() -> None:
"""tutorial002: `Mount("/")` plus the default `streamable_http_path` leaves the endpoint at `/mcp`."""
(mount,) = tutorial002.app.routes
assert isinstance(mount, Mount)
assert mount.path == ""
(inner,) = mount.routes
assert isinstance(inner, Route)
assert inner.path == "/mcp"
async def test_a_root_mount_swallows_routes_listed_after_it() -> None:
"""The mounting bullet: `Mount("/")` matches every path, so your own routes go before it in the list."""
async def about(request: Request) -> Response:
return PlainTextResponse("about")
mcp_app = MCPServer("Notes").streamable_http_app()
listed_after = Starlette(routes=[Mount("/", app=mcp_app), Route("/about", about)])
listed_before = Starlette(routes=[Route("/about", about), Mount("/", app=mcp_app)])
transport = httpx.ASGITransport(app=listed_after)
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
assert (await http.get("/about")).status_code == 404
transport = httpx.ASGITransport(app=listed_before)
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
assert (await http.get("/about")).status_code == 200
async def test_the_host_lifespan_enters_the_session_manager() -> None:
"""tutorial002: the host app's lifespan owns `session_manager.run()` and starts and stops cleanly."""
async with tutorial002.lifespan(tutorial002.app):
async with Client(tutorial002.mcp) as client:
result = await client.call_tool("add_note", {"text": "milk"})
assert result.structured_content == {"result": "Saved: milk"}
async def test_two_servers_get_two_mounts() -> None:
"""tutorial003: each server is mounted under its own prefix, each still ending in `/mcp`."""
notes_mount, tasks_mount = tutorial003.app.routes
assert isinstance(notes_mount, Mount)
assert isinstance(tasks_mount, Mount)
assert notes_mount.path == "/notes"
assert tasks_mount.path == "/tasks"
async def test_one_lifespan_starts_both_session_managers() -> None:
"""tutorial003: a single `AsyncExitStack` lifespan runs both managers; both servers answer."""
async with tutorial003.lifespan(tutorial003.app):
async with Client(tutorial003.notes) as client:
notes_result = await client.call_tool("add_note", {"text": "milk"})
assert notes_result.structured_content == {"result": "Saved: milk"}
async with Client(tutorial003.tasks) as client:
tasks_result = await client.call_tool("add_task", {"title": "ship"})
assert tasks_result.structured_content == {"result": "Created: ship"}
async def test_streamable_http_path_moves_the_endpoint_to_the_mount_prefix() -> None:
"""tutorial004: `streamable_http_path="/"` makes the `Mount` prefix the whole public path."""
(mount,) = tutorial004.app.routes
assert isinstance(mount, Mount)
assert mount.path == "/notes"
(inner,) = mount.routes
assert isinstance(inner, Route)
assert inner.path == "/"
async def test_cors_exposes_the_session_id_header() -> None:
"""tutorial005: the browser origin gets the three MCP methods and can read `Mcp-Session-Id`."""
(middleware,) = tutorial005.app.user_middleware
assert middleware.cls is CORSMiddleware
transport = httpx.ASGITransport(app=tutorial005.app)
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
preflight = await http.options(
"/mcp",
headers={"Origin": "https://app.example.com", "Access-Control-Request-Method": "POST"},
)
assert preflight.status_code == 200
assert preflight.headers["access-control-allow-methods"] == "GET, POST, DELETE"
response = await http.get("/not-the-endpoint", headers={"Origin": "https://app.example.com"})
assert response.headers["access-control-allow-origin"] == "https://app.example.com"
assert response.headers["access-control-expose-headers"] == "Mcp-Session-Id"
async def test_custom_route_lands_next_to_the_mcp_endpoint() -> None:
"""tutorial006: `@mcp.custom_route()` adds a plain Starlette route to the returned app."""
mcp_route, health_route = tutorial006.app.routes
assert isinstance(mcp_route, Route)
assert isinstance(health_route, Route)
assert mcp_route.path == "/mcp"
assert health_route.path == "/health"
async def test_the_health_check_answers_outside_the_protocol() -> None:
"""tutorial006: `GET /health` is ordinary HTTP, with no session manager and no MCP."""
transport = httpx.ASGITransport(app=tutorial006.app)
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
response = await http.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
INITIALIZE = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "b", "version": "1"}},
}
MCP_HEADERS = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"}
async def test_the_default_app_is_localhost_only() -> None:
"""The "Localhost only" section: with no `transport_security=`, the app answers a real hostname
with the page's `421 Invalid Host header` and a foreign Origin with `403 Invalid Origin header`,
before any MCP code runs."""
bare = MCPServer("Notes")
app = bare.streamable_http_app()
transport = httpx.ASGITransport(app=app)
async with bare.session_manager.run():
async with httpx.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http:
wrong_host = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
async with httpx.AsyncClient(transport=transport, base_url="http://localhost:8000") as http:
wrong_origin = await http.post(
"/mcp", json=INITIALIZE, headers={**MCP_HEADERS, "Origin": "https://app.example.com"}
)
assert (wrong_host.status_code, wrong_host.text) == (421, "Invalid Host header")
assert (wrong_origin.status_code, wrong_origin.text) == (403, "Invalid Origin header")
async def test_the_documented_browser_origin_works_end_to_end() -> None:
"""tutorial005: the page's scenario for real. The public hostname, the browser origin, a
realistic preflight naming the `Mcp-*` headers, then the actual request."""
transport = httpx.ASGITransport(app=tutorial005.app)
async with tutorial005.lifespan(tutorial005.app):
async with httpx.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http:
preflight = await http.options(
"/mcp",
headers={
"Origin": "https://app.example.com",
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "content-type, mcp-protocol-version, mcp-session-id",
},
)
assert preflight.status_code == 200
allowed = {h.strip().lower() for h in preflight.headers["access-control-allow-headers"].split(",")}
assert {"content-type", "mcp-protocol-version", "mcp-session-id"} <= allowed
response = await http.post(
"/mcp", json=INITIALIZE, headers={**MCP_HEADERS, "Origin": "https://app.example.com"}
)
assert response.status_code == 200
assert response.headers["mcp-session-id"]
assert response.headers["access-control-allow-origin"] == "https://app.example.com"
assert response.headers["access-control-expose-headers"] == "Mcp-Session-Id"
+98
View File
@@ -0,0 +1,98 @@
"""`docs/run/authorization.md`: every claim the page makes, proved against the real SDK."""
import httpx
import pytest
from inline_snapshot import snapshot
from mcp_types import TextContent
from starlette.routing import Route
from docs_src.authorization import tutorial001, tutorial002
from mcp import Client
from mcp.client.streamable_http import streamable_http_client
from mcp.server import MCPServer
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_the_in_memory_client_never_authenticates() -> None:
"""tutorial001: `Client(mcp)` connects to the server object directly, so no token is ever checked."""
async with Client(tutorial001.mcp) as client:
result = await client.call_tool("list_notes", {})
assert not result.is_error
assert result.structured_content == {"result": ["Buy milk", "Ship the release"]}
async def test_token_verifier_and_auth_settings_must_travel_together() -> None:
"""tutorial001: passing `token_verifier=` without `auth=` is refused at construction time."""
with pytest.raises(ValueError, match="Cannot specify auth_server_provider or token_verifier without auth settings"):
MCPServer("Notes", token_verifier=tutorial001.StaticTokenVerifier())
async def test_the_app_grows_a_protected_resource_metadata_route() -> None:
"""tutorial001: the HTTP app has the `/mcp` endpoint plus the RFC 9728 well-known route."""
mcp_route, metadata_route = tutorial001.mcp.streamable_http_app().routes
assert isinstance(mcp_route, Route)
assert isinstance(metadata_route, Route)
assert mcp_route.path == "/mcp"
assert metadata_route.path == "/.well-known/oauth-protected-resource/mcp"
async def test_the_metadata_document_is_built_from_auth_settings() -> None:
"""tutorial001: `GET` on the well-known route returns the Protected Resource Metadata the page shows."""
transport = httpx.ASGITransport(app=tutorial001.mcp.streamable_http_app())
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client:
response = await http_client.get("/.well-known/oauth-protected-resource/mcp")
assert response.status_code == 200
assert response.json() == snapshot(
{
"resource": "http://127.0.0.1:8000/mcp",
"authorization_servers": ["https://auth.example.com/"],
"scopes_supported": ["notes:read"],
"bearer_methods_supported": ["header"],
}
)
async def test_a_request_without_a_token_never_reaches_the_protocol() -> None:
"""The `!!! check`: no `Authorization` header means a 401 that points at the metadata document."""
transport = httpx.ASGITransport(app=tutorial001.mcp.streamable_http_app())
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client:
response = await http_client.post("/mcp", json={})
assert response.status_code == 401
assert response.json() == {"error": "invalid_token", "error_description": "Authentication required"}
assert response.headers["www-authenticate"] == (
'Bearer error="invalid_token", error_description="Authentication required", '
'resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"'
)
async def test_a_token_the_verifier_rejects_gets_the_same_401() -> None:
"""tutorial001: `verify_token` returning `None` and a missing header are indistinguishable to the caller."""
transport = httpx.ASGITransport(app=tutorial001.mcp.streamable_http_app())
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client:
response = await http_client.post("/mcp", json={}, headers={"Authorization": "Bearer not-a-real-token"})
assert response.status_code == 401
assert response.json() == {"error": "invalid_token", "error_description": "Authentication required"}
async def test_get_access_token_is_none_outside_an_authenticated_request() -> None:
"""tutorial002: in-memory there is no HTTP layer, so `get_access_token()` returns `None`."""
async with Client(tutorial002.mcp) as client:
result = await client.call_tool("whoami", {})
assert result.structured_content == {"result": "anonymous"}
async def test_get_access_token_is_the_callers_access_token() -> None:
"""tutorial002: over Streamable HTTP a valid bearer token reaches the tool as an `AccessToken`."""
url = "http://127.0.0.1:8000/mcp"
transport = httpx.ASGITransport(app=tutorial002.mcp.streamable_http_app())
headers = {"Authorization": "Bearer alice-token"}
async with tutorial002.mcp.session_manager.run():
async with (
httpx.AsyncClient(transport=transport, base_url=url, headers=headers) as http_client,
Client(streamable_http_client(url, http_client=http_client)) as client,
):
result = await client.call_tool("whoami", {})
assert result.content == [TextContent(type="text", text="alice (scopes: notes:read)")]
assert result.structured_content == {"result": "alice (scopes: notes:read)"}
+209
View File
@@ -0,0 +1,209 @@
"""`docs/client/caching.md`: every claim the page makes, proved against the real SDK."""
from collections.abc import Mapping
from typing import Any, cast
import anyio
import pytest
from inline_snapshot import snapshot
from mcp_types import INTERNAL_ERROR, ListToolsResult, PaginatedRequestParams, Tool
from docs_src.caching import tutorial001, tutorial002, tutorial003
from mcp import Client, MCPError
from mcp.client import CacheConfig
from mcp.client.caching import InMemoryResponseCacheStore
from mcp.server import CacheHint, MCPServer, Server, ServerRequestContext
from mcp.server.caching import CacheableMethod
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_a_mapped_method_carries_the_configured_hint() -> None:
"""tutorial001: `tools/list` is in the map, so clients see one minute, public."""
async with Client(tutorial001.mcp) as client:
tools = await client.list_tools()
assert tools.ttl_ms == 60_000
assert tools.cache_scope == "public"
async def test_a_hint_without_a_scope_stays_private() -> None:
"""tutorial001: `resources/read` set only `ttl_ms`; scope keeps the conservative default."""
async with Client(tutorial001.mcp) as client:
result = await client.read_resource("config://units")
assert result.ttl_ms == 5_000
assert result.cache_scope == "private"
async def test_an_unmapped_method_stays_immediately_stale_and_private() -> None:
"""tutorial001: `resources/list` is not in the map - the defaults hold."""
async with Client(tutorial001.mcp) as client:
resources = await client.list_resources()
assert resources.ttl_ms == 0
assert resources.cache_scope == "private"
async def test_a_non_cacheable_method_is_rejected_at_construction() -> None:
"""The page's claim: anything but the six cacheable methods raises at construction."""
with pytest.raises(ValueError) as exc:
MCPServer("Weather", cache_hints=cast(Any, {"tools/call": CacheHint(ttl_ms=1_000)}))
assert str(exc.value) == snapshot(
"cache_hints keys must be cacheable methods (see CacheableMethod); got: 'tools/call'"
)
async def test_the_handler_value_wins_over_the_map_per_field() -> None:
"""tutorial002: the handler's `ttl_ms=1_000` beats the map's `60_000`; the scope
the handler left unset takes the map's `"public"`."""
async with Client(tutorial002.server) as client:
tools = await client.list_tools()
assert tools.ttl_ms == 1_000
assert tools.cache_scope == "public"
async def test_the_client_program_on_the_page_makes_three_fetches_for_four_calls(
capsys: pytest.CaptureFixture[str],
) -> None:
"""tutorial003: a cache hit, an expiry, and `cache_mode="refresh"` make four calls cost three fetches."""
await tutorial003.main()
assert capsys.readouterr().out == "4 calls, 3 fetches\n"
def _counting_tools_server(*, ttl_ms: int | None = 60_000) -> tuple[Server[Any], list[str | None]]:
"""Each tools/list fetch returns a distinct tool name, so a cache hit is
payload-distinguishable from a refetch; `ttl_ms=None` sends no hints."""
fetches: list[str | None] = []
async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult:
fetches.append(params.cursor if params is not None else None)
return ListToolsResult(tools=[Tool(name=f"t{len(fetches) - 1}", input_schema={"type": "object"})])
hints: Mapping[CacheableMethod, CacheHint] | None = None
if ttl_ms is not None:
hints = {"tools/list": CacheHint(ttl_ms=ttl_ms)}
return Server("counting", on_list_tools=list_tools, cache_hints=hints), fetches
async def test_caching_is_on_by_default_the_second_call_makes_no_fetch() -> None:
server, fetches = _counting_tools_server()
async with Client(server) as client:
first = await client.list_tools()
second = await client.list_tools()
assert fetches == [None]
assert second == first
async def test_a_hintless_result_is_not_cached_by_default() -> None:
"""`default_ttl_ms` defaults to 0, so a hintless server sees its usual call-for-call traffic."""
server, fetches = _counting_tools_server(ttl_ms=None)
async with Client(server) as client:
await client.list_tools()
await client.list_tools()
assert fetches == [None, None]
async def test_cache_false_makes_every_call_a_round_trip() -> None:
server, fetches = _counting_tools_server()
async with Client(server, cache=False) as client:
await client.list_tools()
await client.list_tools()
assert fetches == [None, None]
async def test_refresh_refetches_and_replaces_the_cached_entry() -> None:
server, fetches = _counting_tools_server()
async with Client(server) as client:
await client.list_tools()
refreshed = await client.list_tools(cache_mode="refresh")
served = await client.list_tools()
assert fetches == [None, None]
assert [tool.name for tool in refreshed.tools] == ["t1"]
assert served == refreshed
async def test_bypass_fetches_without_reading_or_writing_the_cache() -> None:
server, fetches = _counting_tools_server()
async with Client(server) as client:
first = await client.list_tools()
bypassed = await client.list_tools(cache_mode="bypass")
served = await client.list_tools()
assert fetches == [None, None]
assert [tool.name for tool in bypassed.tools] == ["t1"]
assert served == first
async def test_an_expired_entry_is_not_revived_when_the_refetch_fails() -> None:
"""SDK ruling: no stale-if-error - the refetch failure propagates."""
now = 1_000_000.0
fetches: list[None] = []
async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult:
fetches.append(None)
if len(fetches) > 1:
raise MCPError(code=INTERNAL_ERROR, message="backend down")
return ListToolsResult(tools=[Tool(name="t0", input_schema={"type": "object"})])
server = Server("flaky", on_list_tools=list_tools, cache_hints={"tools/list": CacheHint(ttl_ms=60_000)})
async with Client(server, cache=CacheConfig(clock=lambda: now)) as client:
await client.list_tools()
now += 60.0 # past the 60s TTL
with pytest.raises(MCPError) as exc:
await client.list_tools()
assert exc.value.code == INTERNAL_ERROR
assert len(fetches) == 2
async def test_two_concurrent_identical_calls_are_two_fetches() -> None:
"""SDK ruling: no coalescing. The handler barrier releases only once both
calls are inside it, so the test passes only if the fetches were concurrent."""
both_fetching = anyio.Event()
fetches: list[None] = []
async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult:
fetches.append(None)
if len(fetches) == 2:
both_fetching.set()
with anyio.fail_after(5):
await both_fetching.wait()
return ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"})])
server = Server("concurrent", on_list_tools=list_tools, cache_hints={"tools/list": CacheHint(ttl_ms=60_000)})
async with Client(server) as client:
async with anyio.create_task_group() as tg:
tg.start_soon(client.list_tools)
tg.start_soon(client.list_tools)
assert len(fetches) == 2
async def test_a_session_tier_call_always_makes_the_round_trip() -> None:
"""The cache lives on the `Client` verbs; `client.session` sits below it."""
server, fetches = _counting_tools_server()
async with Client(server) as client:
await client.list_tools()
await client.session.list_tools()
assert fetches == [None, None]
async def test_a_custom_store_requires_a_partition() -> None:
with pytest.raises(ValueError) as exc:
CacheConfig(store=InMemoryResponseCacheStore())
assert str(exc.value) == snapshot("a custom store requires an explicit partition")
async def test_a_custom_store_with_an_in_process_server_requires_target_id() -> None:
server, _ = _counting_tools_server()
with pytest.raises(ValueError) as exc:
Client(server, cache=CacheConfig(store=InMemoryResponseCacheStore(), partition="user-1"))
assert str(exc.value) == snapshot(
"a custom cache store requires CacheConfig.target_id when the server is not a URL: in-process servers "
"and Transport instances get a random per-client identity, so their entries in a shared store could "
"never be served to another client"
)
async def test_the_wire_presence_check_the_page_recommends_works() -> None:
"""The page's claim: `"ttl_ms" in result.model_fields_set` distinguishes a
server that sent the field from one that said nothing (model defaults)."""
async with Client(tutorial001.mcp) as client:
tools = await client.list_tools()
assert "ttl_ms" in tools.model_fields_set
+185
View File
@@ -0,0 +1,185 @@
"""`docs/client/index.md`: every claim the page makes, proved against the real SDK."""
import pytest
from inline_snapshot import snapshot
from mcp_types import Prompt, PromptArgument, PromptReference, TextContent, TextResourceContents, Tool
from docs_src.client import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005, tutorial006, tutorial007
from mcp import Client, MCPDeprecationWarning, MCPError
from mcp.shared.metadata_utils import get_display_name
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_every_client_program_on_the_page_runs(capsys: pytest.CaptureFixture[str]) -> None:
"""Each `main()` is the literal client program shown on the page; all seven run clean in-memory."""
await tutorial001.main()
await tutorial002.main()
await tutorial003.main()
await tutorial004.main()
await tutorial005.main()
await tutorial006.main()
await tutorial007.main()
assert "Bookshop" in capsys.readouterr().out
async def test_connected_properties_are_populated_inside_the_block() -> None:
"""tutorial001: server_info, server_capabilities, protocol_version and instructions are just there."""
async with Client(tutorial001.mcp) as client:
assert client.server_info.name == "Bookshop"
assert client.protocol_version == "2026-07-28"
assert client.instructions == "Search the catalog before recommending a book."
assert client.server_capabilities.tools is not None
assert client.server_capabilities.logging is None
async def test_a_client_is_not_reusable_after_the_block_ends() -> None:
"""tutorial001: `async with` is the whole lifecycle. Construct a new Client per connection."""
client = Client(tutorial001.mcp)
async with client:
assert client.server_info.name == "Bookshop"
with pytest.raises(RuntimeError, match="cannot reenter"):
await client.__aenter__()
async def test_list_tools_returns_the_full_definition() -> None:
"""tutorial002: each listed tool carries its name, title, description and the derived input schema."""
async with Client(tutorial002.mcp) as client:
(tool,) = (await client.list_tools()).tools
assert tool.name == "search_books"
assert tool.title == "Search the catalog"
assert tool.description == "Search the catalog by title or author."
assert tool.input_schema == snapshot(
{
"type": "object",
"properties": {
"query": {"title": "Query", "type": "string"},
"limit": {"default": 10, "title": "Limit", "type": "integer"},
},
"required": ["query"],
"title": "search_booksArguments",
}
)
def test_get_display_name_prefers_the_title() -> None:
"""The `!!! tip`: get_display_name returns the title when there is one and the name when there isn't."""
titled = Tool(name="search_books", title="Search the catalog", input_schema={"type": "object"})
untitled = Tool(name="search_books", input_schema={"type": "object"})
assert get_display_name(titled) == "Search the catalog"
assert get_display_name(untitled) == "search_books"
async def test_call_tool_result_has_three_things_to_read() -> None:
"""tutorial003: content for the model, structured_content for code, is_error for both."""
async with Client(tutorial003.mcp) as client:
result = await client.call_tool("lookup_book", {"title": "Dune"})
assert not result.is_error
(block,) = result.content
assert isinstance(block, TextContent)
assert block.text == '{\n "title": "Dune",\n "author": "Frank Herbert",\n "year": 1965\n}'
assert result.structured_content == {"title": "Dune", "author": "Frank Herbert", "year": 1965}
async def test_a_raising_tool_is_a_result_not_an_exception() -> None:
"""tutorial003 `!!! check`: the exception's message comes back in content with is_error=True."""
async with Client(tutorial003.mcp) as client:
result = await client.call_tool("lookup_book", {"title": "Solaris"})
assert result.is_error
(block,) = result.content
assert isinstance(block, TextContent)
assert block.text == "Error executing tool lookup_book: No book titled 'Solaris' in the catalog."
assert result.structured_content is None
async def test_an_unknown_tool_name_is_a_result_not_an_exception() -> None:
"""The `!!! warning`: a tool the server doesn't have comes back as is_error=True, not as MCPError."""
async with Client(tutorial003.mcp) as client:
result = await client.call_tool("does_not_exist", {})
assert result.is_error
(block,) = result.content
assert isinstance(block, TextContent)
assert block.text == "Unknown tool: does_not_exist"
assert result.structured_content is None
async def test_resources_and_templates_are_two_separate_lists() -> None:
"""tutorial004: concrete resources and parameterised templates come back from different verbs."""
async with Client(tutorial004.mcp) as client:
(resource,) = (await client.list_resources()).resources
assert resource.uri == "catalog://genres"
(template,) = (await client.list_resource_templates()).resource_templates
assert template.uri_template == "catalog://genres/{genre}"
async def test_read_resource_fills_in_a_template() -> None:
"""tutorial004: read_resource takes a plain str URI; narrow the contents with isinstance."""
async with Client(tutorial004.mcp) as client:
(contents,) = (await client.read_resource("catalog://genres/poetry")).contents
assert isinstance(contents, TextResourceContents)
assert contents.text == "3 books filed under poetry."
async def test_resource_subscriptions_are_listen_based_on_the_modern_wire() -> None:
"""The Resources section: at 2026-07-28 `resources.subscribe` is True (served via
subscriptions/listen) while the legacy subscribe_resource verb answers -32601."""
async with Client(tutorial004.mcp) as client:
assert client.server_capabilities.resources is not None
assert client.server_capabilities.resources.subscribe is True
with pytest.raises(MCPError) as exc_info:
# The verb is itself deprecated; the modern wire also rejects it.
with pytest.warns(MCPDeprecationWarning, match="use Client.listen"):
await client.subscribe_resource("catalog://genres") # pyright: ignore[reportDeprecated]
assert exc_info.value.error.code == -32601
assert exc_info.value.error.message == "Method not found"
async def test_list_prompts_describes_the_arguments() -> None:
"""tutorial005: a listed prompt carries its name, title and the arguments it needs."""
async with Client(tutorial005.mcp) as client:
(prompt,) = (await client.list_prompts()).prompts
assert prompt == snapshot(
Prompt(
name="recommend",
title="Recommend a book",
description="Ask for a recommendation in a genre.",
arguments=[PromptArgument(name="genre", required=True)],
)
)
async def test_get_prompt_renders_the_messages() -> None:
"""tutorial005: get_prompt returns the rendered messages a host hands to the model."""
async with Client(tutorial005.mcp) as client:
result = await client.get_prompt("recommend", {"genre": "poetry"})
(message,) = result.messages
assert message.role == "user"
assert message.content == TextContent(
type="text", text="Recommend one poetry book from the catalog and say why."
)
async def test_complete_suggests_values_for_an_argument() -> None:
"""tutorial006: complete takes a ref and a name/value pair and returns the matching values."""
async with Client(tutorial006.mcp) as client:
result = await client.complete(
ref=PromptReference(type="ref/prompt", name="recommend"),
argument={"name": "genre", "value": "p"},
)
assert result.completion.values == ["poetry"]
async def test_a_single_page_server_ends_the_pagination_loop_immediately() -> None:
"""tutorial007: every list_* takes cursor=; next_cursor is None when there is nothing left."""
async with Client(tutorial007.mcp) as client:
page = await client.list_tools(cursor=None)
assert page.next_cursor is None
assert [tool.name for tool in page.tools] == ["search_books", "reserve_book"]
async def test_raise_exceptions_is_a_constructor_flag() -> None:
"""The `## In tests` section: `raise_exceptions=True` is accepted by the in-memory Client."""
async with Client(tutorial001.mcp, raise_exceptions=True) as client:
result = await client.call_tool("search_books", {"query": "dune"})
assert result.structured_content == {"result": "Found 3 books matching 'dune'."}
+129
View File
@@ -0,0 +1,129 @@
"""`docs/client/callbacks.md`: every claim the page makes, proved against the real SDK."""
import pytest
from inline_snapshot import snapshot
from mcp_types import (
INVALID_REQUEST,
CreateMessageRequestParams,
CreateMessageResult,
ElicitRequestFormParams,
ElicitRequestParams,
ElicitResult,
ErrorData,
ListRootsResult,
Root,
SamplingMessage,
TextContent,
)
from pydantic import FileUrl
from docs_src.client_callbacks import tutorial001, tutorial002, tutorial003, tutorial004
from mcp import Client, MCPError
from mcp.client import ClientRequestContext
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_the_callback_answers_the_servers_question() -> None:
"""tutorial001+002: the server's `ctx.elicit` is resolved by the client's `elicitation_callback`."""
async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=tutorial002.handle_elicitation) as client:
result = await client.call_tool("issue_card")
assert not result.is_error
assert result.content == [TextContent(type="text", text="Card issued to Ada Lovelace.")]
async def test_the_callback_receives_the_servers_question_as_form_params() -> None:
"""tutorial002: the callback gets `ElicitRequestFormParams` (the message and the requested schema)."""
received: list[ElicitRequestParams] = []
async def recording(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
received.append(params)
return await tutorial002.handle_elicitation(context, params)
async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=recording) as client:
await client.call_tool("issue_card")
(params,) = received
assert isinstance(params, ElicitRequestFormParams)
assert params.mode == "form"
assert params.message == "What name should go on the card?"
assert params.requested_schema == snapshot(
{
"properties": {"name": {"title": "Name", "type": "string"}},
"required": ["name"],
"title": "CardHolder",
"type": "object",
}
)
async def test_returning_error_data_refuses_the_request_and_fails_the_call() -> None:
"""The callback's only other return type: `ErrorData` refuses the request and fails the whole call."""
async def refuse(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult | ErrorData:
return ErrorData(code=INVALID_REQUEST, message="No forms here.")
async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=refuse) as client:
with pytest.raises(MCPError, match="No forms here") as exc_info:
await client.call_tool("issue_card")
assert exc_info.value.error.code == INVALID_REQUEST
async def test_without_the_callback_the_servers_request_is_refused() -> None:
"""The `!!! check`: no `elicitation_callback` means the SDK answers with an error and the call fails."""
async with Client(tutorial001.mcp, mode="legacy") as client:
with pytest.raises(MCPError, match="Elicitation not supported") as exc_info:
await client.call_tool("issue_card")
assert exc_info.value.error.code == INVALID_REQUEST
async def test_registering_the_callback_declares_the_capability() -> None:
"""tutorial003: `elicitation_callback` alone advertises exactly the `elicitation` capability."""
async with Client(tutorial003.mcp, mode="legacy", elicitation_callback=tutorial002.handle_elicitation) as client:
result = await client.call_tool("client_features")
assert result.structured_content == {"result": ["elicitation"]}
async def test_no_callbacks_means_no_capabilities() -> None:
"""tutorial003: a client constructed without callbacks declares nothing."""
async with Client(tutorial003.mcp, mode="legacy") as client:
result = await client.call_tool("client_features")
assert result.structured_content == {"result": []}
async def test_each_callback_declares_its_own_capability() -> None:
"""The page's table: the elicitation, sampling, and roots callbacks each declare their capability."""
async with Client(
tutorial003.mcp,
mode="legacy",
elicitation_callback=tutorial002.handle_elicitation,
sampling_callback=tutorial004.handle_sampling,
list_roots_callback=tutorial004.handle_list_roots,
) as client:
result = await client.call_tool("client_features")
assert result.structured_content == {"result": ["elicitation", "sampling", "roots"]}
async def test_the_modern_in_memory_path_has_no_back_channel() -> None:
"""The `!!! info`: under the default mode the negotiated path has no back-channel for `elicitation/create`."""
async with Client(tutorial001.mcp, elicitation_callback=tutorial002.handle_elicitation) as client:
with pytest.raises(MCPError, match="no back-channel"):
await client.call_tool("issue_card")
async def test_the_deprecated_callbacks_return_what_the_page_says() -> None:
"""tutorial004: the sampling and roots callbacks produce the result types the page names."""
async with Client(tutorial003.mcp, mode="legacy") as client:
context = ClientRequestContext(session=client.session, request_id=1)
params = CreateMessageRequestParams(
messages=[SamplingMessage(role="user", content=TextContent(type="text", text="6 * 7?"))],
max_tokens=16,
)
assert await tutorial004.handle_sampling(context, params) == snapshot(
CreateMessageResult(
role="assistant", content=TextContent(type="text", text="The answer is 42."), model="my-llm"
)
)
assert await tutorial004.handle_list_roots(context) == snapshot(
ListRootsResult(roots=[Root(uri=FileUrl("file:///home/ada/notebooks"), name="notebooks")])
)
+58
View File
@@ -0,0 +1,58 @@
"""`docs/client/transports.md`: every claim the page makes, proved against the real SDK."""
import inspect
import pytest
from docs_src.client_transports import tutorial001, tutorial004
from mcp import Client
from mcp.client.stdio import get_default_environment, stdio_client
from mcp.client.streamable_http import streamable_http_client
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_the_in_memory_program_on_the_page_runs(capsys: pytest.CaptureFixture[str]) -> None:
"""tutorial001's `main()` is the literal client program on the page; it runs clean end to end."""
await tutorial001.main()
assert "Found 3 books matching 'dune'." in capsys.readouterr().out
async def test_in_memory_client_talks_to_the_server_object() -> None:
"""tutorial001: passing the server object connects in-process. No subprocess, no port."""
async with Client(tutorial001.mcp) as client:
assert client.server_info.name == "Bookshop"
assert client.protocol_version == "2026-07-28"
result = await client.call_tool("search_books", {"query": "dune"})
assert result.structured_content == {"result": "Found 3 books matching 'dune'."}
async def test_constructing_a_client_does_not_connect_it() -> None:
"""tutorial002: a URL string is accepted as-is, and nothing happens until `async with`."""
client = Client("http://localhost:8000/mcp")
with pytest.raises(RuntimeError, match="Client must be used within an async context manager"):
client.session
async def test_streamable_http_configuration_lives_on_the_httpx_client() -> None:
"""tutorial003: `streamable_http_client` takes `http_client=`; there is no `headers=` or any other HTTP knob."""
assert list(inspect.signature(streamable_http_client).parameters) == ["url", "http_client", "terminate_on_close"]
async def test_stdio_parameters_are_wrapped_by_stdio_client() -> None:
"""tutorial004: `stdio_client(params)` is the transport, and `Client` takes it like any other."""
client = Client(stdio_client(tutorial004.server))
with pytest.raises(RuntimeError, match="Client must be used within an async context manager"):
client.session
async def test_the_child_environment_is_an_allowlist(monkeypatch: pytest.MonkeyPatch) -> None:
"""tutorial004: a variable set in the parent process is not inherited; `env=` adds it back explicitly."""
monkeypatch.setenv("BOOKSHOP_API_KEY", "from-the-parent")
inherited = get_default_environment()
assert "PATH" in inherited
assert "BOOKSHOP_API_KEY" not in inherited
extra = tutorial004.server.env
assert extra is not None
assert (inherited | extra)["BOOKSHOP_API_KEY"] == "secret"
+116
View File
@@ -0,0 +1,116 @@
"""`docs/servers/completions.md`: every claim the page makes, proved against the real SDK."""
import pytest
from inline_snapshot import snapshot
from mcp_types import (
Completion,
CompletionContext,
CompletionsCapability,
ErrorData,
PromptReference,
ResourceTemplateReference,
)
from docs_src.completions import tutorial001, tutorial002, tutorial003
from mcp import Client, MCPError
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
TEMPLATE_REF = ResourceTemplateReference(uri="github://repos/{owner}/{repo}")
PROMPT_REF = PromptReference(name="review_code")
async def test_a_server_with_no_handler_has_no_completions_capability() -> None:
"""tutorial001: there is something worth completing, but no handler and no advertised capability."""
async with Client(tutorial001.mcp) as client:
(template,) = (await client.list_resource_templates()).resource_templates
assert template.uri_template == "github://repos/{owner}/{repo}"
(prompt,) = (await client.list_prompts()).prompts
assert prompt.name == "review_code"
assert client.server_capabilities.completions is None
async def test_completing_without_a_handler_is_method_not_found() -> None:
"""tutorial001: nothing handles `completion/complete`, so the request is a JSON-RPC error."""
async with Client(tutorial001.mcp) as client:
with pytest.raises(MCPError) as excinfo:
await client.complete(ref=PROMPT_REF, argument={"name": "language", "value": "py"})
assert excinfo.value.error == ErrorData(code=-32601, message="Method not found", data="completion/complete")
async def test_registering_the_handler_advertises_the_capability() -> None:
"""tutorial002: `@mcp.completion()` is the whole declaration; the capability is derived from it."""
async with Client(tutorial002.mcp) as client:
assert client.server_capabilities.completions == CompletionsCapability()
async def test_prompt_argument_completion_filters_on_the_typed_prefix() -> None:
"""tutorial002: the handler returns the languages that start with `argument.value`."""
async with Client(tutorial002.mcp) as client:
result = await client.complete(ref=PROMPT_REF, argument={"name": "language", "value": "py"})
assert result.completion == snapshot(Completion(values=["python"]))
async def test_empty_value_returns_every_suggestion() -> None:
"""tutorial002: an empty prefix matches everything, so the client gets the whole list."""
async with Client(tutorial002.mcp) as client:
result = await client.complete(ref=PROMPT_REF, argument={"name": "language", "value": ""})
assert result.completion.values == ["go", "javascript", "python", "rust", "typescript"]
async def test_returning_none_is_an_empty_list_not_an_error() -> None:
"""tutorial002: an argument the handler does not recognise produces `values=[]`, never a failure."""
async with Client(tutorial002.mcp) as client:
result = await client.complete(ref=PROMPT_REF, argument={"name": "code", "value": "x"})
assert result.completion == snapshot(Completion(values=[]))
result = await client.complete(ref=TEMPLATE_REF, argument={"name": "repo", "value": ""})
assert result.completion.values == []
async def test_context_arguments_resolve_a_dependent_parameter() -> None:
"""tutorial003: the already-resolved `owner` arrives in `context.arguments` and picks the repo list."""
async with Client(tutorial003.mcp) as client:
result = await client.complete(
ref=TEMPLATE_REF,
argument={"name": "repo", "value": ""},
context_arguments={"owner": "modelcontextprotocol"},
)
assert result.completion == snapshot(Completion(values=["python-sdk", "typescript-sdk", "inspector"]))
async def test_the_typed_prefix_still_filters_a_dependent_parameter() -> None:
"""tutorial003: `argument.value` narrows the owner's repos exactly as it narrows a prompt argument."""
async with Client(tutorial003.mcp) as client:
result = await client.complete(
ref=TEMPLATE_REF,
argument={"name": "repo", "value": "py"},
context_arguments={"owner": "modelcontextprotocol"},
)
assert result.completion.values == ["python-sdk"]
def test_context_arguments_is_optional() -> None:
"""tutorial003: `context.arguments` is `dict[str, str] | None`; the handler's `None` guard is required."""
assert CompletionContext.model_fields["arguments"].annotation == (dict[str, str] | None)
assert CompletionContext().arguments is None
async def test_no_context_means_no_suggestions() -> None:
"""tutorial003: without a resolved `owner` (or with an unknown one) the handler has nothing to offer."""
async with Client(tutorial003.mcp) as client:
result = await client.complete(ref=TEMPLATE_REF, argument={"name": "repo", "value": ""})
assert result.completion.values == []
result = await client.complete(
ref=TEMPLATE_REF,
argument={"name": "repo", "value": ""},
context_arguments={"owner": "nobody"},
)
assert result.completion.values == []
async def test_the_prompt_branch_is_untouched_by_the_new_one() -> None:
"""tutorial003: adding the resource-template branch leaves prompt-argument completion as it was."""
async with Client(tutorial003.mcp) as client:
result = await client.complete(ref=PROMPT_REF, argument={"name": "language", "value": "type"})
assert result.completion.values == ["typescript"]
+88
View File
@@ -0,0 +1,88 @@
"""`docs/handlers/context.md`: every claim the page makes, proved against the real SDK."""
import re
import pytest
from inline_snapshot import snapshot
from mcp_types import TextContent, TextResourceContents, ToolListChangedNotification
from docs_src.context import tutorial001, tutorial002, tutorial003
from mcp import Client
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_the_context_parameter_is_not_in_the_input_schema() -> None:
"""tutorial001: the injected `Context` never appears in the schema the model sees."""
async with Client(tutorial001.mcp) as client:
(tool,) = (await client.list_tools()).tools
assert tool.input_schema == snapshot(
{
"type": "object",
"properties": {"query": {"title": "Query", "type": "string"}},
"required": ["query"],
"title": "search_booksArguments",
}
)
async def test_every_request_gets_its_own_context() -> None:
"""tutorial001: `ctx.request_id` identifies the request being served, so it changes per call."""
async with Client(tutorial001.mcp) as client:
first = await client.call_tool("search_books", {"query": "dune"})
second = await client.call_tool("search_books", {"query": "dune"})
assert isinstance(first.content[0], TextContent)
assert isinstance(second.content[0], TextContent)
assert re.fullmatch(r"\[request \d+\] Found 3 books matching 'dune'\.", first.content[0].text)
assert first.content[0].text != second.content[0].text
async def test_a_tool_reads_the_servers_own_resource() -> None:
"""tutorial002: `ctx.read_resource` resolves the URI through the same registry `resources/read` uses."""
async with Client(tutorial002.mcp) as client:
result = await client.call_tool("describe_catalog", {})
assert not result.is_error
assert result.content == [
TextContent(type="text", text="The catalog is organised into: fiction, non-fiction, poetry")
]
(contents,) = (await client.read_resource("catalog://genres")).contents
assert isinstance(contents, TextResourceContents)
assert contents.text == "fiction, non-fiction, poetry"
async def test_a_context_only_tool_takes_no_arguments() -> None:
"""tutorial002: a tool whose only parameter is the `Context` has an empty input schema."""
async with Client(tutorial002.mcp) as client:
tools = {tool.name: tool for tool in (await client.list_tools()).tools}
assert tools["describe_catalog"].input_schema == snapshot(
{"type": "object", "properties": {}, "title": "describe_catalogArguments"}
)
async def test_register_a_tool_at_runtime_and_notify_the_client() -> None:
"""tutorial003: `mcp.add_tool` takes effect immediately and `send_tool_list_changed` reaches the client."""
messages: list[object] = []
async def collect(message: object) -> None:
messages.append(message)
async with Client(tutorial003.mcp, mode="legacy", message_handler=collect) as client:
assert [tool.name for tool in (await client.list_tools()).tools] == ["enable_recommendations"]
missing = await client.call_tool("recommend_book", {"genre": "fiction"})
assert missing.is_error
assert missing.content == [TextContent(type="text", text="Unknown tool: recommend_book")]
enabled = await client.call_tool("enable_recommendations", {})
assert enabled.content == [TextContent(type="text", text="Recommendations are now available.")]
assert [tool.name for tool in (await client.list_tools()).tools] == [
"enable_recommendations",
"recommend_book",
]
result = await client.call_tool("recommend_book", {"genre": "fiction"})
assert result.content == [TextContent(type="text", text="In fiction, try 'Dune'.")]
(notification,) = messages
assert isinstance(notification, ToolListChangedNotification)
+158
View File
@@ -0,0 +1,158 @@
"""`docs/handlers/dependencies.md`: every claim the page makes, proved against the real SDK."""
from typing import Literal
import pytest
from inline_snapshot import snapshot
from mcp_types import CreateMessageRequestParams, CreateMessageResult, ElicitRequestParams, ElicitResult, TextContent
from docs_src.dependencies import tutorial001, tutorial002, tutorial003, tutorial004
from mcp import Client
from mcp.client import ClientRequestContext
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_the_resolver_fills_the_parameter_from_the_tools_own_argument() -> None:
"""tutorial001: `check_stock` receives `title` by name and its return value becomes `stock`."""
async with Client(tutorial001.mcp) as client:
in_stock = await client.call_tool("reserve_book", {"title": "Dune"})
sold_out = await client.call_tool("reserve_book", {"title": "Neuromancer"})
assert in_stock.content == [TextContent(type="text", text="Reserved 'Dune' (6 copies left).")]
assert sold_out.content == [TextContent(type="text", text="'Neuromancer' is out of stock.")]
async def test_the_resolved_parameter_is_invisible_to_the_model() -> None:
"""tutorial001: the input schema shown on the page is exactly what `tools/list` reports."""
async with Client(tutorial001.mcp) as client:
(tool,) = (await client.list_tools()).tools
assert tool.input_schema == snapshot(
{
"type": "object",
"properties": {"title": {"title": "Title", "type": "string"}},
"required": ["title"],
"title": "reserve_bookArguments",
}
)
async def test_a_client_supplied_value_for_a_resolved_parameter_is_ignored() -> None:
"""tutorial001: the resolver's value is the only one the tool can receive."""
async with Client(tutorial001.mcp) as client:
result = await client.call_tool("reserve_book", {"title": "Dune", "stock": {"title": "Dune", "copies": 999}})
assert result.content == [TextContent(type="text", text="Reserved 'Dune' (6 copies left).")]
async def test_a_resolver_can_depend_on_another_resolver() -> None:
"""tutorial002: `estimate_delivery` consumes `check_stock`'s result, and the tool gets both."""
async with Client(tutorial002.mcp) as client:
in_stock = await client.call_tool("order_book", {"title": "Dune"})
backorder = await client.call_tool("order_book", {"title": "Neuromancer"})
assert in_stock.content == [TextContent(type="text", text="Ordered 'Dune'; it arrives tomorrow.")]
assert backorder.content == [
TextContent(type="text", text="'Neuromancer' is on backorder; it would arrive in 2-3 weeks.")
]
async def test_a_shared_dependency_runs_once_per_call(monkeypatch: pytest.MonkeyPatch) -> None:
"""tutorial002: `stock` and `delivery` both need `check_stock`; one call, one inventory lookup."""
class CountingInventory:
def __init__(self, data: dict[str, int]) -> None:
self.data = data
self.lookups: list[str] = []
def get(self, key: str, default: int) -> int:
self.lookups.append(key)
return self.data.get(key, default)
inventory = CountingInventory(dict(tutorial002.INVENTORY))
monkeypatch.setattr(tutorial002, "INVENTORY", inventory)
async with Client(tutorial002.mcp) as client:
await client.call_tool("order_book", {"title": "Dune"})
assert inventory.lookups == ["Dune"]
# Memoization is per call, not per server: the next call looks the title up again.
await client.call_tool("order_book", {"title": "Dune"})
assert inventory.lookups == ["Dune", "Dune"]
# The `!!! info` claims the tutorial003 behaviour is transport-independent, so each claim is
# proved on both: mode="legacy" elicits synchronously mid-call (2025-11-25 and earlier), while
# mode="auto" negotiates 2026-07-28, where the question rides a multi-round-trip `tools/call`
# and `Client` drives the retries.
@pytest.mark.parametrize("mode", ["legacy", "auto"])
async def test_an_in_stock_order_asks_no_question(mode: Literal["legacy", "auto"]) -> None:
"""tutorial003: `confirm_backorder` returns directly when stock exists - no round-trip."""
async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover
raise AssertionError("an in-stock order must not elicit")
async with Client(tutorial003.mcp, mode=mode, elicitation_callback=never) as client:
result = await client.call_tool("order_book", {"title": "Dune"})
assert result.content == [TextContent(type="text", text="Ordered 'Dune'.")]
@pytest.mark.parametrize("mode", ["legacy", "auto"])
@pytest.mark.parametrize(
("confirm", "expected"),
[
(True, "Backordered 'Neuromancer'; it ships in 2-3 weeks."),
(False, "No order placed."),
],
)
async def test_an_out_of_stock_order_asks_and_honours_the_answer(
mode: Literal["legacy", "auto"], confirm: bool, expected: str
) -> None:
"""tutorial003: the resolver elicits, the SDK validates the answer, the tool reads it."""
asked: list[str] = []
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
asked.append(params.message)
return ElicitResult(action="accept", content={"confirm": confirm})
async with Client(tutorial003.mcp, mode=mode, elicitation_callback=on_elicit) as client:
result = await client.call_tool("order_book", {"title": "Neuromancer"})
assert result.content == [TextContent(type="text", text=expected)]
assert asked == ["'Neuromancer' is out of stock (2-3 weeks). Order anyway?"]
@pytest.mark.parametrize("mode", ["legacy", "auto"])
async def test_declining_an_unwrapped_dependency_aborts_the_call(mode: Literal["legacy", "auto"]) -> None:
"""tutorial003: no answer, no order - the error text on the page is the real one."""
async def decline(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
return ElicitResult(action="decline")
async with Client(tutorial003.mcp, mode=mode, elicitation_callback=decline) as client:
result = await client.call_tool("order_book", {"title": "Neuromancer"})
assert result.is_error
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == (
"Error executing tool order_book: Resolver for parameter 'backorder' could not resolve: elicitation was decline"
)
@pytest.mark.parametrize("mode", ["legacy", "auto"])
async def test_a_resolver_can_sample_the_clients_llm(mode: Literal["legacy", "auto"]) -> None:
"""tutorial004: `suggest_title` runs through the client's sampling callback on both eras."""
prompts: list[str] = []
async def sampler(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult:
content = params.messages[0].content
assert isinstance(content, TextContent)
prompts.append(content.text)
return CreateMessageResult(role="assistant", content=TextContent(type="text", text="Dune"), model="m")
async with Client(tutorial004.mcp, mode=mode, sampling_callback=sampler) as client:
result = await client.call_tool("recommend_book", {"genre": "sci-fi"})
assert result.content == [TextContent(type="text", text="Today's sci-fi pick: Dune")]
assert prompts == ["Suggest one sci-fi book title. Answer with the title only."]
+230
View File
@@ -0,0 +1,230 @@
"""`docs/run/deploy.md`: every claim the page makes, proved against the real SDK."""
import anyio
import httpx
import pytest
from mcp_types import (
INVALID_PARAMS,
CallToolResult,
ElicitResult,
InputRequiredResult,
ResourceUpdatedNotification,
SubscriptionFilter,
SubscriptionsListenRequest,
SubscriptionsListenRequestParams,
SubscriptionsListenResult,
TextContent,
)
from docs_src.deploy import tutorial001, tutorial002, tutorial003, tutorial004
from mcp import Client, MCPError
from mcp.server import MCPServer
from mcp.server.mcpserver import Context, RequestStateSecurity
from mcp.server.subscriptions import InMemorySubscriptionBus
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
_KEY = "0123456789abcdef0123456789abcdef" # 32 bytes: the smallest secret the SDK accepts.
INITIALIZE = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "b", "version": "1"}},
}
MCP_HEADERS = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"}
# -- the Host allowlist ----------------------------------------------------------------
async def test_the_default_app_rejects_a_real_hostname_before_mcp_runs() -> None:
"""The section's `!!! check`: without `transport_security=`, a deployed hostname gets the page's exact 421."""
bare = MCPServer("Notes")
app = bare.streamable_http_app()
async with bare.session_manager.run():
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="https://api.example.com") as h:
response = await h.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
assert (response.status_code, response.text) == (421, "Invalid Host header")
async def test_the_allowlisted_app_serves_its_hostname_and_still_rejects_others() -> None:
"""tutorial001: `allowed_hosts=` opens exactly the hostname you named, and nothing else."""
transport = httpx.ASGITransport(app=tutorial001.app)
async with tutorial001.mcp.session_manager.run():
async with httpx.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http:
allowed = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
async with httpx.AsyncClient(transport=transport, base_url="https://api.example.com") as http:
rejected = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
assert allowed.status_code == 200
assert allowed.headers["mcp-session-id"]
assert (rejected.status_code, rejected.text) == (421, "Invalid Host header")
# -- `requestState` across workers -----------------------------------------------------
async def _first_round(client: Client, amount: int) -> str:
"""Round one of `refund`: no answers yet, so the server returns the `InputRequiredResult`."""
first = await client.session.call_tool("refund", {"amount": amount}, allow_input_required=True)
assert isinstance(first, InputRequiredResult)
assert first.request_state is not None
return first.request_state
async def _retry(client: Client, amount: int, token: str) -> CallToolResult | InputRequiredResult:
"""The retry: same tool, same arguments, the elicited answer, and the echoed token."""
return await client.session.call_tool(
"refund",
{"amount": amount},
input_responses={"ok": ElicitResult(action="accept", content={"ok": True})},
request_state=token,
allow_input_required=True,
)
def _assert_frozen_rejection(exc: pytest.ExceptionInfo[MCPError]) -> None:
"""The one wire shape every inbound `requestState` verification failure produces."""
assert exc.value.error.code == INVALID_PARAMS
assert exc.value.error.message == "Invalid or expired requestState"
assert exc.value.error.data == {"reason": "invalid_request_state"}
async def test_a_retry_that_reaches_a_different_worker_is_rejected_by_default() -> None:
"""tutorial002: two default servers hold two `os.urandom(32)` keys, so a cross-instance retry is refused."""
worker_a = tutorial002.make_server()
worker_b = tutorial002.make_server()
with anyio.fail_after(5):
async with Client(worker_a) as on_a, Client(worker_b) as on_b:
token = await _first_round(on_a, 120)
with pytest.raises(MCPError) as exc:
await _retry(on_b, 120, token)
# Land back on the worker that minted the token and the identical retry completes.
second = await _retry(on_a, 120, token)
_assert_frozen_rejection(exc)
assert isinstance(second, CallToolResult)
assert second.content == [TextContent(type="text", text="refunded $120")]
async def test_a_refund_the_human_declined_is_not_issued() -> None:
"""tutorial002/003: the second round reads the answer, so anything but an accepted ok is no refund."""
server = tutorial002.make_server()
with anyio.fail_after(5):
async with Client(server) as client:
token = await _first_round(client, 120)
declined = await client.session.call_tool(
"refund",
{"amount": 120},
input_responses={"ok": ElicitResult(action="decline")},
request_state=token,
allow_input_required=True,
)
assert isinstance(declined, CallToolResult)
assert declined.content == [TextContent(type="text", text="refund cancelled")]
async def test_a_shared_key_and_name_let_any_worker_finish_a_round_trip() -> None:
"""tutorial003: instances built with the same key and the same name unseal what a sibling minted."""
worker_a = tutorial003.make_server(_KEY)
worker_b = tutorial003.make_server(_KEY)
with anyio.fail_after(5):
async with Client(worker_a) as on_a, Client(worker_b) as on_b:
token = await _first_round(on_a, 120)
second = await _retry(on_b, 120, token)
assert isinstance(second, CallToolResult)
assert not second.is_error
assert second.content == [TextContent(type="text", text="refunded $120")]
async def test_a_shared_key_is_not_enough_without_a_shared_name() -> None:
"""The `!!! warning`: the server name is the default `audience` claim, so keys alone don't cross instances."""
def named(name: str) -> MCPServer:
mcp = MCPServer(name, request_state_security=RequestStateSecurity(keys=[_KEY]))
@mcp.tool()
async def refund(amount: int, ctx: Context) -> str | InputRequiredResult:
if ctx.input_responses is None:
return InputRequiredResult(input_requests={"ok": tutorial002.CONFIRM}, request_state="pending")
return f"refunded ${amount}"
return mcp
with anyio.fail_after(5):
async with Client(named("billing-1")) as on_one, Client(named("billing-2")) as on_two:
token = await _first_round(on_one, 120)
with pytest.raises(MCPError) as exc:
await _retry(on_two, 120, token)
# Same keys AND the same name: back on the instance that minted it, the retry completes.
second = await _retry(on_one, 120, token)
_assert_frozen_rejection(exc)
assert isinstance(second, CallToolResult)
assert second.content == [TextContent(type="text", text="refunded $120")]
# -- change notifications across replicas ----------------------------------------------
class _Stream:
"""Collects a listen stream's frames and lets the test await arrival counts."""
def __init__(self) -> None:
self.received: list[object] = []
self._arrival = anyio.Event()
async def handler(self, message: object) -> None:
self.received.append(message)
self._arrival.set()
self._arrival = anyio.Event()
async def wait_for(self, count: int) -> None:
with anyio.fail_after(5):
while len(self.received) < count:
await self._arrival.wait()
async def test_one_bus_carries_a_publish_on_one_replica_to_a_stream_on_another() -> None:
"""tutorial004: a `subscriptions/listen` stream on replica A hears a publish that happened on replica B."""
bus = InMemorySubscriptionBus()
replica_a = tutorial004.make_server(bus)
replica_b = tutorial004.make_server(bus)
stream = _Stream()
with anyio.fail_after(10):
await _listen_and_edit(replica_a, replica_b, stream)
async def _listen_and_edit(replica_a: MCPServer, replica_b: MCPServer, stream: _Stream) -> None:
"""Open a listen stream on replica A, edit on replica B, and wait for the update to cross the bus."""
async with (
Client(replica_a, mode="2026-07-28", message_handler=stream.handler) as on_a,
Client(replica_b) as on_b,
):
async with anyio.create_task_group() as tg:
async def listen() -> None:
await on_a.session.send_request(
SubscriptionsListenRequest(
params=SubscriptionsListenRequestParams(
notifications=SubscriptionFilter(resource_subscriptions=["note://todo"])
)
),
SubscriptionsListenResult,
)
tg.start_soon(listen)
await stream.wait_for(1) # the acknowledgment: the stream is live on replica A
await on_b.call_tool("edit_note", {"name": "todo", "text": "water plants"})
await stream.wait_for(2)
updated = stream.received[1]
assert isinstance(updated, ResourceUpdatedNotification)
assert updated.params.uri == "note://todo"
tg.cancel_scope.cancel()
+144
View File
@@ -0,0 +1,144 @@
"""`docs/deprecated.md`: the page's behavioural claims, executed against the live SDK.
This chapter has no `docs_src/` example by design: it is the one page allowed to name
the deprecated methods, and a runnable example would teach exactly what the page tells
the reader not to build. So instead of importing an example, each test here runs a
claim the page states in prose (the warning category and text, the warn-*then*-raise
order on a modern connection, the `ping` removal, and both `filterwarnings` recipes)
so the prose cannot drift away from what the SDK does.
"""
import warnings
import pytest
from mcp_types import CreateMessageRequestParams, CreateMessageResult, SamplingMessage, TextContent
from mcp import Client, MCPDeprecationWarning, MCPError
from mcp.client import ClientRequestContext
from mcp.server import MCPServer
from mcp.server.mcpserver import Context
from mcp.shared.exceptions import NoBackChannelError
pytestmark = pytest.mark.anyio
mcp = MCPServer("Deprecated")
@mcp.tool()
async def ask_model(prompt: str, ctx: Context) -> str:
"""A tool still built on server-initiated sampling."""
result = await ctx.session.create_message( # pyright: ignore[reportDeprecated]
messages=[SamplingMessage(role="user", content=TextContent(type="text", text=prompt))],
max_tokens=8,
)
return str(result.content)
@mcp.tool()
async def old_log(ctx: Context) -> str:
"""A tool still built on protocol logging."""
await ctx.info("hello") # pyright: ignore[reportDeprecated]
return "ok"
async def test_create_message_warns_and_then_raises_on_a_modern_connection() -> None:
"""The `!!! warning`: on a modern connection sampling warns AND THEN the send raises.
The two signals are independent: `@deprecated` fires the moment the method is
called, and only afterwards does the channel refuse the send. The page reports
both, in that order.
"""
async with Client(mcp) as client:
with (
pytest.warns(
MCPDeprecationWarning,
match=r"^The sampling capability is deprecated as of 2026-07-28 \(SEP-2577\)\.$",
),
pytest.raises(NoBackChannelError) as exc,
):
await client.call_tool("ask_model", {"prompt": "hi"})
assert str(exc.value) == (
"Cannot send 'sampling/createMessage': "
"this transport context has no back-channel for server-initiated requests."
)
async def test_a_deprecated_feature_still_works_on_a_legacy_session() -> None:
"""The page's headline: the deprecation is advisory.
On a classic-handshake session, the same `ask_model` tool that fails on a modern
connection runs to completion: sampling round-trips through the client's callback
and the result comes back. The only difference is the visible warning.
"""
async def canned_sampling(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult:
return CreateMessageResult(
role="assistant",
content=TextContent(type="text", text="four"),
model="canned",
stop_reason="endTurn",
)
async with Client(mcp, mode="legacy", sampling_callback=canned_sampling) as client:
with pytest.warns(MCPDeprecationWarning, match=r"The sampling capability is deprecated"):
result = await client.call_tool("ask_model", {"prompt": "What is 2 + 2?"})
assert not result.is_error
[content] = result.content
assert isinstance(content, TextContent)
assert "four" in content.text
async def test_send_ping_still_carries_the_deprecation_warning() -> None:
"""The opening sentence: every retired method carries an `MCPDeprecationWarning`.
`ping` is removed from the 2026-07-28 protocol rather than put in a deprecation
window, but the SDK method is still decorated (its message says *removed*) and
a modern connection answers the actual request with "Method not found".
"""
async with Client(mcp) as client:
with (
pytest.warns(
MCPDeprecationWarning,
match=r"^ping is removed as of 2026-07-28; the method only works under mode='legacy'\.$",
),
pytest.raises(MCPError, match="^Method not found$"),
):
await client.send_ping() # pyright: ignore[reportDeprecated]
def test_mcp_deprecation_warning_is_a_user_warning() -> None:
"""The "Deprecated is advisory" section: the category subclasses `UserWarning`.
Python's default filter hides `DeprecationWarning` outside `__main__`; deriving
from `UserWarning` is what makes the warning visible with no `-W` flag.
"""
assert issubclass(MCPDeprecationWarning, UserWarning)
assert not issubclass(MCPDeprecationWarning, DeprecationWarning)
@pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")
async def test_error_filter_turns_the_deprecated_call_into_the_documented_tool_error() -> None:
"""The `!!! check`: `"error::mcp.MCPDeprecationWarning"` makes `old_log` fail.
Under the error filter the warning becomes the raised exception, the tool manager
wraps it, and the result is exactly the tool error the page quotes.
"""
async with Client(mcp) as client:
result = await client.call_tool("old_log", {})
assert result.is_error
[content] = result.content
assert isinstance(content, TextContent)
assert content.text == (
"Error executing tool old_log: The logging capability is deprecated as of 2026-07-28 (SEP-2577)."
)
async def test_filterwarnings_ignore_silences_the_whole_category() -> None:
"""The "Silencing the warning" snippet: one `filterwarnings` line quiets the category."""
async with Client(mcp) as client:
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
warnings.filterwarnings("ignore", category=MCPDeprecationWarning)
result = await client.call_tool("old_log", {})
assert not result.is_error
assert not any(issubclass(w.category, MCPDeprecationWarning) for w in caught)
+299
View File
@@ -0,0 +1,299 @@
"""`docs/handlers/elicitation.md`: every claim the page makes, proved against the real SDK."""
from typing import Literal
import pytest
from inline_snapshot import snapshot
from mcp_types import (
ElicitCompleteNotification,
ElicitRequestFormParams,
ElicitRequestParams,
ElicitRequestURLParams,
ElicitResult,
TextContent,
)
from pydantic import BaseModel
from docs_src.elicitation import tutorial001, tutorial002, tutorial003, tutorial004
from mcp import Client, MCPError
from mcp.client import ClientRequestContext
from mcp.server import MCPServer
from mcp.server.mcpserver import Context
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_an_accepted_answer_resumes_the_tool() -> None:
"""tutorial001: the user's answer comes back into the same call as a validated model."""
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
return ElicitResult(action="accept", content={"accept_alternative": True, "date": "2025-12-26"})
async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
result = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
assert not result.is_error
assert result.content == [TextContent(type="text", text="Booked a table for 2 on 2025-12-26.")]
async def test_an_alternative_that_is_also_full_is_asked_about_again() -> None:
"""tutorial001: the accepted date goes back through `book_table`, so a full date is re-asked, not booked."""
asked: list[str] = []
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
asked.append(params.message)
date = "2025-12-25" if len(asked) == 1 else "2025-12-27"
return ElicitResult(action="accept", content={"accept_alternative": True, "date": date})
async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
result = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
assert result.content == [TextContent(type="text", text="Booked a table for 2 on 2025-12-27.")]
assert asked == [
"No tables for 2 on 2025-12-25. Would you like to try another date?",
"No tables for 2 on 2025-12-25. Would you like to try another date?",
]
async def test_the_client_receives_the_message_and_the_generated_schema() -> None:
"""tutorial001: form mode sends your message plus a JSON Schema built from the Pydantic model."""
received: list[ElicitRequestParams] = []
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
received.append(params)
return ElicitResult(action="accept", content={"accept_alternative": False})
async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
(params,) = received
assert isinstance(params, ElicitRequestFormParams)
assert params.message == "No tables for 2 on 2025-12-25. Would you like to try another date?"
assert params.requested_schema == snapshot(
{
"properties": {
"accept_alternative": {
"description": "Try another date?",
"title": "Accept Alternative",
"type": "boolean",
},
"date": {
"default": "2025-12-26",
"description": "Alternative date (YYYY-MM-DD)",
"title": "Date",
"type": "string",
},
},
"required": ["accept_alternative"],
"title": "AlternativeDate",
"type": "object",
}
)
async def test_decline_and_cancel_are_ordinary_return_values() -> None:
"""tutorial001: a refusal is not an error; the tool sees the action and answers the model normally."""
async def on_decline(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
return ElicitResult(action="decline")
async def on_cancel(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
return ElicitResult(action="cancel")
async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_decline) as client:
declined = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_cancel) as client:
cancelled = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
assert declined.content == [TextContent(type="text", text="No booking made.")]
assert not declined.is_error
assert cancelled.content == [TextContent(type="text", text="No booking made.")]
async def test_a_tool_that_does_not_ask_needs_nothing_from_the_client() -> None:
"""tutorial001: the elicitation only happens on the path that needs it."""
async with Client(tutorial001.mcp, mode="legacy") as client:
result = await client.call_tool("book_table", {"date": "2025-12-30", "party_size": 4})
assert result.content == [TextContent(type="text", text="Booked a table for 4 on 2025-12-30.")]
async def test_an_answer_that_does_not_match_the_schema_never_reaches_the_tool_code() -> None:
"""`!!! tip`: the client's content is validated against the model; a mismatch fails the call."""
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
return ElicitResult(action="accept", content={"accept_alternative": "maybe"})
async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
result = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
assert result.is_error
assert isinstance(result.content[0], TextContent)
assert "does not match the requested schema" in result.content[0].text
class Address(BaseModel):
city: str
class Applicant(BaseModel):
name: str
address: Address
class Seating(BaseModel):
area: Literal["inside", "terrace"]
schema_gate_server = MCPServer("Bistro")
"""The `!!! warning` claims: what the elicitation schema gate accepts and rejects."""
@schema_gate_server.tool()
async def sign_up(ctx: Context) -> str:
"""Collect the new customer's details."""
return str(await ctx.elicit(message="Who are you?", schema=Applicant))
@schema_gate_server.tool()
async def choose_seating(ctx: Context) -> str:
"""Ask where the party wants to sit."""
result = await ctx.elicit(message="Where would you like to sit?", schema=Seating)
assert result.action == "accept"
return result.data.area
async def test_a_nested_model_is_rejected_before_anything_is_sent() -> None:
"""`!!! warning`: a non-primitive field raises `TypeError` inside `ctx.elicit`, with this exact message."""
async with Client(schema_gate_server, mode="legacy") as client:
result = await client.call_tool("sign_up", {})
assert result.is_error
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == (
"Error executing tool sign_up: Elicitation schema field 'address' rendered as "
"{'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition"
)
async def test_a_literal_field_passes_the_gate_as_an_enum() -> None:
"""`!!! warning`: a `Literal[...]` of strings renders as a JSON Schema `enum`, which the spec allows."""
received: list[ElicitRequestParams] = []
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
received.append(params)
return ElicitResult(action="accept", content={"area": "terrace"})
async with Client(schema_gate_server, mode="legacy", elicitation_callback=on_elicit) as client:
result = await client.call_tool("choose_seating", {})
assert result.content == [TextContent(type="text", text="terrace")]
(params,) = received
assert isinstance(params, ElicitRequestFormParams)
assert params.requested_schema["properties"]["area"] == snapshot(
{"enum": ["inside", "terrace"], "title": "Area", "type": "string"}
)
async def test_url_mode_sends_a_url_and_gets_consent_back_not_data() -> None:
"""tutorial002: the client receives the URL and the elicitation id; only the action comes back."""
received: list[ElicitRequestParams] = []
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
received.append(params)
return ElicitResult(action="accept")
async with Client(tutorial002.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
result = await client.call_tool("pay_deposit", {"booking_id": "b42"})
assert result.content == [TextContent(type="text", text="Complete the payment in your browser.")]
(params,) = received
assert isinstance(params, ElicitRequestURLParams)
assert params.url == "https://pay.example.com/deposit/b42"
assert params.elicitation_id == "deposit-b42"
async def test_a_declined_url_elicitation_is_an_ordinary_return_value() -> None:
"""tutorial002: the tool decides what a refusal means."""
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
return ElicitResult(action="decline")
async with Client(tutorial002.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
result = await client.call_tool("pay_deposit", {"booking_id": "b42"})
assert result.content == [TextContent(type="text", text="No deposit taken. The booking expires in one hour.")]
async def test_send_elicit_complete_notifies_the_client_with_the_same_id() -> None:
"""tutorial002: `send_elicit_complete` emits `notifications/elicitation/complete`."""
notifications: list[object] = []
async def on_message(message: object) -> None:
notifications.append(message)
async with Client(tutorial002.mcp, mode="legacy", message_handler=on_message) as client:
result = await client.call_tool("confirm_deposit", {"booking_id": "b42"})
assert result.content == [TextContent(type="text", text="Deposit received for booking b42.")]
(notification,) = notifications
assert isinstance(notification, ElicitCompleteNotification)
assert notification.params.elicitation_id == "deposit-b42"
async def test_the_docs_client_callback_handles_both_modes() -> None:
"""tutorial003: one `elicitation_callback` answers the form and the URL consent."""
async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=tutorial003.handle_elicitation) as client:
booked = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
async with Client(tutorial002.mcp, mode="legacy", elicitation_callback=tutorial003.handle_elicitation) as client:
paid = await client.call_tool("pay_deposit", {"booking_id": "b42"})
assert booked.content == [TextContent(type="text", text="Booked a table for 2 on 2025-12-27.")]
assert paid.content == [TextContent(type="text", text="Complete the payment in your browser.")]
async def test_a_client_without_the_callback_cannot_be_asked() -> None:
"""`!!! check`: no `elicitation_callback` means no `elicitation` capability; the call is a protocol error."""
async with Client(tutorial001.mcp, mode="legacy") as client:
with pytest.raises(MCPError, match="Elicitation not supported"):
await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
async def test_resolver_asks_only_when_the_folder_is_not_empty() -> None:
"""tutorial004: `confirm_delete` resolves an empty folder directly and elicits otherwise."""
tutorial004._FOLDERS.update({"/tmp/empty": [], "/tmp/project": ["main.py", "README.md"]})
asked: list[str] = []
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
assert isinstance(params, ElicitRequestFormParams)
asked.append(params.message)
return ElicitResult(action="accept", content={"ok": True})
async with Client(tutorial004.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
empty = await client.call_tool("delete_folder", {"path": "/tmp/empty"})
non_empty = await client.call_tool("delete_folder", {"path": "/tmp/project"})
assert empty.content == [TextContent(type="text", text="deleted /tmp/empty")]
assert non_empty.content == [TextContent(type="text", text="deleted /tmp/project")]
assert asked == ["/tmp/project has 2 file(s). Delete anyway?"] # the empty folder was not queried
async def test_the_resolved_parameter_is_hidden_from_the_tool_schema() -> None:
"""tutorial004: the `Resolve`-filled parameter never appears in the client-facing input schema."""
async with Client(tutorial004.mcp, mode="legacy") as client:
(tool,) = (await client.list_tools()).tools
assert tool.name == "delete_folder"
assert set(tool.input_schema["properties"]) == {"path"}
@pytest.mark.parametrize(
("action", "content", "expected"),
[
("accept", {"ok": False}, "kept the folder"),
("decline", None, "declined: folder not deleted"),
("cancel", None, "cancelled: folder not deleted"),
],
)
async def test_the_tool_branches_on_every_elicitation_outcome(
action: Literal["accept", "decline", "cancel"],
content: dict[str, str | int | float | bool | list[str] | None] | None,
expected: str,
) -> None:
"""tutorial004: annotating the result union lets the tool handle accept/decline/cancel."""
tutorial004._FOLDERS["/tmp/project"] = ["main.py", "README.md"]
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
return ElicitResult(action=action, content=content)
async with Client(tutorial004.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
result = await client.call_tool("delete_folder", {"path": "/tmp/project"})
assert result.content == [TextContent(type="text", text=expected)]
+132
View File
@@ -0,0 +1,132 @@
"""`docs/advanced/extensions.md`: every claim the page makes, proved against the real SDK."""
import logging
import pytest
from inline_snapshot import snapshot
from mcp_types import METHOD_NOT_FOUND, MISSING_REQUIRED_CLIENT_CAPABILITY, TextContent
from docs_src.extensions import (
tutorial001,
tutorial002,
tutorial003,
tutorial004,
tutorial005,
tutorial006,
tutorial007,
)
from mcp import Client, MCPError
from mcp.client import advertise
from mcp.server.extension import Extension
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_using_an_extension_advertises_its_capability() -> None:
"""tutorial001: `extensions=[Apps()]` is all it takes for the server to advertise
the extension under `capabilities.extensions`."""
async with Client(tutorial001.mcp) as client:
assert client.server_capabilities.extensions == {"io.modelcontextprotocol/ui": {}}
def test_a_prefixless_identifier_fails_at_class_definition() -> None:
"""tutorial002 + the page's TypeError block: the identifier is validated when the
subclass is defined, with the exact message the page shows."""
assert tutorial002.Stamps.identifier == "com.example/stamps"
with pytest.raises(TypeError) as exc_info:
type("Stamps", (Extension,), {"identifier": "stamps"})
assert str(exc_info.value) == snapshot(
"Stamps.identifier must be a `vendor-prefix/name` string (reverse-DNS prefix required), got 'stamps'"
)
async def test_extension_settings_advertised_under_capabilities() -> None:
"""tutorial003: `settings()` becomes the entry at `capabilities.extensions[identifier]`."""
async with Client(tutorial003.mcp) as client:
assert client.server_capabilities.extensions == {"com.example/stamps": {"sealed": True}}
async def test_contributed_tool_is_listed_and_callable() -> None:
"""tutorial003: a `ToolBinding` registers like any `add_tool` call: listed and callable."""
async with Client(tutorial003.mcp) as client:
listed = await client.list_tools()
assert [tool.name for tool in listed.tools] == ["stamp"]
result = await client.call_tool("stamp", {"text": "hello"})
assert result.content == [TextContent(type="text", text="[stamped] hello")]
async def test_the_stamps_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None:
"""tutorial003: `main()` is the literal client program on the page; both printed
lines match the page's comments."""
await tutorial003.main()
out = capsys.readouterr().out
assert "{'com.example/stamps': {'sealed': True}}" in out
assert "[stamped] hello" in out
async def test_the_search_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None:
"""tutorial004: `main()` declares the extension and gets the vendor method's result."""
await tutorial004.main()
assert "['mcp-0', 'mcp-1', 'mcp-2']" in capsys.readouterr().out
async def test_vendor_method_rejects_a_non_declaring_client_with_32021() -> None:
"""tutorial004: `require_client_extension` answers a non-declaring client with `-32021`
and the machine-readable `requiredCapabilities` payload."""
async with Client(tutorial004.mcp) as client:
request = tutorial004.SearchRequest(params=tutorial004.SearchParams(query="mcp"))
with pytest.raises(MCPError) as exc_info:
await client.session.send_request(request, tutorial004.SearchResult)
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
assert exc_info.value.error.data == {"requiredCapabilities": {"extensions": {"com.example/search": {}}}}
async def test_version_pinned_method_is_not_found_on_a_legacy_connection() -> None:
"""tutorial004: `protocol_versions={"2026-07-28"}` makes the method METHOD_NOT_FOUND
at any other wire version; for a legacy client it doesn't exist."""
async with Client(tutorial004.mcp, mode="legacy", extensions=[advertise(tutorial004.EXTENSION_ID)]) as client:
request = tutorial004.SearchRequest(params=tutorial004.SearchParams(query="mcp"))
with pytest.raises(MCPError) as exc_info:
await client.session.send_request(request, tutorial004.SearchResult)
assert exc_info.value.code == METHOD_NOT_FOUND
async def test_interceptor_observes_the_call_and_passes_the_result_through(
caplog: pytest.LogCaptureFixture,
) -> None:
"""tutorial005: the interceptor logs the tool name and returns `call_next`'s result unchanged."""
with caplog.at_level(logging.INFO, logger=tutorial005.logger.name):
async with Client(tutorial005.mcp) as client:
result = await client.call_tool("add", {"a": 2, "b": 3})
assert result.structured_content == {"result": 5}
messages = [record.getMessage() for record in caplog.records if record.name == tutorial005.logger.name]
assert messages == ["tool 'add' called"]
async def test_the_receipts_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None:
"""tutorial006: `main()` runs as printed and the output is the redeemed result, never the claimed shape."""
await tutorial006.main()
assert "goods for r-117" in capsys.readouterr().out
async def test_a_client_without_the_extension_is_refused_by_the_gate() -> None:
"""The page's off-by-default claim: the server's capability gate refuses a non-declaring client."""
async with Client(tutorial006.mcp) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("buy", {"item": "lamp"})
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
async def test_session_tier_allow_claimed_returns_the_raw_shape() -> None:
"""The page's escape hatch: `allow_claimed=True` returns the parsed claim model, not the resolved result."""
async with Client(tutorial006.mcp, extensions=[tutorial006.Receipts()]) as client:
result = await client.session.call_tool("buy", {"item": "lamp"}, allow_claimed=True)
assert isinstance(result, tutorial006.ReceiptResult)
assert result.receipt_token == "r-117"
async def test_the_jobs_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None:
"""tutorial007: a vendor request with `name_param` round-trips `send_request` with no registration."""
await tutorial007.main()
assert "job-7 is running" in capsys.readouterr().out
+98
View File
@@ -0,0 +1,98 @@
"""`docs/get-started/first-steps.md`: every claim the page makes, proved against the real SDK."""
import pytest
from inline_snapshot import snapshot
from mcp_types import (
PromptArgument,
PromptMessage,
TextContent,
TextResourceContents,
)
from docs_src.first_steps import tutorial001
from mcp import Client
from mcp.server import MCPServer
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_each_decorator_registers_one_primitive() -> None:
"""tutorial001: name, description and schema all come from the decorated function."""
async with Client(tutorial001.mcp) as client:
(tool,) = (await client.list_tools()).tools
assert tool.name == "add"
assert tool.description == "Add two numbers."
assert tool.input_schema == snapshot(
{
"type": "object",
"properties": {
"a": {"title": "A", "type": "integer"},
"b": {"title": "B", "type": "integer"},
},
"required": ["a", "b"],
"title": "addArguments",
}
)
(template,) = (await client.list_resource_templates()).resource_templates
assert template.name == "greeting"
assert template.uri_template == "greeting://{name}"
assert template.description == "Greet someone by name."
(prompt,) = (await client.list_prompts()).prompts
assert prompt.name == "summarize"
assert prompt.description == "Summarize a piece of text in one sentence."
assert prompt.arguments == [PromptArgument(name="text", required=True)]
async def test_call_the_tool() -> None:
"""tutorial001: the Inspector walkthrough. `add` with 1 and 2 answers 3."""
async with Client(tutorial001.mcp) as client:
result = await client.call_tool("add", {"a": 1, "b": 2})
assert not result.is_error
assert result.content == [TextContent(type="text", text="3")]
assert result.structured_content == {"result": 3}
async def test_templated_resource_is_a_template_not_a_resource() -> None:
"""tutorial001: a `{param}` in the URI means the concrete-resource list stays empty."""
async with Client(tutorial001.mcp) as client:
assert (await client.list_resources()).resources == []
async def test_read_the_resource_template() -> None:
"""tutorial001: supplying a `name` reads the template as a concrete resource."""
async with Client(tutorial001.mcp) as client:
result = await client.read_resource("greeting://World")
assert result.contents == [
TextResourceContents(uri="greeting://World", mime_type="text/plain", text="Hello, World!")
]
async def test_get_the_prompt() -> None:
"""tutorial001: the returned string becomes a single user message."""
async with Client(tutorial001.mcp) as client:
result = await client.get_prompt("summarize", {"text": "MCP is a protocol."})
rendered = "Summarize the following text in one sentence:\n\nMCP is a protocol."
assert result.messages == [PromptMessage(role="user", content=TextContent(type="text", text=rendered))]
async def test_the_three_primitive_capabilities_are_always_declared() -> None:
"""tutorial001: `MCPServer` always declares tools/resources/prompts; only `completions` follows your code.
An `MCPServer` with nothing registered declares the same three, which is why the
page ties registration to the *optional* capabilities only.
"""
async with Client(tutorial001.mcp) as client:
declared = client.server_capabilities
# The exact dictionary the page prints from `model_dump(exclude_none=True)`.
assert declared.model_dump(exclude_none=True) == snapshot(
{
"prompts": {"list_changed": True},
"resources": {"subscribe": True, "list_changed": True},
"tools": {"list_changed": True},
}
)
async with Client(MCPServer("Empty")) as client:
assert client.server_capabilities == declared
+86
View File
@@ -0,0 +1,86 @@
"""`docs/servers/handling-errors.md`: every claim the page makes, proved against the real SDK."""
import pytest
from mcp_types import INVALID_PARAMS, ErrorData, TextContent, TextResourceContents
from docs_src.handling_errors import tutorial001, tutorial002, tutorial003
from mcp import Client, MCPError
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_a_plain_exception_becomes_a_tool_error_the_model_reads() -> None:
"""tutorial001: any non-`MCPError` exception comes back as `is_error=True` with the message in `content`."""
async with Client(tutorial001.mcp) as client:
result = await client.call_tool("get_author", {"title": "Nothing"})
assert result.is_error
assert result.content == [
TextContent(type="text", text="Error executing tool get_author: No book titled 'Nothing' in the catalog.")
]
assert result.structured_content is None
async def test_a_title_the_catalog_knows_is_an_ordinary_result() -> None:
"""tutorial001: the non-raising path is a plain `is_error=False` result."""
async with Client(tutorial001.mcp) as client:
result = await client.call_tool("get_author", {"title": "Dune"})
assert not result.is_error
assert result.structured_content == {"result": "Frank Herbert"}
async def test_a_bad_argument_never_reaches_the_function() -> None:
"""tutorial001: schema validation rejects the call before `get_author` runs, as the same kind of tool error."""
async with Client(tutorial001.mcp) as client:
result = await client.call_tool("get_author", {"title": 42})
assert result.is_error
assert isinstance(result.content[0], TextContent)
assert "Input should be a valid string" in result.content[0].text
async def test_mcp_error_makes_the_call_itself_fail() -> None:
"""tutorial002: `MCPError` is not caught. It surfaces as a JSON-RPC error, with `code` and `message` intact."""
async with Client(tutorial002.mcp) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("get_author", {"title": "Nothing"})
assert exc_info.value.code == INVALID_PARAMS
assert exc_info.value.message == "No book titled 'Nothing' in the catalog."
async def test_mcp_error_only_fires_on_the_raising_path() -> None:
"""tutorial002: a title the catalog knows still returns a normal result."""
async with Client(tutorial002.mcp) as client:
result = await client.call_tool("get_author", {"title": "Dune"})
assert not result.is_error
assert result.structured_content == {"result": "Frank Herbert"}
async def test_resource_not_found_error_maps_to_invalid_params() -> None:
"""tutorial003: `ResourceNotFoundError` from a template handler is `-32602` with the URI in `data`."""
async with Client(tutorial003.mcp) as client:
with pytest.raises(MCPError) as exc_info:
await client.read_resource("books://Nothing")
assert exc_info.value.error == ErrorData(
code=INVALID_PARAMS,
message="No book titled 'Nothing' in the catalog.",
data={"uri": "books://Nothing"},
)
async def test_raise_exceptions_does_not_turn_a_tool_error_into_a_traceback() -> None:
"""The closing `!!! info`: even `raise_exceptions=True` leaves a failing tool as the `is_error=True` result."""
async with Client(tutorial001.mcp, raise_exceptions=True) as client:
result = await client.call_tool("get_author", {"title": "Nothing"})
assert result.is_error
assert result.content == [
TextContent(type="text", text="Error executing tool get_author: No book titled 'Nothing' in the catalog.")
]
async def test_a_title_the_template_knows_reads_normally() -> None:
"""tutorial003: the non-raising path resolves the template and returns text contents."""
async with Client(tutorial003.mcp) as client:
result = await client.read_resource("books://Dune")
(contents,) = result.contents
assert isinstance(contents, TextResourceContents)
assert contents.text == "Dune by Frank Herbert"
+196
View File
@@ -0,0 +1,196 @@
"""`docs/client/identity-assertion.md`: every claim the page makes, proved against the real SDK."""
import inspect
from urllib.parse import parse_qsl
import httpx
import jwt
import pytest
from inline_snapshot import snapshot
from pydantic import AnyHttpUrl
from starlette.applications import Starlette
from docs_src.identity_assertion import tutorial001, tutorial002
from docs_src.oauth_clients import tutorial001 as oauth_clients_tutorial001
from mcp import Client
from mcp.client.auth import OAuthClientProvider
from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider
from mcp.client.streamable_http import streamable_http_client
from mcp.server import MCPServer
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import IdentityAssertionParams, ProviderTokenVerifier, TokenError
from mcp.server.auth.settings import AuthSettings
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
MCP_SERVER_URL = "http://localhost:8001/mcp"
class RecordingASGITransport(httpx.ASGITransport):
"""An `httpx.ASGITransport` that appends every (method, path, body) it carries to a shared log."""
def __init__(self, app: Starlette, log: list[tuple[str, str, bytes]]) -> None:
super().__init__(app=app)
self.log = log
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
self.log.append((request.method, request.url.path, request.content))
return await super().handle_async_request(request)
async def test_the_provider_is_an_httpx_auth_but_not_an_oauth_client_provider() -> None:
"""tutorial001: same `auth=` slot as the rest of OAuth clients, but nothing is discovered or registered."""
assert isinstance(tutorial001.oauth, httpx.Auth)
assert not isinstance(tutorial001.oauth, OAuthClientProvider)
async def test_main_is_the_main_from_the_oauth_clients_page() -> None:
"""The page says `main()` is unchanged to the character from the OAuth clients page."""
assert inspect.getsource(tutorial001.main) == inspect.getsource(oauth_clients_tutorial001.main)
async def test_a_client_secret_is_required() -> None:
"""tutorial001: the provider refuses to be constructed as a public client."""
with pytest.raises(ValueError, match="client_secret is required"):
IdentityAssertionOAuthProvider(
server_url=MCP_SERVER_URL,
storage=tutorial001.InMemoryTokenStorage(),
client_id="finance-agent",
client_secret="",
issuer=tutorial002.ISSUER,
assertion_provider=tutorial001.fetch_id_jag,
)
async def test_an_issuer_is_required() -> None:
"""tutorial001: the authorization server is configuration, not discovery."""
with pytest.raises(ValueError, match="issuer is required"):
IdentityAssertionOAuthProvider(
server_url=MCP_SERVER_URL,
storage=tutorial001.InMemoryTokenStorage(),
client_id="finance-agent",
client_secret="finance-agent-secret",
issuer="",
assertion_provider=tutorial001.fetch_id_jag,
)
async def test_the_id_jag_is_a_typed_jwt_carrying_the_claims_the_page_lists() -> None:
"""tutorial001: the stand-in IdP signs a real ID-JAG; its header `typ` and claim set are the extension's."""
assertion = tutorial001.idp_issue_id_jag("alice@example.com", tutorial002.ISSUER, MCP_SERVER_URL)
assert jwt.get_unverified_header(assertion)["typ"] == "oauth-id-jag+jwt"
claims = jwt.decode(assertion, tutorial001.IDP_SIGNING_KEY, algorithms=["HS256"], audience=tutorial002.ISSUER)
assert list(claims) == snapshot(["iss", "sub", "aud", "client_id", "resource", "scope", "jti", "iat", "exp"])
assert claims["client_id"] == "finance-agent"
assert claims["resource"] == MCP_SERVER_URL
async def test_a_forged_assertion_is_rejected() -> None:
"""tutorial002: the signature check fails closed with `invalid_grant`."""
client = tutorial002.REGISTERED_CLIENTS["finance-agent"]
with pytest.raises(TokenError) as exc_info:
await tutorial002.provider.exchange_identity_assertion(
client, IdentityAssertionParams(assertion="not-an-id-jag")
)
assert exc_info.value.error == "invalid_grant"
assert exc_info.value.error_description == "the assertion did not verify"
async def test_an_assertion_for_another_audience_is_rejected() -> None:
"""tutorial002: an ID-JAG whose `aud` is not this authorization server is `invalid_grant`."""
client = tutorial002.REGISTERED_CLIENTS["finance-agent"]
assertion = tutorial001.idp_issue_id_jag("alice@example.com", "https://other.example.com/", MCP_SERVER_URL)
with pytest.raises(TokenError) as exc_info:
await tutorial002.provider.exchange_identity_assertion(client, IdentityAssertionParams(assertion=assertion))
assert exc_info.value.error == "invalid_grant"
assert exc_info.value.error_description == "the assertion did not verify"
async def test_an_assertion_for_an_unknown_resource_is_rejected() -> None:
"""tutorial002: an ID-JAG naming a resource this server does not serve is `invalid_target`."""
client = tutorial002.REGISTERED_CLIENTS["finance-agent"]
assertion = tutorial001.idp_issue_id_jag("alice@example.com", tutorial002.ISSUER, "https://other.example.com/mcp")
with pytest.raises(TokenError) as exc_info:
await tutorial002.provider.exchange_identity_assertion(client, IdentityAssertionParams(assertion=assertion))
assert exc_info.value.error == "invalid_target"
assert exc_info.value.error_description == "the assertion is for a resource this server does not serve"
async def test_a_replayed_assertion_is_rejected() -> None:
"""tutorial002: `jti` is tracked, so presenting the same ID-JAG twice fails the second time."""
client = tutorial002.REGISTERED_CLIENTS["finance-agent"]
assertion = tutorial001.idp_issue_id_jag("alice@example.com", tutorial002.ISSUER, MCP_SERVER_URL)
params = IdentityAssertionParams(assertion=assertion)
first = await tutorial002.provider.exchange_identity_assertion(client, params)
assert first.token_type == "Bearer"
with pytest.raises(TokenError) as exc_info:
await tutorial002.provider.exchange_identity_assertion(client, params)
assert exc_info.value.error == "invalid_grant"
assert exc_info.value.error_description == "the assertion has already been used"
async def test_the_metadata_advertises_the_grant_type_and_the_id_jag_profile() -> None:
"""tutorial002: the flag turns on both the `jwt-bearer` grant type and the grant-profile advertisement."""
transport = httpx.ASGITransport(app=tutorial002.auth_app)
async with httpx.AsyncClient(transport=transport, base_url="https://auth.example.com") as http_client:
response = await http_client.get("/.well-known/oauth-authorization-server")
assert response.status_code == 200
metadata = response.json()
assert metadata["issuer"] == "https://auth.example.com/"
assert "urn:ietf:params:oauth:grant-type:jwt-bearer" in metadata["grant_types_supported"]
assert metadata["authorization_grant_profiles_supported"] == ["urn:ietf:params:oauth:grant-profile:id-jag"]
async def test_the_whole_grant_is_one_token_request() -> None:
"""The `!!! check`: a 401, the well-known fetch, one `POST /token`, the retry; the subject reaches the tool."""
mcp = MCPServer(
"Notes",
token_verifier=ProviderTokenVerifier(tutorial002.provider),
auth=AuthSettings(
issuer_url=AnyHttpUrl(tutorial002.ISSUER),
resource_server_url=AnyHttpUrl(MCP_SERVER_URL),
required_scopes=["notes:read"],
),
)
@mcp.tool()
def whoami() -> str:
"""Report which end user the ID-JAG named."""
token = get_access_token()
assert token is not None
assert token.subject is not None
return f"{token.subject} ({', '.join(token.scopes)})"
log: list[tuple[str, str, bytes]] = []
transport = RecordingASGITransport(mcp.streamable_http_app(), log)
mounts = {"https://auth.example.com": RecordingASGITransport(tutorial002.auth_app, log)}
async with mcp.session_manager.run():
async with (
httpx.AsyncClient(auth=tutorial001.oauth, transport=transport, mounts=mounts) as http_client,
Client(streamable_http_client(MCP_SERVER_URL, http_client=http_client)) as client,
):
result = await client.call_tool("whoami", {})
assert result.structured_content == {"result": "alice@example.com (notes:read)"}
assert [(method, path) for method, path, _ in log] == snapshot(
[
("POST", "/mcp"),
("GET", "/.well-known/oauth-authorization-server"),
("POST", "/token"),
("POST", "/mcp"),
("POST", "/mcp"),
("POST", "/mcp"),
]
)
token_request = dict(parse_qsl(log[2][2].decode()))
assert sorted(token_request) == snapshot(
["assertion", "client_id", "client_secret", "grant_type", "resource", "scope"]
)
assert token_request["grant_type"] == "urn:ietf:params:oauth:grant-type:jwt-bearer"
assert token_request["client_id"] == "finance-agent"
assert token_request["resource"] == MCP_SERVER_URL
assert token_request["scope"] == "notes:read"
assert jwt.get_unverified_header(token_request["assertion"]) == snapshot(
{"alg": "HS256", "typ": "oauth-id-jag+jwt"}
)
+31
View File
@@ -0,0 +1,31 @@
"""`docs/index.md`: the landing-page server does exactly what the page says it does."""
import pytest
from inline_snapshot import snapshot
from mcp_types import CallToolResult, TextContent, TextResourceContents
from docs_src.index.tutorial001 import mcp
from mcp import Client
# `pyproject.toml` globally downgrades `mcp.MCPDeprecationWarning` to *ignore* because the
# SDK still calls those methods internally. A documentation example must never lean on
# that allowance, so every test that runs one re-arms the warning as an error. This is a
# per-module mark, not a conftest hook, because `pytest_collection_modifyitems` receives
# every item in the session. A hook here would break unrelated tests across the repo.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_add_tool() -> None:
async with Client(mcp) as client:
result = await client.call_tool("add", {"a": 1, "b": 2})
assert result == snapshot(
CallToolResult(content=[TextContent(type="text", text="3")], structured_content={"result": 3})
)
async def test_greeting_resource_template() -> None:
async with Client(mcp) as client:
result = await client.read_resource("greeting://World")
assert result.contents == snapshot(
[TextResourceContents(uri="greeting://World", mime_type="text/plain", text="Hello, World!")]
)
+136
View File
@@ -0,0 +1,136 @@
"""`docs/run/legacy-clients.md`: every claim the page makes, proved against the real SDK."""
import inspect
import httpx
import pytest
from mcp_types import INVALID_REQUEST, ResourceUpdatedNotification, TextContent
from docs_src.legacy_clients import tutorial001, tutorial002, tutorial003
from mcp import Client, MCPError
from mcp.client.streamable_http import streamable_http_client
from mcp.server import MCPServer
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
INITIALIZE = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {"protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": {"name": "b", "version": "1"}},
}
LIST_TOOLS = {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}
MCP_HEADERS = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"}
URL = "http://localhost:8000/mcp"
async def test_one_resolve_tool_serves_a_legacy_and_a_modern_client_at_once(
capsys: pytest.CaptureFixture[str],
) -> None:
"""tutorial001's `main()`, exactly as the page renders it: two eras of client, one server, one answer."""
await tutorial001.main()
assert capsys.readouterr().out == (
"""2025-11-25 {'result': "Reserved 2 of 'Dune'."}\n2026-07-28 {'result': "Reserved 2 of 'Dune'."}\n"""
)
async def test_neither_era_of_client_sees_the_resolved_parameter() -> None:
"""tutorial001: there is one tool schema. The `Resolve`-filled parameter is hidden from both eras."""
async with Client(tutorial001.mcp, mode="legacy") as legacy, Client(tutorial001.mcp) as modern:
for client in (legacy, modern):
(tool,) = (await client.list_tools()).tools
assert set(tool.input_schema["properties"]) == {"title"}
def test_streamable_http_app_has_no_era_knob() -> None:
"""The opener: nothing in `streamable_http_app()`'s signature selects, rejects, or configures an era."""
parameters = set(inspect.signature(MCPServer.streamable_http_app).parameters) - {"self"}
assert parameters == {
"streamable_http_path",
"json_response",
"stateless_http",
"event_store",
"retry_interval",
"transport_security",
"host",
}
async def test_a_legacy_session_is_minted_in_process_and_a_stray_session_id_is_a_404() -> None:
"""The cost section: a legacy `initialize` gets an `Mcp-Session-Id`, and a request naming a session
this process never minted gets a `404`. That miss is exactly what a load balancer without sticky
routing produces."""
app = MCPServer("Bookshop").streamable_http_app()
async with (
app.router.lifespan_context(app),
httpx.ASGITransport(app) as transport,
httpx.AsyncClient(transport=transport, base_url="http://localhost:8000") as http,
):
opened = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
assert opened.status_code == 200
assert opened.headers["mcp-session-id"]
stray = await http.post("/mcp", json=LIST_TOOLS, headers={**MCP_HEADERS, "Mcp-Session-Id": 32 * "f"})
assert stray.status_code == 404
async def test_stateless_http_never_mints_a_session() -> None:
"""The `stateless_http=True` section: the same legacy `initialize` no longer gets an `Mcp-Session-Id`."""
app = MCPServer("Bookshop").streamable_http_app(stateless_http=True)
async with (
app.router.lifespan_context(app),
httpx.ASGITransport(app) as transport,
httpx.AsyncClient(transport=transport, base_url="http://localhost:8000") as http,
):
opened = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
assert opened.status_code == 200
assert "mcp-session-id" not in opened.headers
async def test_stateless_http_kills_the_legacy_back_channel_and_only_the_legacy_one() -> None:
"""tutorial002: over the same `stateless_http=True` app, the modern client still gets its answer and
the legacy client's call fails as the top-level `MCPError` the `!!! check` quotes."""
async with (
tutorial002.app.router.lifespan_context(tutorial002.app),
httpx.ASGITransport(tutorial002.app) as transport,
httpx.AsyncClient(transport=transport) as http,
):
modern_target = streamable_http_client(URL, http_client=http)
async with Client(modern_target, elicitation_callback=tutorial001.answer) as modern:
assert modern.protocol_version == "2026-07-28"
result = await modern.call_tool("reserve", {"title": "Dune"})
assert result.content == [TextContent(type="text", text="Reserved 2 of 'Dune'.")]
legacy_target = streamable_http_client(URL, http_client=http)
async with Client(legacy_target, mode="legacy", elicitation_callback=tutorial001.answer) as legacy:
assert legacy.protocol_version == "2025-11-25"
with pytest.raises(MCPError) as exc_info: # pragma: no branch
await legacy.call_tool("reserve", {"title": "Dune"})
assert exc_info.value.error.code == INVALID_REQUEST
assert exc_info.value.error.message == (
"Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests."
)
async def test_the_legacy_notification_verb_reaches_a_legacy_client() -> None:
"""tutorial003: `ctx.session.send_resource_updated` lands on the legacy client's standalone stream."""
received: list[object] = []
async def on_message(message: object) -> None:
received.append(message)
async with Client(tutorial003.mcp, mode="legacy", message_handler=on_message) as client:
result = await client.call_tool("restock", {"title": "Dune", "copies": 2})
assert not result.is_error
(notification,) = received
assert isinstance(notification, ResourceUpdatedNotification)
assert notification.params.uri == "stock://Dune"
async def test_calling_both_notification_verbs_is_safe_on_both_eras() -> None:
"""tutorial003: the two-line fork never errors, whichever era the caller is on."""
async with Client(tutorial003.mcp, mode="legacy") as legacy, Client(tutorial003.mcp) as modern:
for client in (legacy, modern):
result = await client.call_tool("restock", {"title": "Dune", "copies": 1})
assert not result.is_error
+113
View File
@@ -0,0 +1,113 @@
"""`docs/handlers/lifespan.md`: every claim the page makes, proved against the real SDK."""
import pytest
from inline_snapshot import snapshot
from mcp_types import TextContent, TextResourceContents
from docs_src.lifespan import tutorial001, tutorial002
from mcp import Client, MCPError
from mcp.server import MCPServer
from mcp.server.mcpserver import Context
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_lifespan_object_reaches_the_tool() -> None:
"""tutorial001: the object the lifespan yields is `ctx.request_context.lifespan_context`."""
async with Client(tutorial001.mcp) as client:
result = await client.call_tool("count_books", {"genre": "poetry"})
assert not result.is_error
assert result.content == [TextContent(type="text", text="3 books in 'poetry'.")]
assert result.structured_content == {"result": "3 books in 'poetry'."}
async def test_context_parameter_never_reaches_the_input_schema() -> None:
"""tutorial001: `ctx` is injected by the SDK, so `genre` is the only argument the model sees."""
async with Client(tutorial001.mcp) as client:
(tool,) = (await client.list_tools()).tools
assert tool.input_schema == snapshot(
{
"type": "object",
"properties": {"genre": {"title": "Genre", "type": "string"}},
"required": ["genre"],
"title": "count_booksArguments",
}
)
async def test_startup_runs_before_the_first_request_and_shutdown_after_the_last() -> None:
"""tutorial002: `connect()` runs at startup, the `finally` runs `disconnect()` at shutdown."""
assert not tutorial002.database.connected
async with Client(tutorial002.mcp) as client:
assert tutorial002.database.connected
result = await client.call_tool("database_status", {})
assert result.structured_content == {"result": "connected"}
assert not tutorial002.database.connected
async def test_bare_context_reaches_the_lifespan_object_in_resources_and_prompts() -> None:
"""A resource or prompt declaring a bare `ctx: Context` gets the same lifespan object a tool gets."""
mcp = MCPServer("Bookshop", lifespan=tutorial001.app_lifespan)
@mcp.resource("books://{genre}/count")
def genre_count(genre: str, ctx: Context) -> str:
"""Count the books in a genre."""
app = ctx.request_context.lifespan_context
assert isinstance(app, tutorial001.AppContext)
return f"{app.db.query()} books in {genre!r}."
@mcp.prompt()
def stock_report(ctx: Context) -> str:
"""Ask for a stock report."""
app = ctx.request_context.lifespan_context
assert isinstance(app, tutorial001.AppContext)
return f"Summarise a shelf of {app.db.query()} books."
async with Client(mcp) as client:
resource = await client.read_resource("books://poetry/count")
assert resource.contents == [
TextResourceContents(uri="books://poetry/count", mime_type="text/plain", text="3 books in 'poetry'.")
]
prompt = await client.get_prompt("stock_report")
(message,) = prompt.messages
assert message.content == TextContent(type="text", text="Summarise a shelf of 3 books.")
async def test_parameterized_context_is_tool_only(caplog: pytest.LogCaptureFixture) -> None:
"""`Context[AppContext]` on a resource or prompt fails every call; the server logs the `ValueError`."""
mcp = MCPServer("Bookshop", lifespan=tutorial001.app_lifespan)
@mcp.resource("books://{genre}/count")
def genre_count(genre: str, ctx: Context[tutorial001.AppContext]) -> str:
"""Count the books in a genre."""
return f"{ctx.request_context.lifespan_context.db.query()} books in {genre!r}."
@mcp.prompt()
def stock_report(ctx: Context[tutorial001.AppContext]) -> str:
"""Ask for a stock report."""
return f"Summarise a shelf of {ctx.request_context.lifespan_context.db.query()} books."
async with Client(mcp) as client:
with pytest.raises(MCPError, match="Error creating resource from template"):
await client.read_resource("books://poetry/count")
assert "ValueError: Context is not available outside of a request" in caplog.text
caplog.clear()
with pytest.raises(MCPError):
await client.get_prompt("stock_report")
assert "ValueError: Context is not available outside of a request" in caplog.text
async def test_default_lifespan_yields_an_empty_dict() -> None:
"""No `lifespan=`: the SDK's default yields `{}`, so `lifespan_context` is never `None`."""
bare = MCPServer("Bare")
@bare.tool()
def show(ctx: Context) -> str:
"""Show the lifespan context."""
return repr(ctx.request_context.lifespan_context)
async with Client(bare) as client:
result = await client.call_tool("show", {})
assert result.structured_content == {"result": "{}"}
+62
View File
@@ -0,0 +1,62 @@
"""`docs/handlers/logging.md`: every claim the page makes, proved against the real SDK."""
import logging
import pytest
from inline_snapshot import snapshot
from mcp_types import CallToolResult, TextContent
from docs_src.logging import tutorial001
from mcp import Client
from mcp.server import MCPServer
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_the_tool_logs_through_the_standard_library(caplog: pytest.LogCaptureFixture) -> None:
"""tutorial001: `logger.info(...)` inside a tool emits an ordinary stdlib record named after the module."""
caplog.set_level(logging.INFO)
async with Client(tutorial001.mcp) as client:
await client.call_tool("search_books", {"query": "dune"})
(record,) = list(filter(lambda r: r.name == tutorial001.logger.name, caplog.records))
assert record.levelname == "INFO"
assert record.getMessage() == "Searching for 'dune'"
async def test_the_log_line_never_reaches_the_client() -> None:
"""tutorial001: the result is only the return value. Log output is invisible to the model."""
async with Client(tutorial001.mcp) as client:
result = await client.call_tool("search_books", {"query": "dune"})
assert result == snapshot(
CallToolResult(
content=[TextContent(type="text", text="Found 3 books matching 'dune'.")],
structured_content={"result": "Found 3 books matching 'dune'."},
)
)
def test_log_level_configures_the_root_logger() -> None:
"""`MCPServer(log_level=...)` calls `logging.basicConfig()` when nothing has configured logging yet."""
root = logging.getLogger()
handlers, level = root.handlers[:], root.level
root.handlers = []
try:
MCPServer("Bookshop", log_level="DEBUG")
assert root.level == logging.DEBUG
assert len(root.handlers) == 1
finally:
root.handlers, root.level = handlers, level
def test_an_existing_logging_configuration_wins() -> None:
"""`logging.basicConfig()` is a no-op once a handler is installed, so your own setup is not overridden."""
root = logging.getLogger()
handlers, level = root.handlers[:], root.level
root.handlers, root.level = [logging.NullHandler()], logging.WARNING
try:
MCPServer("Bookshop", log_level="DEBUG")
assert root.level == logging.WARNING
assert len(root.handlers) == 1
finally:
root.handlers, root.level = handlers, level
+143
View File
@@ -0,0 +1,143 @@
"""`docs/advanced/low-level-server.md`: every claim the page makes, proved against the real SDK."""
import pytest
from inline_snapshot import snapshot
from mcp_types import INTERNAL_ERROR, CallToolRequestParams, CallToolResult, ErrorData, RequestParams, TextContent
from docs_src.lowlevel import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005, tutorial006
from mcp import Client, MCPError
from mcp.server import Server, ServerRequestContext
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_the_input_schema_on_the_wire_is_the_dict_you_wrote() -> None:
"""tutorial001: nothing is derived. `tools/list` returns the literal `input_schema` dict."""
async with Client(tutorial001.server) as client:
(tool,) = (await client.list_tools()).tools
assert tool.name == "search_books"
assert tool.description == "Search the catalog by title or author."
assert tool.input_schema == snapshot(
{
"type": "object",
"properties": {"query": {"type": "string"}, "limit": {"type": "integer"}},
"required": ["query", "limit"],
}
)
assert tool.output_schema is None
async def test_the_client_does_not_care_which_server_class_it_connects_to() -> None:
"""tutorial001: `Client(server)` accepts a low-level `Server` and the call answers like **Tools**."""
async with Client(tutorial001.server) as client:
result = await client.call_tool("search_books", {"query": "dune", "limit": 5})
assert not result.is_error
assert result.content == [TextContent(type="text", text="Found 3 books matching 'dune' (showing up to 5).")]
assert result.structured_content is None
async def test_only_the_handlers_you_passed_become_capabilities() -> None:
"""tutorial001: two tool handlers advertise `tools` and nothing else."""
async with Client(tutorial001.server) as client:
assert client.server_capabilities.model_dump(exclude_none=True) == snapshot({"tools": {"list_changed": False}})
async def test_arguments_are_not_validated_against_your_schema() -> None:
"""tutorial001: a call missing a `required` argument still reaches the handler and blows up there."""
async with Client(tutorial001.server) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("search_books", {"query": "dune"})
assert exc_info.value.error == ErrorData(code=INTERNAL_ERROR, message="Internal server error", data=None)
async def test_one_handler_routes_every_tool() -> None:
"""tutorial002: `on_call_tool` is the single entry point; it dispatches on `params.name`."""
async with Client(tutorial002.server) as client:
assert [tool.name for tool in (await client.list_tools()).tools] == ["search_books", "add_book"]
result = await client.call_tool("add_book", {"title": "Dune", "author": "Frank Herbert", "year": 1965})
assert result.content == [TextContent(type="text", text="Added 'Dune' by Frank Herbert (1965).")]
async def test_an_unknown_tool_name_becomes_a_protocol_error_not_a_tool_error() -> None:
"""tutorial002: raising from a handler is a `-32603` JSON-RPC error, never an `is_error` result."""
async with Client(tutorial002.server) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("does_not_exist", {})
assert exc_info.value.error == ErrorData(code=INTERNAL_ERROR, message="Internal server error", data=None)
async def test_output_schema_and_structured_content_are_both_yours_to_build() -> None:
"""tutorial003: you declare the schema on the `Tool` and you build the matching payload."""
async with Client(tutorial003.server) as client:
(tool,) = (await client.list_tools()).tools
assert tool.output_schema == snapshot(
{
"type": "object",
"properties": {"matches": {"type": "integer"}, "query": {"type": "string"}},
"required": ["matches", "query"],
}
)
result = await client.call_tool("search_books", {"query": "dune", "limit": 5})
assert result.content == [TextContent(type="text", text="Found 3 books matching 'dune'.")]
assert result.structured_content == {"matches": 3, "query": "dune"}
async def test_the_client_checks_the_schema_you_promised() -> None:
"""The page's warning: a `structured_content` that violates your `output_schema` fails in `call_tool`."""
async def promise_breaker(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
return CallToolResult(content=[TextContent(type="text", text="oops")], structured_content={"matches": "three"})
lying = Server("Bookshop", on_list_tools=tutorial003.list_tools, on_call_tool=promise_breaker)
async with Client(lying) as client:
with pytest.raises(RuntimeError, match="Invalid structured content returned by tool search_books"):
await client.call_tool("search_books", {"query": "dune", "limit": 5})
async def test_meta_reaches_the_client_application() -> None:
"""tutorial004: `_meta=` on the result comes back as `result.meta` and serialises under `_meta`."""
async with Client(tutorial004.server) as client:
result = await client.call_tool("search_books", {"query": "dune", "limit": 5})
assert result.meta == {"bookshop/record_ids": ["bk_17", "bk_42", "bk_99"]}
assert result.model_dump(by_alias=True, exclude_none=True) == snapshot(
{
"_meta": {"bookshop/record_ids": ["bk_17", "bk_42", "bk_99"]},
"content": [{"type": "text", "text": "Found 3 books matching 'dune'."}],
"structuredContent": {"matches": 3, "query": "dune"},
"isError": False,
"resultType": "complete",
}
)
async def test_the_lifespan_object_reaches_every_handler_with_its_type() -> None:
"""tutorial005: what the lifespan yields is `ctx.lifespan_context`, typed by `Server[Catalog]`."""
async with Client(tutorial005.server) as client:
result = await client.call_tool("search_books", {"query": "dune"})
assert result.content == [TextContent(type="text", text="Found 3 books: Dune, Dune Messiah, Children of Dune.")]
async def test_add_request_handler_registers_a_method_the_constructor_does_not_know() -> None:
"""tutorial006: the registry holds the handler and the params model it validates against."""
entry = tutorial006.server.get_request_handler("bookshop/reindex")
assert entry is not None
assert entry.params_type is tutorial006.ReindexParams
assert tutorial006.server.get_request_handler("bookshop/burn") is None
async def test_a_custom_method_never_changes_the_advertised_capabilities() -> None:
"""tutorial006: only the spec's method families map to capabilities. `bookshop/reindex` is invisible."""
async with Client(tutorial006.server) as client:
assert client.server_capabilities.model_dump(exclude_none=True) == snapshot({"tools": {"list_changed": False}})
def test_initialize_is_reserved() -> None:
"""The page's `ValueError`: the handshake belongs to the runner, not to `add_request_handler`."""
server = Server("Bookshop")
async def grab_the_handshake(ctx: ServerRequestContext, params: RequestParams) -> None:
raise NotImplementedError
with pytest.raises(ValueError, match="'initialize' is handled by the server runner"):
server.add_request_handler("initialize", RequestParams, grab_the_handshake)
+62
View File
@@ -0,0 +1,62 @@
"""`docs/servers/media.md`: every claim the page makes, proved against the real SDK."""
import base64
import pytest
from mcp_types import AudioContent, Icon, ImageContent
from docs_src.media import tutorial001, tutorial002, tutorial003
from mcp import Client
from mcp.server.mcpserver import Audio, Image
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_image_return_becomes_an_image_content_block() -> None:
"""tutorial001: `-> Image` reaches the client as a base64 `ImageContent` block, not text."""
async with Client(tutorial001.mcp) as client:
result = await client.call_tool("logo", {})
assert not result.is_error
assert result.content == [
ImageContent(type="image", data=base64.b64encode(tutorial001.LOGO_PNG).decode(), mime_type="image/png")
]
async def test_image_result_has_no_structured_content_and_no_output_schema() -> None:
"""tutorial001: media is content for the model, not data for the application."""
async with Client(tutorial001.mcp) as client:
(tool,) = (await client.list_tools()).tools
assert tool.output_schema is None
result = await client.call_tool("logo", {})
assert result.structured_content is None
async def test_audio_return_becomes_an_audio_content_block() -> None:
"""tutorial002: `Audio` is the same shape as `Image`."""
async with Client(tutorial002.mcp) as client:
result = await client.call_tool("chime", {})
assert not result.is_error
assert result.content == [
AudioContent(type="audio", data=base64.b64encode(tutorial002.CHIME_WAV).decode(), mime_type="audio/wav")
]
assert result.structured_content is None
def test_raw_data_without_a_format_falls_back_to_a_default_mime_type() -> None:
"""The `!!! check`: with `data=` there is no suffix to guess from, so `format=` decides."""
assert Image(data=b"\x89PNG\r\n\x1a\n", format="png").to_image_content().mime_type == "image/png"
assert Image(data=b"\x89PNG\r\n\x1a\n").to_image_content().mime_type == "image/png"
assert Audio(data=b"\xff\xfb").to_audio_content().mime_type == "audio/wav"
async def test_icons_are_visible_where_they_were_declared() -> None:
"""tutorial003: server icons land on `server_info`, tool icons on the `Tool`, resource icons on the `Resource`."""
async with Client(tutorial003.mcp) as client:
assert client.server_info.icons == [
Icon(src="https://example.com/brand-kit.png", mime_type="image/png", sizes=["48x48"])
]
(tool,) = (await client.list_tools()).tools
assert tool.icons == [Icon(src="https://example.com/palette.svg", mime_type="image/svg+xml", sizes=["any"])]
(resource,) = (await client.list_resources()).resources
assert resource.icons == [Icon(src="https://example.com/brand-kit.png", mime_type="image/png", sizes=["48x48"])]
+116
View File
@@ -0,0 +1,116 @@
"""`docs/advanced/middleware.md`: every claim the page makes, proved against the real SDK."""
import logging
import re
import pytest
from mcp_types import (
INVALID_REQUEST,
METHOD_NOT_FOUND,
CallToolRequestParams,
ErrorData,
RequestId,
TextContent,
)
from docs_src.middleware import tutorial001
from mcp import Client, MCPError
from mcp.server import Server, ServerRequestContext
from mcp.server.context import CallNext, HandlerResult
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
def _is_timing_record(record: logging.LogRecord) -> bool:
"""A record emitted by tutorial001's `log_timing` middleware (and nothing else caplog caught)."""
return record.name == tutorial001.logger.name
def test_timing_record_predicate() -> None:
"""The caplog filter keeps the middleware's own records and drops everyone else's."""
args = (logging.INFO, __file__, 1, "msg", None, None)
assert _is_timing_record(logging.LogRecord(tutorial001.logger.name, *args))
assert not _is_timing_record(logging.LogRecord("somebody.elses.logger", *args))
async def test_middleware_observes_every_inbound_message(caplog: pytest.LogCaptureFixture) -> None:
"""tutorial001: two client calls produce three timed lines. `server/discover` is wrapped too."""
with caplog.at_level(logging.INFO, logger=tutorial001.logger.name):
async with Client(tutorial001.server) as client:
await client.list_tools()
await client.call_tool("search_books", {"query": "dune"})
messages = [record.getMessage() for record in filter(_is_timing_record, caplog.records)]
assert [message.split(" took ")[0] for message in messages] == ["server/discover", "tools/list", "tools/call"]
assert re.fullmatch(r"tools/call took \d+\.\d ms", messages[-1])
async def test_the_result_passes_through_unchanged() -> None:
"""tutorial001: `log_timing` returns what `call_next` returned, so the client sees the real result."""
async with Client(tutorial001.server) as client:
result = await client.call_tool("search_books", {"query": "dune"})
assert not result.is_error
assert result.content == [TextContent(type="text", text="Found 3 books matching 'dune'.")]
async def test_a_notification_has_no_request_id() -> None:
"""`ctx.request_id is None` is how middleware tells a notification from a request."""
seen: list[tuple[str, RequestId | None]] = []
async def spy(ctx: ServerRequestContext, call_next: CallNext) -> HandlerResult:
seen.append((ctx.method, ctx.request_id))
return await call_next(ctx)
server = Server("Bookshop", on_list_tools=tutorial001.on_list_tools, on_call_tool=tutorial001.on_call_tool)
server.middleware.append(spy)
async with Client(server, mode="legacy") as client:
await client.list_tools()
assert seen == [("initialize", 1), ("notifications/initialized", None), ("tools/list", 2)]
async def test_raising_before_call_next_refuses_the_message() -> None:
"""A middleware that raises instead of calling `call_next` answers with a JSON-RPC error."""
async def gate(ctx: ServerRequestContext, call_next: CallNext) -> HandlerResult:
if ctx.method == "tools/call":
raise MCPError(code=INVALID_REQUEST, message="No calls on Sundays.")
return await call_next(ctx)
server = Server("Bookshop", on_list_tools=tutorial001.on_list_tools, on_call_tool=tutorial001.on_call_tool)
server.middleware.append(gate)
async with Client(server) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("search_books", {"query": "dune"})
assert exc_info.value.error.code == INVALID_REQUEST
assert exc_info.value.error.message == "No calls on Sundays."
assert len((await client.list_tools()).tools) == 1
async def test_an_unhandled_method_raises_through_the_middleware() -> None:
"""A method without a handler raises `METHOD_NOT_FOUND` out of `call_next`, through the middleware."""
seen: list[tuple[str, int]] = []
async def spy(ctx: ServerRequestContext, call_next: CallNext) -> HandlerResult:
try:
return await call_next(ctx)
except MCPError as exc:
seen.append((ctx.method, exc.error.code))
raise
server = Server("Bookshop", on_list_tools=tutorial001.on_list_tools, on_call_tool=tutorial001.on_call_tool)
server.middleware.append(spy)
async with Client(server) as client:
with pytest.raises(MCPError) as exc_info:
await client.read_resource("config://settings")
assert exc_info.value.error == ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="resources/read")
assert seen == [("resources/read", METHOD_NOT_FOUND)]
async def test_initialize_cannot_be_replaced_only_wrapped() -> None:
"""`add_request_handler("initialize", ...)` is rejected: middleware is the sanctioned hook."""
expected = (
"'initialize' is handled by the server runner and cannot be overridden; "
"use Server.middleware to observe or wrap initialization"
)
with pytest.raises(ValueError, match=re.escape(expected)):
tutorial001.server.add_request_handler("initialize", CallToolRequestParams, tutorial001.on_call_tool)
+197
View File
@@ -0,0 +1,197 @@
"""`docs/handlers/multi-round-trip.md`: every claim the page makes, proved against the real SDK."""
import pytest
from inline_snapshot import snapshot
from mcp_types import (
INTERNAL_ERROR,
INVALID_REQUEST,
CallToolResult,
CreateMessageRequest,
CreateMessageRequestParams,
ElicitRequest,
ElicitRequestFormParams,
ElicitRequestParams,
ElicitResult,
GetPromptResult,
InputRequiredResult,
PromptMessage,
TextContent,
)
from docs_src.mrtr import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005
from mcp import Client, MCPError
from mcp.client import ClientRequestContext
from mcp.server.mcpserver import InvalidRequestState
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_first_call_returns_an_input_required_result() -> None:
"""tutorial001: a tool that is missing input returns `InputRequiredResult` instead of calling back."""
async with Client(tutorial001.server) as client:
result = await client.session.call_tool("provision", {"name": "orders"}, allow_input_required=True)
assert result == snapshot(
InputRequiredResult(
result_type="input_required",
input_requests={
"region": ElicitRequest(
method="elicitation/create",
params=ElicitRequestFormParams(
mode="form",
message="Which region should the database live in?",
requested_schema={
"type": "object",
"properties": {"region": {"type": "string"}},
"required": ["region"],
},
),
)
},
request_state="provision-v1",
)
)
async def test_the_auto_loop_drives_the_call_to_completion() -> None:
"""tutorial003: register `elicitation_callback`, call the tool, get a plain `CallToolResult` back."""
async with Client(tutorial001.server, elicitation_callback=tutorial003.handle_elicitation) as client:
result = await client.call_tool("provision", {"name": "orders"})
assert result == snapshot(
CallToolResult(content=[TextContent(type="text", text="Provisioned 'orders' in eu-west-1.")])
)
async def test_the_auto_loop_without_a_callback_raises_mcp_error() -> None:
"""The page's `!!! check`: no `elicitation_callback` means the SDK's stand-in answers with an error."""
async with Client(tutorial001.server) as client:
with pytest.raises(MCPError) as exc:
await client.call_tool("provision", {"name": "orders"})
assert exc.value.error.code == INVALID_REQUEST
assert exc.value.error.message == "Elicitation not supported"
async def test_retry_with_input_responses_and_request_state_completes_the_call() -> None:
"""tutorial001: the retry carries `input_responses` keyed like `input_requests` plus the echoed token."""
async with Client(tutorial001.server) as client:
result = await client.call_tool(
"provision",
{"name": "orders"},
input_responses={"region": ElicitResult(action="accept", content={"region": "eu-west-1"})},
request_state="provision-v1",
)
assert result == snapshot(
CallToolResult(content=[TextContent(type="text", text="Provisioned 'orders' in eu-west-1.")])
)
async def test_the_manual_loop_drives_the_call_to_completion() -> None:
"""tutorial002: `client.session.call_tool(..., allow_input_required=True)` for callers who own the loop."""
async with Client(tutorial001.server) as client:
result = await tutorial002.provision(client, "billing")
assert result == snapshot(
CallToolResult(content=[TextContent(type="text", text="Provisioned 'billing' in eu-west-1.")])
)
async def test_the_in_memory_client_negotiates_2026_07_28() -> None:
"""`InputRequiredResult` only exists at 2026-07-28; `Client(server)` lands there without being asked."""
async with Client(tutorial001.server) as client:
assert client.protocol_version == "2026-07-28"
async def test_a_pre_2026_session_has_nowhere_to_put_the_result() -> None:
"""The page's `!!! warning`: on a legacy session the runner cannot serialize an `InputRequiredResult`."""
async with Client(tutorial001.server, mode="legacy") as client:
with pytest.raises(MCPError) as exc:
await client.call_tool("provision", {"name": "orders"})
assert exc.value.error.code == INTERNAL_ERROR
assert exc.value.error.message == "Handler returned an invalid result"
def test_fulfil_refuses_a_request_it_cannot_answer() -> None:
"""tutorial002: `fulfil` is the dispatch point. This client only knows how to answer an `ElicitRequest`."""
request = CreateMessageRequest(params=CreateMessageRequestParams(messages=[], max_tokens=64))
with pytest.raises(NotImplementedError, match="sampling/createMessage"):
tutorial002.fulfil(request)
async def test_a_prompt_returns_an_input_required_result_on_the_first_round() -> None:
"""tutorial004: `prompts/get` participates in the same flow — the `@mcp.prompt()` function
returns the `InputRequiredResult` itself."""
async with Client(tutorial004.mcp) as client:
result = await client.session.get_prompt("briefing", allow_input_required=True)
assert result == snapshot(
InputRequiredResult(
result_type="input_required",
input_requests={
"audience": ElicitRequest(
method="elicitation/create",
params=ElicitRequestFormParams(
mode="form",
message="Who is the briefing for?",
requested_schema={
"type": "object",
"properties": {"audience": {"type": "string"}},
"required": ["audience"],
},
),
)
},
)
)
async def _answer_audience(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
return ElicitResult(action="accept", content={"audience": "the board"})
async def test_the_prompt_auto_loop_returns_the_final_messages() -> None:
"""tutorial004 + the page's client-side claim: `get_prompt` drives the same loop, so the
caller sees only the complete `GetPromptResult`."""
async with Client(tutorial004.mcp, elicitation_callback=_answer_audience) as client:
result = await client.get_prompt("briefing")
assert result == snapshot(
GetPromptResult(
description="Draft a briefing tuned to its audience.",
messages=[
PromptMessage(
role="user",
content=TextContent(type="text", text="Write a briefing for the board."),
)
],
)
)
def test_a_custom_codec_round_trips_what_it_sealed() -> None:
"""tutorial005: `unseal(seal(payload))` returns the payload; the token itself is opaque hex."""
codec = tutorial005.EnvelopeCodec(tutorial005.unwrap_data_key())
token = codec.seal(b"round-1")
assert token.startswith(tutorial005.PREFIX)
assert b"round-1" not in token.encode()
assert codec.unseal(token) == b"round-1"
def test_a_custom_codec_raises_invalid_request_state_for_any_bad_token() -> None:
"""tutorial005: any token the codec did not mint intact raises `InvalidRequestState`."""
codec = tutorial005.EnvelopeCodec(tutorial005.unwrap_data_key())
token = codec.seal(b"round-1")
with pytest.raises(InvalidRequestState):
codec.unseal(token + "00")
with pytest.raises(InvalidRequestState):
codec.unseal("not-a-token")
def test_a_custom_codec_rejects_every_alias_of_a_minted_token() -> None:
"""tutorial005: only the exact minted string verifies; rewritten spellings of it do not."""
codec = tutorial005.EnvelopeCodec(tutorial005.unwrap_data_key())
token = codec.seal(b"round-1")
body = token.removeprefix(tutorial005.PREFIX)
for alias in (
body, # prefix stripped
tutorial005.PREFIX + body.upper(), # non-canonical hex case
tutorial005.PREFIX + body[:8] + " " + body[8:], # whitespace bytes.fromhex would skip
):
with pytest.raises(InvalidRequestState):
codec.unseal(alias)
+132
View File
@@ -0,0 +1,132 @@
"""`docs/client/oauth-clients.md`: every claim the page makes, proved against the real SDK."""
import inspect
import httpx
import pytest
from pydantic import AnyUrl, ValidationError
from docs_src.oauth_clients import tutorial001, tutorial002
from mcp.client.auth import OAuthClientProvider, OAuthFlowError, OAuthRegistrationError, OAuthTokenError, TokenStorage
from mcp.client.auth.extensions.client_credentials import (
PrivateKeyJWTOAuthProvider,
RFC7523OAuthClientProvider,
static_assertion_provider,
)
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
from mcp.shared.exceptions import MCPDeprecationWarning
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_in_memory_storage_satisfies_the_token_storage_protocol() -> None:
"""tutorial001: `TokenStorage` is a Protocol: four async methods, no base class."""
storage: TokenStorage = tutorial001.InMemoryTokenStorage()
assert await storage.get_tokens() is None
assert await storage.get_client_info() is None
async def test_storage_round_trips_tokens_and_client_info() -> None:
"""tutorial001: whatever the provider stores, it gets back: the whole persistence contract."""
storage = tutorial001.InMemoryTokenStorage()
tokens = OAuthToken(access_token="at-123", refresh_token="rt-456", expires_in=3600, scope="user")
client_info = OAuthClientInformationFull(
client_id="generated-by-the-as",
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
)
await storage.set_tokens(tokens)
await storage.set_client_info(client_info)
assert await storage.get_tokens() == tokens
assert await storage.get_client_info() == client_info
async def test_the_provider_is_an_httpx_auth() -> None:
"""tutorial001: `OAuthClientProvider` plugs into httpx, not into MCP."""
assert isinstance(tutorial001.oauth, httpx.Auth)
async def test_the_metadata_defaults_are_the_authorization_code_flow() -> None:
"""tutorial001: `grant_types` and `response_types` default to code + refresh: nothing to set."""
metadata = tutorial001.oauth.context.client_metadata
assert metadata.grant_types == ["authorization_code", "refresh_token"]
assert metadata.response_types == ["code"]
async def test_redirect_uris_is_required() -> None:
"""The `!!! check`: registration metadata is validated locally, before any network."""
with pytest.raises(ValidationError, match="redirect_uris\n Field required"):
OAuthClientMetadata.model_validate({"client_name": "Bookshop Agent"})
async def test_the_redirect_handler_receives_the_authorization_url(capsys: pytest.CaptureFixture[str]) -> None:
"""tutorial001: `redirect_handler` is the one place the authorization URL surfaces."""
await tutorial001.open_browser("https://auth.example.com/authorize?client_id=abc")
assert capsys.readouterr().out == "Visit: https://auth.example.com/authorize?client_id=abc\n"
async def test_client_credentials_provider_has_no_human_in_the_loop() -> None:
"""tutorial002: `ClientCredentialsOAuthProvider` is the same `httpx.Auth`, minus the handlers."""
assert isinstance(tutorial002.oauth, OAuthClientProvider)
assert isinstance(tutorial002.oauth, httpx.Auth)
assert tutorial002.oauth.context.redirect_handler is None
assert tutorial002.oauth.context.callback_handler is None
async def test_client_credentials_provider_builds_its_own_metadata() -> None:
"""tutorial002: the grant is `client_credentials`, there is nothing to redirect to."""
metadata = tutorial002.oauth.context.client_metadata
assert metadata.grant_types == ["client_credentials"]
assert metadata.token_endpoint_auth_method == "client_secret_basic"
assert metadata.redirect_uris is None
assert metadata.scope == "user"
async def test_the_three_remaining_keyword_arguments_have_defaults() -> None:
"""The page names `timeout`, `client_metadata_url` and `validate_resource_url` as the remainder."""
parameters = inspect.signature(OAuthClientProvider.__init__).parameters
supplied = ["server_url", "client_metadata", "storage", "redirect_handler", "callback_handler"]
remainder = ["timeout", "client_metadata_url", "validate_resource_url"]
assert list(parameters) == ["self", *supplied, *remainder]
assert all(parameters[name].default is not inspect.Parameter.empty for name in remainder)
async def test_the_one_more_provider_is_private_key_jwt() -> None:
"""The `!!! info`: `PrivateKeyJWTOAuthProvider` is the same `httpx.Auth`, built the same way."""
provider = PrivateKeyJWTOAuthProvider(
server_url="http://localhost:8001/mcp",
storage=tutorial002.InMemoryTokenStorage(),
client_id="reporting-agent",
assertion_provider=static_assertion_provider("a.prebuilt.jwt"),
)
assert isinstance(provider, OAuthClientProvider)
assert isinstance(provider, httpx.Auth)
assert provider.context.client_metadata.token_endpoint_auth_method == "private_key_jwt"
async def test_the_page_does_not_count_the_deprecated_provider() -> None:
"""Why the `!!! info` says *one* more provider: `RFC7523OAuthClientProvider` warns on construction."""
with pytest.warns(MCPDeprecationWarning, match="RFC7523OAuthClientProvider is deprecated"):
RFC7523OAuthClientProvider(
server_url="http://localhost:8001/mcp",
client_metadata=tutorial001.oauth.context.client_metadata,
storage=tutorial001.InMemoryTokenStorage(),
)
async def test_every_oauth_error_is_an_oauth_flow_error() -> None:
"""Catch `OAuthFlowError` and you have caught registration and token failures too."""
assert issubclass(OAuthRegistrationError, OAuthFlowError)
assert issubclass(OAuthTokenError, OAuthFlowError)
async def test_not_everything_is_a_flow_error() -> None:
"""A bad argument is a `ValueError`, not an `OAuthFlowError`: the page says *OAuth* failures."""
with pytest.raises(ValueError, match="client_metadata_url must be a valid HTTPS URL") as exc_info:
OAuthClientProvider(
server_url="http://localhost:8001/mcp",
client_metadata=tutorial001.oauth.context.client_metadata,
storage=tutorial001.InMemoryTokenStorage(),
client_metadata_url="http://not-https.example/client.json",
)
assert not isinstance(exc_info.value, OAuthFlowError)
+35
View File
@@ -0,0 +1,35 @@
"""`docs/run/opentelemetry.md`: every claim the page makes, proved against the real SDK."""
import pytest
from logfire.testing import CaptureLogfire
from docs_src.opentelemetry import tutorial001
from mcp import Client
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_a_plain_server_is_traced_with_no_extra_code(capfire: CaptureLogfire) -> None:
"""tutorial001: calling a tool emits a `tools/call` SERVER span, though the example adds no middleware."""
async with Client(tutorial001.mcp) as client:
await client.call_tool("search_books", {"query": "dune"})
spans = {s["name"]: s for s in capfire.exporter.exported_spans_as_dict()}
assert "tools/call search_books" in spans
attributes = spans["tools/call search_books"]["attributes"]
assert attributes["mcp.method.name"] == "tools/call"
assert attributes["gen_ai.operation.name"] == "execute_tool"
assert attributes["gen_ai.tool.name"] == "search_books"
async def test_client_and_server_share_one_trace(capfire: CaptureLogfire) -> None:
"""When both sides run the SDK, the client and server spans land in one trace (SEP-414)."""
async with Client(tutorial001.mcp, mode="legacy") as client:
await client.call_tool("search_books", {"query": "dune"})
spans = {s["name"]: s for s in capfire.exporter.exported_spans_as_dict()}
client_span = spans["MCP send tools/call search_books"]
server_span = spans["tools/call search_books"]
assert server_span["context"]["trace_id"] == client_span["context"]["trace_id"]
+80
View File
@@ -0,0 +1,80 @@
"""`docs/advanced/pagination.md`: every claim the page makes, proved against the real SDK."""
import pytest
from mcp_types import Resource
from docs_src.pagination import tutorial001, tutorial002
from mcp import Client, MCPError
from mcp.server import MCPServer
from mcp.server.mcpserver.resources import TextResource
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
mcp = MCPServer("Bookshop")
for n in range(1, 101):
mcp.add_resource(TextResource(uri=f"books://catalog/book-{n}", name=f"book-{n}", text=f"book-{n}"))
async def test_mcpserver_never_pages() -> None:
"""The page's framing: `MCPServer` answers `resources/list` in one page with `next_cursor=None`."""
async with Client(mcp) as client:
result = await client.list_resources()
assert len(result.resources) == 100
assert result.next_cursor is None
async def test_first_page_has_ten_resources_and_a_cursor() -> None:
"""tutorial001: no cursor means page one: ten resources and a `next_cursor` the client may ignore."""
async with Client(tutorial001.server) as client:
page = await client.list_resources()
assert [resource.name for resource in page.resources] == [f"book-{n}" for n in range(1, 11)]
assert page.next_cursor == "10"
async def test_the_cursor_resumes_where_the_last_page_stopped() -> None:
"""tutorial001: handing `next_cursor` straight back yields the next page, no overlap."""
async with Client(tutorial001.server) as client:
page = await client.list_resources(cursor="10")
assert page.resources[0].name == "book-11"
assert page.next_cursor == "20"
async def test_the_last_page_carries_no_cursor() -> None:
"""tutorial001: `next_cursor=None` is the only end-of-list signal."""
async with Client(tutorial001.server) as client:
page = await client.list_resources(cursor="90")
assert len(page.resources) == 10
assert page.next_cursor is None
async def test_the_loop_collects_all_one_hundred() -> None:
"""tutorial001: the `cursor=` loop visits ten pages and reassembles the whole catalog."""
async with Client(tutorial001.server) as client:
resources: list[Resource] = []
cursor: str | None = None
pages = 0
while True:
page = await client.list_resources(cursor=cursor)
resources.extend(page.resources)
pages += 1
if page.next_cursor is None:
break
cursor = page.next_cursor
assert pages == 10
assert len({resource.uri for resource in resources}) == 100
async def test_the_client_program_on_the_page_runs(capsys: pytest.CaptureFixture[str]) -> None:
"""tutorial002: `main()` is the literal client program on the page and prints the stitched total."""
await tutorial002.main()
assert capsys.readouterr().out == "100 resources\n"
async def test_an_invented_cursor_is_an_error() -> None:
"""Cursors are opaque: a string the server never minted blows up inside the handler."""
async with Client(tutorial001.server) as client:
with pytest.raises(MCPError) as excinfo:
await client.list_resources(cursor="page-2")
assert excinfo.value.code == -32603
assert str(excinfo.value) == "Internal server error"
+102
View File
@@ -0,0 +1,102 @@
"""`docs/handlers/progress.md`: every claim the page makes, proved against the real SDK."""
import inspect
import anyio
import pytest
from mcp_types import TextContent
from docs_src.progress import tutorial001, tutorial002
from mcp import Client
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
URLS = ["https://example.com/a.json", "https://example.com/b.json"]
async def test_context_parameter_is_invisible_to_the_model() -> None:
"""tutorial001: `ctx` comes from the type hint and never reaches the input schema."""
async with Client(tutorial001.mcp) as client:
(tool,) = (await client.list_tools()).tools
assert tool.input_schema["properties"] == {
"urls": {"items": {"type": "string"}, "title": "Urls", "type": "array"}
}
assert tool.input_schema["required"] == ["urls"]
async def test_each_report_becomes_one_callback_invocation_in_order() -> None:
"""tutorial001: `progress_callback` receives every `(progress, total, message)` the tool reported."""
updates: list[tuple[float, float | None, str | None]] = []
async def show(progress: float, total: float | None, message: str | None) -> None:
updates.append((progress, total, message))
async with Client(tutorial001.mcp) as client:
result = await client.call_tool("import_catalog", {"urls": URLS}, progress_callback=show)
assert updates == [
(1, 2, "Imported https://example.com/a.json"),
(2, 2, "Imported https://example.com/b.json"),
]
assert result.content == [TextContent(type="text", text="Imported 2 records.")]
assert result.structured_content == {"result": "Imported 2 records."}
async def test_over_a_wire_dispatcher_callbacks_race_the_result() -> None:
"""The `!!! info`: only the in-memory connection runs the callback inline.
On a wire dispatcher (`mode="legacy"` here) each progress notification starts its own task, so
`call_tool` can return while a slow callback is still running. The callbacks below block on an
event that is only set *after* `call_tool` has returned: exactly the situation the page tells
you not to rule out.
"""
release = anyio.Event()
done = anyio.Event()
finished: list[float] = []
async def gated(progress: float, total: float | None, message: str | None) -> None:
await release.wait()
finished.append(progress)
if len(finished) == 2:
done.set()
async with Client(tutorial001.mcp, mode="legacy") as client:
with anyio.fail_after(5):
result = await client.call_tool("import_catalog", {"urls": URLS}, progress_callback=gated)
assert finished == []
release.set()
with anyio.fail_after(5):
await done.wait()
assert sorted(finished) == [1, 2]
assert result.structured_content == {"result": "Imported 2 records."}
async def test_without_a_callback_report_progress_is_a_no_op() -> None:
"""The `!!! check`: omit `progress_callback` and the tool runs to the same result, no error."""
async with Client(tutorial001.mcp) as client:
result = await client.call_tool("import_catalog", {"urls": URLS})
assert not result.is_error
assert result.structured_content == {"result": "Imported 2 records."}
def test_progress_callback_is_per_call_not_per_client() -> None:
"""The `!!! warning`: `call_tool` takes `progress_callback`; the `Client` constructor does not."""
assert "progress_callback" in inspect.signature(Client.call_tool).parameters
assert "progress_callback" not in inspect.signature(Client.__init__).parameters
async def test_omitting_total_reaches_the_callback_as_none() -> None:
"""tutorial002: a report without `total` arrives as `total=None`: activity, not a percentage."""
updates: list[tuple[float, float | None, str | None]] = []
async def show(progress: float, total: float | None, message: str | None) -> None:
updates.append((progress, total, message))
async with Client(tutorial002.mcp) as client:
result = await client.call_tool("import_feed", {"feed_url": "https://example.com/feed"}, progress_callback=show)
assert updates == [
(1, None, "Imported https://example.com/feed#Dune"),
(2, None, "Imported https://example.com/feed#Neuromancer"),
(3, None, "Imported https://example.com/feed#Hyperion"),
]
assert result.structured_content == {"result": "Imported 3 records."}
+101
View File
@@ -0,0 +1,101 @@
"""`docs/servers/prompts.md`: every claim the page makes, proved against the real SDK."""
import traceback
import pytest
from inline_snapshot import snapshot
from mcp_types import PromptArgument, PromptMessage, TextContent
from docs_src.prompts import tutorial001, tutorial002, tutorial003
from mcp import Client, MCPError
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_function_becomes_the_prompt() -> None:
"""tutorial001: the name, the docstring and the parameters are the whole `prompts/list` entry."""
async with Client(tutorial001.mcp) as client:
(prompt,) = (await client.list_prompts()).prompts
assert prompt.model_dump(mode="json", by_alias=True, exclude_none=True) == snapshot(
{
"name": "review_code",
"description": "Review a piece of code.",
"arguments": [{"name": "code", "required": True}],
}
)
async def test_returned_string_becomes_one_user_message() -> None:
"""tutorial001: a `str` return value is rendered as a single `user` message."""
async with Client(tutorial001.mcp) as client:
result = await client.get_prompt("review_code", {"code": "def add(a, b): return a + b"})
assert result.model_dump(mode="json", by_alias=True, exclude_none=True) == snapshot(
{
"description": "Review a piece of code.",
"messages": [
{
"role": "user",
"content": {
"type": "text",
"text": "Please review this code:\n\ndef add(a, b): return a + b",
},
}
],
"resultType": "complete",
}
)
async def test_missing_required_argument_is_a_protocol_error() -> None:
"""tutorial001: omitting a required argument fails the request itself. There is no error result."""
async with Client(tutorial001.mcp) as client:
with pytest.raises(MCPError) as exc_info:
await client.get_prompt("review_code")
assert exc_info.value.code == -32603
assert exc_info.value.message == "Internal server error"
# The line a traceback prints, exactly as the page quotes it: the code is not in the message.
assert traceback.format_exception_only(exc_info.value) == snapshot(
["mcp.shared.exceptions.MCPError: Internal server error\n"]
)
async def test_message_list_becomes_a_multi_turn_template() -> None:
"""tutorial002: a list of `UserMessage` / `AssistantMessage` renders in order, roles intact."""
async with Client(tutorial002.mcp) as client:
assert [p.name for p in (await client.list_prompts()).prompts] == ["review_code", "debug_error"]
result = await client.get_prompt("debug_error", {"error": "TypeError: 'int' object is not iterable"})
assert result.messages == [
PromptMessage(role="user", content=TextContent(type="text", text="I'm seeing this error:")),
PromptMessage(
role="user",
content=TextContent(type="text", text="TypeError: 'int' object is not iterable"),
),
PromptMessage(
role="assistant",
content=TextContent(type="text", text="I'll help debug that. What have you tried so far?"),
),
]
async def test_title_and_argument_descriptions() -> None:
"""tutorial003: `title=` and `Field(description=...)` land in the `prompts/list` entry."""
async with Client(tutorial003.mcp) as client:
(prompt,) = (await client.list_prompts()).prompts
assert prompt.title == "Code review"
assert prompt.arguments == [
PromptArgument(name="code", description="The code to review.", required=True),
PromptArgument(name="language", description="The language the code is written in.", required=False),
]
async def test_default_value_makes_the_argument_optional() -> None:
"""tutorial003: a parameter with a default can be omitted and the default is used in the render."""
async with Client(tutorial003.mcp) as client:
result = await client.get_prompt("review_code", {"code": "x = 1"})
assert result.messages == [
PromptMessage(
role="user",
content=TextContent(type="text", text="Please review this python code:\n\nx = 1"),
)
]
+94
View File
@@ -0,0 +1,94 @@
"""`docs/protocol-versions.md`: every claim the page makes, proved against the real SDK."""
import re
import pytest
from mcp_types import DiscoverResult, Implementation, ServerCapabilities
from docs_src.protocol_versions import tutorial001, tutorial002, tutorial003, tutorial004
from mcp import Client
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_auto_lands_on_the_modern_version() -> None:
"""tutorial001: the default `mode="auto"` probes `server/discover` and adopts the result."""
async with Client(tutorial001.mcp) as client:
assert client.protocol_version == "2026-07-28"
assert client.server_info.name == "Bookshop"
assert client.session.discover_result is not None
assert client.session.initialize_result is None
async def test_legacy_forces_the_initialize_handshake() -> None:
"""tutorial002: `mode="legacy"` runs `initialize` against the very same server."""
async with Client(tutorial002.mcp, mode="legacy") as client:
assert client.protocol_version == "2025-11-25"
assert client.server_info.name == "Bookshop"
assert client.session.initialize_result is not None
assert client.session.discover_result is None
async def test_version_pin_sends_nothing_and_knows_nothing() -> None:
"""tutorial003: a pin adopts the version locally; `server_info` and capabilities are blank."""
async with Client(tutorial003.mcp, mode="2026-07-28") as client:
assert client.protocol_version == "2026-07-28"
assert client.server_info == Implementation(name="", version="")
# The `!!! check` fence is the literal `print(client.server_info)` output.
assert str(client.server_info) == "name='' title=None version='' description=None website_url=None icons=None"
assert client.server_capabilities == ServerCapabilities()
result = await client.call_tool("search_books", {"query": "dune"})
assert result.structured_content == {"result": "Found 3 books matching 'dune'."}
def test_handshake_era_version_is_not_a_valid_pin() -> None:
"""A pre-2026 version string is rejected at construction with the exact error the page shows."""
with pytest.raises(
ValueError,
match=re.escape(
"mode must be 'legacy', 'auto', or one of ['2026-07-28']; "
"got '2025-06-18' ('2025-06-18' is a handshake-era version; use mode='legacy')"
),
):
Client(tutorial003.mcp, mode="2025-06-18")
async def test_prior_discover_round_trips() -> None:
"""tutorial004: save `discover_result`, reconnect with it, and the identity comes back."""
async with Client(tutorial004.mcp) as client:
saved = client.session.discover_result
assert saved is not None
assert saved.supported_versions == ["2026-07-28"]
async with Client(tutorial004.mcp, mode="2026-07-28", prior_discover=saved) as client:
assert client.protocol_version == "2026-07-28"
assert client.server_info.name == "Bookshop"
assert client.server_capabilities.tools is not None
async def test_discover_result_survives_json() -> None:
"""`DiscoverResult` is a Pydantic model: dump it to JSON, validate it back, reconnect with it."""
async with Client(tutorial004.mcp) as client:
saved = client.session.discover_result
assert saved is not None
restored = DiscoverResult.model_validate_json(saved.model_dump_json())
assert restored == saved
async with Client(tutorial004.mcp, mode="2026-07-28", prior_discover=restored) as client:
assert client.server_info.name == "Bookshop"
async def test_prior_discover_is_ignored_unless_mode_is_a_pin() -> None:
"""The `!!! tip`: under `auto` the client probes anyway; under `legacy` it never discovers."""
stale = DiscoverResult(
supported_versions=["2026-07-28"],
capabilities=ServerCapabilities(),
server_info=Implementation(name="Stale", version="0.0.0"),
)
async with Client(tutorial004.mcp, prior_discover=stale) as client:
assert client.server_info.name == "Bookshop"
async with Client(tutorial004.mcp, mode="legacy", prior_discover=stale) as client:
assert client.session.discover_result is None
assert client.protocol_version == "2025-11-25"
+54
View File
@@ -0,0 +1,54 @@
"""`docs/get-started/real-host.md`: the one server every host section on the page launches, driven in memory."""
import pytest
from inline_snapshot import snapshot
from mcp_types import TextContent, TextResourceContents
from docs_src.real_host import tutorial001
from mcp import Client
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_the_host_sees_exactly_what_the_decorators_registered() -> None:
"""tutorial001: `tools/list` is what a host hands its model. Name, description, and schema come from the code."""
async with Client(tutorial001.mcp) as client:
search, get = (await client.list_tools()).tools
assert search.name == "search_books"
assert search.description == "Search the catalog by title or author."
assert search.input_schema == snapshot(
{
"type": "object",
"properties": {"query": {"title": "Query", "type": "string"}},
"required": ["query"],
"title": "search_booksArguments",
}
)
assert get.name == "get_author"
async def test_a_tool_call_round_trips_the_way_a_host_drives_it() -> None:
"""tutorial001: `tools/call` sends arguments in; the function's return value comes back as the result."""
async with Client(tutorial001.mcp) as client:
result = await client.call_tool("search_books", {"query": "gibson"})
assert not result.is_error
assert result.structured_content == {"result": ["Neuromancer"]}
author = await client.call_tool("get_author", {"title": "Dune"})
assert author.content == [TextContent(type="text", text="Frank Herbert")]
async def test_the_resource_a_host_can_attach_to_context() -> None:
"""tutorial001: `catalog://titles` has no parameter, so it is a concrete, listable, readable resource."""
async with Client(tutorial001.mcp) as client:
(resource,) = (await client.list_resources()).resources
assert str(resource.uri) == "catalog://titles"
result = await client.read_resource("catalog://titles")
assert result.contents == [
TextResourceContents(
uri="catalog://titles",
mime_type="text/plain",
text="Dune\nNeuromancer\nThe Left Hand of Darkness",
)
]
+119
View File
@@ -0,0 +1,119 @@
"""`docs/servers/resources.md`: every claim the page makes, proved against the real SDK."""
import base64
import pytest
from inline_snapshot import snapshot
from mcp_types import BlobResourceContents, Resource, ResourceTemplate, TextResourceContents
from docs_src.resources import tutorial001, tutorial002, tutorial003
from mcp import Client
from mcp.server import MCPServer
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_function_becomes_a_listed_resource() -> None:
"""tutorial001: the URI, the function name and the docstring are the whole listing entry."""
async with Client(tutorial001.mcp) as client:
(resource,) = (await client.list_resources()).resources
assert resource == snapshot(
Resource(
name="get_config",
uri="config://app",
description="The active shop configuration.",
mime_type="text/plain",
)
)
async def test_read_returns_the_return_value_as_text() -> None:
"""tutorial001: reading the URI runs the function and wraps the `str` in `TextResourceContents`."""
async with Client(tutorial001.mcp) as client:
result = await client.read_resource("config://app")
assert result.contents == [
TextResourceContents(uri="config://app", mime_type="text/plain", text="theme=dark\nlanguage=en")
]
async def test_template_is_listed_separately_from_resources() -> None:
"""tutorial002: a `{placeholder}` moves the entry from `resources/list` to `resources/templates/list`."""
async with Client(tutorial002.mcp) as client:
assert [r.uri for r in (await client.list_resources()).resources] == ["config://app"]
(template,) = (await client.list_resource_templates()).resource_templates
assert template == snapshot(
ResourceTemplate(
name="get_user_profile",
uri_template="users://{user_id}/profile",
description="A customer's profile.",
mime_type="text/plain",
)
)
async def test_reading_a_template_fills_the_placeholder() -> None:
"""tutorial002: the client reads a concrete URI; the matched value arrives as the function argument."""
async with Client(tutorial002.mcp) as client:
result = await client.read_resource("users://42/profile")
assert result.contents == [
TextResourceContents(
uri="users://42/profile", mime_type="text/plain", text="User 42: 12 orders since 2021."
)
]
def test_uri_params_must_match_function_params() -> None:
"""The `!!! check`: a placeholder/parameter mismatch is rejected at decoration time, not at read time."""
broken = MCPServer("Bookshop")
with pytest.raises(ValueError) as exc_info:
@broken.resource("users://{user_id}/profile")
def get_user_profile(user: str) -> None:
"""A customer's profile."""
assert str(exc_info.value) == snapshot(
"Mismatch between URI parameters {'user_id'} and function parameters {'user'}"
)
async def test_mime_type_is_what_you_declare() -> None:
"""tutorial003: `mime_type=` lands in the listing verbatim; the SDK never guesses it from the value."""
async with Client(tutorial003.mcp) as client:
resources = (await client.list_resources()).resources
assert {r.uri: r.mime_type for r in resources} == snapshot(
{
"docs://readme": "text/markdown",
"stats://catalog": "application/json",
"covers://placeholder": "image/gif",
}
)
async def test_str_return_is_sent_as_is() -> None:
"""tutorial003: a `str` return value is the text content, untouched."""
async with Client(tutorial003.mcp) as client:
(content,) = (await client.read_resource("docs://readme")).contents
assert isinstance(content, TextResourceContents)
assert content.text == "# Bookshop\n\nSearch the catalog with the `search_books` tool."
async def test_dict_return_becomes_json_text() -> None:
"""tutorial003: a non-`str`, non-`bytes` return value is serialised to JSON text."""
async with Client(tutorial003.mcp) as client:
(content,) = (await client.read_resource("stats://catalog")).contents
assert isinstance(content, TextResourceContents)
assert content.text == snapshot('{\n "books": 1204,\n "authors": 391\n}')
async def test_bytes_return_becomes_a_blob() -> None:
"""tutorial003: a `bytes` return value arrives as `BlobResourceContents`, base64-encoded in `blob`."""
async with Client(tutorial003.mcp) as client:
(content,) = (await client.read_resource("covers://placeholder")).contents
assert isinstance(content, BlobResourceContents)
assert content == BlobResourceContents(
uri="covers://placeholder",
mime_type="image/gif",
blob="R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
)
assert base64.b64decode(content.blob) == tutorial003.placeholder_cover()
+52
View File
@@ -0,0 +1,52 @@
"""`docs/run/index.md`: every claim the page makes that is observable without a transport."""
from typing import Any
import pytest
from inline_snapshot import snapshot
from mcp_types import CallToolResult, TextContent
from docs_src.run import tutorial001, tutorial002, tutorial003
from mcp import Client
from mcp.server import MCPServer
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_the_run_call_is_guarded_so_importing_does_not_start_a_server() -> None:
"""tutorial001: `run()` sits under `__main__`, so the module imports cleanly and serves in-memory."""
async with Client(tutorial001.mcp) as client:
result = await client.call_tool("search_books", {"query": "dune"})
assert result == snapshot(
CallToolResult(
content=[TextContent(type="text", text="Found 3 books matching 'dune'.")],
structured_content={"result": "Found 3 books matching 'dune'."},
)
)
async def test_the_transport_never_changes_what_the_server_is() -> None:
"""tutorial001/002/003 differ only in how they run: every client sees the identical tool."""
async with (
Client(tutorial001.mcp) as stdio_client,
Client(tutorial002.mcp) as http_client,
Client(tutorial003.mcp) as configured_client,
):
baseline = await stdio_client.list_tools()
assert baseline == await http_client.list_tools()
assert baseline == await configured_client.list_tools()
def test_transport_options_are_not_constructor_options() -> None:
"""The page's warning: `port=` belongs to `run()`; the constructor rejects it."""
options: dict[str, Any] = {"port": 3001}
with pytest.raises(TypeError, match="unexpected keyword argument 'port'"):
MCPServer("Bookshop", **options)
def test_settings_are_constructor_arguments_and_land_on_settings() -> None:
"""tutorial003: `log_level=` ends up on `mcp.settings`; the defaults are INFO and not-debug."""
assert tutorial001.mcp.settings.log_level == "INFO"
assert tutorial001.mcp.settings.debug is False
assert tutorial003.mcp.settings.log_level == "DEBUG"
+62
View File
@@ -0,0 +1,62 @@
"""`docs/handlers/sampling-and-roots.md`: every claim the page makes, proved against the real SDK."""
from typing import Literal
import pytest
from mcp_types import (
MISSING_REQUIRED_CLIENT_CAPABILITY,
CreateMessageRequestParams,
CreateMessageResult,
ListRootsResult,
Root,
TextContent,
)
from pydantic import FileUrl
from docs_src.sampling_and_roots import tutorial001, tutorial002
from mcp import Client
from mcp.client import ClientRequestContext
from mcp.shared.exceptions import MCPError
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
@pytest.mark.parametrize("mode", ["legacy", "auto"])
async def test_a_sampling_dependency_receives_the_clients_completion(mode: Literal["legacy", "auto"]) -> None:
"""tutorial001: `draft_blurb` runs through the client's model on both protocol versions."""
prompts: list[str] = []
async def sampler(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult:
content = params.messages[0].content
assert isinstance(content, TextContent)
prompts.append(content.text)
return CreateMessageResult(
role="assistant", content=TextContent(type="text", text="A desert planet holds the key."), model="m"
)
async with Client(tutorial001.mcp, mode=mode, sampling_callback=sampler) as client:
result = await client.call_tool("blurb", {"title": "Dune"})
assert result.content == [TextContent(type="text", text="A desert planet holds the key.")]
assert prompts == ["Write a one-sentence blurb for the book 'Dune'."]
@pytest.mark.parametrize("mode", ["legacy", "auto"])
async def test_a_roots_dependency_receives_the_clients_folders(mode: Literal["legacy", "auto"]) -> None:
"""tutorial002: `workspace_roots` fetches the client's roots list."""
async def client_roots(context: ClientRequestContext) -> ListRootsResult:
return ListRootsResult(roots=[Root(uri=FileUrl("file:///workspace/catalog"), name="catalog")])
async with Client(tutorial002.mcp, mode=mode, list_roots_callback=client_roots) as client:
result = await client.call_tool("catalog_folder", {})
assert result.content == [TextContent(type="text", text="file:///workspace/catalog")]
async def test_an_undeclared_capability_fails_before_a_request_is_sent() -> None:
"""The page's gate claim: no `sampling` capability means a -32021 protocol error."""
async with Client(tutorial001.mcp) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("blurb", {"title": "Dune"})
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
+98
View File
@@ -0,0 +1,98 @@
"""`docs/client/session-groups.md`: every claim the page makes, proved against the real SDK.
`connect_to_server` opens a real transport (a subprocess or a socket), so these tests drive the
exact same aggregation path through `connect_with_session` with in-memory sessions instead.
"""
import traceback
import pytest
from mcp_types import INVALID_PARAMS, Implementation
from docs_src.session_groups import tutorial001, tutorial002, tutorial004
from mcp import Client, ClientSessionGroup, MCPError
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_both_servers_call_their_tool_search() -> None:
"""tutorial001 + tutorial002: two unrelated servers, one colliding tool name."""
async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web:
(library_tool,) = (await library.list_tools()).tools
(web_tool,) = (await web.list_tools()).tools
assert library_tool.name == "search"
assert web_tool.name == "search"
async def test_a_connected_server_is_aggregated_into_the_group() -> None:
"""tutorial003: the group exposes every component of every connected server as a dict."""
async with Client(tutorial001.mcp) as library:
group = ClientSessionGroup()
await group.connect_with_session(library.server_info, library.session)
assert sorted(group.tools) == ["search"]
assert sorted(group.resources) == ["hours"]
assert group.prompts == {}
assert group.tools["search"].description == "Search the library catalog."
async def test_colliding_names_are_rejected() -> None:
"""tutorial003: without a hook the second `search` raises, and nothing from `Web` is kept."""
async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web:
group = ClientSessionGroup()
await group.connect_with_session(library.server_info, library.session)
with pytest.raises(MCPError) as exc_info:
await group.connect_with_session(web.server_info, web.session)
assert str(exc_info.value) == "{'search'} already exist in group tools."
assert exc_info.value.error.code == INVALID_PARAMS
assert sorted(group.tools) == ["search"]
# The page's `!!! check` fence is the last line of the traceback, verbatim.
assert traceback.format_exception_only(exc_info.value) == [
"mcp.shared.exceptions.MCPError: {'search'} already exist in group tools.\n"
]
async def test_component_name_hook_prefixes_every_name() -> None:
"""tutorial004: the hook rewrites every registered name, so both servers coexist."""
async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web:
group = ClientSessionGroup(component_name_hook=tutorial004.by_server)
await group.connect_with_session(library.server_info, library.session)
await group.connect_with_session(web.server_info, web.session)
assert sorted(group.tools) == ["Library.search", "Web.search"]
assert sorted(group.resources) == ["Library.hours"]
def test_the_hook_is_a_plain_function_of_name_and_server_info() -> None:
"""tutorial004: `by_server` builds the key from `server_info.name`."""
assert tutorial004.by_server("search", Implementation(name="Web", version="1.0.0")) == "Web.search"
async def test_the_key_is_prefixed_but_the_wire_name_is_not() -> None:
"""tutorial004: the dict key is yours; the `Tool` inside keeps the name the server declared."""
async with Client(tutorial002.mcp) as web:
group = ClientSessionGroup(component_name_hook=tutorial004.by_server)
await group.connect_with_session(web.server_info, web.session)
assert group.tools["Web.search"].name == "search"
async def test_call_tool_routes_to_the_owning_server() -> None:
"""tutorial004: `group.call_tool` resolves the prefixed name to the session that owns it."""
async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web:
group = ClientSessionGroup(component_name_hook=tutorial004.by_server)
await group.connect_with_session(library.server_info, library.session)
await group.connect_with_session(web.server_info, web.session)
web_result = await group.call_tool("Web.search", {"query": "model context protocol"})
assert web_result.structured_content == {"result": "12 pages match 'model context protocol'."}
library_result = await group.call_tool("Library.search", {"query": "dune"})
assert library_result.structured_content == {"result": "3 books match 'dune'."}
async def test_disconnect_removes_every_component_of_that_server() -> None:
"""tutorial004: `disconnect_from_server` takes the session back out of all three dicts."""
async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web:
group = ClientSessionGroup(component_name_hook=tutorial004.by_server)
await group.connect_with_session(library.server_info, library.session)
web_session = await group.connect_with_session(web.server_info, web.session)
await group.disconnect_from_server(web_session)
assert sorted(group.tools) == ["Library.search"]
assert sorted(group.resources) == ["Library.hours"]
+145
View File
@@ -0,0 +1,145 @@
"""Structural invariants every `docs_src/` example must satisfy.
These are deliberately string/regex checks, not an AST analyzer: each predicate
is branch-free at the call site so the suite stays compatible with the repo's
100% branch-coverage gate, and a contributor whose doc PR goes red gets a
one-line reason, not a parser traceback.
"""
import importlib
import re
from itertools import filterfalse
from pathlib import Path
import pytest
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")
REPO_ROOT = Path(__file__).parent.parent.parent
DOCS_SRC = REPO_ROOT / "docs_src"
EXAMPLE_FILES = sorted(p for p in DOCS_SRC.rglob("*.py") if p.name != "__init__.py")
"""Every example module under `docs_src/` (the `__init__.py` scaffolding is not an example)."""
_PRIVATE_MCP_IMPORT = re.compile(r"^\s*(?:from|import)\s+(mcp(?:\.\w+)*\._\w+)", re.MULTILINE)
"""A `_`-private segment inside the imported MODULE path: `from mcp.client._memory import X`."""
_PRIVATE_MCP_NAME = re.compile(r"^\s*from\s+(mcp(?:\.\w+)*)\s+import\s+[^#\n]*?\b(_\w+)\b", re.MULTILINE)
"""A `_`-private NAME imported from a public `mcp` module: `from mcp.client import _memory`."""
RETIRED_NAMES = ("UrlElicitationRequiredError",)
"""Public SDK names built on protocol surfaces retired by the 2026-07-28 spec.
`UrlElicitationRequiredError` is the `-32042` flow; the spec lists that code as
reserved-never-reused, so no documentation example may teach it even while the
symbol is still exported.
"""
_INCLUDE_DIRECTIVE = re.compile(r"(?:--8<--\s*\"|<!-- snippet-source\s+)(docs_src/[^\s\"]+)")
"""A `--8<-- "docs_src/..."` mkdocs include or a `<!-- snippet-source docs_src/... -->` README marker."""
def _rel(path: Path) -> str:
"""A repo-relative path, used as the parametrize id so failures name the file."""
return path.relative_to(REPO_ROOT).as_posix()
def _module_name(path: Path) -> str:
"""The dotted import name of an example, derived from its repo-relative path."""
return _rel(path).removesuffix(".py").replace("/", ".")
def _private_mcp_imports(source: str) -> list[str]:
"""Every `mcp.*` import in `source` that reaches a `_`-private module OR name.
Two single-line spellings are covered: a private segment in the module path
(`from mcp.client._memory import X`, `import mcp.server._otel`) and a private
name pulled from a public module (`from mcp.client import _memory`).
"""
named = [f"{module}.{name}" for module, name in _PRIVATE_MCP_NAME.findall(source)]
return _PRIVATE_MCP_IMPORT.findall(source) + named
def _retired_names_used(source: str) -> list[str]:
"""The retired SDK names that appear anywhere in `source`."""
return [name for name in RETIRED_NAMES if name in source]
def _referenced_examples() -> set[str]:
"""Every `docs_src/...` path that some docs page or the README actually includes."""
pages = [*sorted((REPO_ROOT / "docs").rglob("*.md")), REPO_ROOT / "README.md"]
return {ref for page in pages for ref in _INCLUDE_DIRECTIVE.findall(page.read_text(encoding="utf-8"))}
def _is_real_file(rel: str) -> bool:
"""Whether a repo-relative path exists on disk."""
return (REPO_ROOT / rel).is_file()
def test_private_mcp_import_detector() -> None:
"""The detector flags both single-line spellings of a private `mcp` reach-in, and only those.
It does not parse Python: a private name hidden behind an `as` alias or inside a
parenthesised multi-line `import` would slip through. Examples are short single-line
imports, so the cheap detector is the right trade against a 100-line AST analyzer.
"""
assert _private_mcp_imports("from mcp.client._memory import InMemoryTransport") == ["mcp.client._memory"]
assert _private_mcp_imports("import mcp.server._otel") == ["mcp.server._otel"]
assert _private_mcp_imports("from mcp.client import _memory") == ["mcp.client._memory"]
assert _private_mcp_imports("from mcp.server import MCPServer\nfrom mcp.client.client import Client") == []
# only `mcp` is policed: another library's private module is not this test's business
assert _private_mcp_imports("from pydantic._internal import _fields") == []
def test_retired_name_detector() -> None:
"""The detector flags a retired name and stays quiet on clean source."""
assert _retired_names_used("raise UrlElicitationRequiredError([])") == ["UrlElicitationRequiredError"]
assert _retired_names_used("from mcp.server import MCPServer") == []
@pytest.mark.parametrize("path", EXAMPLE_FILES, ids=_rel)
def test_example_imports(path: Path) -> None:
"""The example imports cleanly against the current SDK.
A renamed symbol, a moved import path, or a changed keyword argument breaks an
example at import time, long before anyone reads the page it appears on.
Honest scope: an example another test in this directory already imported is a
`sys.modules` cache hit here and its real coverage is that behavioural test.
This test is the floor for the example that has a page but no test yet.
"""
importlib.import_module(_module_name(path))
@pytest.mark.parametrize("path", EXAMPLE_FILES, ids=_rel)
def test_example_uses_only_public_mcp_modules(path: Path) -> None:
"""An example is the public API contract: it must never import a `_`-private `mcp` module."""
assert not _private_mcp_imports(path.read_text(encoding="utf-8")), f"{_rel(path)} reaches into private mcp"
@pytest.mark.parametrize("path", EXAMPLE_FILES, ids=_rel)
def test_example_avoids_retired_api(path: Path) -> None:
"""An example must not teach an API the 2026-07-28 spec retired, even while it is still exported."""
assert not _retired_names_used(path.read_text(encoding="utf-8")), f"{_rel(path)} uses a retired API"
def test_every_example_is_included_by_a_page() -> None:
"""Every `docs_src/` example is shown by at least one docs page or the README.
An orphan example is dead documentation: it gets type-checked and tested
but no reader ever sees it, so it silently stops describing anything.
"""
examples = {_rel(p) for p in EXAMPLE_FILES}
orphans = sorted(examples - _referenced_examples())
assert not orphans, f"docs_src files no page includes: {orphans}"
def test_every_included_path_exists() -> None:
"""Every `docs_src/` path a page includes exists on disk.
`zensical build --strict` also enforces this, but only when the docs are
built; this puts the same guarantee inside the ordinary `pytest` run.
"""
missing = sorted(filterfalse(_is_real_file, _referenced_examples()))
assert not missing, f"pages include docs_src files that do not exist: {missing}"
+192
View File
@@ -0,0 +1,192 @@
"""`docs/servers/structured-output.md`: every claim the page makes, proved against the real SDK."""
import pytest
from inline_snapshot import snapshot
from mcp_types import TextContent
from docs_src.structured_output import (
tutorial001,
tutorial002,
tutorial003,
tutorial004,
tutorial005,
tutorial006,
tutorial007,
tutorial008,
tutorial009,
)
from mcp import Client
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import InvalidSignature
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_scalar_return_is_wrapped() -> None:
"""tutorial001: `-> int` becomes a `{"result": ...}` output schema and fills both channels."""
async with Client(tutorial001.mcp) as client:
(tool,) = (await client.list_tools()).tools
assert tool.output_schema == snapshot(
{
"properties": {"result": {"title": "Result", "type": "integer"}},
"required": ["result"],
"title": "get_temperatureOutput",
"type": "object",
}
)
result = await client.call_tool("get_temperature", {"city": "London"})
assert not result.is_error
assert result.content == [TextContent(type="text", text="17")]
assert result.structured_content == {"result": 17}
async def test_basemodel_is_the_schema() -> None:
"""tutorial002: a `BaseModel` return type is the output schema itself: no wrapper."""
async with Client(tutorial002.mcp) as client:
(tool,) = (await client.list_tools()).tools
assert tool.output_schema == snapshot(
{
"properties": {
"temperature": {"description": "Degrees Celsius.", "title": "Temperature", "type": "number"},
"humidity": {"description": "Relative humidity, 0 to 1.", "title": "Humidity", "type": "number"},
"conditions": {"title": "Conditions", "type": "string"},
},
"required": ["temperature", "humidity", "conditions"],
"title": "WeatherData",
"type": "object",
}
)
result = await client.call_tool("get_weather", {"city": "London"})
assert result.structured_content == {"temperature": 16.2, "humidity": 0.83, "conditions": "Overcast"}
serialized = '{\n "temperature": 16.2,\n "humidity": 0.83,\n "conditions": "Overcast"\n}'
assert result.content == [TextContent(type="text", text=serialized)]
async def test_typeddict_produces_the_same_schema() -> None:
"""tutorial003: a `TypedDict` return type produces the same object schema as the `BaseModel`."""
async with Client(tutorial003.mcp) as client:
(tool,) = (await client.list_tools()).tools
assert tool.output_schema == snapshot(
{
"properties": {
"temperature": {"title": "Temperature", "type": "number"},
"humidity": {"title": "Humidity", "type": "number"},
"conditions": {"title": "Conditions", "type": "string"},
},
"required": ["temperature", "humidity", "conditions"],
"title": "WeatherData",
"type": "object",
}
)
result = await client.call_tool("get_weather", {"city": "London"})
assert result.structured_content == {"temperature": 16.2, "humidity": 0.83, "conditions": "Overcast"}
async def test_dataclass_produces_the_same_schema() -> None:
"""tutorial004: a dataclass (an annotated class) produces the same object schema again."""
async with Client(tutorial004.mcp) as client:
(tool,) = (await client.list_tools()).tools
assert tool.output_schema == snapshot(
{
"properties": {
"temperature": {"title": "Temperature", "type": "number"},
"humidity": {"title": "Humidity", "type": "number"},
"conditions": {"title": "Conditions", "type": "string"},
},
"required": ["temperature", "humidity", "conditions"],
"title": "WeatherData",
"type": "object",
}
)
result = await client.call_tool("get_weather", {"city": "London"})
assert result.structured_content == {"temperature": 16.2, "humidity": 0.83, "conditions": "Overcast"}
async def test_list_return_is_wrapped() -> None:
"""tutorial005: `-> list[WeatherData]` is wrapped in `{"result": ...}` and flattened into one block per item."""
async with Client(tutorial005.mcp) as client:
(tool,) = (await client.list_tools()).tools
assert tool.output_schema == snapshot(
{
"$defs": {
"WeatherData": {
"properties": {
"temperature": {"title": "Temperature", "type": "number"},
"humidity": {"title": "Humidity", "type": "number"},
"conditions": {"title": "Conditions", "type": "string"},
},
"required": ["temperature", "humidity", "conditions"],
"title": "WeatherData",
"type": "object",
}
},
"properties": {
"result": {"items": {"$ref": "#/$defs/WeatherData"}, "title": "Result", "type": "array"}
},
"required": ["result"],
"title": "get_forecastOutput",
"type": "object",
}
)
result = await client.call_tool("get_forecast", {"city": "London", "days": 2})
assert result.structured_content == {
"result": [
{"temperature": 16.2, "humidity": 0.83, "conditions": "Overcast"},
{"temperature": 17.2, "humidity": 0.83, "conditions": "Overcast"},
]
}
assert len(result.content) == 2
async def test_dict_str_return_is_not_wrapped() -> None:
"""tutorial006: `dict[str, float]` is already a JSON object, so there is no `result` wrapper."""
async with Client(tutorial006.mcp) as client:
(tool,) = (await client.list_tools()).tools
assert tool.output_schema == snapshot(
{"additionalProperties": {"type": "number"}, "title": "get_temperaturesDictOutput", "type": "object"}
)
result = await client.call_tool("get_temperatures", {"cities": ["London", "Reykjavik"]})
assert result.structured_content == {"London": 16.2, "Reykjavik": 4.4}
async def test_return_value_is_validated_against_the_schema() -> None:
"""tutorial007: a return value that does not match the output schema is a tool error, not a result."""
async with Client(tutorial007.mcp) as client:
result = await client.call_tool("get_weather", {"city": "London"})
assert result.is_error
assert result.structured_content is None
assert isinstance(result.content[0], TextContent)
assert result.content[0].text.startswith("Error executing tool get_weather: 1 validation error for WeatherData")
assert "humidity\n Field required" in result.content[0].text
async def test_structured_output_false_opts_out() -> None:
"""tutorial008: `structured_output=False` drops the schema and the structured channel entirely."""
async with Client(tutorial008.mcp) as client:
(tool,) = (await client.list_tools()).tools
assert tool.output_schema is None
result = await client.call_tool("weather_report", {"city": "London"})
assert result.structured_content is None
assert result.content == [
TextContent(type="text", text="London: 17 degrees, overcast, light rain easing by evening.")
]
async def test_class_without_type_hints_is_silently_unstructured() -> None:
"""tutorial009: a class with no annotations on its body gets no schema, and the model gets a `repr`."""
async with Client(tutorial009.mcp) as client:
(tool,) = (await client.list_tools()).tools
assert tool.output_schema is None
result = await client.call_tool("get_station", {"name": "north"})
assert not result.is_error
assert result.structured_content is None
assert isinstance(result.content[0], TextContent)
assert result.content[0].text.startswith('"<docs_src.structured_output.tutorial009.Station object at 0x')
def test_structured_output_true_makes_the_silence_an_error() -> None:
"""tutorial009: `structured_output=True` refuses a return type it cannot build a schema for."""
mcp = MCPServer("Weather")
with pytest.raises(InvalidSignature, match="is not serializable for structured output"):
mcp.add_tool(tutorial009.get_station, structured_output=True)
+303
View File
@@ -0,0 +1,303 @@
"""`docs/{handlers,client}/subscriptions.md`: every claim the two pages make, proved against the real SDK."""
from collections.abc import Awaitable, Callable
from typing import Any
import anyio
import mcp_types as types
import pytest
from trio.testing import MockClock
from docs_src.subscriptions import (
tutorial001,
tutorial002,
tutorial003,
tutorial004_anyio,
tutorial004_asyncio,
tutorial004_trio,
tutorial005,
)
from mcp import Client
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from mcp.server.subscriptions import SUBSCRIPTION_ID_META_KEY, ListenHandler, ToolsListChanged
_ReadResource = Callable[
[ServerRequestContext[Any], types.ReadResourceRequestParams], Awaitable[types.ReadResourceResult]
]
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
@pytest.fixture(autouse=True)
def _module_runner_lease() -> None:
"""Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`."""
class _Stream:
"""Collects listen-stream notifications and lets tests await arrival counts."""
def __init__(self) -> None:
self.received: list[types.ServerNotification] = []
self._arrival = anyio.Event()
async def handler(
self,
message: object,
) -> None:
# The only messages these connections produce are the stream's frames.
assert isinstance(
message,
types.SubscriptionsAcknowledgedNotification
| types.ResourceUpdatedNotification
| types.ToolListChangedNotification,
), message
self.received.append(message)
self._arrival.set()
self._arrival = anyio.Event()
async def wait_for(self, count: int) -> None:
with anyio.fail_after(5):
while len(self.received) < count:
await self._arrival.wait()
class _Reads:
"""Counts server-side resource reads so a test can await the Nth refetch."""
def __init__(self) -> None:
self.count = 0
self._bump = anyio.Event()
def counting(self, handler: _ReadResource) -> _ReadResource:
async def counted(
ctx: ServerRequestContext[Any], params: types.ReadResourceRequestParams
) -> types.ReadResourceResult:
result = await handler(ctx, params)
self.count += 1
self._bump.set()
self._bump = anyio.Event()
return result
return counted
async def wait_for(self, count: int) -> None:
with anyio.fail_after(5):
while self.count < count:
await self._bump.wait()
def _listen_request(**fields: Any) -> types.SubscriptionsListenRequest:
return types.SubscriptionsListenRequest(
params=types.SubscriptionsListenRequestParams(notifications=types.SubscriptionFilter(**fields))
)
@pytest.fixture(autouse=True)
def _fresh_server_state() -> Any:
"""Each test starts from an all-unfinished board and the base tool set.
The tutorials mutate module state deliberately (that is what publishes events), so the
board contents and the `enable_reports` registration have to be undone between tests.
"""
boards = {name: dict(tasks) for name, tasks in tutorial001.BOARDS.items()}
lowlevel_board = dict(tutorial002.BOARD)
tools = dict(tutorial001.mcp._tool_manager._tools) # pyright: ignore[reportPrivateUsage]
yield
tutorial001.BOARDS.clear()
tutorial001.BOARDS.update(boards)
tutorial002.BOARD.clear()
tutorial002.BOARD.update(lowlevel_board)
tutorial001.mcp._tool_manager._tools.clear() # pyright: ignore[reportPrivateUsage]
tutorial001.mcp._tool_manager._tools.update(tools) # pyright: ignore[reportPrivateUsage]
async def test_publishes_reach_the_stream_filtered_and_tagged() -> None:
"""tutorial001: the full arc - ack first, exact-URI filtering, list_changed
leading to a refreshed tool list, and client-side close."""
stream = _Stream()
async with Client(tutorial001.mcp, mode="2026-07-28", message_handler=stream.handler) as client:
async with anyio.create_task_group() as tg:
async def listen() -> None:
await client.session.send_request(
_listen_request(tools_list_changed=True, resource_subscriptions=["board://sprint"]),
types.SubscriptionsListenResult,
)
tg.start_soon(listen)
await stream.wait_for(1)
ack = stream.received[0]
assert isinstance(ack, types.SubscriptionsAcknowledgedNotification)
assert ack.params.notifications == types.SubscriptionFilter(
tools_list_changed=True, resource_subscriptions=["board://sprint"]
)
assert ack.params.meta is not None and SUBSCRIPTION_ID_META_KEY in ack.params.meta
# An edit to a URI the stream did not subscribe to stays silent...
await client.call_tool("complete_task", {"board": "backlog", "task": "tidy docs"})
# ...and the subscribed URI delivers, tagged with the same subscription id.
await client.call_tool("complete_task", {"board": "sprint", "task": "design"})
await stream.wait_for(2)
updated = stream.received[1]
assert isinstance(updated, types.ResourceUpdatedNotification)
assert updated.params.uri == "board://sprint"
assert updated.params.meta == ack.params.meta
await client.call_tool("enable_reports", {})
await stream.wait_for(3)
assert isinstance(stream.received[2], types.ToolListChangedNotification)
# The client ends the stream by closing it - cancel the parked request.
tg.cancel_scope.cancel()
# The list_changed told us to re-fetch: the new tool is there, and the
# session outlives the closed stream.
tools = await client.list_tools()
assert "sprint_report" in {tool.name for tool in tools.tools}
contents = (await client.read_resource("board://sprint")).contents[0]
assert isinstance(contents, types.TextResourceContents)
assert contents.text == "[x] design\n[ ] build\n[ ] ship"
async def test_publish_with_no_subscribers_is_a_no_op() -> None:
"""tutorial001: publishing to an idle server does nothing and breaks nothing."""
async with Client(tutorial001.mcp, mode="2026-07-28") as client:
result = await client.call_tool("complete_task", {"board": "sprint", "task": "design"})
assert result.is_error is not True
async def test_lowlevel_composition_serves_the_same_stream() -> None:
"""tutorial002: bus + ListenHandler on the lowlevel Server is the same machinery."""
stream = _Stream()
async with Client(tutorial002.server, mode="2026-07-28", message_handler=stream.handler) as client:
tools = await client.list_tools()
assert [tool.name for tool in tools.tools] == ["complete_task"]
async with anyio.create_task_group() as tg:
async def listen() -> None:
await client.session.send_request(
_listen_request(resource_subscriptions=["board://sprint"]),
types.SubscriptionsListenResult,
)
tg.start_soon(listen)
await stream.wait_for(1)
await client.call_tool("complete_task", {"task": "design"})
await stream.wait_for(2)
updated = stream.received[1]
assert isinstance(updated, types.ResourceUpdatedNotification)
assert updated.params.uri == "board://sprint"
# The bus you constructed is also the publish surface outside a
# request; an unrequested kind never reaches this stream.
await tutorial002.bus.publish(ToolsListChanged())
await client.call_tool("complete_task", {"task": "build"})
await stream.wait_for(3)
assert isinstance(stream.received[2], types.ResourceUpdatedNotification)
tg.cancel_scope.cancel()
async def test_follow_board_prints_the_refetched_board_and_the_new_tool_list(
capsys: pytest.CaptureFixture[str],
) -> None:
"""tutorial003: each event drives a refetch - the board reprints, and a tools change reprints the tool names."""
async with Client(tutorial001.mcp) as client:
async with anyio.create_task_group() as tg:
tg.start_soon(tutorial003.follow_board, client)
# Let the watcher park on its stream (ack complete) before publishing.
await anyio.wait_all_tasks_blocked()
await client.call_tool("complete_task", {"board": "sprint", "task": "design"})
await anyio.wait_all_tasks_blocked()
await client.call_tool("enable_reports", {})
await anyio.wait_all_tasks_blocked()
tg.cancel_scope.cancel()
printed = capsys.readouterr().out
assert "[x] design\n[ ] build\n[ ] ship" in printed
assert "sprint_report" in printed
EMPTY_BOARD = "[ ] design\n[ ] build\n[ ] ship"
FINISHED_BOARD = "[x] design\n[x] build\n[x] ship"
def _assert_snapshot_then_current_board(printed: str) -> None:
"""The snapshot taken inside the open subscription came first, and the watcher ended up current.
How many times the watcher printed is deliberately not asserted: identical events that pile up
unconsumed coalesce, so a fast main flow can turn three completions into one refetch. What the
stream guarantees is that no change after the acknowledgment is missed.
"""
assert printed.startswith(EMPTY_BOARD), printed
assert printed.strip().endswith(FINISHED_BOARD), printed
async def test_the_asyncio_watcher_runs_beside_the_main_flow(capsys: pytest.CaptureFixture[str]) -> None:
"""tutorial004 (asyncio tab): run_sprint opens the subscription, snapshots the board, then a watcher
task reprints it while the main flow keeps calling tools.
The example connects over HTTP; the in-memory client here is the maintainer-side stand-in."""
async with Client(tutorial001.mcp) as client:
await tutorial004_asyncio.run_sprint(client)
_assert_snapshot_then_current_board(capsys.readouterr().out)
@pytest.mark.parametrize("anyio_backend", [pytest.param("trio", id="trio")])
async def test_the_trio_watcher_runs_beside_the_main_flow(capsys: pytest.CaptureFixture[str]) -> None:
"""tutorial004 (trio tab): the same shape as the asyncio tab, with a nursery owning the watcher."""
async with Client(tutorial001.mcp) as client:
await tutorial004_trio.run_sprint(client)
_assert_snapshot_then_current_board(capsys.readouterr().out)
async def test_the_anyio_watcher_runs_beside_the_main_flow(capsys: pytest.CaptureFixture[str]) -> None:
"""tutorial004 (anyio tab): the same shape again, with a task group owning the watcher."""
async with Client(tutorial001.mcp) as client:
await tutorial004_anyio.run_sprint(client)
_assert_snapshot_then_current_board(capsys.readouterr().out)
@pytest.mark.parametrize(
"anyio_backend",
[pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")],
)
async def test_the_follower_re_listens_after_the_stream_ends(capsys: pytest.CaptureFixture[str]) -> None:
"""tutorial005: a graceful server close ends one stream; the loop backs off, re-listens, and refetches.
Runs on trio's autojumping MockClock so the loop's backoff sleep takes no wall-clock time.
"""
reads = _Reads()
handler = ListenHandler(tutorial002.bus)
server = Server(
"sprint-board",
on_read_resource=reads.counting(tutorial002.read_resource),
on_list_tools=tutorial002.list_tools,
on_call_tool=tutorial002.call_tool,
on_subscriptions_listen=handler,
)
async with Client(server) as client:
async with anyio.create_task_group() as tg:
tg.start_soon(tutorial005.keep_following, client)
# First stream: the entry refetch reads the board, then an event reads it again.
await reads.wait_for(1)
await client.call_tool("complete_task", {"task": "design"})
await reads.wait_for(2)
# End that stream gracefully. The loop backs off (the mock clock jumps the
# sleep), re-listens, and refetches on entry: that is the third read.
handler.close()
await reads.wait_for(3)
await client.call_tool("complete_task", {"task": "build"})
await reads.wait_for(4)
tg.cancel_scope.cancel()
printed = capsys.readouterr().out
assert "[x] design\n[ ] build" in printed # first stream, after design
assert "[x] design\n[x] build" in printed # second stream, after build
+23
View File
@@ -0,0 +1,23 @@
"""`docs/get-started/testing.md`: the page's own test, run for real.
The page shows this test against a `server.py` next to it; here the import path
is the only difference.
"""
import pytest
from inline_snapshot import snapshot
from mcp_types import CallToolResult, TextContent
from docs_src.testing.tutorial001 import mcp
from mcp import Client
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_call_add_tool() -> None:
async with Client(mcp, raise_exceptions=True) as client:
result = await client.call_tool("add", {"a": 1, "b": 2})
assert result == snapshot(
CallToolResult(content=[TextContent(type="text", text="3")], structured_content={"result": 3})
)
+107
View File
@@ -0,0 +1,107 @@
"""`docs/servers/tools.md`: every claim the page makes, proved against the real SDK."""
import pytest
from inline_snapshot import snapshot
from mcp_types import TextContent, ToolAnnotations
from docs_src.tools import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005
from mcp import Client
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_signature_becomes_the_schema() -> None:
"""tutorial001: the function name, the docstring and the type hints are the whole tool definition."""
async with Client(tutorial001.mcp) as client:
(tool,) = (await client.list_tools()).tools
assert tool.name == "search_books"
assert tool.description == "Search the catalog by title or author."
assert tool.input_schema == snapshot(
{
"type": "object",
"properties": {
"query": {"title": "Query", "type": "string"},
"limit": {"title": "Limit", "type": "integer"},
},
"required": ["query", "limit"],
"title": "search_booksArguments",
}
)
async def test_call_returns_text_and_structured_content() -> None:
"""tutorial001: the return value reaches the model as text and the client as typed data."""
async with Client(tutorial001.mcp) as client:
result = await client.call_tool("search_books", {"query": "dune", "limit": 5})
assert not result.is_error
assert result.content == [TextContent(type="text", text="Found 3 books matching 'dune' (showing up to 5).")]
assert result.structured_content == {"result": "Found 3 books matching 'dune' (showing up to 5)."}
async def test_default_value_makes_the_argument_optional() -> None:
"""tutorial002: a plain Python default drops the argument from `required` and lands in the schema.
The whole schema is pinned because the page quotes it verbatim.
"""
async with Client(tutorial002.mcp) as client:
(tool,) = (await client.list_tools()).tools
assert tool.input_schema == snapshot(
{
"type": "object",
"properties": {
"query": {"title": "Query", "type": "string"},
"limit": {"default": 10, "title": "Limit", "type": "integer"},
},
"required": ["query"],
"title": "search_booksArguments",
}
)
result = await client.call_tool("search_books", {"query": "dune"})
assert result.structured_content == {"result": "Found 3 books matching 'dune' (showing up to 10)."}
async def test_field_constraints_land_in_the_schema() -> None:
"""tutorial003: `Field(...)` metadata and `Literal` choices become JSON Schema the model can see."""
async with Client(tutorial003.mcp) as client:
(tool,) = (await client.list_tools()).tools
props = tool.input_schema["properties"]
assert props["query"]["description"] == "Title or author to search for."
assert props["limit"] == snapshot(
{
"default": 10,
"description": "Maximum number of results.",
"maximum": 50,
"minimum": 1,
"title": "Limit",
"type": "integer",
}
)
assert props["genre"]["anyOf"][0]["enum"] == ["fiction", "non-fiction", "poetry"]
async def test_constraint_violation_is_an_error_the_model_can_read() -> None:
"""tutorial003: an out-of-range argument is rejected by the schema, not by your code."""
async with Client(tutorial003.mcp) as client:
result = await client.call_tool("search_books", {"query": "dune", "limit": 999})
assert result.is_error
assert isinstance(result.content[0], TextContent)
assert "less than or equal to 50" in result.content[0].text
async def test_pydantic_model_parameter() -> None:
"""tutorial004: a `BaseModel` parameter nests its own schema and arrives as a real instance."""
async with Client(tutorial004.mcp) as client:
(tool,) = (await client.list_tools()).tools
assert tool.input_schema["$defs"]["Book"]["required"] == ["title", "author", "year"]
book = {"title": "Dune", "author": "Frank Herbert", "year": 1965}
result = await client.call_tool("add_book", {"book": book})
assert result.structured_content == {"result": "Added 'Dune' by Frank Herbert (1965)."}
async def test_title_and_annotations() -> None:
"""tutorial005: `title` and `ToolAnnotations` are display and behaviour metadata for the client."""
async with Client(tutorial005.mcp) as client:
(tool,) = (await client.list_tools()).tools
assert tool.title == "Search the catalog"
assert tool.annotations == ToolAnnotations(read_only_hint=True, open_world_hint=False)
+281
View File
@@ -0,0 +1,281 @@
"""`docs/troubleshooting.md`: every error string the page names, reproduced against the real SDK."""
import logging
from typing import Any
import httpx
import pytest
from mcp_types import (
INVALID_PARAMS,
INVALID_REQUEST,
MISSING_REQUIRED_CLIENT_CAPABILITY,
ElicitRequestParams,
ElicitResult,
ErrorData,
TextContent,
)
from docs_src.troubleshooting import (
tutorial001,
tutorial002,
tutorial003,
tutorial004,
tutorial005,
tutorial006,
tutorial007,
tutorial008,
)
from mcp import Client, MCPError
from mcp.client import ClientRequestContext
from mcp.client.streamable_http import streamable_http_client
from mcp.server import MCPServer
from mcp.server.mcpserver import RequestStateSecurity
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
INITIALIZE = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "b", "version": "1"}},
}
MCP_HEADERS = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"}
async def _confirm(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
"""The page's one `elicitation_callback`: always accept the booking."""
return ElicitResult(action="accept", content={"confirm": True})
async def test_an_error_leaving_the_async_with_block_arrives_wrapped_in_an_exception_group() -> None:
"""The `unhandled errors in a TaskGroup` entry: anyio group-wraps whatever escapes the block."""
with pytest.raises(Exception) as exc_info:
async with Client(tutorial001.mcp) as client:
await client.read_resource("weather://Atlantis")
assert not isinstance(exc_info.value, MCPError)
assert exc_info.group_contains(MCPError, match=r"^No forecast for 'Atlantis'\.$")
async def test_the_same_error_caught_inside_the_block_is_the_bare_mcp_error() -> None:
"""The fix on the page: `except MCPError` inside the `async with` never sees an `ExceptionGroup`."""
async with Client(tutorial001.mcp) as client:
with pytest.raises(MCPError) as exc_info:
await client.read_resource("weather://Atlantis")
assert str(exc_info.value) == "No forecast for 'Atlantis'."
assert exc_info.value.error.code == INVALID_PARAMS
async def test_a_client_outside_its_async_with_refuses_every_call() -> None:
"""`Client(...)` only constructs. Nothing connects until `async with`, so every call refuses."""
client = Client(tutorial001.mcp)
with pytest.raises(RuntimeError, match="^Client must be used within an async context manager$"):
await client.list_tools()
async def test_a_failing_tool_returns_is_error_true_instead_of_raising() -> None:
"""The `Error executing tool` entry: it is a result, not an exception. Nothing to `except`."""
async with Client(tutorial001.mcp) as client:
result = await client.call_tool("forecast", {"city": "Atlantis"})
assert result.is_error
assert result.content == [
TextContent(type="text", text="Error executing tool forecast: No forecast for 'Atlantis'.")
]
async def test_an_unknown_tool_is_the_same_kind_of_result() -> None:
"""`Unknown tool: <name>` travels the same `is_error=True` path as a failing tool."""
async with Client(tutorial001.mcp) as client:
result = await client.call_tool("get_forecast", {"city": "London"})
assert result.is_error
assert result.content == [TextContent(type="text", text="Unknown tool: get_forecast")]
async def test_the_tool_decorator_without_parentheses_raises_at_import_time() -> None:
"""`@mcp.tool` (no parentheses) hands the function itself to `name=`; the SDK refuses immediately."""
mcp = MCPServer("Weather")
undecorated: Any = mcp.tool
with pytest.raises(TypeError, match=r"Use @tool\(\) instead of @tool"):
@undecorated
def forecast(city: str) -> None:
"""Today's forecast for one city. Never called: the decoration itself is what raises."""
async def test_a_duplicate_tool_name_keeps_the_first_and_drops_the_second() -> None:
"""tutorial002: `tools/list` reports one `forecast`, and it is the first registration that won."""
async with Client(tutorial002.mcp) as client:
(tool,) = (await client.list_tools()).tools
assert tool.name == "forecast"
assert tool.description == "Today's forecast for one city."
async def test_a_duplicate_registration_logs_tool_already_exists(caplog: pytest.LogCaptureFixture) -> None:
"""The only signal for a dropped duplicate is the `Tool already exists:` warning in the server log."""
with caplog.at_level(logging.WARNING, logger="mcp.server.mcpserver.tools.tool_manager"):
@tutorial002.mcp.tool(name="forecast")
def forecast_weekly(city: str) -> None:
"""The week ahead for one city. Never called: it is the duplicate that gets dropped."""
assert "Tool already exists: forecast" in caplog.messages
async def test_the_default_streamable_http_app_answers_a_real_hostname_with_421(
caplog: pytest.LogCaptureFixture,
) -> None:
"""tutorial003: one 421, three spellings. The page presents all three as the same event."""
transport = httpx.ASGITransport(app=tutorial003.app)
async with tutorial003.mcp.session_manager.run():
# What curl (or the reverse proxy's access log) shows: the status and the plain-text body.
async with httpx.AsyncClient(transport=transport, base_url="http://mcp.example.com") as raw:
with caplog.at_level(logging.WARNING, logger="mcp.server.transport_security"):
response = await raw.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
assert (response.status_code, response.text) == (421, "Invalid Host header")
# No `Content-Type: application/json`, which is exactly why the python client cannot show the body.
assert response.headers.get("content-type") is None
# What the server operator finds by grepping the server log.
assert "Invalid Host header: mcp.example.com" in caplog.messages
# What the python `Client` raises instead: the generic stand-in, wrapped by the task group.
async with httpx.AsyncClient(transport=transport) as http_client:
client = Client(streamable_http_client("http://mcp.example.com/mcp", http_client=http_client))
with pytest.raises(Exception) as exc_info: # pragma: no branch
await client.__aenter__() # the connection attempt itself is what fails
assert not isinstance(exc_info.value, MCPError)
assert exc_info.group_contains(MCPError, match="^Server returned an error response$")
async def test_an_allowlisted_hostname_connects_and_calls_a_tool() -> None:
"""tutorial004: `transport_security=` names the deployed hostname, and the same client connects."""
transport = httpx.ASGITransport(app=tutorial004.app)
async with tutorial004.mcp.session_manager.run():
async with httpx.AsyncClient(transport=transport) as http_client:
allowed = streamable_http_client("http://mcp.example.com/mcp", http_client=http_client)
async with Client(allowed) as c: # pragma: no branch
assert c.protocol_version == "2026-07-28"
result = await c.call_tool("forecast", {"city": "London"})
assert result.structured_content == {"result": "London: Rain."}
async def test_a_mounted_app_without_a_lifespan_fails_on_the_first_request() -> None:
"""tutorial005: Starlette never runs a mounted sub-app's lifespan, so nothing starts the manager."""
transport = httpx.ASGITransport(app=tutorial005.app)
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http:
with pytest.raises(RuntimeError, match=r"Task group is not initialized\. Make sure to use run\(\)\."):
await http.post("/mcp")
async def test_a_session_id_the_server_never_issued_gets_a_404_session_not_found() -> None:
"""`Session not found` is a 404 with a JSON-RPC body, so the python `Client` surfaces it verbatim."""
mcp = MCPServer("Weather")
app = mcp.streamable_http_app()
async with mcp.session_manager.run():
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://127.0.0.1:8000") as h:
response = await h.post(
"/mcp",
json={"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}},
headers={**MCP_HEADERS, "mcp-session-id": "deadbeef"},
)
assert response.status_code == 404
assert response.headers["content-type"] == "application/json"
assert response.json() == {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Session not found"}}
async def test_ctx_elicit_at_2026_has_no_back_channel() -> None:
"""tutorial006: at 2026-07-28 the server refuses to send `elicitation/create` at all."""
async with Client(tutorial006.mcp) as client:
assert client.protocol_version == "2026-07-28"
with pytest.raises(MCPError) as exc_info:
await client.call_tool("book_table", {"date": "Friday"})
assert exc_info.value.error == ErrorData(
code=INVALID_REQUEST,
message=(
"Cannot send 'elicitation/create': "
"this transport context has no back-channel for server-initiated requests."
),
)
async def test_an_elicitation_callback_does_not_fix_ctx_elicit_at_2026() -> None:
"""The page's claim: registering the callback changes nothing. No request ever reaches the client."""
async with Client(tutorial006.mcp, elicitation_callback=_confirm) as client:
with pytest.raises(MCPError, match="no back-channel for server-initiated requests"):
await client.call_tool("book_table", {"date": "Friday"})
async def test_ctx_elicit_on_a_legacy_connection_works() -> None:
"""The legacy aside: `ctx.elicit` is a server-to-client request, and only a legacy session has those."""
async with Client(tutorial006.mcp, mode="legacy", elicitation_callback=_confirm) as client:
result = await client.call_tool("book_table", {"date": "Friday"})
assert result.structured_content == {"result": "Booked for Friday."}
async def test_the_resolver_form_works_on_a_2026_connection() -> None:
"""tutorial007: the fix. Same question, same callback, but the server returns it instead of calling back."""
async with Client(tutorial007.mcp, elicitation_callback=_confirm) as client:
assert client.protocol_version == "2026-07-28"
result = await client.call_tool("book_table", {"date": "Friday"})
assert result.structured_content == {"result": "Booked for Friday."}
async def test_the_resolver_form_without_a_callback_names_the_missing_capability() -> None:
"""The `-32021` entry: the server refuses up front, and `data` names the capability to declare."""
async with Client(tutorial007.mcp) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("book_table", {"date": "Friday"})
assert exc_info.value.error == ErrorData(
code=MISSING_REQUIRED_CLIENT_CAPABILITY,
message=(
"Client did not declare the form elicitation capability required by resolver "
"'docs_src.troubleshooting.tutorial007:ask_to_confirm'"
),
data={"requiredCapabilities": {"elicitation": {"form": {}}}},
)
async def test_a_legacy_ctx_elicit_without_a_callback_says_elicitation_not_supported() -> None:
"""The `Elicitation not supported` entry: no `elicitation_callback` means nobody to ask."""
async with Client(tutorial006.mcp, mode="legacy") as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("book_table", {"date": "Friday"})
assert exc_info.value.error == ErrorData(code=INVALID_REQUEST, message="Elicitation not supported")
async def test_ctx_elicit_over_stateless_http_has_no_back_channel() -> None:
"""tutorial008: `stateless_http=True` leaves the server no channel to send `elicitation/create`."""
transport = httpx.ASGITransport(app=tutorial008.app)
async with tutorial008.mcp.session_manager.run():
async with httpx.AsyncClient(transport=transport) as http_client:
stateless = streamable_http_client("http://127.0.0.1:8000/mcp", http_client=http_client)
async with Client(stateless) as c: # pragma: no branch
with pytest.raises(MCPError) as exc_info: # pragma: no branch
await c.call_tool("book_table", {"date": "Friday"})
assert exc_info.value.error == ErrorData(
code=INVALID_REQUEST,
message=(
"Cannot send 'elicitation/create': "
"this transport context has no back-channel for server-initiated requests."
),
)
async def test_a_request_state_the_server_did_not_mint_is_rejected(caplog: pytest.LogCaptureFixture) -> None:
"""The wire message is deliberately frozen; the real reason goes only to the server log."""
async with Client(tutorial001.mcp) as client:
with caplog.at_level(logging.WARNING, logger="mcp.server.request_state"):
with pytest.raises(MCPError) as exc_info: # pragma: no branch
await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a")
assert exc_info.value.error == ErrorData(
code=INVALID_PARAMS, message="Invalid or expired requestState", data={"reason": "invalid_request_state"}
)
assert "requestState rejected on tools/call: malformed" in caplog.messages
async def test_a_short_request_state_key_is_rejected_at_construction() -> None:
"""`RequestStateSecurity(keys=[...])` refuses anything under 32 bytes and says how to make one."""
with pytest.raises(ValueError) as exc_info:
RequestStateSecurity(keys=[b"hunter2"])
assert str(exc_info.value) == (
"request-state keys must be at least 32 bytes of secret randomness; keys[0] is 7 bytes. "
'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"'
)
+214
View File
@@ -0,0 +1,214 @@
"""`docs/servers/uri-templates.md`: every claim the page makes, proved against the real SDK."""
from pathlib import Path
import pytest
from inline_snapshot import snapshot
from mcp_types import INVALID_PARAMS, ErrorData, ResourceTemplate, TextResourceContents
from docs_src.uri_templates import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005
from mcp import Client, MCPError
from mcp.server import MCPServer
from mcp.shared.path_security import PathEscapeError, contains_path_traversal, safe_join
from mcp.shared.uri_template import InvalidUriTemplate, UriTemplate
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_simple_expansion_maps_the_segment_to_the_argument() -> None:
"""tutorial001: `books://{isbn}` reads `books://978-...` and the matched string is the argument."""
async with Client(tutorial001.mcp) as client:
(content,) = (await client.read_resource("books://978-0441172719")).contents
assert isinstance(content, TextResourceContents)
assert content.text == snapshot('{\n "title": "Dune",\n "author": "Frank Herbert"\n}')
async def test_an_int_parameter_is_converted_from_the_uri_string() -> None:
"""tutorial001: `order_id: int` receives `12345`, not `"12345"`, so `order_id + 1` is `12346`."""
async with Client(tutorial001.mcp) as client:
(content,) = (await client.read_resource("orders://12345")).contents
assert isinstance(content, TextResourceContents)
assert content.text == snapshot('{\n "order_id": 12345,\n "next_order": 12346,\n "status": "shipped"\n}')
async def test_plus_keeps_the_slashes_in_the_captured_value() -> None:
"""tutorial001: `{+path}` matches `printing/setup.md` as one value; a plain `{path}` would not."""
async with Client(tutorial001.mcp) as client:
(content,) = (await client.read_resource("manuals://printing/setup.md")).contents
assert isinstance(content, TextResourceContents)
assert content.text == "# Printer setup\n\nLoad paper, then power on."
async def test_omitted_query_params_fall_through_to_function_defaults() -> None:
"""tutorial001: `{?limit,sort}` is lenient. No query string means `limit=10, sort="newest"`."""
async with Client(tutorial001.mcp) as client:
(content,) = (await client.read_resource("reviews://978-0441172719")).contents
assert isinstance(content, TextResourceContents)
assert content.text == "10 newest reviews of Dune"
async def test_a_query_param_overrides_only_the_default_it_names() -> None:
"""tutorial001: `?sort=top` sets `sort` and leaves `limit` at its default."""
async with Client(tutorial001.mcp) as client:
(content,) = (await client.read_resource("reviews://978-0441172719?sort=top")).contents
assert isinstance(content, TextResourceContents)
assert content.text == "10 top reviews of Dune"
async def test_exploded_path_arrives_as_a_list_of_segments() -> None:
"""tutorial001: `{/path*}` splits `/fiction/sci-fi` into `["fiction", "sci-fi"]`."""
async with Client(tutorial001.mcp) as client:
(content,) = (await client.read_resource("shelves://browse/fiction/sci-fi")).contents
assert isinstance(content, TextResourceContents)
assert content.text == "catalog > fiction > sci-fi"
def test_two_adjacent_variables_are_rejected_at_parse_time() -> None:
"""'What the parser rejects': nothing separates `path` from `ext`, so the template is refused."""
with pytest.raises(InvalidUriTemplate) as exc_info:
UriTemplate.parse("manuals://{+path}{ext}")
assert str(exc_info.value) == snapshot(
"Variables 'path' and 'ext' are adjacent with no literal separator; matching cannot "
"determine where one ends and the other begins. Add a literal between them or use a single variable."
)
def test_a_self_delimiting_operator_supplies_the_separator() -> None:
"""'What the parser rejects': `{.ext}` contributes the `.` itself, so `{+path}{.ext}` is accepted."""
template = UriTemplate.parse("manuals://{+path}{.ext}")
assert template.match("manuals://printing/setup.md") == {"path": "printing/setup", "ext": "md"}
def test_a_second_multi_segment_variable_is_rejected_at_parse_time() -> None:
"""'What the parser rejects': two `{+...}` are ambiguous about which one absorbs an extra segment."""
with pytest.raises(InvalidUriTemplate) as exc_info:
UriTemplate.parse("copy://{+source}/to/{+destination}")
assert str(exc_info.value) == snapshot(
"Template contains more than one multi-segment variable ({+var}, {#var}, or explode modifier); "
"matching would be ambiguous"
)
def test_a_query_parameter_without_a_python_default_is_rejected_at_decoration_time() -> None:
"""'What the parser rejects': a client may omit `{?limit}`, so the bound parameter must declare a default."""
strict = MCPServer("Bookshop")
with pytest.raises(ValueError) as exc_info:
@strict.resource("reviews://{isbn}{?limit}")
def list_reviews(isbn: str, limit: int) -> None:
"""Reviews of a book."""
assert str(exc_info.value) == snapshot(
"Resource 'reviews://{isbn}{?limit}': query parameter(s) ['limit'] have no default value. "
"A client may omit a {?...}/{&...} query parameter, so the matching handler parameter "
"must declare a default."
)
async def test_traversal_is_rejected_before_the_handler_runs() -> None:
"""The `!!! check`: `../` triggers `-32602` "Unknown resource" and `read_manual` is never called."""
async with Client(tutorial001.mcp) as client:
with pytest.raises(MCPError) as exc_info:
await client.read_resource("manuals://../etc/passwd")
assert exc_info.value.error == snapshot(
ErrorData(
code=INVALID_PARAMS,
message="Unknown resource: manuals://../etc/passwd",
data={"uri": "manuals://../etc/passwd"},
)
)
def test_dotdot_is_a_component_check_not_a_substring_scan() -> None:
"""The page's prose: `v1.0..v2.0` passes because `..` is not a standalone path segment."""
assert contains_path_traversal("../etc") is True
assert contains_path_traversal("v1.0..v2.0") is False
async def test_safe_join_serves_a_file_inside_the_base_directory(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""tutorial002: `safe_join(DOCS_ROOT, path).read_text()` returns the file under the base."""
(tmp_path / "printing").mkdir()
(tmp_path / "printing" / "setup.md").write_text("# Printer setup")
monkeypatch.setattr(tutorial002, "DOCS_ROOT", tmp_path)
async with Client(tutorial002.mcp) as client:
(content,) = (await client.read_resource("manuals://printing/setup.md")).contents
assert isinstance(content, TextResourceContents)
assert content.text == "# Printer setup"
def test_safe_join_raises_when_the_resolved_path_escapes_the_base(tmp_path: Path) -> None:
"""tutorial002: a path that climbs out of `DOCS_ROOT` raises `PathEscapeError`."""
with pytest.raises(PathEscapeError):
safe_join(tmp_path, "../etc/passwd")
async def test_exempt_params_lets_an_absolute_path_through() -> None:
"""tutorial003: `exempt_params={"source"}` skips the checks for that one parameter."""
async with Client(tutorial003.mcp) as client:
(content,) = (await client.read_resource("imports://preview//srv/incoming/catalog.csv")).contents
assert isinstance(content, TextResourceContents)
assert content.text == "Would import from /srv/incoming/catalog.csv"
async def test_server_wide_resource_security_relaxes_every_resource() -> None:
"""tutorial003: `resource_security=ResourceSecurity(reject_path_traversal=False)` exempts the whole server."""
async with Client(tutorial003.relaxed) as client:
(content,) = (await client.read_resource("imports://preview/../sibling/catalog.csv")).contents
assert isinstance(content, TextResourceContents)
assert content.text == "Would import from ../sibling/catalog.csv"
async def test_lowlevel_static_dispatch_lists_and_reads_by_exact_uri() -> None:
"""tutorial004: the registry is the listing, and a known URI returns its text."""
async with Client(tutorial004.server) as client:
listed = (await client.list_resources()).resources
assert [r.uri for r in listed] == ["config://shop", "status://health"]
(content,) = (await client.read_resource("status://health")).contents
assert content == TextResourceContents(uri="status://health", text="ok")
async def test_lowlevel_unknown_uri_raises() -> None:
"""tutorial004: a URI outside the registry raises and surfaces as a protocol error."""
async with Client(tutorial004.server) as client:
with pytest.raises(MCPError):
await client.read_resource("config://missing")
def test_uritemplate_match_returns_a_dict_or_none() -> None:
"""tutorial005: `match()` extracts decoded variables, or `None` when the URI doesn't fit."""
assert tutorial005.TEMPLATES["manuals"].match("manuals://printing/setup.md") == {"path": "printing/setup.md"}
assert tutorial005.TEMPLATES["books"].match("manuals://nope") is None
async def test_lowlevel_match_routes_the_request_to_the_right_template() -> None:
"""tutorial005: two templates, one handler. Each concrete URI lands in its own branch."""
async with Client(tutorial005.server) as client:
(manual,) = (await client.read_resource("manuals://printing/setup.md")).contents
assert manual == TextResourceContents(uri="manuals://printing/setup.md", text="# Printer setup")
(book,) = (await client.read_resource("books://978-0441172719")).contents
assert book == TextResourceContents(uri="books://978-0441172719", text="Dune by Frank Herbert")
async def test_lowlevel_handler_applies_the_safety_checks_itself() -> None:
"""tutorial005: there is no default policy down here; `read_manual_safely` is the gate."""
async with Client(tutorial005.server) as client:
with pytest.raises(MCPError):
await client.read_resource("manuals://../etc/passwd")
with pytest.raises(MCPError):
await client.read_resource("nothing://matches")
async def test_str_of_a_template_round_trips_to_the_original_string() -> None:
"""tutorial005: `str(template)` is the source string, so the listing reuses the parsed templates."""
assert str(tutorial005.TEMPLATES["manuals"]) == "manuals://{+path}"
async with Client(tutorial005.server) as client:
result = await client.list_resource_templates()
assert result.resource_templates == snapshot(
[
ResourceTemplate(name="manuals", uri_template="manuals://{+path}"),
ResourceTemplate(name="books", uri_template="books://{isbn}"),
]
)
+65
View File
@@ -0,0 +1,65 @@
"""`docs/whats-new.md`: the v2 half of the low-level before/after example, proved against the real SDK.
The v1 half of that example targets the 1.x line and cannot run here; it was
validated by running it verbatim against a real `mcp==1.28.1` install.
"""
import pytest
from mcp_types import INTERNAL_ERROR, INVALID_PARAMS, TextContent
from docs_src.whats_new import tutorial001
from mcp import Client, MCPError
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_the_advertised_schema_is_the_literal_dict() -> None:
"""Annotation 1: the schema is advertised to clients exactly as written."""
async with Client(tutorial001.server) as client:
(tool,) = (await client.list_tools()).tools
assert tool.name == "search_books"
assert tool.input_schema == {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
}
async def test_a_valid_call_answers() -> None:
"""The example works end to end through the in-process `Client`."""
async with Client(tutorial001.server) as client:
result = await client.call_tool("search_books", {"query": "dune"})
assert not result.is_error
assert result.content == [TextContent(type="text", text="Found 3 books matching 'dune'.")]
async def test_arguments_are_not_validated_and_a_handler_exception_is_sanitized() -> None:
"""Annotations 1, 6, and 7, in one flow.
A call missing the required `query` REACHES the handler (nothing validates
arguments against `input_schema`; v1 rejected this call before the handler
ran). The handler's own `KeyError` then comes back as a sanitized protocol
error, never an `is_error=True` result the model could read. A call with no
arguments at all exercises `params.arguments or {}` the same way.
"""
async with Client(tutorial001.server) as client:
with pytest.raises(MCPError) as excinfo:
await client.call_tool("search_books", {"limit": 5})
assert excinfo.value.code == INTERNAL_ERROR
assert excinfo.value.message == "Internal server error"
with pytest.raises(MCPError) as excinfo:
await client.call_tool("search_books")
assert excinfo.value.code == INTERNAL_ERROR
assert excinfo.value.message == "Internal server error"
async def test_an_unknown_tool_is_a_deliberate_wire_error() -> None:
"""Annotation 5: a raised `MCPError` passes through with its code and message
intact (the spec's answer for an unknown tool), unlike the sanitized path."""
async with Client(tutorial001.server) as client:
with pytest.raises(MCPError) as excinfo:
await client.call_tool("shelve_book", {"query": "dune"})
assert excinfo.value.code == INVALID_PARAMS
assert excinfo.value.message == "Unknown tool: shelve_book"
View File
+170
View File
@@ -0,0 +1,170 @@
"""Discovery + parametrization for the example-stories matrix.
Reads ``examples/stories/manifest.toml`` and expands each story across
(server_variant × transport × era). The story modules are imported as
real packages (the ``mcp-example-stories`` workspace member installs ``stories``
editable), so pyright sees them and a signature change red-lines every story.
The HTTP-ASGI leg reuses the interaction suite's in-process bridge directly
from ``tests.interaction.transports._bridge`` (both live under ``tests/``); the
move to ``stories._shared.bridge`` is a later batch.
"""
from __future__ import annotations
import importlib
import sys
from collections.abc import AsyncIterator
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import httpx
import pytest
import stories
from mcp_types.version import LATEST_MODERN_VERSION
from starlette.applications import Starlette
from stories._harness import AuthBuilder, TargetFactory
from stories._hosting import asgi_from
from mcp.client.streamable_http import streamable_http_client
from tests.interaction.transports._bridge import StreamingASGITransport
if sys.version_info >= (3, 11): # pragma: lax no cover
import tomllib
else: # pragma: lax no cover
import tomli as tomllib
STORIES_DIR = Path(stories.__file__).parent
BASE_URL = "http://127.0.0.1:8000"
MANIFEST = tomllib.loads((STORIES_DIR / "manifest.toml").read_text())
DEFAULTS: dict[str, Any] = MANIFEST["defaults"]
STORIES: dict[str, dict[str, Any]] = MANIFEST["story"]
_ERA_TO_MODE = {"modern": LATEST_MODERN_VERSION, "legacy": "legacy", "in-body": "auto"}
"""``Client`` rejects handshake-era version strings, so ``legacy`` resolves to
``mode='legacy'`` rather than ``LATEST_HANDSHAKE_VERSION``. ``in-body`` legs pin
their connection modes inside ``main`` themselves, so they get ``"auto"`` — the
``Client`` default; the era axis still passes every ``mode=`` explicitly."""
def story_cfg(name: str) -> dict[str, Any]:
return DEFAULTS | STORIES.get(name, {})
def _expand_era(era: str) -> tuple[str, ...]:
if era == "dual":
return ("modern", "legacy")
if era == "dual-in-body":
return ("in-body",)
return (era,)
@dataclass(frozen=True)
class Leg:
story: str
server_variant: str
transport: str
era: str
@property
def id(self) -> str:
return "-".join((self.story, self.server_variant, self.transport, self.era))
@property
def mode(self) -> str:
"""The explicit ``mode=`` this leg passes to the story's ``main``."""
return _ERA_TO_MODE[self.era]
def _legs() -> list[tuple[Leg, dict[str, Any]]]:
out: list[tuple[Leg, dict[str, Any]]] = []
for name in STORIES:
cfg = story_cfg(name)
variants = ["server"] + (["server_lowlevel"] if cfg["lowlevel"] else [])
out.extend(
(Leg(name, variant, transport, era), cfg)
for variant in variants
for transport in cfg["transports"]
for era in _expand_era(cfg["era"])
)
return out
def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
if "leg" not in metafunc.fixturenames:
return
params: list[Any] = []
for leg, cfg in _legs():
marks: list[pytest.MarkDecorator] = []
if f"{leg.transport}:{leg.era}" in cfg["xfail"]:
marks.append(pytest.mark.xfail(strict=True, reason="manifest xfail")) # pragma: lax no cover
params.append(pytest.param(leg, marks=marks, id=leg.id))
metafunc.parametrize("leg", params)
@pytest.fixture
def cfg(leg: Leg) -> dict[str, Any]:
return story_cfg(leg.story)
@pytest.fixture
def server_module(leg: Leg) -> Any:
return importlib.import_module(f"stories.{leg.story}.{leg.server_variant}")
@pytest.fixture
def client_module(leg: Leg) -> Any:
return importlib.import_module(f"stories.{leg.story}.client")
@dataclass
class Hosted:
"""One server/app instance hosted for the leg's whole duration.
``targets`` yields a fresh connection target against that single instance on
every call, so state observed by one connection is visible to the next.
``http`` is the shared raw ``httpx.AsyncClient`` bound to the same ASGI app,
or ``None`` on the in-memory leg.
"""
targets: TargetFactory
http: httpx.AsyncClient | None
@pytest.fixture
async def hosted(
leg: Leg, cfg: dict[str, Any], server_module: Any, client_module: Any, monkeypatch: pytest.MonkeyPatch
) -> AsyncIterator[Hosted]:
"""Build the leg's server/app once and keep it running for the test.
The story's ``main`` owns the ``Client(target, mode=...)`` construction; this
fixture only decides what ``target`` is. Auth stories thread an ``httpx.Auth``
onto the bridge client via a module-level ``build_auth(http)`` export.
"""
for key, value in cfg["env"].items():
monkeypatch.setenv(key, value)
path = cfg["mcp_path"]
if leg.transport == "in-memory":
server = server_module.build_server()
yield Hosted(lambda: server, None)
return
# http-asgi: one Starlette app per leg. ``server_export="app"`` stories hand us the
# app directly; ``"factory"`` stories are wrapped via ``asgi_from``. Either way the
# app's own lifespan is what brings the session manager up, and the in-process
# bridge never fires ASGI lifespan events itself, so enter it explicitly.
if cfg["server_export"] == "app":
app: Starlette = server_module.build_app()
else:
app = asgi_from(server_module.build_server(), path=path)
build_auth: AuthBuilder | None = getattr(client_module, "build_auth", None)
async with (
app.router.lifespan_context(app),
httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http_client,
):
if build_auth is not None:
http_client.auth = build_auth(http_client)
yield Hosted(lambda: streamable_http_client(f"{BASE_URL}{path}", http_client=http_client), http_client)
+74
View File
@@ -0,0 +1,74 @@
"""Run every story's ``main`` over the in-process (transport × era × variant) matrix."""
from __future__ import annotations
import importlib
import inspect
from typing import Any
import anyio
import pytest
from tests.examples.conftest import MANIFEST, STORIES, STORIES_DIR, Hosted, Leg, story_cfg
pytestmark = pytest.mark.anyio
async def test_story(leg: Leg, cfg: dict[str, Any], hosted: Hosted, client_module: Any) -> None:
kwargs: dict[str, Any] = {"mode": leg.mode}
if cfg["needs_http"]:
kwargs["http"] = hosted.http
with anyio.fail_after(cfg["timeout_s"]):
if cfg["multi_connection"]:
await client_module.main(hosted.targets, **kwargs)
else:
await client_module.main(hosted.targets(), **kwargs)
def test_manifest_matches_filesystem() -> None:
"""Manifest [story.*] / [deferred] keys and on-disk story directories agree exactly."""
dirs = {d.name for d in STORIES_DIR.iterdir() if d.is_dir() and not d.name.startswith(("_", "."))}
runnable = {d for d in dirs if (STORIES_DIR / d / "client.py").exists()}
in_manifest = set(STORIES)
assert runnable == in_manifest, {"only_on_disk": runnable - in_manifest, "only_in_manifest": in_manifest - runnable}
# README-only stub dirs must be exactly the [deferred] table.
deferred_manifest = set(MANIFEST.get("deferred", {}))
assert dirs - runnable == deferred_manifest, {
"stub_dirs_missing_from_manifest": (dirs - runnable) - deferred_manifest,
"deferred_entries_missing_dir": deferred_manifest - (dirs - runnable),
}
assert runnable.isdisjoint(deferred_manifest), "deferred stories must not have a client.py"
_ERAS = {"dual", "modern", "legacy", "dual-in-body"}
_TRANSPORTS = {"in-memory", "http-asgi"}
_SERVER_EXPORTS = {"factory", "app"}
def test_manifest_schema_valid() -> None:
"""Declared manifest values are mutually consistent with the story files."""
for name in STORIES:
cfg = story_cfg(name)
assert "-" not in name, f"{name!r}: story directories must be underscored"
assert cfg["era"] in _ERAS, f"{name!r}: era={cfg['era']!r} not in {_ERAS}"
assert cfg["server_export"] in _SERVER_EXPORTS, f"{name!r}: server_export={cfg['server_export']!r}"
assert set(cfg["transports"]) <= _TRANSPORTS, f"{name!r}: transports={cfg['transports']!r}"
assert (STORIES_DIR / name / "__init__.py").exists(), f"{name!r}: missing __init__.py"
if cfg["server_export"] == "factory":
assert (STORIES_DIR / name / "server.py").exists(), f"{name!r}: missing server.py"
else:
assert "in-memory" not in cfg["transports"], f"{name!r}: server_export='app' cannot run in-memory"
if cfg["needs_http"]:
assert cfg["transports"] == ["http-asgi"], f"{name!r}: needs_http requires transports=['http-asgi']"
ll = STORIES_DIR / name / "server_lowlevel.py"
assert cfg["lowlevel"] == ll.exists(), f"{name!r}: lowlevel={cfg['lowlevel']} vs server_lowlevel.py on disk"
@pytest.mark.parametrize("name", sorted(STORIES))
def test_main_signature_matches_manifest(name: str) -> None:
"""``main``'s first parameter is ``target``/``targets`` per ``multi_connection``; ``http`` iff ``needs_http``."""
cfg = story_cfg(name)
params = list(inspect.signature(importlib.import_module(f"stories.{name}.client").main).parameters)
first = "targets" if cfg["multi_connection"] else "target"
assert params[0] == first, f"{name}: first param is {params[0]!r}, expected {first!r}"
assert ("http" in params) == cfg["needs_http"], f"{name}: 'http' param vs needs_http={cfg['needs_http']}"
+57
View File
@@ -0,0 +1,57 @@
"""Subprocess smoke for the story ``__main__`` paths.
The in-process matrix in ``test_stories.py`` never executes a story's
``if __name__ == "__main__"`` block, so ``run_client`` / ``run_server_from_args`` /
``run_app_from_args`` and the real stdio + uvicorn entries are unverified by
construction. This file proves that plumbing by running the literal commands the
story READMEs print: stdio (``run_client`` spawns the server over stdio) and bare
``--http`` (``run_client`` self-hosts the server on a real uvicorn socket on a
port it owns, then terminates it).
lax no cover: gated on ``MCP_EXAMPLES_SMOKE=1``, which CI sets on exactly one
matrix cell (ubuntu / 3.12 / locked — see ``shared.yml``). Every other cell
skips at collection, so the test body is uncovered there and the per-job 100%
gate would otherwise fail.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
import anyio
import pytest
pytestmark = [
pytest.mark.anyio,
pytest.mark.skipif(
os.environ.get("MCP_EXAMPLES_SMOKE") != "1",
reason="subprocess smoke runs on one CI cell only; set MCP_EXAMPLES_SMOKE=1",
),
]
_REPO_ROOT = Path(__file__).parents[2]
# httpx in the spawned client honours these and tries to mount a SOCKS transport even for
# 127.0.0.1; strip them so the smoke run is hermetic regardless of the caller's shell.
_PROXY_VARS = {v for base in ("all_proxy", "http_proxy", "https_proxy", "ftp_proxy") for v in (base, base.upper())}
_ENV = {k: v for k, v in os.environ.items() if k not in _PROXY_VARS}
@pytest.mark.parametrize(
"argv",
[
("stories.tools.client",),
("stories.tools.client", "--http"),
("stories.bearer_auth.client", "--http"),
],
ids=["tools-stdio", "tools-http", "bearer_auth-http"],
)
async def test_story_main_runs_end_to_end(argv: tuple[str, ...]) -> None: # pragma: lax no cover
"""``python -m <story>.client [--http]`` (the README command) exits 0 over a real subprocess."""
with anyio.fail_after(60):
async with await anyio.open_process(
[sys.executable, "-m", *argv], cwd=_REPO_ROOT, env=_ENV, stdout=None, stderr=None
) as proc:
await proc.wait()
assert proc.returncode == 0
+122
View File
@@ -0,0 +1,122 @@
"""AST shape-check: stories keep the SDK construction visible and the harness contained.
The python analogue of typescript-sdk's eslint import-allowlist over its examples,
strictly stronger: it also asserts each ``main`` constructs ``Client(...)`` itself —
the regression the harness inversion exists to prevent.
"""
from __future__ import annotations
import ast
from pathlib import Path
import pytest
from tests.examples.conftest import STORIES, STORIES_DIR, story_cfg
_HARNESS_ALLOWLIST = frozenset({"run_client", "target_from_args", "Target", "TargetFactory"})
"""The only ``stories._harness`` names a ``client.py`` may use. ``AuthBuilder`` is
additionally allowed in a ``client.py`` that defines ``build_auth`` (the auth seam
``run_client`` and the conftest both look up by name)."""
_MCPSERVER_TIER = ("mcp.server.mcpserver", "mcp.server.MCPServer")
"""Both spellings of the high-level tier: the ``mcpserver`` module and its ``mcp.server`` re-export."""
_LOWLEVEL_STORIES = [name for name in sorted(STORIES) if story_cfg(name)["lowlevel"]]
def _parse(path: Path) -> ast.Module:
"""Parse ``path`` into an AST module."""
return ast.parse(path.read_text(), filename=str(path))
def _resolve(node: ast.ImportFrom, package: str) -> str:
"""The absolute module path ``node`` imports from, resolving a relative import against ``package``."""
parents = package.split(".")[: -(node.level - 1) or None] if node.level else []
return ".".join([*parents, *([node.module] if node.module else [])])
def _module_paths(tree: ast.Module, package: str) -> set[str]:
"""Every dotted module path the file (a module in ``package``) references — imports, with relative
ones resolved to absolute, plus attribute chains rooted at an import-bound name (``import mcp.shared``
+ ``mcp.shared._memory.f()``), so a reach-in is caught however it is spelled."""
paths: set[str] = set()
bound: dict[str, str] = {}
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
paths.add(alias.name)
local = alias.asname or alias.name.partition(".")[0]
bound[local] = alias.name if alias.asname else local
elif isinstance(node, ast.ImportFrom):
module = _resolve(node, package)
for alias in node.names:
paths.add(f"{module}.{alias.name}")
bound[alias.asname or alias.name] = f"{module}.{alias.name}"
for node in ast.walk(tree):
attrs: list[str] = []
expr: ast.AST = node
while isinstance(expr, ast.Attribute):
attrs.append(expr.attr)
expr = expr.value
if attrs and isinstance(expr, ast.Name) and expr.id in bound:
paths.add(".".join([bound[expr.id], *reversed(attrs)]))
return paths
def _is_private_mcp(path: str) -> bool:
"""True when ``path`` crosses a ``_``-private segment inside the ``mcp`` package."""
head, *rest = path.split(".")
return head == "mcp" and any(part.startswith("_") for part in rest)
def _is_story_module(path: str) -> bool:
"""True for ``stories.<story>...`` — a story package, not a ``stories._*`` scaffold."""
head, _, rest = path.partition(".")
return head == "stories" and bool(rest) and not rest.startswith("_")
@pytest.mark.parametrize("name", sorted(STORIES))
def test_main_constructs_client_inline(name: str) -> None:
"""``main``'s body contains a literal ``Client(...)`` call; the construction is never hidden in a helper."""
tree = _parse(STORIES_DIR / name / "client.py")
mains = [n for n in tree.body if isinstance(n, ast.AsyncFunctionDef) and n.name == "main"]
assert mains, f"{name}/client.py defines no top-level async `main`"
calls = {n.func.id for n in ast.walk(mains[0]) if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)}
assert "Client" in calls, f"{name}/client.py: main() never calls Client(...) itself"
@pytest.mark.parametrize("name", sorted(STORIES))
def test_client_harness_imports_within_allowlist(name: str) -> None:
"""``client.py`` takes nothing from ``stories._harness`` beyond the allowlist, bounding the harness surface."""
tree = _parse(STORIES_DIR / name / "client.py")
defines_build_auth = any(isinstance(n, ast.FunctionDef) and n.name == "build_auth" for n in tree.body)
allowed = _HARNESS_ALLOWLIST | {"AuthBuilder"} if defines_build_auth else _HARNESS_ALLOWLIST
paths = _module_paths(tree, package=f"stories.{name}")
used = {p.removeprefix("stories._harness.").partition(".")[0] for p in paths if p.startswith("stories._harness.")}
assert used <= allowed, f"{name}/client.py uses {sorted(used - allowed)} from stories._harness"
@pytest.mark.parametrize("name", sorted(STORIES))
def test_story_files_import_no_private_mcp_module(name: str) -> None:
"""No file in a story directory references a ``_``-private ``mcp.*`` module."""
for path in sorted((STORIES_DIR / name).glob("*.py")):
private = sorted(p for p in _module_paths(_parse(path), package=f"stories.{name}") if _is_private_mcp(p))
assert not private, f"{path.relative_to(STORIES_DIR)} reaches into private mcp module(s): {private}"
@pytest.mark.parametrize("name", _LOWLEVEL_STORIES)
def test_server_lowlevel_imports_no_mcpserver_tier(name: str) -> None:
"""``server_lowlevel.py`` stays on the lowlevel tier; it never references ``MCPServer`` or its module."""
paths = _module_paths(_parse(STORIES_DIR / name / "server_lowlevel.py"), package=f"stories.{name}")
high = sorted(p for p in paths if any(f"{p}.".startswith(f"{tier}.") for tier in _MCPSERVER_TIER))
assert not high, f"{name}/server_lowlevel.py references the MCPServer tier: {high}"
@pytest.mark.parametrize("scaffold", ["_harness.py", "_hosting.py"])
def test_scaffold_imports_no_story_module(scaffold: str) -> None:
"""The dependency is one-way: ``_harness.py`` / ``_hosting.py`` import no ``stories.<story>`` module."""
story_refs = sorted(
p for p in _module_paths(_parse(STORIES_DIR / scaffold), package="stories") if _is_story_module(p)
)
assert not story_refs, f"{scaffold} imports a story module: {story_refs}"
+287
View File
@@ -0,0 +1,287 @@
# Interaction-model test suite
This suite enumerates the MCP interaction model as end-to-end tests: one test per piece of
functionality, asserting the full client↔server round trip through the public API. It exists to
pin the SDK's observable behaviour — every request type, every notification direction, every
error plane — so that internal rewrites of the send/receive path can be proven equivalent by
running the suite before and after.
```bash
uv run --frozen pytest tests/interaction/
```
The whole suite is in-process and event-driven — including the streamable HTTP, SSE, and OAuth
flows — with a single subprocess test for stdio.
## Ground rules
- **Public API only.** Tests drive a `Client` connected to a `Server` or `MCPServer`. Nothing
reaches into session internals, so the suite keeps working when those internals change.
`ClientSession` is used directly only for behaviours `Client` cannot express (skipping
initialization, requesting a non-default protocol version).
- **Pin current behaviour.** Every test passes against the current `main`, including behaviours
that diverge from the specification. A failing or xfailed test proves nothing about whether a
rewrite preserved behaviour; a passing test that pins the wrong output exactly does. Known
divergences are recorded as data on the requirement (see below), not worked around in the test.
- **Spec-mandated assertions, not implementation quirks.** Error *codes* are asserted against
the constants in `mcp_types`; error *message strings* are pinned only where they are the
SDK's own deliberate output.
- **No sleeps, no real I/O.** Concurrency is coordinated with `anyio.Event`; every wait that
could hang is bounded by `anyio.fail_after(5)`. A test that must let in-flight deliveries
settle before teardown (an abandoned request's late error response, say) may use
`anyio.wait_all_tasks_blocked()`: the whole suite is single-loop and task-driven, so
quiescence is deterministic. The HTTP and OAuth tests drive the Starlette
app in-process through the suite's streaming ASGI bridge (`transports/_bridge.py`), which
delivers each response chunk as the server produces it — full duplex, but still no sockets,
threads, or subprocesses anywhere outside the one stdio test.
## Layout
```text
tests/interaction/
_requirements.py the requirements manifest (see below)
_helpers.py shared type aliases + the wire-recording transport
_connect.py the transport-parametrized connection factories
conftest.py the connect fixture (the transport matrix)
test_coverage.py enforces the manifest ↔ test contract
lowlevel/ one file per feature area, against the low-level Server
mcpserver/ the same feature areas in MCPServer's natural idiom
transports/ behaviour specific to one transport (sessions, resumability, framing)
auth/ OAuth flows against an in-process authorization server
```
The two server APIs produce genuinely different wire output for the same conceptual feature
(`MCPServer` generates schemas, converts exceptions to `isError` results, attaches structured
content), so they get parallel directories with mirrored file names rather than one parametrized
test body — each directory pins its flavour's true output exactly.
### The transport matrix
Transport-agnostic tests take the `connect` fixture instead of constructing `Client(server)`
directly, and therefore run once per transport: over the in-memory transport, over the server's
real streamable HTTP app driven in-process through the streaming bridge (in both stateful and
stateless configurations), and over the legacy SSE transport the same way. A test connects with
`async with connect(server, ...) as client:` and asserts the same output on every leg, because the
transport is not supposed to change observable behaviour. Requirements that need a server-to-client
back-channel or persisted session state are carved out of the stateless arm via `arm_exclusions`.
Tests that are tied to one transport do not use the fixture: the wire-recording tests
(their seam is the in-memory stream pair), the bare-`ClientSession` lifecycle tests, the
real-clock timeout tests (the timeout machinery is transport-independent and must not race
transport latency), and everything under `transports/`, which pins behaviour only observable on
that transport.
A transport conformance test in `transports/` speaks raw `httpx` against the mounted ASGI app
**only** when its assertion is about HTTP semantics that `Client` cannot observe — status codes,
response headers, SSE event fields, which stream a message travels on. Any other behaviour is
asserted through a `Client`, connected to the mounted app via `client_via_http(http)` so several
clients can share one session manager.
## The requirements manifest
`_requirements.py` maps every behaviour the suite covers to the reason it must hold:
```python
"tools:call:content:text": Requirement(
source=f"{SPEC_BASE_URL}/server/tools#text-content",
behavior="tools/call delivers arguments to the tool handler and returns its text content.",
),
```
- **`source`** is a deep link into the MCP specification for externally mandated behaviour,
the literal string `"sdk"` for behaviour the SDK chose where the spec is silent, or
`"issue:#n"` for a regression lock.
- **`behavior`** describes the *required* behaviour — what the specification (or the SDK's own
contract) says should happen. Tests always pin the SDK's current behaviour; where that falls
short of `behavior`, the gap is recorded as data rather than hidden in the test.
- **`divergence`** records that gap for entries whose tests pin the divergent current behaviour.
- **`deferred`** marks a behaviour that is tracked but has no test in this suite, with a precise
reason: the SDK does not implement it, the negative cannot be observed, the assertion is
schema-level rather than interaction-level, the feature is experimental (tasks), or the test
would require real-time waits the suite refuses.
- **`transports`** names the transports a behaviour applies to; omitted means transport-independent.
- **`issue`** carries the tracking link for a recorded gap once one is filed.
- **`note`** carries free-form context that does not fit `divergence` or `deferred`.
- **`added_in`** / **`removed_in`** bound the spec versions the behaviour exists in, as a half-open
`[added_in, removed_in)` window.
- **`supersedes`** / **`superseded_by`** link a retired entry to its replacement; the link is
bidirectional and both ends must be versioned.
- **`arm_exclusions`** carve specific `(transport, spec_version)` matrix cells out with a typed
`ArmExclusionReason`.
- **`known_failures`** mark specific `(transport, spec_version)` cells as strict xfail.
Tests link themselves to the manifest with a decorator:
```python
@requirement("tools:call:content:text")
async def test_call_tool_returns_text_content() -> None: ...
```
`test_coverage.py` enforces the contract in both directions: every non-deferred requirement must
be exercised by at least one test, every deferred requirement by none, and an unknown ID fails at
import time. A behaviour without a manifest entry cannot be silently half-tested, and a manifest
entry without a test cannot be silently aspirational.
### The divergence lifecycle
1. A test reveals that the SDK does not do what the spec says. The test pins what the SDK
*actually does* and a `Divergence(note=..., issue=...)` goes on the requirement.
2. When the behaviour is eventually fixed, the pinned test fails. Whoever makes the change finds
the divergence note explaining that the old behaviour was a known gap, re-pins the test to the
spec-correct output, and deletes the `Divergence`.
3. An empty divergence list means the SDK is spec-conformant on every behaviour the suite covers.
A requirement may carry both `divergence` and `deferred`: the divergence records that the SDK falls
short of the spec, and the deferral records why no test pins it (typically because the divergent
behaviour cannot be driven through the public API). Divergence alone implies a test pins the
divergent behaviour; divergence plus deferred means the gap is known but unpinned.
This is also the triage key for any rewrite: a test that fails on the new code path either has a
divergence note (the rewrite accidentally fixed a known gap — decide whether to keep the fix) or
it does not (the rewrite broke something that was correct — fix the rewrite).
### Spec versions and the era axis
`SPEC_VERSIONS` in `_requirements.py` is the ordered tuple of protocol revisions the suite
exercises. `SPEC_BASE_URL` (and `SPEC_2026_BASE_URL`) are pinned literals — not derived from
`SPEC_VERSIONS` — so growing the active axis never repoints existing `source` links. The
`connect` fixture fans out over `CONNECTABLE_TRANSPORTS × SPEC_VERSIONS`, but the grid is
filtered per test:
`pytest_generate_tests` reads the test's stacked `@requirement` marks and calls `compute_cells()`,
which intersects the admissible cells across every cited requirement — a cell survives only if
**all** of the test's requirements admit it.
`streamable-http-stateless` is the fourth connectable transport: the 2025-era unofficial stateless
mode where each request opens a fresh transport, no session id is issued, and there is no standalone
GET stream. Requirements that need a server→client back-channel or persisted session state are
excluded from that arm via `arm_exclusions` (reasons `server-initiated-request` and
`requires-session`).
What admits or excludes a cell:
- **`added_in` / `removed_in`** gate which spec versions a requirement exists in, as a half-open
`[added_in, removed_in)` window. A test runs only on versions inside every cited requirement's
window.
- **`arm_exclusions`** carve specific `(transport, spec_version)` cells out with a typed
`ArmExclusionReason`. The reason vocabulary doubles as a re-admission checklist: when the gap
closes, grep for the reason string to find every cell to re-admit.
- **`known_failures`** keep a cell in the grid but mark it as a strict xfail — the test runs and
must fail; an unexpected pass fails the suite.
- **`TRANSPORT_SPEC_VERSIONS`** era-locks a transport to a subset of spec versions (currently only
`sse` is locked to `2025-11-25`). A `(transport, version)` cell is dropped if the version is not
in the transport's entry; transports absent from the map serve every spec version. This is the
mechanism for cutting an entire transport off from a new revision (or admitting it).
- **`transports`** is descriptive metadata for the non-`connect` transport-specific suites under
`transports/` and does **not** drive cell generation. Only `arm_exclusions`, `added_in`,
`removed_in`, and `TRANSPORT_SPEC_VERSIONS` filter the grid.
- **`supersedes` / `superseded_by`** link a retired entry to its replacement. `test_coverage.py`
enforces that links are bidirectional and versioned: the retired entry carries `removed_in`, the
replacement carries `added_in`.
Node IDs stay `[transport]` while `len(SPEC_VERSIONS) == 1`, so today's test IDs are
byte-identical to before the era axis existed. They become `[transport-version]` the moment a
second version is appended to `SPEC_VERSIONS`.
When a new spec revision lands:
1. Append the version string to `SPEC_VERSIONS` (and to the `SpecVersion` `Literal`).
2. Walk the new revision's changelog.
3. For each affected requirement: set `removed_in` on retired behaviour, add a new entry with
`added_in` for its replacement, and link the pair with `supersedes` / `superseded_by`.
Behaviour that survives unchanged needs nothing beyond a re-audit of its `source` URL.
4. For requirements that cannot run on the new era's path, add an `arm_exclusions` entry with the
appropriate `ArmExclusionReason`.
5. Review `TRANSPORT_SPEC_VERSIONS`: any era-locked transport will not produce cells on the new
version unless its entry is extended (or removed); add an entry for any transport the new
revision retires.
## Writing a test
The shortest complete example of the conventions:
```python
@requirement("tools:call:content:text")
async def test_call_tool_returns_text_content() -> None:
"""Arguments reach the tool handler; its content comes back as the call result."""
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "add"
assert params.arguments is not None
return CallToolResult(content=[TextContent(text=str(params.arguments["a"] + params.arguments["b"]))])
server = Server("adder", on_call_tool=call_tool)
async with Client(server) as client:
result = await client.call_tool("add", {"a": 2, "b": 3})
assert result == snapshot(CallToolResult(content=[TextContent(text="5")]))
```
- **The server is defined inside the test** (or in a small fixture at the top of the file when
several tests genuinely share it). The whole observable behaviour fits on one screen.
- **Test names are behaviour sentences** — they state the observable outcome, not the feature
being poked. Docstrings add the one or two sentences of context a reviewer needs, including
whether the assertion is spec-mandated, SDK-defined, or a known divergence.
- **Handlers assert their dispatch identity first** (`assert params.name == "add"`), proving the
request that arrived is the request the test sent.
- **The result proves the round trip.** Server-side observations travel back to the test through
the protocol itself (a tool returns what it saw) or through a closure-captured list; the test
asserts after the call returns.
- **Order within a test**: server handlers → server construction → client callbacks → connect →
act → assert. The test reads in the order the conversation happens.
- A registered handler or tool that a test never invokes gets a `raise NotImplementedError` body
so it cannot silently become load-bearing.
- A test that needs a peer no real `Server` or `Client` can play (a server that answers initialize
with an unsupported version, a client that sends malformed params) plays that side of the wire by
hand over `create_client_server_memory_streams()`. This scripted-peer pattern is the suite's only
way to drive behaviour the typed API cannot produce, and the docstring of every such test says so.
Stack a second `@requirement` decorator only when a test's natural assertions incidentally prove
another behaviour — one capabilities snapshot proving four `*:capability:declared` entries, one
input-schema identity check proving each preserved keyword. Do not build a test around covering
many requirements at once; if the assertions would be separate, write separate tests.
### Choosing an assertion
| The property under test is… | Assert with |
|---|---|
| the result of a transformation (arguments → output, exception → error result) | `result == snapshot(...)` of the full object, so any field the implementation adds or drops fails the test |
| pass-through of an opaque value (`_meta`, cursors) | identity against the same variable that was sent — a snapshot of a pass-through value only matches the input because a human checked two literals correspond |
| an error | `pytest.raises(MCPError)` and a snapshot of `exc.value.error` when the message is the SDK's own; a plain `==` on `.code` against the `mcp_types` constant when it is not |
| third-party output embedded in a result (validation messages) | the stable prefix only — never pin text that changes with a dependency upgrade |
### Notifications and concurrency
The client's dispatcher starts a task per incoming notification in arrival order but does not
await it before reading the next message, so completion order is not structural. What still
holds: the in-memory transport delivers everything on one ordered stream, and a callback that
records synchronously (no `await` before the append) finishes its scheduling slice before the
awaited request's waiter — woken strictly later — resumes. So tests whose callbacks are plain
appends may still collect into a list and assert after the call. A callback that awaits before
recording loses that ordering and must synchronise. The other exceptions:
- a notification not triggered by a request the test is awaiting needs an `anyio.Event` set in
the receiving handler and awaited under `anyio.fail_after(5)`;
- the ordering guarantee does not survive transports that split messages across streams (the
streamable HTTP standalone GET stream) — see `transports/test_streamable_http.py`.
### Coverage
CI requires 100% line and branch coverage, including `tests/`, and `strict-no-cover` fails the
build if a line marked `# pragma: no cover` is ever executed. When a new test starts covering a
pragma'd line in `src/`, delete the pragma in the same change. Do not add new `# type: ignore` or
`# noqa` comments; restructure instead. Two pragmas are sanctioned in this suite's test code, both
for known-upstream tracer bugs and only after restructuring has been tried: `# pragma: no branch`
on a `with`/`async with` line whose only fault is coverage.py mis-tracing the exit arc of a nested
async context (reserve it for shapes that cannot collapse — a sync `with` adjacent to an
`async with`); and `# pragma: lax no cover` on a single statement that 3.11's tracer drops because
the preceding `async with` unwinds via `coro.throw()` (python/cpython#106749, wontfix on 3.11) —
this hits any test that must run statements after a `ClientSession`/`streamable_http_client` exits
but still inside an outer `async with`, and no restructure can avoid it.
A handful of `# pragma: lax no cover` markers in `src/` cover teardown exception handlers whose
execution is timing-dependent under the in-process HTTP bridge — the POST-stream and
stateless-session `except Exception` handlers in `server/streamable_http*.py` and the
`_terminated` check in `message_router`. `strict-no-cover` does not check `lax` lines; do not
promote them to strict `no cover` without first making the teardown ordering deterministic. The
suite also relies on a one-line `src/mcp/server/sse.py` fix (`sse_stream_reader.aclose()`) that
closes a stream the SSE leg would otherwise leak.
View File
+402
View File
@@ -0,0 +1,402 @@
"""Transport-parametrized connection factories for the interaction suite.
The `connect` fixture (see conftest.py) hands tests one of these factories so the same test body
runs over each transport without naming any of them: the factory is a drop-in replacement for
constructing `Client(server, ...)` and yields the connected client. The HTTP factories drive the
server's real Starlette app through the in-process streaming bridge, so the full transport layer
(session ids, SSE encoding, session management) runs with no sockets, threads, or subprocesses.
"""
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from functools import partial
from typing import Any, Protocol
import httpx
from httpx_sse import ServerSentEvent, aconnect_sse
from mcp_types import (
ClientCapabilities,
Implementation,
InitializeRequestParams,
JSONRPCMessage,
JSONRPCRequest,
JSONRPCResponse,
jsonrpc_message_adapter,
)
from mcp_types.version import LATEST_HANDSHAKE_VERSION, MODERN_PROTOCOL_VERSIONS
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import Response
from starlette.routing import Mount, Route
from mcp.client.client import Client
from mcp.client.extension import ClientExtension
from mcp.client.session import ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT
from mcp.client.sse import sse_client
from mcp.client.streamable_http import streamable_http_client
from mcp.server import Server
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, TokenVerifier
from mcp.server.auth.settings import AuthSettings
from mcp.server.mcpserver import MCPServer
from mcp.server.sse import SseServerTransport
from mcp.server.streamable_http import EventStore
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from mcp.server.transport_security import TransportSecuritySettings
from tests.interaction.transports._bridge import StreamingASGITransport
# The in-process app is mounted at this origin purely so URLs are well-formed; nothing listens here.
BASE_URL = "http://127.0.0.1:8000"
# DNS-rebinding protection validates Host/Origin headers against a real network attack that cannot
# exist for an in-process ASGI app, so the in-process factories disable it; tests that exercise the
# protection itself pass explicit settings (or transport_security=None to get the localhost
# auto-enable behaviour).
NO_DNS_REBINDING_PROTECTION = TransportSecuritySettings(enable_dns_rebinding_protection=False)
class Connect(Protocol):
"""Connect a Client to a server over the transport selected by the `connect` fixture.
Accepts the same keyword arguments as `Client` and yields the connected client.
"""
def __call__(
self,
server: Server | MCPServer,
*,
read_timeout_seconds: float | None = None,
sampling_callback: SamplingFnT | None = None,
list_roots_callback: ListRootsFnT | None = None,
logging_callback: LoggingFnT | None = None,
message_handler: MessageHandlerFnT | None = None,
client_info: Implementation | None = None,
elicitation_callback: ElicitationFnT | None = None,
extensions: Sequence[ClientExtension] | None = None,
spec_version: str = LATEST_HANDSHAKE_VERSION,
) -> AbstractAsyncContextManager[Client]: ...
@asynccontextmanager
async def connect_in_memory(
server: Server | MCPServer,
*,
read_timeout_seconds: float | None = None,
sampling_callback: SamplingFnT | None = None,
list_roots_callback: ListRootsFnT | None = None,
logging_callback: LoggingFnT | None = None,
message_handler: MessageHandlerFnT | None = None,
client_info: Implementation | None = None,
elicitation_callback: ElicitationFnT | None = None,
extensions: Sequence[ClientExtension] | None = None,
spec_version: str = LATEST_HANDSHAKE_VERSION,
) -> AsyncIterator[Client]:
"""Yield a Client connected to the server over the in-memory transport.
When `spec_version` is a modern (2026-07-28+) revision the Client is opened with
`mode=<version>`, which drives the server through the DirectDispatcher peer-pair
(per-request `serve_one`, no initialize handshake) instead of the legacy stream pair.
"""
async with Client(
server,
mode=spec_version if spec_version in MODERN_PROTOCOL_VERSIONS else "legacy",
read_timeout_seconds=read_timeout_seconds,
sampling_callback=sampling_callback,
list_roots_callback=list_roots_callback,
logging_callback=logging_callback,
message_handler=message_handler,
client_info=client_info,
elicitation_callback=elicitation_callback,
extensions=extensions,
) as client:
yield client
@asynccontextmanager
async def connect_over_streamable_http(
server: Server | MCPServer,
*,
stateless_http: bool = False,
json_response: bool = False,
event_store: EventStore | None = None,
retry_interval: int | None = None,
read_timeout_seconds: float | None = None,
sampling_callback: SamplingFnT | None = None,
list_roots_callback: ListRootsFnT | None = None,
logging_callback: LoggingFnT | None = None,
message_handler: MessageHandlerFnT | None = None,
client_info: Implementation | None = None,
elicitation_callback: ElicitationFnT | None = None,
extensions: Sequence[ClientExtension] | None = None,
spec_version: str = LATEST_HANDSHAKE_VERSION,
) -> AsyncIterator[Client]:
"""Yield a Client connected to the server's streamable HTTP app, entirely in process.
With the defaults this is the matrix leg (stateful sessions, SSE responses); the stateless
matrix arm binds `stateless_http=True` (see `connect_over_streamable_http_stateless`);
transport-specific tests pass `json_response` to select the other server mode, and the
resumability tests pass an `event_store` (with `retry_interval=0` so the client's
reconnection wait is a no-op).
When `spec_version` is a modern (2026-07-28+) revision the Client is opened with
`mode=<version>`, which adopts a synthesized DiscoverResult instead of running the legacy
initialize handshake.
"""
app = server.streamable_http_app(
stateless_http=stateless_http,
json_response=json_response,
event_store=event_store,
retry_interval=retry_interval,
transport_security=NO_DNS_REBINDING_PROTECTION,
)
async with (
server.session_manager.run(),
httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http_client,
Client(
streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client),
mode=spec_version if spec_version in MODERN_PROTOCOL_VERSIONS else "legacy",
read_timeout_seconds=read_timeout_seconds,
sampling_callback=sampling_callback,
list_roots_callback=list_roots_callback,
logging_callback=logging_callback,
message_handler=message_handler,
client_info=client_info,
elicitation_callback=elicitation_callback,
extensions=extensions,
) as client,
):
yield client
connect_over_streamable_http_stateless: Connect = partial(connect_over_streamable_http, stateless_http=True)
"""The streamable-http matrix arm with the server in stateless mode (fresh transport per request,
no session id, no standalone GET stream). The same shared Server instance backs every request --
stateless mode does not require a server factory."""
@asynccontextmanager
async def mounted_app(
server: Server | MCPServer,
*,
stateless_http: bool = False,
json_response: bool = False,
event_store: EventStore | None = None,
retry_interval: int | None = None,
transport_security: TransportSecuritySettings | None = NO_DNS_REBINDING_PROTECTION,
on_request: Callable[[httpx.Request], Awaitable[None]] | None = None,
on_response: Callable[[httpx.Response], Awaitable[None]] | None = None,
headers: dict[str, str] | None = None,
auth: AuthSettings | None = None,
token_verifier: TokenVerifier | None = None,
auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any] | None = None,
) -> AsyncIterator[tuple[httpx.AsyncClient, StreamableHTTPSessionManager]]:
"""Mount the server's streamable HTTP app on the in-process bridge and yield an httpx client.
Yields the httpx client (rooted at the in-process origin) and the live session manager. Tests
use this in two ways: for raw-httpx assertions (status codes, headers, SSE bytes) the test
speaks HTTP through the yielded client directly; for client-driven assertions the test wraps
that client in `client_via_http(http)`, which lets several `Client`s share the one mounted
session manager. `on_request` observes every outgoing HTTP request before it leaves the
yielded client; `on_response` observes every HTTP response as its headers arrive (response
bodies of SSE streams are not yet read at that point).
DNS-rebinding protection is disabled by default; pass explicit settings (or `None` for the
localhost auto-enable behaviour) to test the protection itself.
"""
lowlevel = server._lowlevel_server if isinstance(server, MCPServer) else server
app = lowlevel.streamable_http_app(
stateless_http=stateless_http,
json_response=json_response,
event_store=event_store,
retry_interval=retry_interval,
transport_security=transport_security,
auth=auth,
token_verifier=token_verifier,
auth_server_provider=auth_server_provider,
)
event_hooks: dict[str, list[Callable[..., Awaitable[None]]]] = {}
if on_request is not None:
event_hooks["request"] = [on_request]
if on_response is not None:
event_hooks["response"] = [on_response]
async with (
server.session_manager.run(),
httpx.AsyncClient(
transport=StreamingASGITransport(app), base_url=BASE_URL, event_hooks=event_hooks, headers=headers
) as http_client,
):
yield http_client, server.session_manager
@asynccontextmanager
async def client_via_http(
http_client: httpx.AsyncClient,
*,
logging_callback: LoggingFnT | None = None,
message_handler: MessageHandlerFnT | None = None,
elicitation_callback: ElicitationFnT | None = None,
) -> AsyncIterator[Client]:
"""Connect a `Client` over an already-mounted streamable HTTP app.
Use with `mounted_app(...)` so several `Client`s share the one session manager, or so a
client-driven assertion can sit alongside raw-httpx assertions in the same test. The
underlying `httpx.AsyncClient` is left open when the `Client` exits.
"""
transport = streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client)
async with Client(
transport,
# Callers assert the legacy HTTP wire shape (session-id header, standalone GET stream,
# closing DELETE); the modern flow is sessionless and would silently change the subject.
mode="legacy",
logging_callback=logging_callback,
message_handler=message_handler,
elicitation_callback=elicitation_callback,
) as client:
yield client
def parse_sse_messages(events: Iterable[ServerSentEvent]) -> list[JSONRPCMessage]:
"""Decode SSE events into JSON-RPC messages, skipping priming events that carry no data."""
return [jsonrpc_message_adapter.validate_json(event.data) for event in events if event.data]
async def post_jsonrpc(
http: httpx.AsyncClient, body: dict[str, object], *, session_id: str | None = None
) -> tuple[httpx.Response, list[JSONRPCMessage]]:
"""POST a JSON-RPC body and read its SSE response stream to completion.
Returns the HTTP response (for header/status assertions) and the parsed JSON-RPC messages
that arrived on the response's SSE stream. Only meaningful for requests the server answers
with `text/event-stream`; for error responses or 202 notification acknowledgements, use
`httpx.AsyncClient.post` directly and assert on the response.
"""
async with aconnect_sse(http, "POST", "/mcp", json=body, headers=base_headers(session_id=session_id)) as source:
events = [event async for event in source.aiter_sse()]
return source.response, parse_sse_messages(events)
def base_headers(*, session_id: str | None = None) -> dict[str, str]:
"""Standard request headers for raw-httpx streamable-HTTP tests.
Every well-formed request carries these (Accept covering both response representations,
Content-Type for POST bodies, MCP-Protocol-Version at the newest handshake revision, and the session
ID once one exists), so a test that wants to assert a specific rejection only varies the one
header under test.
"""
headers = {
"accept": "application/json, text/event-stream",
"content-type": "application/json",
"mcp-protocol-version": LATEST_HANDSHAKE_VERSION,
}
if session_id is not None:
headers["mcp-session-id"] = session_id
return headers
def initialize_body(request_id: int = 1) -> dict[str, object]:
"""A wire-level initialize JSON-RPC request body, exactly as an SDK client would send it."""
params = InitializeRequestParams(
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ClientCapabilities(),
client_info=Implementation(name="raw", version="0.0.0"),
)
return JSONRPCRequest(
jsonrpc="2.0", id=request_id, method="initialize", params=params.model_dump(by_alias=True, exclude_none=True)
).model_dump(by_alias=True, exclude_none=True)
async def initialize_via_http(http: httpx.AsyncClient) -> str:
"""Perform the initialize handshake over a raw `httpx.AsyncClient` and return the session ID.
Validates the SSE response and sends the `notifications/initialized` follow-up, so the server
is fully ready for subsequent feature requests when this returns.
"""
async with aconnect_sse(http, "POST", "/mcp", json=initialize_body(), headers=base_headers()) as source:
assert source.response.status_code == 200
# An event-store-backed server opens the stream with a priming event (empty data); skip it.
events = [event async for event in source.aiter_sse() if event.data]
assert len(events) == 1
assert JSONRPCResponse.model_validate_json(events[0].data).id == 1
session_id = source.response.headers["mcp-session-id"]
initialized = await http.post(
"/mcp",
json={"jsonrpc": "2.0", "method": "notifications/initialized"},
headers=base_headers(session_id=session_id),
)
assert initialized.status_code == 202
return session_id
def build_sse_app(server: Server | MCPServer) -> tuple[Starlette, SseServerTransport]:
"""Mount a server on a Starlette app exposing the legacy SSE transport at /sse and /messages/.
`MCPServer.sse_app()` exists but does not expose the underlying `SseServerTransport`, which
the SSE-specific tests need; building the app explicitly here gives both server flavours the
same routing while keeping that handle.
"""
sse = SseServerTransport(
"/messages/", security_settings=TransportSecuritySettings(enable_dns_rebinding_protection=False)
)
lowlevel = server._lowlevel_server if isinstance(server, MCPServer) else server
async def handle_sse(request: Request) -> Response:
async with sse.connect_sse(request.scope, request.receive, request._send) as (read, write):
await lowlevel.run(read, write, lowlevel.create_initialization_options())
return Response()
app = Starlette(
routes=[
Route("/sse", endpoint=handle_sse, methods=["GET"]),
Mount("/messages/", app=sse.handle_post_message),
],
)
return app, sse
@asynccontextmanager
async def connect_over_sse(
server: Server | MCPServer,
*,
read_timeout_seconds: float | None = None,
sampling_callback: SamplingFnT | None = None,
list_roots_callback: ListRootsFnT | None = None,
logging_callback: LoggingFnT | None = None,
message_handler: MessageHandlerFnT | None = None,
client_info: Implementation | None = None,
elicitation_callback: ElicitationFnT | None = None,
extensions: Sequence[ClientExtension] | None = None,
spec_version: str = LATEST_HANDSHAKE_VERSION,
) -> AsyncIterator[Client]:
"""Yield a Client connected to the server's legacy SSE transport, entirely in process."""
app, _ = build_sse_app(server)
def httpx_client_factory(
headers: dict[str, str] | None = None,
timeout: httpx.Timeout | None = None,
auth: httpx.Auth | None = None,
) -> httpx.AsyncClient:
# The SSE server transport's connect_sse runs the entire MCP session inside the GET
# request and only releases its streams after that request observes a disconnect, so the
# bridge must let the application drain rather than cancelling at close.
return httpx.AsyncClient(
transport=StreamingASGITransport(app, cancel_on_close=False),
base_url=BASE_URL,
headers=headers,
timeout=timeout,
auth=auth,
)
transport = sse_client(f"{BASE_URL}/sse", httpx_client_factory=httpx_client_factory)
async with Client(
transport,
# SSE is a legacy-only transport; the modern path has no SSE story.
mode="legacy",
read_timeout_seconds=read_timeout_seconds,
sampling_callback=sampling_callback,
list_roots_callback=list_roots_callback,
logging_callback=logging_callback,
message_handler=message_handler,
client_info=client_info,
elicitation_callback=elicitation_callback,
extensions=extensions,
) as client:
yield client
+108
View File
@@ -0,0 +1,108 @@
"""Shared helpers for the interaction suite.
Keep this module small: it exists only for (a) types that every test would otherwise have to
assemble from the SDK's internals to annotate a client callback, and (b) the recording transport
used by the wire-level tests. Server fixtures and assertion helpers belong in the test that uses
them.
"""
from types import TracebackType
import anyio
from mcp_types import ClientResult, ServerNotification, ServerRequest
from typing_extensions import Self
from mcp.client._transport import ReadStream, Transport, TransportStreams, WriteStream
from mcp.shared.message import SessionMessage
from mcp.shared.session import RequestResponder
# TODO: this union is the parameter type of every client message handler (MessageHandlerFnT),
# but the SDK does not export a name for it -- writing a correctly-typed handler requires
# importing RequestResponder from mcp.shared.session and assembling the union by hand. It
# should be a named, exported alias next to MessageHandlerFnT (like ClientRequestContext is
# for the request callbacks), at which point this alias can be deleted.
IncomingMessage = RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception
"""Everything a client message handler can receive."""
class _RecordingReadStream:
"""Delegates to a read stream, appending every received message to a log."""
def __init__(self, inner: ReadStream[SessionMessage | Exception], log: list[SessionMessage | Exception]) -> None:
self._inner = inner
self._log = log
async def receive(self) -> SessionMessage | Exception:
item = await self._inner.receive()
self._log.append(item)
return item
async def aclose(self) -> None:
await self._inner.aclose()
def __aiter__(self) -> Self:
return self
async def __anext__(self) -> SessionMessage | Exception:
try:
return await self.receive()
except anyio.EndOfStream:
raise StopAsyncIteration from None
async def __aenter__(self) -> Self:
return self
async def __aexit__(
self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None
) -> bool | None:
await self.aclose()
return None
class _RecordingWriteStream:
"""Delegates to a write stream, appending every sent message to a log."""
def __init__(self, inner: WriteStream[SessionMessage], log: list[SessionMessage]) -> None:
self._inner = inner
self._log = log
async def send(self, item: SessionMessage, /) -> None:
# Record only after the inner send returns: a failed or cancelled send never reached the transport.
await self._inner.send(item)
self._log.append(item)
async def aclose(self) -> None:
await self._inner.aclose()
async def __aenter__(self) -> Self:
return self
async def __aexit__(
self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None
) -> bool | None:
await self.aclose()
return None
class RecordingTransport:
"""Wraps a Transport and records every message crossing the client's transport boundary.
`sent` holds everything the client wrote towards the server; `received` holds everything the
server delivered to the client. The recording sits at the transport seam -- the exact payloads
a real transport would serialise -- and never touches the session, so wire-level assertions
written against it survive changes to the receive path.
"""
def __init__(self, inner: Transport) -> None:
self.inner = inner
self.sent: list[SessionMessage] = []
self.received: list[SessionMessage | Exception] = []
async def __aenter__(self) -> TransportStreams:
read_stream, write_stream = await self.inner.__aenter__()
return _RecordingReadStream(read_stream, self.received), _RecordingWriteStream(write_stream, self.sent)
async def __aexit__(
self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None
) -> bool | None:
return await self.inner.__aexit__(exc_type, exc_val, exc_tb)
+78
View File
@@ -0,0 +1,78 @@
"""Guard against 2026-era protocol vocabulary leaking onto legacy (2025-era) exchanges.
The 2026-07-28 spec revision introduces wire vocabulary that did not exist before it --
result-envelope fields (`resultType`, `ttlMs`, `cacheScope`), namespaced
`io.modelcontextprotocol/*` `_meta` keys, the version literal itself, and the per-request HTTP
headers `Mcp-Method` / `Mcp-Name` / `Mcp-Param-*`. None of that may appear on a connection
negotiated at an earlier protocol version: a test that records a plain legacy round trip and
runs it through :func:`assert_no_modern_vocabulary` will start failing the moment a 2026 change
leaks onto the existing wire.
Tests construct a :class:`RecordedExchange` from whatever instrumentation they have to hand --
the `on_request` / `on_response` hooks on :func:`tests.interaction._connect.mounted_app` for the
HTTP seam, and :class:`tests.interaction._helpers.RecordingTransport` for the JSON-RPC frames --
and pass it to the assertion. The helper scans header names and serialised bodies; it makes no
assumptions about which side produced what.
"""
from dataclasses import dataclass
import httpx
from mcp_types import JSONRPCMessage, jsonrpc_message_adapter
#: Substrings that must not appear anywhere in a request body or JSON-RPC frame on a legacy
#: exchange. Matching is by raw substring against the by-alias JSON serialisation, so a leaked
#: field name, `_meta` key prefix, or version literal is caught regardless of where in the
#: payload it sits.
MODERN_BODY_TOKENS: frozenset[str] = frozenset(
{
"resultType",
"ttlMs",
"cacheScope",
"io.modelcontextprotocol/",
"2026-07-28",
}
)
#: Lower-cased HTTP header names introduced by the 2026-07-28 transport.
MODERN_HEADER_NAMES: frozenset[str] = frozenset({"mcp-method", "mcp-name"})
#: Lower-cased prefix for the 2026-07-28 per-parameter header family.
MODERN_HEADER_PREFIX = "mcp-param-"
@dataclass
class RecordedExchange:
"""Everything a test captured from one streamable-HTTP conversation, for vocabulary scanning.
`requests` and `responses` are inspected for header names and (for requests) body bytes;
`frames` are re-serialised to their wire JSON and scanned as body text. Response bodies are
not read here -- streamable-HTTP responses are SSE streams that are consumed elsewhere -- so
the server-to-client body content must be supplied via `frames`.
"""
requests: list[httpx.Request]
responses: list[httpx.Response]
frames: list[JSONRPCMessage]
def assert_no_modern_vocabulary(recorded: RecordedExchange) -> None:
"""Fail if any 2026-era header name or body token appears anywhere in `recorded`.
All findings are collected before asserting so a single failure reports every leak.
"""
header_names = [name.lower() for request in recorded.requests for name in request.headers]
header_names += [name.lower() for response in recorded.responses for name in response.headers]
leaked = [
f"header {name!r}"
for name in header_names
if name in MODERN_HEADER_NAMES or name.startswith(MODERN_HEADER_PREFIX)
]
corpus = b"".join(request.content for request in recorded.requests).decode()
corpus += "".join(
jsonrpc_message_adapter.dump_json(frame, by_alias=True, exclude_none=True).decode() for frame in recorded.frames
)
leaked.extend(f"body token {token!r}" for token in MODERN_BODY_TOKENS if token in corpus)
assert not leaked, f"Modern (2026-07-28) protocol vocabulary on a legacy exchange: {leaked}"
File diff suppressed because it is too large Load Diff
View File
+484
View File
@@ -0,0 +1,484 @@
"""In-process harness for the auth interaction tests.
Co-hosts the SDK's authorization-server routes, protected-resource metadata route, and the
bearer-gated MCP endpoint on one Starlette app via `Server.streamable_http_app(auth=...,
token_verifier=..., auth_server_provider=...)`, drives that app through the streaming bridge
on a single `httpx.AsyncClient` carrying `auth=OAuthClientProvider(...)`, and completes the
authorize redirect headlessly by GETing the URL through the same bridge and parsing the code
from the 302 `Location`. The whole authorization-code flow runs in one event loop with no
sockets, no threads, and no real time.
"""
import json
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
from contextlib import AsyncExitStack, asynccontextmanager
from dataclasses import dataclass, field
from typing import Any
from urllib.parse import parse_qs, parse_qsl, urlsplit
import httpx
from pydantic import AnyHttpUrl, AnyUrl, BaseModel
from starlette.types import ASGIApp, Receive, Scope, Send
from mcp.client.auth import OAuthClientProvider
from mcp.client.client import Client
from mcp.client.streamable_http import streamable_http_client
from mcp.server import Server
from mcp.server.auth.provider import AccessToken, ProviderTokenVerifier
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions, RevocationOptions
from mcp.shared.auth import AuthorizationCodeResult, OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
from tests.interaction._connect import BASE_URL, NO_DNS_REBINDING_PROTECTION
from tests.interaction.auth._provider import InMemoryAuthorizationServerProvider
from tests.interaction.transports._bridge import StreamingASGITransport
REDIRECT_URI = f"{BASE_URL}/oauth/callback"
AppShim = Callable[[ASGIApp], ASGIApp]
@dataclass
class RecordedRequest:
"""A snapshot of an `httpx.Request` at the moment it was sent.
The auth flow re-yields the same `httpx.Request` object after mutating its headers in
place for the retry, so tests that need to assert on the first attempt's headers must
capture a copy rather than a live reference. `record_requests` produces these.
"""
method: str
url: httpx.URL
headers: dict[str, str]
content: bytes
@property
def path(self) -> str:
return self.url.path
def record_requests() -> tuple[list[RecordedRequest], Callable[[httpx.Request], None]]:
"""Build an `on_request` callback that snapshots each request, and the list it appends to."""
recorded: list[RecordedRequest] = []
def on_request(request: httpx.Request) -> None:
recorded.append(
RecordedRequest(
method=request.method,
url=request.url,
headers=dict(request.headers),
content=bytes(request.content),
)
)
return recorded, on_request
def metadata_body(model: BaseModel, **extra: object) -> bytes:
"""Serialize a metadata model to a JSON body for `shimmed_app(serve=...)`.
`extra` keys are merged into the serialized object so a test can inject fields the model
does not declare (e.g. an unknown extension field, to prove the client's parser tolerates
unrecognized members per RFC 8414/9728 §3.2). The model itself would silently drop such
fields at construction, so they have to be added after serialization.
"""
document = model.model_dump(by_alias=True, mode="json", exclude_none=True)
document.update(extra)
return json.dumps(document).encode()
class StaticTokenVerifier:
"""A `TokenVerifier` backed by a fixed token→`AccessToken` mapping.
Any token string not in the mapping verifies to `None`, which the bearer middleware treats
as an unrecognized token. Tests seed the mapping with the exact token shapes (valid, expired,
wrong scope, wrong audience) they need so the resource-server gate's behaviour is asserted in
isolation from the authorization-server provider.
"""
def __init__(self, tokens: Mapping[str, AccessToken]) -> None:
self._tokens = dict(tokens)
async def verify_token(self, token: str) -> AccessToken | None:
return self._tokens.get(token)
class InMemoryTokenStorage:
"""A `TokenStorage` that holds tokens and client info as instance attributes.
Tests pre-seed `client_info` (via the constructor or by assignment) to drive the
pre-registered path, and read both attributes after the flow to assert what the SDK
persisted.
"""
def __init__(self, *, client_info: OAuthClientInformationFull | None = None) -> None:
self.tokens: OAuthToken | None = None
self.client_info: OAuthClientInformationFull | None = client_info
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 HeadlessOAuth:
"""Completes the authorize step in-process by following the redirect through the bridge.
`redirect_handler` GETs the authorize URL on the bound client (with `auth=None` so the
request does not re-enter the locked auth flow), parses `code` and `state` from the 302
`Location`, and stashes them; `callback_handler` returns the stashed pair. Tests inspect
`authorize_url` to assert what the SDK put on the authorize request.
`state_override`: when set, `callback_handler` returns this value as the state instead of
the one parsed from the redirect, so tests can drive the state-mismatch path.
`iss_override`: when set, `callback_handler` returns this value as the RFC 9207 issuer
instead of the one parsed from the redirect, so tests can drive the iss-mismatch path.
"""
def __init__(self, *, state_override: str | None = None, iss_override: str | None = None) -> None:
self.authorize_url: str | None = None
self.authorize_urls: list[str] = []
self.error: str | None = None
self._state_override = state_override
self._iss_override = iss_override
self._http: httpx.AsyncClient | None = None
self._code: str = ""
self._state: str | None = None
self._iss: str | None = None
def bind(self, http_client: httpx.AsyncClient) -> None:
self._http = http_client
async def redirect_handler(self, authorization_url: str) -> None:
assert self._http is not None
self.authorize_url = authorization_url
self.authorize_urls.append(authorization_url)
# auth=None is load-bearing: without it the GET re-enters OAuthClientProvider.async_auth_flow
# through its context lock and the flow deadlocks.
response = await self._http.get(authorization_url, follow_redirects=False, auth=None)
assert response.status_code == 302, f"authorize endpoint returned {response.status_code}: {response.text}"
params = parse_qs(urlsplit(response.headers["location"]).query)
self._code = params.get("code", [""])[0]
self._state = params.get("state", [None])[0]
self._iss = params.get("iss", [None])[0]
self.error = params.get("error", [None])[0]
async def callback_handler(self) -> AuthorizationCodeResult:
return AuthorizationCodeResult(
code=self._code,
state=self._state_override if self._state_override is not None else self._state,
iss=self._iss_override if self._iss_override is not None else self._iss,
)
def auth_settings(
*,
required_scopes: Sequence[str] = ("mcp",),
valid_scopes: Sequence[str] | None = None,
identity_assertion_enabled: bool = False,
) -> AuthSettings:
"""Build `AuthSettings` for the co-hosted authorization + resource server.
The issuer and resource URLs use the suite's loopback origin, which `validate_issuer_url`
accepts in lieu of HTTPS. Dynamic client registration is enabled. `valid_scopes` defaults
to `required_scopes` so a client requesting exactly those passes registration scope
validation; tests pass a wider set when they need the protected-resource metadata's
`scopes_supported` (which mirrors `required_scopes`) to differ from what the client may
register or when AS metadata should advertise additional scopes such as `offline_access`.
`identity_assertion_enabled` advertises and accepts the SEP-990 ID-JAG grant (RFC 7523
jwt-bearer); the provider must implement `exchange_identity_assertion` for the endpoint to
issue tokens.
"""
required = list(required_scopes)
valid = list(valid_scopes) if valid_scopes is not None else required
return AuthSettings(
issuer_url=AnyHttpUrl(BASE_URL),
resource_server_url=AnyHttpUrl(f"{BASE_URL}/mcp"),
required_scopes=required,
client_registration_options=ClientRegistrationOptions(
enabled=True, valid_scopes=valid, default_scopes=required
),
revocation_options=RevocationOptions(enabled=False),
identity_assertion_enabled=identity_assertion_enabled,
)
def oauth_client_metadata() -> OAuthClientMetadata:
"""Build the client's registration metadata.
`scope` is left unset so the SDK's scope-selection strategy chooses one from the server's
metadata before registration.
"""
return OAuthClientMetadata(
client_name="interaction-suite",
redirect_uris=[AnyUrl(REDIRECT_URI)],
grant_types=["authorization_code", "refresh_token"],
)
def shimmed_app(
app: ASGIApp,
*,
not_found: frozenset[str] = frozenset(),
serve: Mapping[str, bytes | tuple[int, bytes]] | None = None,
) -> ASGIApp:
"""Wrap an ASGI app so specific paths return canned responses before reaching the real app.
Paths in `serve` return the given body as `application/json` (status 200, or the supplied
status when the value is a `(status, body)` pair); paths in `not_found` return 404;
everything else reaches the wrapped app unchanged. Used by the discovery tests to make a
well-known endpoint 404 or return alternate metadata while keeping the real authorization
and MCP endpoints behind it.
"""
overrides: dict[str, tuple[int, bytes]] = {
path: value if isinstance(value, tuple) else (200, value) for path, value in (serve or {}).items()
}
async def wrapped(scope: Scope, receive: Receive, send: Send) -> None:
path = scope["path"]
if path in overrides:
status, body = overrides[path]
await send(
{
"type": "http.response.start",
"status": status,
"headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(body)).encode()),
],
}
)
await send({"type": "http.response.body", "body": body})
return
if path in not_found:
await send({"type": "http.response.start", "status": 404, "headers": []})
await send({"type": "http.response.body", "body": b""})
return
await app(scope, receive, send)
return wrapped
def shim(
*, not_found: frozenset[str] = frozenset(), serve: Mapping[str, bytes | tuple[int, bytes]] | None = None
) -> AppShim:
"""Build an `app_shim` for `connect_with_oauth` that applies `shimmed_app` with these overrides."""
return lambda app: shimmed_app(app, not_found=not_found, serve=serve)
@dataclass
class _FirstChallenge:
"""ASGI shim that answers the first request to a path with 401 + a given WWW-Authenticate.
Subsequent requests pass through to the wrapped app. Used to make the initial 401 carry
parameters (such as `scope=`) that the SDK's own bearer middleware cannot be configured
to emit, so client behaviour driven by those parameters is reachable end to end. Reserve
this pattern for behaviour the real server cannot be made to produce.
"""
app: ASGIApp
path: str
www_authenticate: str
_seen: set[str] = field(default_factory=set[str])
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] == "http" and scope["path"] == self.path and self.path not in self._seen:
self._seen.add(self.path)
await send(
{
"type": "http.response.start",
"status": 401,
"headers": [(b"www-authenticate", self.www_authenticate.encode())],
}
)
await send({"type": "http.response.body", "body": b""})
return
await self.app(scope, receive, send)
def first_challenge_shim(www_authenticate: str, *, path: str = "/mcp") -> Callable[[ASGIApp], ASGIApp]:
"""Build an `app_shim` that 401s the first request to `path` with the given header value."""
return lambda app: _FirstChallenge(app, path, www_authenticate)
def step_up_shim(www_authenticate: str, *, on_nth_authenticated_post: int = 2) -> AppShim:
"""Build an `app_shim` that 403s the Nth authenticated POST to `/mcp` with the given challenge.
Subsequent requests pass through. Used to drive the client's `insufficient_scope` step-up
handling: the SDK's bearer middleware never emits `scope=` in its 403 challenge (see the
divergence on `hosting:auth:scope-403`), so the test supplies the 403 itself. Reserve this
pattern for behaviour the real server cannot be made to produce.
The default `on_nth_authenticated_post=2` targets the `notifications/initialized` POST: the
first authenticated POST is the auth flow's retry of the original initialize request (yielded
after the 401 branch, where the generator ends without inspecting the response), so a 403
there would not reach the step-up handler.
"""
seen = 0
fired = False
def factory(app: ASGIApp) -> ASGIApp:
async def wrapped(scope: Scope, receive: Receive, send: Send) -> None:
nonlocal seen, fired
if (
not fired
and scope["type"] == "http"
and scope["path"] == "/mcp"
and scope["method"] == "POST"
and any(name == b"authorization" for name, _ in scope["headers"])
):
seen += 1
if seen < on_nth_authenticated_post:
await app(scope, receive, send)
return
fired = True
await send(
{
"type": "http.response.start",
"status": 403,
"headers": [(b"www-authenticate", www_authenticate.encode())],
}
)
await send({"type": "http.response.body", "body": b""})
return
await app(scope, receive, send)
return wrapped
return factory
def m2m_token_shim(provider: InMemoryAuthorizationServerProvider, *, scopes: list[str]) -> AppShim:
"""Build an `app_shim` that handles `grant_type=client_credentials` at `/token`.
The SDK server's `TokenHandler` only routes `authorization_code` and `refresh_token`, so a
`client_credentials` request would fail discriminator validation. This shim mints a token via
`provider.mint_access_token` so the M2M client providers can complete e2e against the real
bearer middleware. The shim is harness; the SDK-under-test is the client provider, whose
outbound `/token` body the test asserts. The shim does not authenticate the client (no
credential check) because the test asserts the credentials on the recorded request, not on
the server's acceptance.
"""
def factory(app: ASGIApp) -> ASGIApp:
async def wrapped(scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] == "http" and scope["path"] == "/token" and scope["method"] == "POST":
# The streaming bridge buffers the request body and delivers it in a single
# http.request event, so one receive is sufficient.
message = await receive()
assert not message.get("more_body", False)
form = dict(parse_qsl(message.get("body", b"").decode()))
assert form.get("grant_type") == "client_credentials", (
f"m2m_token_shim only handles client_credentials; got {form.get('grant_type')!r}"
)
access = provider.mint_access_token(client_id="m2m", scopes=scopes, resource=form.get("resource"))
token = OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=" ".join(scopes))
response_body = token.model_dump_json(exclude_none=True).encode()
await send(
{
"type": "http.response.start",
"status": 200,
"headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(response_body)).encode()),
(b"cache-control", b"no-store"),
],
}
)
await send({"type": "http.response.body", "body": response_body})
return
await app(scope, receive, send)
return wrapped
return factory
@asynccontextmanager
async def connect_with_oauth(
server: Server,
*,
provider: InMemoryAuthorizationServerProvider,
settings: AuthSettings | None = None,
storage: InMemoryTokenStorage | None = None,
client_metadata: OAuthClientMetadata | None = None,
client_metadata_url: str | None = None,
headless: HeadlessOAuth | None = None,
auth: httpx.Auth | None = None,
verify_tokens: bool = True,
app_shim: Callable[[ASGIApp], ASGIApp] | None = None,
on_request: Callable[[httpx.Request], None] | None = None,
) -> AsyncIterator[tuple[Client, HeadlessOAuth]]:
"""Connect a `Client` to a server's bearer-gated streamable-HTTP app, completing OAuth in process.
Yields the connected `Client` and the `HeadlessOAuth` whose `authorize_url` records what the
SDK put on the authorize request. `on_request` records every HTTP request the underlying
`httpx.AsyncClient` issues, including those yielded from inside the auth flow.
`headless`: supply a pre-configured `HeadlessOAuth` to override the callback behaviour
(state mismatch, error redirects). `verify_tokens=False` mounts the MCP endpoint without
the bearer middleware so a flow driven by a shimmed 401 completes regardless of the granted
scopes. `app_shim` wraps the built Starlette app before it reaches the bridge transport,
for tests that need to intercept or rewrite specific server responses.
`auth`: supply a pre-built `httpx.Auth` (such as `ClientCredentialsOAuthProvider`) to use
instead of constructing the default `OAuthClientProvider`; in that case `storage`,
`client_metadata`, `client_metadata_url`, and `headless` are unused (the yielded
`HeadlessOAuth` is never invoked and its `authorize_url` stays None).
"""
settings = settings if settings is not None else auth_settings()
storage = storage if storage is not None else InMemoryTokenStorage()
client_metadata = client_metadata if client_metadata is not None else oauth_client_metadata()
headless = headless if headless is not None else HeadlessOAuth()
oauth = (
auth
if auth is not None
else OAuthClientProvider(
server_url=f"{BASE_URL}/mcp",
client_metadata=client_metadata,
storage=storage,
redirect_handler=headless.redirect_handler,
callback_handler=headless.callback_handler,
client_metadata_url=client_metadata_url,
)
)
app: ASGIApp = server.streamable_http_app(
auth=settings,
token_verifier=ProviderTokenVerifier(provider) if verify_tokens else None,
auth_server_provider=provider,
transport_security=NO_DNS_REBINDING_PROTECTION,
)
if app_shim is not None:
app = app_shim(app)
event_hooks: dict[str, list[Callable[..., Any]]] | None = None
if on_request is not None:
record = on_request
async def hook(request: httpx.Request) -> None:
record(request)
event_hooks = {"request": [hook]}
async with AsyncExitStack() as stack:
await stack.enter_async_context(server.session_manager.run())
http_client = await stack.enter_async_context(
httpx.AsyncClient(
transport=StreamingASGITransport(app), base_url=BASE_URL, auth=oauth, event_hooks=event_hooks
)
)
headless.bind(http_client)
client = await stack.enter_async_context(
# The auth flow tests snapshot the legacy initialize-handshake HTTP shape.
Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client), mode="legacy")
)
yield client, headless
+222
View File
@@ -0,0 +1,222 @@
"""An in-memory implementation of the SDK's OAuth authorization-server provider protocol.
The provider holds clients, authorization codes, refresh tokens and access tokens in plain
instance dicts so tests can inspect them; tokens are minted from `secrets.token_hex` so the
values are unique without being predictable. The behaviour mirrors what the SDK's authorization
handlers expect: `authorize` immediately mints a code and returns the redirect, `exchange_*`
issue and rotate tokens, and `load_*` are simple lookups. Only the parts the auth interaction
suite drives are implemented; methods the suite does not exercise raise `NotImplementedError`.
"""
import secrets
import time
from mcp.server.auth.provider import (
AccessToken,
AuthorizationCode,
AuthorizationParams,
IdentityAssertionParams,
OAuthAuthorizationServerProvider,
RefreshToken,
TokenError,
construct_redirect_uri,
)
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
from tests.interaction._connect import BASE_URL
_TOKEN_LIFETIME_SECONDS = 3600
# The only ID-JAG assertion the in-memory provider accepts; any other value is rejected with
# invalid_grant, standing in for the signature/policy validation a real AS performs.
VALID_ASSERTION = "valid-id-jag"
class InMemoryAuthorizationServerProvider(
OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]
):
"""An OAuth authorization-server provider backed by in-memory dicts.
Holds registered clients, issued codes, refresh tokens and access tokens as instance state
so tests can both drive the SDK's authorization handlers and inspect what was issued.
Knobs:
`default_scopes`: scopes granted when an authorize request supplies none.
`deny_authorize`: every authorize request returns an `error=access_denied` redirect.
`issue_expired_first`: the first issued token's `expires_in` is in the past so the
client immediately considers it expired and refreshes; the server-side
`AccessToken.expires_at` stays in the future so the bearer middleware accepts it
on the retry that completes the connect.
`fail_next_refresh`: the next refresh-token exchange raises `invalid_grant` once.
`reject_all_tokens`: `load_access_token` returns None for every token, so the bearer
middleware 401s every authenticated request.
"""
def __init__(
self,
*,
default_scopes: list[str] | None = None,
deny_authorize: bool = False,
issue_expired_first: bool = False,
fail_next_refresh: bool = False,
reject_all_tokens: bool = False,
issuer: str | None = None,
) -> None:
self._default_scopes = list(default_scopes) if default_scopes is not None else ["mcp"]
# The authorization-response iss must equal the AS metadata issuer the client recorded
# (RFC 9207 simple string comparison). `real_asm` builds the issuer from an AnyHttpUrl
# object, so it carries the trailing slash; the redirect iss matches it. Path-issuer
# tests pass the recorded issuer explicitly.
self._issuer = issuer if issuer is not None else f"{BASE_URL}/"
self._deny_authorize = deny_authorize
self._issue_expired_first = issue_expired_first
self._fail_next_refresh = fail_next_refresh
self._reject_all_tokens = reject_all_tokens
self._tokens_issued = 0
self.clients: dict[str, OAuthClientInformationFull] = {}
self.codes: dict[str, AuthorizationCode] = {}
self.refresh_tokens: dict[str, RefreshToken] = {}
self.access_tokens: dict[str, AccessToken] = {}
# The most recent jwt-bearer request the SDK handler passed to exchange_identity_assertion,
# for tests to assert what the client sent (None until the first exchange).
self.last_assertion_params: IdentityAssertionParams | None = None
def _next_expires_in(self) -> int:
self._tokens_issued += 1
if self._issue_expired_first and self._tokens_issued == 1:
return -_TOKEN_LIFETIME_SECONDS
return _TOKEN_LIFETIME_SECONDS
def mint_access_token(self, *, client_id: str, scopes: list[str], resource: str | None = None) -> str:
"""Mint and store an access token, returning its value.
Used by the auth-code and refresh exchanges and by the M2M `/token` shim. The
server-side `expires_at` is always in the future regardless of `issue_expired_first`,
which only affects what the client is told.
"""
access = f"access_{secrets.token_hex(16)}"
self.access_tokens[access] = AccessToken(
token=access,
client_id=client_id,
scopes=scopes,
expires_at=int(time.time()) + _TOKEN_LIFETIME_SECONDS,
resource=resource,
)
return access
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
return self.clients.get(client_id)
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
assert client_info.client_id is not None
self.clients[client_info.client_id] = client_info
async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str:
"""Mint an authorization code immediately and return the redirect carrying it.
A real provider would interpose user consent here; the test provider grants
unconditionally so the headless redirect handler can complete the flow in-process.
When `deny_authorize` is set, returns an `error=access_denied` redirect instead.
"""
assert client.client_id is not None
if self._deny_authorize:
return construct_redirect_uri(
str(params.redirect_uri), error="access_denied", error_description="user denied", state=params.state
)
code = AuthorizationCode(
code=f"code_{secrets.token_hex(16)}",
client_id=client.client_id,
scopes=params.scopes or self._default_scopes,
expires_at=time.time() + 300,
code_challenge=params.code_challenge,
redirect_uri=params.redirect_uri,
redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly,
resource=params.resource,
)
self.codes[code.code] = code
# `iss` is RFC 9207's authorization-response issuer identifier — an extra parameter many
# real authorization servers send. Including it on every success redirect proves the
# client tolerates unrecognized callback parameters (RFC 6749 §4.1.2 MUST) by virtue of
# every flow test passing unchanged.
return construct_redirect_uri(str(params.redirect_uri), code=code.code, state=params.state, iss=self._issuer)
async def load_authorization_code(
self, client: OAuthClientInformationFull, authorization_code: str
) -> AuthorizationCode | None:
return self.codes.get(authorization_code)
async def exchange_authorization_code(
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
) -> OAuthToken:
"""Mint an access token and a refresh token for a valid authorization code, then consume the code."""
assert client.client_id is not None
access = self.mint_access_token(
client_id=client.client_id, scopes=authorization_code.scopes, resource=authorization_code.resource
)
refresh = f"refresh_{secrets.token_hex(16)}"
self.refresh_tokens[refresh] = RefreshToken(
token=refresh,
client_id=client.client_id,
scopes=authorization_code.scopes,
)
del self.codes[authorization_code.code]
return OAuthToken(
access_token=access,
token_type="Bearer",
expires_in=self._next_expires_in(),
scope=" ".join(authorization_code.scopes),
refresh_token=refresh,
)
async def load_access_token(self, token: str) -> AccessToken | None:
if self._reject_all_tokens:
return None
return self.access_tokens.get(token)
async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None:
return self.refresh_tokens.get(refresh_token)
async def exchange_refresh_token(
self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]
) -> OAuthToken:
"""Mint a new access token and rotate the refresh token, consuming the old one."""
assert client.client_id is not None
if self._fail_next_refresh:
self._fail_next_refresh = False
raise TokenError(error="invalid_grant", error_description="refresh denied by harness")
access = self.mint_access_token(client_id=client.client_id, scopes=scopes)
new_refresh = f"refresh_{secrets.token_hex(16)}"
self.refresh_tokens[new_refresh] = RefreshToken(token=new_refresh, client_id=client.client_id, scopes=scopes)
del self.refresh_tokens[refresh_token.token]
return OAuthToken(
access_token=access,
token_type="Bearer",
expires_in=self._next_expires_in(),
scope=" ".join(scopes),
refresh_token=new_refresh,
)
async def exchange_identity_assertion(
self, client: OAuthClientInformationFull, params: IdentityAssertionParams
) -> OAuthToken:
"""Validate the ID-JAG assertion and mint an MCP access token (RFC 7523 jwt-bearer / SEP-990).
Records `params` for inspection and rejects any assertion other than `VALID_ASSERTION` with
invalid_grant (standing in for signature/policy validation). The granted scopes are exactly
those the client requested; a real provider would derive them from the validated ID-JAG.
"""
self.last_assertion_params = params
assert client.client_id is not None
if params.assertion != VALID_ASSERTION:
raise TokenError(error="invalid_grant", error_description="assertion is not valid")
scopes = params.scopes if params.scopes is not None else self._default_scopes
access = self.mint_access_token(client_id=client.client_id, scopes=scopes, resource=params.resource)
return OAuthToken(
access_token=access,
token_type="Bearer",
expires_in=self._next_expires_in(),
scope=" ".join(scopes),
)
async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
"""Not exercised by this suite; revocation is out of scope for the interaction tests."""
raise NotImplementedError
+300
View File
@@ -0,0 +1,300 @@
"""Error-plane behaviour of the SDK's bundled OAuth authorization-server handlers.
The end-to-end OAuth tests prove the handlers' happy paths; these tests drive the same
mounted authorization server directly with raw httpx so the assertions are the HTTP
semantics (status, redirect target, error body, headers) the OAuth RFCs mandate. Almost
every behaviour here is enforced by the SDK's own handlers; where the pinned output
deviates from the RFC, the manifest entry carries the divergence.
"""
import base64
import hashlib
import secrets
from collections.abc import AsyncIterator
from urllib.parse import parse_qs, urlsplit
import httpx
import pytest
from inline_snapshot import snapshot
from mcp.server import Server
from mcp.server.auth.provider import ProviderTokenVerifier
from mcp.shared.auth import OAuthClientInformationFull
from tests.interaction._connect import mounted_app
from tests.interaction._requirements import requirement
from tests.interaction.auth._harness import REDIRECT_URI, auth_settings, oauth_client_metadata
from tests.interaction.auth._provider import InMemoryAuthorizationServerProvider
pytestmark = pytest.mark.anyio
@pytest.fixture
async def as_app() -> AsyncIterator[tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider]]:
"""Co-host the SDK's authorization-server routes and yield a raw httpx client against them."""
provider = InMemoryAuthorizationServerProvider()
settings = auth_settings()
async with mounted_app(
Server("guarded"),
auth=settings,
token_verifier=ProviderTokenVerifier(provider),
auth_server_provider=provider,
) as (http, _):
yield http, provider
def _pkce_pair() -> tuple[str, str]:
"""Generate a (code_verifier, code_challenge) pair the same way the SDK client does."""
verifier = secrets.token_urlsafe(48)[:64]
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")
return verifier, challenge
async def _register_client(http: httpx.AsyncClient) -> OAuthClientInformationFull:
"""Dynamically register a client and return its full credentials."""
response = await http.post("/register", content=oauth_client_metadata().model_dump_json())
assert response.status_code == 201
return OAuthClientInformationFull.model_validate_json(response.content)
async def _mint_code(http: httpx.AsyncClient) -> tuple[OAuthClientInformationFull, str, str]:
"""Register a client, complete a valid authorize step, and return (client_info, code, verifier)."""
client_info = await _register_client(http)
assert client_info.client_id is not None
verifier, challenge = _pkce_pair()
response = await http.get(
"/authorize",
params={
"response_type": "code",
"client_id": client_info.client_id,
"redirect_uri": REDIRECT_URI,
"code_challenge": challenge,
"code_challenge_method": "S256",
"state": "s",
},
follow_redirects=False,
)
assert response.status_code == 302
redirect = urlsplit(response.headers["location"])
assert f"{redirect.scheme}://{redirect.netloc}{redirect.path}" == REDIRECT_URI
code = parse_qs(redirect.query)["code"][0]
return client_info, code, verifier
def _token_form(client_info: OAuthClientInformationFull, **overrides: str) -> dict[str, str]:
"""Build the form body for an authorization-code token request, with the defaults a real client would send."""
assert client_info.client_id is not None
assert client_info.client_secret is not None
form = {
"grant_type": "authorization_code",
"client_id": client_info.client_id,
"client_secret": client_info.client_secret,
"redirect_uri": REDIRECT_URI,
}
form.update(overrides)
return form
@requirement("hosting:auth:as:authorize-requires-pkce")
async def test_authorize_without_a_code_challenge_is_rejected_with_invalid_request(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""An authorize request omitting `code_challenge` is redirected back with `error=invalid_request`.
PKCE is mandatory: the bundled authorize handler models `code_challenge` as a required field, so
a code without a stored challenge can never be issued. That makes the PKCE-downgrade attack (a
token request carrying a verifier for a code minted without a challenge) structurally impossible
through these handlers, so no separate downgrade-guard test is needed.
"""
http, _ = as_app
client_info = await _register_client(http)
assert client_info.client_id is not None
response = await http.get(
"/authorize",
params={
"response_type": "code",
"client_id": client_info.client_id,
"redirect_uri": REDIRECT_URI,
"state": "abc",
},
follow_redirects=False,
)
assert response.status_code == 302
redirect = urlsplit(response.headers["location"])
assert f"{redirect.scheme}://{redirect.netloc}{redirect.path}" == REDIRECT_URI
params = parse_qs(redirect.query)
assert params["error"] == ["invalid_request"]
assert params["state"] == ["abc"]
assert "code_challenge" in params["error_description"][0]
@requirement("hosting:auth:as:verifier-mismatch")
async def test_a_mismatched_code_verifier_is_rejected_with_invalid_grant(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""A token exchange whose `code_verifier` does not hash to the stored challenge is rejected."""
http, _ = as_app
client_info, code, _ = await _mint_code(http)
response = await http.post("/token", data=_token_form(client_info, code=code, code_verifier="0" * 64))
assert response.status_code == 400
assert response.json() == snapshot({"error": "invalid_grant", "error_description": "incorrect code_verifier"})
@requirement("hosting:auth:as:code-single-use")
async def test_reusing_an_authorization_code_is_rejected_with_invalid_grant(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""An authorization code can be exchanged exactly once; a second exchange is `invalid_grant`.
The handler does not track used codes itself: it returns `invalid_grant` whenever the provider's
`load_authorization_code` returns None, and the in-memory provider deletes the code on first
exchange. The test proves the combination enforces single-use; a provider that did not consume
codes would not get this guarantee from the handler.
"""
http, _ = as_app
client_info, code, verifier = await _mint_code(http)
form = _token_form(client_info, code=code, code_verifier=verifier)
first = await http.post("/token", data=form)
assert first.status_code == 200
assert first.json()["token_type"] == "Bearer"
second = await http.post("/token", data=form)
assert second.status_code == 400
assert second.json() == snapshot(
{"error": "invalid_grant", "error_description": "authorization code does not exist"}
)
@requirement("hosting:auth:as:redirect-uri-binding")
async def test_a_redirect_uri_differing_from_authorize_is_rejected_at_the_token_endpoint(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""A token exchange whose `redirect_uri` differs from the one used at authorize is rejected.
This is the security-critical half of redirect-URI binding: a code intercepted via redirect
substitution cannot be redeemed because the attacker cannot reproduce the original authorize
redirect URI at the token endpoint. RFC 6749 §5.2 specifies `invalid_grant` for this case;
the SDK returns `invalid_request` (see the divergence on the requirement). The rejection
itself is the security property and is correct.
"""
http, _ = as_app
client_info, code, verifier = await _mint_code(http)
response = await http.post(
"/token",
data=_token_form(client_info, code=code, code_verifier=verifier, redirect_uri=f"{REDIRECT_URI}/different"),
)
assert response.status_code == 400
assert response.json() == snapshot(
{
"error": "invalid_request",
"error_description": "redirect_uri did not match the one used when creating auth code",
}
)
@requirement("hosting:auth:as:token-cache-headers")
async def test_token_responses_carry_cache_control_no_store(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""Every token-endpoint response (success and error) carries `Cache-Control: no-store`."""
http, _ = as_app
client_info, code, verifier = await _mint_code(http)
form = _token_form(client_info, code=code, code_verifier=verifier)
success = await http.post("/token", data=form)
assert success.status_code == 200
assert success.headers["cache-control"] == "no-store"
assert success.headers["pragma"] == "no-cache"
failure = await http.post("/token", data=form)
assert failure.status_code == 400
assert failure.headers["cache-control"] == "no-store"
assert failure.headers["pragma"] == "no-cache"
@requirement("hosting:auth:as:register-error-response")
async def test_registration_with_invalid_metadata_is_rejected_with_400(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""Invalid client metadata at the registration endpoint returns 400 with an RFC 7591 error body."""
http, _ = as_app
malformed = await http.post("/register", json={"redirect_uris": ["not-a-url"]})
assert malformed.status_code == 400
assert malformed.json()["error"] == "invalid_client_metadata"
body = oauth_client_metadata().model_dump(mode="json", exclude_none=True)
no_auth_code = await http.post("/register", json=body | {"grant_types": ["refresh_token"]})
assert no_auth_code.status_code == 400
assert no_auth_code.json() == snapshot(
{"error": "invalid_client_metadata", "error_description": "grant_types must include 'authorization_code'"}
)
bad_scope = await http.post("/register", json=body | {"scope": "forbidden"})
assert bad_scope.status_code == 400
body = bad_scope.json()
assert body["error"] == "invalid_client_metadata"
# The description embeds a set difference whose ordering is not stable, so assert the prefix.
assert body["error_description"].startswith("Requested scopes are not valid: ")
@requirement("hosting:auth:as:redirect-uri-binding")
async def test_authorize_with_an_unregistered_redirect_uri_is_rejected_directly(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""An authorize request naming an unregistered `redirect_uri` returns 400 without redirecting to it.
The security property is that the authorization server never redirects to an unvalidated URI:
the response is a direct JSON error to the user agent, not a 302 to the attacker's host.
"""
http, _ = as_app
client_info = await _register_client(http)
assert client_info.client_id is not None
_, challenge = _pkce_pair()
response = await http.get(
"/authorize",
params={
"response_type": "code",
"client_id": client_info.client_id,
"redirect_uri": "http://127.0.0.1:8000/evil",
"code_challenge": challenge,
"code_challenge_method": "S256",
},
follow_redirects=False,
)
assert response.status_code == 400
assert "location" not in response.headers
body = response.json()
assert body["error"] == "invalid_request"
assert "not registered" in body["error_description"]
@requirement("hosting:auth:as:redirect-uri-scheme")
async def test_a_non_loopback_http_redirect_uri_is_accepted_at_registration(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""A registration carrying a non-HTTPS, non-loopback redirect URI is accepted.
The spec requires every redirect URI to be either HTTPS or a loopback host; the bundled
registration handler does not enforce this and registers `http://evil.example/callback`
successfully. See the divergence on the requirement.
"""
http, provider = as_app
body = oauth_client_metadata().model_dump(mode="json", exclude_none=True)
body["redirect_uris"] = ["http://evil.example/callback"]
response = await http.post("/register", json=body)
assert response.status_code == 201
info = OAuthClientInformationFull.model_validate_json(response.content)
assert [str(u) for u in (info.redirect_uris or [])] == ["http://evil.example/callback"]
assert info.client_id in provider.clients
@@ -0,0 +1,417 @@
"""Authorization-request, token-request, and PKCE wire-level invariants of the SDK's OAuth client.
Every test connects a real `Client` end to end via `connect_with_oauth`; the assertions are on
the parsed authorize URL and the recorded `/token` form body, because those wire shapes are what
the spec mandates and `Client` cannot observe them. The recording uses `record_requests`, which
snapshots each request at send time so the auth flow's in-place header mutation on retry never
affects what was captured for the first attempt.
Tests #1/#2/#4/#5 share one `recorded_oauth_flow` fixture (one connect, several disjoint
assertions on its recording); the others connect fresh because each needs a different harness
configuration.
"""
import base64
import hashlib
import json
import re
from collections.abc import AsyncIterator
from dataclasses import dataclass
from urllib.parse import parse_qsl, quote, urlsplit
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import ListToolsResult, Tool
from pydantic import AnyHttpUrl, AnyUrl
from mcp.client.auth import OAuthFlowError
from mcp.server import Server, ServerRequestContext
from mcp.shared.auth import OAuthClientInformationFull, OAuthMetadata
from tests.interaction._connect import BASE_URL
from tests.interaction._requirements import requirement
from tests.interaction.auth._harness import (
REDIRECT_URI,
HeadlessOAuth,
InMemoryTokenStorage,
RecordedRequest,
auth_settings,
connect_with_oauth,
first_challenge_shim,
record_requests,
shimmed_app,
)
from tests.interaction.auth._provider import InMemoryAuthorizationServerProvider
pytestmark = pytest.mark.anyio
PRM_PATH = "/.well-known/oauth-protected-resource/mcp"
ASM_PATH = "/.well-known/oauth-authorization-server"
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name="echo", input_schema={"type": "object"})])
def authorize_params(authorize_url: str) -> dict[str, str]:
"""Parse the authorize URL's query string into a flat dict (one value per key)."""
return dict(parse_qsl(urlsplit(authorize_url).query))
def form_body(request: RecordedRequest) -> dict[str, str]:
"""Parse an `application/x-www-form-urlencoded` request body into a flat dict."""
return dict(parse_qsl(request.content.decode()))
def find(recorded: list[RecordedRequest], method: str, path: str) -> list[RecordedRequest]:
"""Filter recorded requests by method and exact path."""
return [r for r in recorded if r.method == method and r.path == path]
@dataclass
class RecordedFlow:
"""One completed OAuth connect: every recorded request, plus the parsed authorize URL params."""
requests: list[RecordedRequest]
authorize_url: str
@property
def authorize(self) -> dict[str, str]:
return authorize_params(self.authorize_url)
@property
def token_request(self) -> RecordedRequest:
token_posts = find(self.requests, "POST", "/token")
assert len(token_posts) == 1
return token_posts[0]
@pytest.fixture
async def recorded_oauth_flow() -> AsyncIterator[RecordedFlow]:
"""Run one full OAuth connect with default configuration and yield its recorded wire traffic.
`valid_scopes` includes `offline_access` so the AS metadata advertises it and the SDK's
SEP-2207 auto-append (and the resulting `prompt=consent`) is exercised; `required_scopes`
stays at `["mcp"]` so the issued token still passes the bearer middleware.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider()
server = Server("guarded", on_list_tools=list_tools)
settings = auth_settings(required_scopes=["mcp"], valid_scopes=["mcp", "offline_access"])
with anyio.fail_after(5):
async with connect_with_oauth(server, provider=provider, settings=settings, on_request=on_request) as (
client,
headless,
):
await client.list_tools()
assert headless.authorize_url is not None
yield RecordedFlow(requests=recorded, authorize_url=headless.authorize_url)
@requirement("client-auth:pkce:s256")
@requirement("client-auth:resource-parameter")
@requirement("client-auth:authorize:offline-access-consent")
async def test_the_authorize_url_carries_s256_pkce_and_the_resource_indicator(
recorded_oauth_flow: RecordedFlow,
) -> None:
"""Every spec-mandated parameter appears on the authorize URL with the right value.
The full key set is snapshotted so a parameter added or dropped fails the test. The
`code_challenge` length bound is the RFC 7636 §4.2 grammar; an S256 challenge is in
practice always 43 characters, so the upper bound is never approached.
"""
params = recorded_oauth_flow.authorize
assert sorted(params) == snapshot(
[
"client_id",
"code_challenge",
"code_challenge_method",
"prompt",
"redirect_uri",
"resource",
"response_type",
"scope",
"state",
]
)
assert params["response_type"] == "code"
assert params["code_challenge_method"] == "S256"
assert 43 <= len(params["code_challenge"]) <= 128
# The exact resource value depends on canonical-URI normalisation (a spec ambiguity); pin
# the stable prefix so the test does not lock in a trailing-slash decision.
assert params["resource"].startswith(BASE_URL)
assert params["state"] != ""
assert params["scope"].split(" ") == snapshot(["mcp", "offline_access"])
assert params["prompt"] == "consent"
@requirement("client-auth:pkce:s256")
async def test_the_code_verifier_on_the_token_request_hashes_to_the_code_challenge(
recorded_oauth_flow: RecordedFlow,
) -> None:
"""The PKCE verifier sent on /token is the S256 pre-image of the challenge sent on /authorize.
The verifier is also checked against RFC 7636 §4.1's length and `unreserved` charset.
"""
challenge = recorded_oauth_flow.authorize["code_challenge"]
verifier = form_body(recorded_oauth_flow.token_request)["code_verifier"]
assert re.fullmatch(r"[A-Za-z0-9._~-]{43,128}", verifier)
assert base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=") == challenge
@requirement("client-auth:state:verify")
async def test_a_mismatched_state_on_the_callback_aborts_the_flow() -> None:
"""A callback whose state does not match the value sent on /authorize raises and stops the flow.
The auth flow runs inside the streamable-HTTP client's task group, so the `OAuthFlowError`
reaches the test wrapped in nested single-element exception groups; `pytest.RaisesGroup`
asserts the leaf type and the SDK-authored message prefix (the full message embeds two
random tokens).
"""
provider = InMemoryAuthorizationServerProvider()
server = Server("guarded", on_list_tools=list_tools)
headless = HeadlessOAuth(state_override="wrong-state")
with anyio.fail_after(5):
with pytest.RaisesGroup(
pytest.RaisesExc(OAuthFlowError, match="^State parameter mismatch:"), flatten_subgroups=True
):
# Entering the connect raises during the OAuth handshake (inside `Client.__aenter__`),
# so an `async with` body would be unreachable; entering explicitly avoids dead code.
await connect_with_oauth(server, provider=provider, headless=headless).__aenter__()
@requirement("client-auth:authorization-response:iss-verify")
async def test_a_mismatched_iss_on_the_callback_aborts_the_flow() -> None:
"""A callback whose RFC 9207 iss does not match the authorization server issuer aborts the flow.
`iss_override` makes the headless callback return an issuer the AS never advertised; the SDK
compares it to `oauth_metadata.issuer` and raises `OAuthFlowError` before the token exchange.
"""
provider = InMemoryAuthorizationServerProvider()
server = Server("guarded", on_list_tools=list_tools)
headless = HeadlessOAuth(iss_override="https://attacker.example.com")
with anyio.fail_after(5):
with pytest.RaisesGroup(
pytest.RaisesExc(OAuthFlowError, match="^Authorization response iss mismatch:"), flatten_subgroups=True
):
await connect_with_oauth(server, provider=provider, headless=headless).__aenter__()
@requirement("client-auth:resource-parameter")
async def test_the_authorization_code_token_request_carries_grant_type_code_redirect_and_resource(
recorded_oauth_flow: RecordedFlow,
) -> None:
"""The /token form body has exactly the auth-code grant fields, with redirect_uri and resource matching /authorize.
`client_secret` is present because the SDK's dynamic-registration handler issues a secret
and the client defaults to `client_secret_post`.
"""
token_req = recorded_oauth_flow.token_request
body = form_body(token_req)
assert sorted(body) == snapshot(
["client_id", "client_secret", "code", "code_verifier", "grant_type", "redirect_uri", "resource"]
)
assert body["grant_type"] == "authorization_code"
assert body["code"] != ""
assert body["redirect_uri"] == recorded_oauth_flow.authorize["redirect_uri"]
assert body["resource"] == recorded_oauth_flow.authorize["resource"]
assert token_req.headers["content-type"] == "application/x-www-form-urlencoded"
@requirement("client-auth:bearer-header:every-request")
async def test_every_mcp_request_after_auth_carries_the_bearer_header_and_never_a_query_token(
recorded_oauth_flow: RecordedFlow,
) -> None:
"""Every MCP request after the flow has `Authorization: Bearer ...` and never `?access_token=`.
The first /mcp POST is the unauthenticated trigger and is asserted to carry no Authorization
header; that assertion is only meaningful because the recording snapshots requests at send
time (the SDK mutates the same request object in place for the retry).
"""
mcp_posts = find(recorded_oauth_flow.requests, "POST", "/mcp")
assert len(mcp_posts) >= 3
assert "authorization" not in mcp_posts[0].headers
for r in mcp_posts[1:]:
assert r.headers["authorization"].startswith("Bearer ")
assert r.headers["authorization"] != "Bearer "
assert "access_token" not in dict(r.url.params)
@requirement("client-auth:token-endpoint-auth-method")
async def test_a_client_with_a_secret_authenticates_the_token_request_with_http_basic() -> None:
"""A `client_secret_basic` client sends URL-encoded credentials in HTTP Basic, not the body.
Credentials are URL-encoded before base64 per RFC 6749 §2.3.1; the secret contains `/` so
the encoding is observable.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider()
server = Server("guarded", on_list_tools=list_tools)
client_info = OAuthClientInformationFull(
client_id="cid",
client_secret="s/cret",
token_endpoint_auth_method="client_secret_basic",
redirect_uris=[AnyUrl(REDIRECT_URI)],
grant_types=["authorization_code", "refresh_token"],
scope="mcp",
)
await provider.register_client(client_info)
storage = InMemoryTokenStorage(client_info=client_info)
with anyio.fail_after(5):
async with connect_with_oauth(server, provider=provider, storage=storage, on_request=on_request) as (client, _):
await client.list_tools()
assert find(recorded, "POST", "/register") == []
[token_req] = find(recorded, "POST", "/token")
decoded = base64.b64decode(token_req.headers["authorization"].removeprefix("Basic ")).decode()
assert decoded == f"{quote('cid', safe='')}:{quote('s/cret', safe='')}"
assert "client_secret" not in form_body(token_req)
@requirement("client-auth:token-endpoint-auth-method")
async def test_the_registered_auth_method_is_used_regardless_of_as_metadata_advertised_methods() -> None:
"""The token-endpoint auth method comes from the registered client info, not from AS metadata.
The shim serves AS metadata advertising only `client_secret_basic`; the client dynamically
registers and the SDK's registration handler issues `client_secret_post`. The client uses
`client_secret_post` (secret in the body, no Basic header) because the SDK reads the
registered `token_endpoint_auth_method`, not `token_endpoint_auth_methods_supported`. Other
SDKs (TypeScript, Go) do consult the AS metadata; this test pins where the python SDK's
selection point lives.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider()
server = Server("guarded", on_list_tools=list_tools)
override = OAuthMetadata(
issuer=AnyHttpUrl(f"{BASE_URL}/"),
authorization_endpoint=AnyHttpUrl(f"{BASE_URL}/authorize"),
token_endpoint=AnyHttpUrl(f"{BASE_URL}/token"),
registration_endpoint=AnyHttpUrl(f"{BASE_URL}/register"),
scopes_supported=["mcp"],
grant_types_supported=["authorization_code", "refresh_token"],
code_challenge_methods_supported=["S256"],
token_endpoint_auth_methods_supported=["client_secret_basic"],
)
serve = {ASM_PATH: override.model_dump_json(exclude_none=True).encode()}
with anyio.fail_after(5):
async with connect_with_oauth(
server, provider=provider, app_shim=lambda app: shimmed_app(app, serve=serve), on_request=on_request
) as (client, _):
await client.list_tools()
[register] = find(recorded, "POST", "/register")
assert json.loads(register.content).get("token_endpoint_auth_method") is None
[token_req] = find(recorded, "POST", "/token")
body = form_body(token_req)
assert "client_secret" in body
assert body["client_secret"] != ""
assert "authorization" not in token_req.headers
@requirement("client-auth:scope-selection:priority")
async def test_scope_is_selected_from_the_www_authenticate_challenge_over_prm_metadata() -> None:
"""When the 401 challenge carries `scope=`, that value is requested instead of the PRM scopes.
The SDK's bearer middleware never emits `scope=` in WWW-Authenticate (see the divergence
on `hosting:auth:scope-403`), so the test supplies the first 401 itself via
`first_challenge_shim` and disables token verification so the post-auth retry succeeds
regardless of the granted scope. PRM advertises `["from-prm"]` (it mirrors
`required_scopes`); the challenge says `from-header`; the authorize URL must carry
`from-header`.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider(default_scopes=["from-header"])
server = Server("guarded", on_list_tools=list_tools)
settings = auth_settings(required_scopes=["from-prm"], valid_scopes=["from-header", "from-prm"])
challenge = f'Bearer scope="from-header", resource_metadata="{BASE_URL}{PRM_PATH}"'
with anyio.fail_after(5):
async with connect_with_oauth(
server,
provider=provider,
settings=settings,
verify_tokens=False,
app_shim=first_challenge_shim(challenge),
on_request=on_request,
) as (client, headless):
await client.list_tools()
assert headless.authorize_url is not None
assert authorize_params(headless.authorize_url)["scope"] == "from-header"
[register] = find(recorded, "POST", "/register")
assert json.loads(register.content)["scope"] == "from-header"
@requirement("client-auth:pkce:refuse-if-unsupported")
async def test_pkce_is_still_sent_when_as_metadata_omits_code_challenge_methods_supported() -> None:
"""AS metadata without `code_challenge_methods_supported` does not stop the client sending PKCE.
The spec says the client MUST refuse to proceed in this case; the SDK proceeds and the flow
completes. See the divergence on the requirement.
"""
override = OAuthMetadata(
issuer=AnyHttpUrl(f"{BASE_URL}/"),
authorization_endpoint=AnyHttpUrl(f"{BASE_URL}/authorize"),
token_endpoint=AnyHttpUrl(f"{BASE_URL}/token"),
registration_endpoint=AnyHttpUrl(f"{BASE_URL}/register"),
scopes_supported=["mcp"],
grant_types_supported=["authorization_code", "refresh_token"],
)
assert override.code_challenge_methods_supported is None
serve = {ASM_PATH: override.model_dump_json(exclude_none=True).encode()}
provider = InMemoryAuthorizationServerProvider()
server = Server("guarded", on_list_tools=list_tools)
with anyio.fail_after(5):
async with connect_with_oauth(
server, provider=provider, app_shim=lambda app: shimmed_app(app, serve=serve)
) as (client, headless):
result = await client.list_tools()
assert headless.authorize_url is not None
params = authorize_params(headless.authorize_url)
assert params["code_challenge_method"] == "S256"
assert params["code_challenge"] != ""
assert result.tools[0].name == "echo"
@requirement("client-auth:authorize:error-surfaces")
async def test_an_authorize_error_on_the_callback_aborts_the_flow_before_the_token_request() -> None:
"""An `error=` redirect from /authorize aborts the flow with no /token request issued.
The SDK's callback contract is `() -> (code, state)` with no error form, so the failure is
observed as an empty code reaching the SDK and `OAuthFlowError("No authorization code
received")` being raised. The actual `error` value from the redirect is not surfaced to the
caller; that gap is noted in the manifest.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider(deny_authorize=True)
server = Server("guarded", on_list_tools=list_tools)
headless = HeadlessOAuth()
with anyio.fail_after(5):
with pytest.RaisesGroup(
pytest.RaisesExc(OAuthFlowError, match="^No authorization code received$"), flatten_subgroups=True
):
await connect_with_oauth(server, provider=provider, headless=headless, on_request=on_request).__aenter__()
assert headless.error == "access_denied"
assert find(recorded, "POST", "/token") == []
+189
View File
@@ -0,0 +1,189 @@
"""Resource-server bearer-token gate: status codes and `WWW-Authenticate` for each token shape.
These tests mount only the resource-server side of the auth wiring (a `StaticTokenVerifier`
seeded with hand-built tokens, no authorization-server provider) and speak raw HTTP, since
every assertion is about HTTP semantics the SDK `Client` cannot observe: the 401/403 status,
the `WWW-Authenticate` header structure, and that a wrong-audience token reaches the MCP
endpoint behind the gate. The flow side of the same 401 is `test_flow.py`'s flagship test.
"""
import time
from collections.abc import AsyncIterator
import httpx
import pytest
from inline_snapshot import snapshot
from mcp_types import JSONRPCResponse
from mcp.server import Server
from mcp.server.auth.provider import AccessToken
from tests.interaction._connect import base_headers, initialize_body, mounted_app
from tests.interaction._requirements import requirement
from tests.interaction.auth._harness import StaticTokenVerifier, auth_settings
pytestmark = pytest.mark.anyio
REQUIRED_SCOPE = "mcp:read"
RESOURCE_METADATA_URL = "http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"
_FUTURE = int(time.time()) + 3600
_PAST = int(time.time()) - 3600
TOKENS = {
"tok-valid": AccessToken(token="tok-valid", client_id="c", scopes=[REQUIRED_SCOPE], expires_at=_FUTURE),
"tok-expired": AccessToken(token="tok-expired", client_id="c", scopes=[REQUIRED_SCOPE], expires_at=_PAST),
"tok-noscope": AccessToken(token="tok-noscope", client_id="c", scopes=["other:thing"], expires_at=_FUTURE),
"tok-wrong-aud": AccessToken(
token="tok-wrong-aud",
client_id="c",
scopes=[REQUIRED_SCOPE],
expires_at=_FUTURE,
resource="https://other.example/mcp",
),
}
@pytest.fixture
async def protected() -> AsyncIterator[httpx.AsyncClient]:
"""A bearer-gated streamable-HTTP app (resource server only) on the in-process bridge."""
server = Server("rs")
settings = auth_settings(required_scopes=[REQUIRED_SCOPE])
async with mounted_app(server, auth=settings, token_verifier=StaticTokenVerifier(TOKENS)) as (http, _):
yield http
async def post_mcp(
http: httpx.AsyncClient, *, bearer: str | None = None, query: dict[str, str] | None = None
) -> httpx.Response:
"""POST an initialize body to `/mcp`, optionally with a bearer token and/or a query string."""
headers = base_headers()
if bearer is not None:
headers["authorization"] = f"Bearer {bearer}"
return await http.post("/mcp", headers=headers, params=query, json=initialize_body())
def parse_www_authenticate(value: str) -> dict[str, str]:
"""Parse a `Bearer k="v", k="v"` challenge into a dict.
The SDK emits each parameter exactly once, comma-space separated, with double-quoted
values that contain no quotes themselves; this helper relies on that and would fail
visibly if the format changed.
"""
scheme, _, params = value.partition(" ")
assert scheme == "Bearer"
return {key: quoted.strip('"') for key, _, quoted in (pair.partition("=") for pair in params.split(", "))}
@requirement("hosting:auth:missing-401")
async def test_a_request_with_no_authorization_header_is_challenged_with_resource_metadata(
protected: httpx.AsyncClient,
) -> None:
"""No `Authorization` header → 401 with a `WWW-Authenticate` carrying `resource_metadata`.
The snapshot pins current behaviour: the SDK collapses the no-header, unknown-token, and
expired-token cases into one challenge (`error="invalid_token"`, no `scope` parameter). The
spec says the discovery-time challenge SHOULD include `scope` and RFC 6750 says the
no-credentials case SHOULD NOT carry an error code; both gaps are recorded as the divergence
on this requirement. Asserting the dict equals an exact key set also pins that no parameter
appears twice.
"""
response = await post_mcp(protected)
assert response.status_code == 401
assert response.headers["www-authenticate"] == snapshot(
'Bearer error="invalid_token", error_description="Authentication required", '
'resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"'
)
assert parse_www_authenticate(response.headers["www-authenticate"]) == {
"error": "invalid_token",
"error_description": "Authentication required",
"resource_metadata": RESOURCE_METADATA_URL,
}
assert response.json() == snapshot({"error": "invalid_token", "error_description": "Authentication required"})
@requirement("hosting:auth:invalid-401")
async def test_an_unrecognized_bearer_token_is_answered_401_invalid_token(protected: httpx.AsyncClient) -> None:
"""A token the verifier does not recognize is answered 401 `invalid_token`.
The challenge is identical to the no-header case (the backend returns `None` for both); the
missing `scope` parameter is the recorded divergence on this requirement.
"""
response = await post_mcp(protected, bearer="tok-unknown")
assert response.status_code == 401
assert parse_www_authenticate(response.headers["www-authenticate"]) == {
"error": "invalid_token",
"error_description": "Authentication required",
"resource_metadata": RESOURCE_METADATA_URL,
}
@requirement("hosting:auth:expired-401")
async def test_an_expired_token_is_answered_401(protected: httpx.AsyncClient) -> None:
"""A token whose `expires_at` is in the past is answered 401 `invalid_token`.
The expiry check is the bearer backend's, against the wall clock; the test seeds a concrete
past timestamp so no time mocking is involved. The missing `scope` parameter is the recorded
divergence on this requirement.
"""
response = await post_mcp(protected, bearer="tok-expired")
assert response.status_code == 401
assert parse_www_authenticate(response.headers["www-authenticate"])["error"] == "invalid_token"
@requirement("hosting:auth:scope-403")
async def test_a_token_missing_a_required_scope_is_answered_403_insufficient_scope_without_a_scope_param(
protected: httpx.AsyncClient,
) -> None:
"""A token lacking the required scope is answered 403 `insufficient_scope`, with no `scope` parameter.
The spec's runtime-insufficient-scope guidance says the challenge SHOULD include `scope`
naming the required scope; the SDK never emits it, recorded as the divergence on this
requirement. The SDK client reads `scope` from this header to drive step-up, so the gap is
a resource-server/client asymmetry.
"""
response = await post_mcp(protected, bearer="tok-noscope")
assert response.status_code == 403
parsed = parse_www_authenticate(response.headers["www-authenticate"])
assert parsed == {
"error": "insufficient_scope",
"error_description": f"Required scope: {REQUIRED_SCOPE}",
"resource_metadata": RESOURCE_METADATA_URL,
}
assert "scope" not in parsed
@requirement("hosting:auth:aud-validation")
async def test_a_token_with_a_mismatched_audience_is_accepted(protected: httpx.AsyncClient) -> None:
"""A token whose `resource` does not match the server's resource identifier is accepted.
The spec mandates the resource server validate the token's audience; the bearer backend
never inspects `AccessToken.resource`, so the request passes the gate and the MCP endpoint
serves it. This pins current behaviour with the divergence recorded on the requirement.
"""
response = await post_mcp(protected, bearer="tok-wrong-aud")
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/event-stream")
# The body is finite SSE: a result event followed by stream close. Pull the JSON-RPC response
# out of the buffered text to prove the MCP endpoint actually answered the initialize request.
[data] = [line.removeprefix("data: ") for line in response.text.splitlines() if line.startswith("data: ")]
assert "protocolVersion" in JSONRPCResponse.model_validate_json(data).result
@requirement("hosting:auth:query-token-ignored")
async def test_an_access_token_in_the_query_string_is_not_accepted(protected: httpx.AsyncClient) -> None:
"""A valid token presented in the URI query string is treated as no authentication.
The bearer backend reads only the `Authorization` header, so `?access_token=...` is never
consulted; the request is treated as unauthenticated and answered 401. This satisfies, by
absence, the security best-practice that resource servers must not accept query-string
tokens.
"""
response = await post_mcp(protected, query={"access_token": "tok-valid"})
assert response.status_code == 401
assert parse_www_authenticate(response.headers["www-authenticate"])["error"] == "invalid_token"

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