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
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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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)
|
||||
@@ -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世界🌍Привет안녕مرحباשלום"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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},
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
@@ -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"})
|
||||
@@ -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"})]
|
||||
)
|
||||
@@ -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]
|
||||
@@ -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
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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)"}
|
||||
@@ -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
|
||||
@@ -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'."}
|
||||
@@ -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")])
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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."]
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"}
|
||||
)
|
||||
@@ -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!")]
|
||||
)
|
||||
@@ -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
|
||||
@@ -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": "{}"}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"])]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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."}
|
||||
@@ -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"),
|
||||
)
|
||||
]
|
||||
@@ -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"
|
||||
@@ -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",
|
||||
)
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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})
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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))"'
|
||||
)
|
||||
@@ -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}"),
|
||||
]
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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']}"
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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") == []
|
||||
@@ -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
Reference in New Issue
Block a user