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) Waiting to run
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) Waiting to run
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
"""Shared fixtures for `Dispatcher` contract tests.
|
||||
|
||||
The `pair_factory` fixture parametrizes contract tests over every `Dispatcher`
|
||||
implementation, so the same behavioral assertions run against `DirectDispatcher`
|
||||
(in-memory) and `JSONRPCDispatcher` (over crossed anyio memory streams).
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
|
||||
from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair
|
||||
from mcp.shared.dispatcher import Dispatcher
|
||||
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
|
||||
from mcp.shared.message import SessionMessage
|
||||
from mcp.shared.transport_context import TransportContext
|
||||
|
||||
DispatcherTriple = tuple[Dispatcher[TransportContext], Dispatcher[TransportContext], Callable[[], None]]
|
||||
PairFactory = Callable[..., DispatcherTriple]
|
||||
|
||||
|
||||
def direct_pair(*, can_send_request: bool = True) -> DispatcherTriple:
|
||||
client, server = create_direct_dispatcher_pair(can_send_request=can_send_request)
|
||||
|
||||
def close() -> None:
|
||||
client.close()
|
||||
server.close()
|
||||
|
||||
return client, server, close
|
||||
|
||||
|
||||
def jsonrpc_pair(*, can_send_request: bool = True) -> DispatcherTriple:
|
||||
"""Two `JSONRPCDispatcher`s wired over crossed in-memory streams."""
|
||||
c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
|
||||
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
|
||||
|
||||
def builder(_meta: object) -> TransportContext:
|
||||
return TransportContext(kind="jsonrpc", can_send_request=can_send_request)
|
||||
|
||||
client: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(s2c_recv, c2s_send, transport_builder=builder)
|
||||
server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(c2s_recv, s2c_send, transport_builder=builder)
|
||||
|
||||
def close() -> None:
|
||||
for s in (c2s_send, c2s_recv, s2c_send, s2c_recv):
|
||||
s.close()
|
||||
|
||||
return client, server, close
|
||||
|
||||
|
||||
@pytest.fixture(
|
||||
params=[
|
||||
pytest.param(direct_pair, id="direct"),
|
||||
pytest.param(jsonrpc_pair, id="jsonrpc"),
|
||||
]
|
||||
)
|
||||
def pair_factory(request: pytest.FixtureRequest) -> PairFactory:
|
||||
return request.param
|
||||
|
||||
|
||||
__all__ = ["PairFactory", "direct_pair", "jsonrpc_pair"]
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Tests for OAuth 2.0 shared code."""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthMetadata
|
||||
|
||||
|
||||
def test_oauth():
|
||||
"""Should not throw when parsing OAuth metadata."""
|
||||
OAuthMetadata.model_validate(
|
||||
{
|
||||
"issuer": "https://example.com",
|
||||
"authorization_endpoint": "https://example.com/oauth2/authorize",
|
||||
"token_endpoint": "https://example.com/oauth2/token",
|
||||
"scopes_supported": ["read", "write"],
|
||||
"response_types_supported": ["code", "token"],
|
||||
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_oidc():
|
||||
"""Should not throw when parsing OIDC metadata."""
|
||||
OAuthMetadata.model_validate(
|
||||
{
|
||||
"issuer": "https://example.com",
|
||||
"authorization_endpoint": "https://example.com/oauth2/authorize",
|
||||
"token_endpoint": "https://example.com/oauth2/token",
|
||||
"end_session_endpoint": "https://example.com/logout",
|
||||
"id_token_signing_alg_values_supported": ["RS256"],
|
||||
"jwks_uri": "https://example.com/.well-known/jwks.json",
|
||||
"response_types_supported": ["code", "token"],
|
||||
"revocation_endpoint": "https://example.com/oauth2/revoke",
|
||||
"scopes_supported": ["openid", "read", "write"],
|
||||
"subject_types_supported": ["public"],
|
||||
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
|
||||
"userinfo_endpoint": "https://example.com/oauth2/userInfo",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_oauth_with_jarm():
|
||||
"""Should not throw when parsing OAuth metadata that includes JARM response modes."""
|
||||
OAuthMetadata.model_validate(
|
||||
{
|
||||
"issuer": "https://example.com",
|
||||
"authorization_endpoint": "https://example.com/oauth2/authorize",
|
||||
"token_endpoint": "https://example.com/oauth2/token",
|
||||
"scopes_supported": ["read", "write"],
|
||||
"response_types_supported": ["code", "token"],
|
||||
"response_modes_supported": [
|
||||
"query",
|
||||
"fragment",
|
||||
"form_post",
|
||||
"query.jwt",
|
||||
"fragment.jwt",
|
||||
"form_post.jwt",
|
||||
"jwt",
|
||||
],
|
||||
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# RFC 7591 §2 marks client_uri/logo_uri/tos_uri/policy_uri/jwks_uri as OPTIONAL.
|
||||
# Some authorization servers echo the client's omitted metadata back as ""
|
||||
# instead of dropping the keys; without coercion, AnyHttpUrl rejects "" and
|
||||
# the whole registration response is thrown away even though the server
|
||||
# returned a valid client_id.
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"empty_field",
|
||||
["client_uri", "logo_uri", "tos_uri", "policy_uri", "jwks_uri"],
|
||||
)
|
||||
def test_optional_url_empty_string_coerced_to_none(empty_field: str):
|
||||
data = {
|
||||
"redirect_uris": ["https://example.com/callback"],
|
||||
empty_field: "",
|
||||
}
|
||||
metadata = OAuthClientMetadata.model_validate(data)
|
||||
assert getattr(metadata, empty_field) is None
|
||||
|
||||
|
||||
def test_all_optional_urls_empty_together():
|
||||
data = {
|
||||
"redirect_uris": ["https://example.com/callback"],
|
||||
"client_uri": "",
|
||||
"logo_uri": "",
|
||||
"tos_uri": "",
|
||||
"policy_uri": "",
|
||||
"jwks_uri": "",
|
||||
}
|
||||
metadata = OAuthClientMetadata.model_validate(data)
|
||||
assert metadata.client_uri is None
|
||||
assert metadata.logo_uri is None
|
||||
assert metadata.tos_uri is None
|
||||
assert metadata.policy_uri is None
|
||||
assert metadata.jwks_uri is None
|
||||
|
||||
|
||||
def test_valid_url_passes_through_unchanged():
|
||||
data = {
|
||||
"redirect_uris": ["https://example.com/callback"],
|
||||
"client_uri": "https://udemy.com/",
|
||||
}
|
||||
metadata = OAuthClientMetadata.model_validate(data)
|
||||
assert str(metadata.client_uri) == "https://udemy.com/"
|
||||
|
||||
|
||||
def test_information_full_inherits_coercion():
|
||||
"""OAuthClientInformationFull subclasses OAuthClientMetadata, so the
|
||||
same coercion applies to DCR responses parsed via the full model."""
|
||||
data = {
|
||||
"client_id": "abc123",
|
||||
"redirect_uris": ["https://example.com/callback"],
|
||||
"client_uri": "",
|
||||
"logo_uri": "",
|
||||
"tos_uri": "",
|
||||
"policy_uri": "",
|
||||
"jwks_uri": "",
|
||||
}
|
||||
info = OAuthClientInformationFull.model_validate(data)
|
||||
assert info.client_id == "abc123"
|
||||
assert info.client_uri is None
|
||||
assert info.logo_uri is None
|
||||
assert info.tos_uri is None
|
||||
assert info.policy_uri is None
|
||||
assert info.jwks_uri is None
|
||||
|
||||
|
||||
def test_invalid_non_empty_url_still_rejected():
|
||||
"""Coercion must only touch empty strings — garbage URLs still raise."""
|
||||
data = {
|
||||
"redirect_uris": ["https://example.com/callback"],
|
||||
"client_uri": "not a url",
|
||||
}
|
||||
with pytest.raises(ValidationError):
|
||||
OAuthClientMetadata.model_validate(data)
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Tests for OAuth 2.0 Resource Indicators utilities."""
|
||||
|
||||
from pydantic import HttpUrl
|
||||
|
||||
from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url
|
||||
|
||||
# Tests for resource_url_from_server_url function
|
||||
|
||||
|
||||
def test_resource_url_from_server_url_removes_fragment():
|
||||
"""Fragment should be removed per RFC 8707."""
|
||||
assert resource_url_from_server_url("https://example.com/path#fragment") == "https://example.com/path"
|
||||
assert resource_url_from_server_url("https://example.com/#fragment") == "https://example.com/"
|
||||
|
||||
|
||||
def test_resource_url_from_server_url_preserves_path():
|
||||
"""Path should be preserved."""
|
||||
assert (
|
||||
resource_url_from_server_url("https://example.com/path/to/resource") == "https://example.com/path/to/resource"
|
||||
)
|
||||
assert resource_url_from_server_url("https://example.com/") == "https://example.com/"
|
||||
assert resource_url_from_server_url("https://example.com") == "https://example.com"
|
||||
|
||||
|
||||
def test_resource_url_from_server_url_preserves_query():
|
||||
"""Query parameters should be preserved."""
|
||||
assert resource_url_from_server_url("https://example.com/path?foo=bar") == "https://example.com/path?foo=bar"
|
||||
assert resource_url_from_server_url("https://example.com/?key=value") == "https://example.com/?key=value"
|
||||
|
||||
|
||||
def test_resource_url_from_server_url_preserves_port():
|
||||
"""Non-default ports should be preserved."""
|
||||
assert resource_url_from_server_url("https://example.com:8443/path") == "https://example.com:8443/path"
|
||||
assert resource_url_from_server_url("http://example.com:8080/") == "http://example.com:8080/"
|
||||
|
||||
|
||||
def test_resource_url_from_server_url_lowercase_scheme_and_host():
|
||||
"""Scheme and host should be lowercase for canonical form."""
|
||||
assert resource_url_from_server_url("HTTPS://EXAMPLE.COM/path") == "https://example.com/path"
|
||||
assert resource_url_from_server_url("Http://Example.Com:8080/") == "http://example.com:8080/"
|
||||
|
||||
|
||||
def test_resource_url_from_server_url_handles_pydantic_urls():
|
||||
"""Should handle Pydantic URL types."""
|
||||
url = HttpUrl("https://example.com/path")
|
||||
assert resource_url_from_server_url(url) == "https://example.com/path"
|
||||
|
||||
|
||||
# Tests for check_resource_allowed function
|
||||
|
||||
|
||||
def test_check_resource_allowed_identical_urls():
|
||||
"""Identical URLs should match."""
|
||||
assert check_resource_allowed("https://example.com/path", "https://example.com/path") is True
|
||||
assert check_resource_allowed("https://example.com/", "https://example.com/") is True
|
||||
assert check_resource_allowed("https://example.com", "https://example.com") is True
|
||||
|
||||
|
||||
def test_check_resource_allowed_different_schemes():
|
||||
"""Different schemes should not match."""
|
||||
assert check_resource_allowed("https://example.com/path", "http://example.com/path") is False
|
||||
assert check_resource_allowed("http://example.com/", "https://example.com/") is False
|
||||
|
||||
|
||||
def test_check_resource_allowed_different_domains():
|
||||
"""Different domains should not match."""
|
||||
assert check_resource_allowed("https://example.com/path", "https://example.org/path") is False
|
||||
assert check_resource_allowed("https://sub.example.com/", "https://example.com/") is False
|
||||
|
||||
|
||||
def test_check_resource_allowed_different_ports():
|
||||
"""Different ports should not match."""
|
||||
assert check_resource_allowed("https://example.com:8443/path", "https://example.com/path") is False
|
||||
assert check_resource_allowed("https://example.com:8080/", "https://example.com:8443/") is False
|
||||
|
||||
|
||||
def test_check_resource_allowed_hierarchical_matching():
|
||||
"""Child paths should match parent paths."""
|
||||
# Parent resource allows child resources
|
||||
assert check_resource_allowed("https://example.com/api/v1/users", "https://example.com/api") is True
|
||||
assert check_resource_allowed("https://example.com/api/v1", "https://example.com/api") is True
|
||||
assert check_resource_allowed("https://example.com/mcp/server", "https://example.com/mcp") is True
|
||||
|
||||
# Exact match
|
||||
assert check_resource_allowed("https://example.com/api", "https://example.com/api") is True
|
||||
|
||||
# Parent cannot use child's token
|
||||
assert check_resource_allowed("https://example.com/api", "https://example.com/api/v1") is False
|
||||
assert check_resource_allowed("https://example.com/", "https://example.com/api") is False
|
||||
|
||||
|
||||
def test_check_resource_allowed_path_boundary_matching():
|
||||
"""Path matching should respect boundaries."""
|
||||
# Should not match partial path segments
|
||||
assert check_resource_allowed("https://example.com/apiextra", "https://example.com/api") is False
|
||||
assert check_resource_allowed("https://example.com/api123", "https://example.com/api") is False
|
||||
|
||||
# Should match with trailing slash
|
||||
assert check_resource_allowed("https://example.com/api/", "https://example.com/api") is True
|
||||
assert check_resource_allowed("https://example.com/api/v1", "https://example.com/api/") is True
|
||||
|
||||
|
||||
def test_check_resource_allowed_trailing_slash_handling():
|
||||
"""Trailing slashes should be handled correctly."""
|
||||
# With and without trailing slashes
|
||||
assert check_resource_allowed("https://example.com/api/", "https://example.com/api") is True
|
||||
assert check_resource_allowed("https://example.com/api", "https://example.com/api/") is True
|
||||
assert check_resource_allowed("https://example.com/api/v1", "https://example.com/api") is True
|
||||
assert check_resource_allowed("https://example.com/api/v1", "https://example.com/api/") is True
|
||||
|
||||
|
||||
def test_check_resource_allowed_case_insensitive_origin():
|
||||
"""Origin comparison should be case-insensitive."""
|
||||
assert check_resource_allowed("https://EXAMPLE.COM/path", "https://example.com/path") is True
|
||||
assert check_resource_allowed("HTTPS://example.com/path", "https://example.com/path") is True
|
||||
assert check_resource_allowed("https://Example.Com:8080/api", "https://example.com:8080/api") is True
|
||||
|
||||
|
||||
def test_check_resource_allowed_empty_paths():
|
||||
"""Empty paths should be handled correctly."""
|
||||
assert check_resource_allowed("https://example.com", "https://example.com") is True
|
||||
assert check_resource_allowed("https://example.com/", "https://example.com") is True
|
||||
assert check_resource_allowed("https://example.com/api", "https://example.com") is True
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Tests for `BaseContext`.
|
||||
|
||||
`BaseContext` is composition over a `DispatchContext` - it forwards
|
||||
`transport`/`cancel_requested`/`send_raw_request`/`notify`/`progress`
|
||||
and adds `meta`. It must satisfy `Outbound` so `ClientPeer` can wrap it.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
|
||||
from mcp.shared.context import BaseContext
|
||||
from mcp.shared.dispatcher import DispatchContext
|
||||
from mcp.shared.peer import ClientPeer
|
||||
from mcp.shared.transport_context import TransportContext
|
||||
|
||||
from .conftest import direct_pair, jsonrpc_pair
|
||||
from .test_dispatcher import Recorder, echo_handlers, running_pair
|
||||
|
||||
DCtx = DispatchContext[TransportContext]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_base_context_forwards_transport_and_cancel_requested():
|
||||
captured: list[BaseContext[TransportContext]] = []
|
||||
|
||||
async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
bctx = BaseContext(ctx)
|
||||
captured.append(bctx)
|
||||
return {}
|
||||
|
||||
async with running_pair(direct_pair, server_on_request=server_on_request) as (client, *_):
|
||||
with anyio.fail_after(5):
|
||||
await client.send_raw_request("t", None)
|
||||
bctx = captured[0]
|
||||
assert bctx.transport.kind == "direct"
|
||||
assert isinstance(bctx.cancel_requested, anyio.Event)
|
||||
assert bctx.can_send_request is True
|
||||
assert bctx.meta is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_base_context_can_send_request_reflects_dispatch_context_closed_state():
|
||||
"""`can_send_request` must track the dctx, not the static transport flag,
|
||||
so it agrees with whether `send_raw_request` would raise."""
|
||||
captured: list[BaseContext[TransportContext]] = []
|
||||
|
||||
async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
captured.append(BaseContext(ctx))
|
||||
return {}
|
||||
|
||||
async with running_pair(jsonrpc_pair, server_on_request=server_on_request) as (client, *_):
|
||||
with anyio.fail_after(5):
|
||||
await client.send_raw_request("t", None)
|
||||
bctx = captured[0]
|
||||
assert bctx.transport.can_send_request is True
|
||||
assert bctx.can_send_request is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_base_context_send_raw_request_and_notify_forward_to_dispatch_context():
|
||||
crec = Recorder()
|
||||
c_req, c_notify = echo_handlers(crec)
|
||||
|
||||
async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
bctx = BaseContext(ctx)
|
||||
sample = await bctx.send_raw_request("sampling/createMessage", {"x": 1})
|
||||
await bctx.notify("notifications/message", {"level": "info"})
|
||||
return {"sample": sample}
|
||||
|
||||
async with running_pair(
|
||||
direct_pair,
|
||||
server_on_request=server_on_request,
|
||||
client_on_request=c_req,
|
||||
client_on_notify=c_notify,
|
||||
) as (client, *_):
|
||||
with anyio.fail_after(5):
|
||||
result = await client.send_raw_request("tools/call", None)
|
||||
await crec.notified.wait()
|
||||
assert crec.requests == [("sampling/createMessage", {"x": 1})]
|
||||
assert crec.notifications == [("notifications/message", {"level": "info"})]
|
||||
assert result["sample"] == {"echoed": "sampling/createMessage", "params": {"x": 1}}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_base_context_report_progress_invokes_caller_on_progress():
|
||||
received: list[tuple[float, float | None, str | None]] = []
|
||||
|
||||
async def on_progress(progress: float, total: float | None, message: str | None) -> None:
|
||||
received.append((progress, total, message))
|
||||
|
||||
async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
bctx = BaseContext(ctx)
|
||||
await bctx.report_progress(0.5, total=1.0, message="halfway")
|
||||
return {}
|
||||
|
||||
async with running_pair(direct_pair, server_on_request=server_on_request) as (client, *_):
|
||||
with anyio.fail_after(5):
|
||||
await client.send_raw_request("t", None, {"on_progress": on_progress})
|
||||
assert received == [(0.5, 1.0, "halfway")]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_base_context_satisfies_outbound_so_peer_mixin_works():
|
||||
"""Wrapping a BaseContext in ClientPeer proves it satisfies Outbound structurally."""
|
||||
|
||||
async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
bctx = BaseContext(ctx)
|
||||
await ClientPeer(bctx).ping()
|
||||
return {}
|
||||
|
||||
crec = Recorder()
|
||||
c_req, c_notify = echo_handlers(crec)
|
||||
async with running_pair(
|
||||
direct_pair, server_on_request=server_on_request, client_on_request=c_req, client_on_notify=c_notify
|
||||
) as (client, *_):
|
||||
with anyio.fail_after(5):
|
||||
await client.send_raw_request("t", None)
|
||||
assert crec.requests == [("ping", None)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_base_context_meta_holds_supplied_request_params_meta():
|
||||
async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
bctx = BaseContext(ctx, meta={"progressToken": "abc"})
|
||||
assert bctx.meta is not None and bctx.meta.get("progressToken") == "abc"
|
||||
return {}
|
||||
|
||||
async with running_pair(direct_pair, server_on_request=server_on_request) as (client, *_):
|
||||
with anyio.fail_after(5):
|
||||
await client.send_raw_request("t", None)
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Tests for the contextvars-carrying memory-stream wrappers."""
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
|
||||
from mcp.shared._context_streams import create_context_streams
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
async def test_sync_close_closes_the_underlying_streams() -> None:
|
||||
"""The wrappers mirror anyio's memory streams: close() is the sync form of aclose()."""
|
||||
send, receive = create_context_streams[str](1)
|
||||
await send.send("queued")
|
||||
send.close()
|
||||
receive.close()
|
||||
with pytest.raises(anyio.ClosedResourceError):
|
||||
await send.send("after close")
|
||||
with pytest.raises(anyio.ClosedResourceError):
|
||||
await receive.receive()
|
||||
@@ -0,0 +1,578 @@
|
||||
"""Behavioral tests for the Dispatcher Protocol.
|
||||
|
||||
The contract tests are parametrized over every `Dispatcher` implementation via
|
||||
the `pair_factory` fixture (see `conftest.py`); they must pass for both
|
||||
`DirectDispatcher` and `JSONRPCDispatcher`. Implementation-specific tests pass
|
||||
a concrete factory directly.
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncIterator, Mapping
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
from mcp_types import (
|
||||
CONNECTION_CLOSED,
|
||||
INTERNAL_ERROR,
|
||||
INVALID_PARAMS,
|
||||
INVALID_REQUEST,
|
||||
REQUEST_TIMEOUT,
|
||||
ErrorData,
|
||||
RequestId,
|
||||
Tool,
|
||||
)
|
||||
|
||||
from mcp.shared._compat import resync_tracer
|
||||
from mcp.shared.direct_dispatcher import DirectDispatcher, create_direct_dispatcher_pair
|
||||
from mcp.shared.dispatcher import DispatchContext, Dispatcher, OnNotify, OnNotifyIntercept, OnRequest, Outbound
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp.shared.transport_context import TransportContext
|
||||
|
||||
from .conftest import PairFactory, direct_pair
|
||||
|
||||
|
||||
class Recorder:
|
||||
def __init__(self) -> None:
|
||||
self.requests: list[tuple[str, Mapping[str, Any] | None]] = []
|
||||
self.notifications: list[tuple[str, Mapping[str, Any] | None]] = []
|
||||
self.contexts: list[DispatchContext[TransportContext]] = []
|
||||
self.notified = anyio.Event()
|
||||
|
||||
|
||||
def echo_handlers(recorder: Recorder) -> tuple[OnRequest, OnNotify]:
|
||||
async def on_request(
|
||||
ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
||||
) -> dict[str, Any]:
|
||||
# Strip `_meta` so JSON-RPC and direct dispatch record identically:
|
||||
# the JSON-RPC outbound path always attaches `_meta` (otel injection).
|
||||
recorded = {k: v for k, v in (params or {}).items() if k != "_meta"} if params is not None else None
|
||||
recorder.requests.append((method, recorded))
|
||||
recorder.contexts.append(ctx)
|
||||
return {"echoed": method, "params": recorded or {}}
|
||||
|
||||
async def on_notify(ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None) -> None:
|
||||
recorder.notifications.append((method, params))
|
||||
recorder.notified.set()
|
||||
|
||||
return on_request, on_notify
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def running_pair(
|
||||
factory: PairFactory,
|
||||
*,
|
||||
server_on_request: OnRequest | None = None,
|
||||
server_on_notify: OnNotify | None = None,
|
||||
client_on_request: OnRequest | None = None,
|
||||
client_on_notify: OnNotify | None = None,
|
||||
client_on_notify_intercept: OnNotifyIntercept | None = None,
|
||||
can_send_request: bool = True,
|
||||
) -> AsyncIterator[tuple[Dispatcher[TransportContext], Dispatcher[TransportContext], Recorder, Recorder]]:
|
||||
"""Yield `(client, server, client_recorder, server_recorder)` with both `run()` loops live."""
|
||||
client, server, close = factory(can_send_request=can_send_request)
|
||||
client_rec, server_rec = Recorder(), Recorder()
|
||||
c_req, c_notify = echo_handlers(client_rec)
|
||||
s_req, s_notify = echo_handlers(server_rec)
|
||||
try:
|
||||
async with anyio.create_task_group() as tg:
|
||||
await tg.start(
|
||||
client.run, client_on_request or c_req, client_on_notify or c_notify, client_on_notify_intercept
|
||||
)
|
||||
await tg.start(server.run, server_on_request or s_req, server_on_notify or s_notify)
|
||||
try:
|
||||
yield client, server, client_rec, server_rec
|
||||
finally:
|
||||
tg.cancel_scope.cancel()
|
||||
finally:
|
||||
await resync_tracer()
|
||||
close()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_send_raw_request_returns_result_from_peer_on_request(pair_factory: PairFactory):
|
||||
async with running_pair(pair_factory) as (client, _server, _crec, srec):
|
||||
with anyio.fail_after(5):
|
||||
result = await client.send_raw_request("tools/list", {"cursor": "abc"})
|
||||
assert result == {"echoed": "tools/list", "params": {"cursor": "abc"}}
|
||||
assert srec.requests == [("tools/list", {"cursor": "abc"})]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_send_raw_request_reraises_mcperror_from_handler_unchanged(pair_factory: PairFactory):
|
||||
async def on_request(
|
||||
ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
||||
) -> dict[str, Any]:
|
||||
raise MCPError(code=INVALID_PARAMS, message="bad cursor")
|
||||
|
||||
async with running_pair(pair_factory, server_on_request=on_request) as (client, *_):
|
||||
with anyio.fail_after(5), pytest.raises(MCPError) as exc:
|
||||
await client.send_raw_request("tools/list", {})
|
||||
assert exc.value.error.code == INVALID_PARAMS
|
||||
assert exc.value.error.message == "bad cursor"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_send_raw_request_maps_validation_error_to_invalid_params(pair_factory: PairFactory):
|
||||
"""A pydantic `ValidationError` from the handler surfaces as the
|
||||
normalized INVALID_PARAMS shape on every dispatcher."""
|
||||
|
||||
async def on_request(
|
||||
ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
||||
) -> dict[str, Any]:
|
||||
Tool.model_validate({"name": 123}) # raises ValidationError
|
||||
raise NotImplementedError
|
||||
|
||||
async with running_pair(pair_factory, server_on_request=on_request) as (client, *_):
|
||||
with anyio.fail_after(5), pytest.raises(MCPError) as exc:
|
||||
await client.send_raw_request("tools/list", None)
|
||||
assert exc.value.error == ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_send_raw_request_with_timeout_raises_mcperror_request_timeout(pair_factory: PairFactory):
|
||||
async def on_request(
|
||||
ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
||||
) -> dict[str, Any]:
|
||||
await anyio.sleep_forever()
|
||||
raise NotImplementedError
|
||||
|
||||
async with running_pair(pair_factory, server_on_request=on_request) as (client, *_):
|
||||
with anyio.fail_after(5), pytest.raises(MCPError) as exc:
|
||||
await client.send_raw_request("slow", None, {"timeout": 0})
|
||||
assert exc.value.error.code == REQUEST_TIMEOUT
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notify_invokes_peer_on_notify(pair_factory: PairFactory):
|
||||
async with running_pair(pair_factory) as (client, _server, _crec, srec):
|
||||
with anyio.fail_after(5):
|
||||
await client.notify("notifications/initialized", {"v": 1})
|
||||
await srec.notified.wait()
|
||||
assert srec.notifications == [("notifications/initialized", {"v": 1})]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_ctx_send_raw_request_round_trips_to_calling_side(pair_factory: PairFactory):
|
||||
"""A handler's ctx.send_raw_request reaches the side that made the inbound request."""
|
||||
|
||||
async def server_on_request(
|
||||
ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
||||
) -> dict[str, Any]:
|
||||
sample = await ctx.send_raw_request("sampling/createMessage", {"prompt": "hi"})
|
||||
return {"sampled": sample}
|
||||
|
||||
async with running_pair(pair_factory, server_on_request=server_on_request) as (client, _server, crec, _srec):
|
||||
with anyio.fail_after(5):
|
||||
result = await client.send_raw_request("tools/call", None)
|
||||
assert crec.requests == [("sampling/createMessage", {"prompt": "hi"})]
|
||||
assert result == {"sampled": {"echoed": "sampling/createMessage", "params": {"prompt": "hi"}}}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_ctx_send_raw_request_raises_nobackchannelerror_when_transport_disallows(pair_factory: PairFactory):
|
||||
async def server_on_request(
|
||||
ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
||||
) -> dict[str, Any]:
|
||||
return await ctx.send_raw_request("sampling/createMessage", None)
|
||||
|
||||
async with running_pair(pair_factory, server_on_request=server_on_request, can_send_request=False) as (client, *_):
|
||||
with anyio.fail_after(5), pytest.raises(MCPError) as exc:
|
||||
await client.send_raw_request("tools/call", None)
|
||||
assert exc.value.error.code == INVALID_REQUEST
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_ctx_notify_invokes_calling_side_on_notify(pair_factory: PairFactory):
|
||||
async def server_on_request(
|
||||
ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
||||
) -> dict[str, Any]:
|
||||
await ctx.notify("notifications/message", {"level": "info"})
|
||||
return {}
|
||||
|
||||
async with running_pair(pair_factory, server_on_request=server_on_request) as (client, _server, crec, _srec):
|
||||
with anyio.fail_after(5):
|
||||
await client.send_raw_request("tools/call", None)
|
||||
await crec.notified.wait()
|
||||
assert crec.notifications == [("notifications/message", {"level": "info"})]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_ctx_progress_invokes_caller_on_progress_callback(pair_factory: PairFactory):
|
||||
async def server_on_request(
|
||||
ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
||||
) -> dict[str, Any]:
|
||||
await ctx.progress(0.5, total=1.0, message="halfway")
|
||||
return {}
|
||||
|
||||
received: list[tuple[float, float | None, str | None]] = []
|
||||
|
||||
async def on_progress(progress: float, total: float | None, message: str | None) -> None:
|
||||
received.append((progress, total, message))
|
||||
|
||||
async with running_pair(pair_factory, server_on_request=server_on_request) as (client, *_):
|
||||
with anyio.fail_after(5):
|
||||
await client.send_raw_request("tools/call", None, {"on_progress": on_progress})
|
||||
assert received == [(0.5, 1.0, "halfway")]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_ctx_progress_is_noop_when_caller_supplied_no_callback(pair_factory: PairFactory):
|
||||
async def server_on_request(
|
||||
ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
||||
) -> dict[str, Any]:
|
||||
await ctx.progress(0.5)
|
||||
return {"ok": True}
|
||||
|
||||
async with running_pair(pair_factory, server_on_request=server_on_request) as (client, *_):
|
||||
with anyio.fail_after(5):
|
||||
result = await client.send_raw_request("tools/call", None)
|
||||
assert result == {"ok": True}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_ctx_message_metadata_is_none_when_transport_attaches_nothing(pair_factory: PairFactory):
|
||||
"""Plain requests carry no transport metadata, so handlers see `None`."""
|
||||
async with running_pair(pair_factory) as (client, _server, _crec, srec):
|
||||
with anyio.fail_after(5):
|
||||
await client.send_raw_request("tools/call", None)
|
||||
assert len(srec.contexts) == 1
|
||||
assert srec.contexts[0].message_metadata is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_ctx_request_id_exposes_inbound_id(pair_factory: PairFactory):
|
||||
"""Every dispatcher assigns each inbound request a distinct int id; JSON-RPC carries
|
||||
the wire id through, DirectDispatcher synthesizes one (SDK-defined)."""
|
||||
async with running_pair(pair_factory) as (client, _server, _crec, srec):
|
||||
with anyio.fail_after(5):
|
||||
await client.send_raw_request("tools/call", None)
|
||||
await client.send_raw_request("tools/call", None)
|
||||
a, b = (ctx.request_id for ctx in srec.contexts)
|
||||
assert isinstance(a, int) and isinstance(b, int)
|
||||
assert a != b
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_direct_send_raw_request_wraps_non_mcperror_exception_as_internal_error_with_cause():
|
||||
"""DirectDispatcher-specific: the original exception is chained via __cause__."""
|
||||
|
||||
async def on_request(
|
||||
ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
||||
) -> dict[str, Any]:
|
||||
raise ValueError("oops")
|
||||
|
||||
async with running_pair(direct_pair, server_on_request=on_request) as (client, *_):
|
||||
with anyio.fail_after(5), pytest.raises(MCPError) as exc:
|
||||
await client.send_raw_request("tools/list", {})
|
||||
assert exc.value.error.code == INTERNAL_ERROR
|
||||
assert isinstance(exc.value.__cause__, ValueError)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_direct_send_raw_request_issued_before_peer_run_blocks_until_peer_ready():
|
||||
client, server = create_direct_dispatcher_pair()
|
||||
s_req, s_notify = echo_handlers(Recorder())
|
||||
c_req, c_notify = echo_handlers(Recorder())
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
await tg.start(client.run, c_req, c_notify)
|
||||
# start_soon: the server side only becomes ready once the request below has parked.
|
||||
tg.start_soon(server.run, s_req, s_notify)
|
||||
with anyio.fail_after(5):
|
||||
result = await client.send_raw_request("ping", None)
|
||||
assert result == {"echoed": "ping", "params": {}}
|
||||
client.close()
|
||||
server.close()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_direct_send_raw_request_before_run_raises_runtimeerror():
|
||||
"""The not-running guard fires immediately - before any waiting on the peer - matching JSONRPCDispatcher."""
|
||||
client, _server = create_direct_dispatcher_pair()
|
||||
with anyio.fail_after(5), pytest.raises(RuntimeError) as exc:
|
||||
await client.send_raw_request("ping", None)
|
||||
assert str(exc.value) == "DirectDispatcher.send_raw_request called before run()"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_direct_send_raw_request_to_never_run_peer_honors_timeout():
|
||||
"""A configured timeout bounds the wait for a peer whose run() has not started."""
|
||||
client, _server = create_direct_dispatcher_pair()
|
||||
c_req, c_notify = echo_handlers(Recorder())
|
||||
async with anyio.create_task_group() as tg:
|
||||
await tg.start(client.run, c_req, c_notify)
|
||||
with anyio.fail_after(5), pytest.raises(MCPError) as exc:
|
||||
await client.send_raw_request("ping", None, {"timeout": 0})
|
||||
assert exc.value.error.code == REQUEST_TIMEOUT
|
||||
client.close()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_direct_request_parked_waiting_for_peer_run_is_woken_by_peer_close():
|
||||
"""A request waiting on a never-run peer fails with CONNECTION_CLOSED when that peer closes."""
|
||||
client, server = create_direct_dispatcher_pair()
|
||||
c_req, c_notify = echo_handlers(Recorder())
|
||||
with anyio.fail_after(5):
|
||||
async with anyio.create_task_group() as tg:
|
||||
await tg.start(client.run, c_req, c_notify)
|
||||
|
||||
async def send() -> None:
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await client.send_raw_request("ping", None)
|
||||
assert exc.value.error.code == CONNECTION_CLOSED
|
||||
client.close()
|
||||
|
||||
tg.start_soon(send)
|
||||
await anyio.wait_all_tasks_blocked()
|
||||
server.close()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_direct_send_raw_request_after_local_close_raises_and_notify_is_dropped():
|
||||
"""After this side has closed, send_raw_request raises CONNECTION_CLOSED and notify
|
||||
drops fire-and-forget, matching JSONRPCDispatcher (SDK-defined)."""
|
||||
async with running_pair(direct_pair) as (client, _server, _crec, srec):
|
||||
pass # exiting cancels both run() loops, closing both sides
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await client.send_raw_request("ping", None)
|
||||
assert exc.value.error.code == CONNECTION_CLOSED
|
||||
await client.notify("notifications/roots/list_changed", None)
|
||||
assert srec.requests == []
|
||||
assert srec.notifications == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_direct_inbound_after_peer_close_refuses_requests_and_drops_notifications():
|
||||
"""Dispatch to a closed side fails the peer's request with CONNECTION_CLOSED and silently
|
||||
drops the peer's notify; the closed side's handlers are never invoked (SDK-defined)."""
|
||||
client, server = create_direct_dispatcher_pair()
|
||||
crec, srec = Recorder(), Recorder()
|
||||
c_req, c_notify = echo_handlers(crec)
|
||||
s_req, s_notify = echo_handlers(srec)
|
||||
with anyio.fail_after(5):
|
||||
async with anyio.create_task_group() as tg:
|
||||
await tg.start(client.run, c_req, c_notify)
|
||||
await tg.start(server.run, s_req, s_notify)
|
||||
client.close()
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await server.send_raw_request("roots/list", None)
|
||||
assert exc.value.error.code == CONNECTION_CLOSED
|
||||
await server.notify("notifications/message", None)
|
||||
server.close()
|
||||
assert crec.requests == []
|
||||
assert crec.notifications == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_direct_inbound_to_closed_never_run_peer_fails_with_connection_closed():
|
||||
"""A peer that closed without ever running refuses dispatch instead of parking the caller."""
|
||||
client, server = create_direct_dispatcher_pair()
|
||||
c_req, c_notify = echo_handlers(Recorder())
|
||||
server.close()
|
||||
with anyio.fail_after(5):
|
||||
async with anyio.create_task_group() as tg:
|
||||
await tg.start(client.run, c_req, c_notify)
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await client.send_raw_request("ping", None)
|
||||
assert exc.value.error.code == CONNECTION_CLOSED
|
||||
client.close()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_direct_send_raw_request_and_notify_raise_runtimeerror_when_no_peer_connected():
|
||||
d = DirectDispatcher(TransportContext(kind="direct", can_send_request=True))
|
||||
with pytest.raises(RuntimeError, match="no peer"):
|
||||
await d.send_raw_request("ping", None)
|
||||
with pytest.raises(RuntimeError, match="no peer"):
|
||||
await d.notify("ping", None)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_direct_close_makes_run_return():
|
||||
client, server = create_direct_dispatcher_pair()
|
||||
on_request, on_notify = echo_handlers(Recorder())
|
||||
with anyio.fail_after(5):
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(server.run, on_request, on_notify)
|
||||
tg.start_soon(client.run, on_request, on_notify)
|
||||
client.close()
|
||||
server.close()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_send_raw_request_honors_caller_supplied_request_id_verbatim_typed(pair_factory: PairFactory):
|
||||
"""A caller-supplied `CallOptions["request_id"]` reaches the peer's context verbatim —
|
||||
"7" stays a string, never the integer 7 — and the next call without one still mints
|
||||
a dispatcher id as before."""
|
||||
async with running_pair(pair_factory) as (client, _server, _crec, srec):
|
||||
with anyio.fail_after(5):
|
||||
await client.send_raw_request("first", None, {"request_id": "7"})
|
||||
await client.send_raw_request("second", None)
|
||||
supplied, minted = (ctx.request_id for ctx in srec.contexts)
|
||||
assert supplied == "7"
|
||||
assert type(supplied) is str
|
||||
assert type(minted) is int
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_send_raw_request_with_in_flight_request_id_raises_and_frees_id_on_completion(
|
||||
pair_factory: PairFactory,
|
||||
):
|
||||
"""Reusing an id while it is in flight is a loud `ValueError` — silent reuse would
|
||||
corrupt response correlation. Once the first request completes, the id is free
|
||||
again: the reservation is in-flight-scoped, not permanent."""
|
||||
entered = anyio.Event()
|
||||
release = anyio.Event()
|
||||
|
||||
async def parked(
|
||||
ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
||||
) -> dict[str, Any]:
|
||||
entered.set()
|
||||
await release.wait()
|
||||
return {"served": method}
|
||||
|
||||
async with running_pair(pair_factory, server_on_request=parked) as (client, *_):
|
||||
with anyio.fail_after(5):
|
||||
async with anyio.create_task_group() as tg:
|
||||
|
||||
async def first() -> None:
|
||||
await client.send_raw_request("slow", None, {"request_id": "listen-1"})
|
||||
|
||||
tg.start_soon(first)
|
||||
await entered.wait()
|
||||
with pytest.raises(ValueError, match="already in flight"):
|
||||
await client.send_raw_request("duplicate", None, {"request_id": "listen-1"})
|
||||
release.set()
|
||||
result = await client.send_raw_request("again", None, {"request_id": "listen-1"})
|
||||
assert result == {"served": "again"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_minted_ids_skip_a_caller_supplied_id_still_in_flight(pair_factory: PairFactory):
|
||||
"""The dispatcher mints PAST a key a supplied id occupies — the collision error
|
||||
is reserved for the caller who chose the id, never an innocent minted request."""
|
||||
entered = anyio.Event()
|
||||
release = anyio.Event()
|
||||
seen_ids: list[RequestId | None] = []
|
||||
|
||||
async def maybe_park(
|
||||
ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
||||
) -> dict[str, Any]:
|
||||
seen_ids.append(ctx.request_id)
|
||||
if method == "park":
|
||||
entered.set()
|
||||
await release.wait()
|
||||
return {}
|
||||
|
||||
async with running_pair(pair_factory, server_on_request=maybe_park) as (client, *_):
|
||||
with anyio.fail_after(5):
|
||||
async with anyio.create_task_group() as tg:
|
||||
|
||||
async def parked() -> None:
|
||||
await client.send_raw_request("park", None, {"request_id": "3"})
|
||||
|
||||
tg.start_soon(parked)
|
||||
await entered.wait()
|
||||
# The counter mints 1 and 2, then skips the occupied 3 to 4.
|
||||
for _ in range(3):
|
||||
await client.send_raw_request("plain", None)
|
||||
release.set()
|
||||
assert [request_id for request_id in seen_ids if request_id != "3"] == [1, 2, 4]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_supplied_numeric_string_id_collides_with_its_int_twin(pair_factory: PairFactory):
|
||||
""" "7" and 7 are one id in the collision domain on BOTH dispatchers, so the
|
||||
in-memory pair raises exactly where the wire dispatcher (whose pending keys
|
||||
are coerced for response correlation) would."""
|
||||
entered = anyio.Event()
|
||||
release = anyio.Event()
|
||||
|
||||
async def parked(
|
||||
ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
||||
) -> dict[str, Any]:
|
||||
entered.set()
|
||||
await release.wait()
|
||||
return {}
|
||||
|
||||
async with running_pair(pair_factory, server_on_request=parked) as (client, *_):
|
||||
with anyio.fail_after(5):
|
||||
async with anyio.create_task_group() as tg:
|
||||
|
||||
async def first() -> None:
|
||||
await client.send_raw_request("slow", None, {"request_id": 7})
|
||||
|
||||
tg.start_soon(first)
|
||||
await entered.wait()
|
||||
with pytest.raises(ValueError, match="already in flight"):
|
||||
await client.send_raw_request("duplicate", None, {"request_id": "7"})
|
||||
release.set()
|
||||
# Completion frees the id for either spelling.
|
||||
assert await client.send_raw_request("again", None, {"request_id": "7"}) == {}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notify_intercept_sees_every_notification_and_consumes_on_true(pair_factory: PairFactory):
|
||||
"""The intercept sees every inbound notification; a frame it consumes never reaches `on_notify`, the rest do."""
|
||||
intercepted: list[str] = []
|
||||
|
||||
def intercept(method: str, params: Mapping[str, Any] | None) -> bool:
|
||||
intercepted.append(method)
|
||||
return method == "notifications/consumed"
|
||||
|
||||
async with running_pair(pair_factory, client_on_notify_intercept=intercept) as (_client, server, crec, _srec):
|
||||
with anyio.fail_after(5):
|
||||
await server.notify("notifications/consumed", None)
|
||||
await server.notify("notifications/passed", None)
|
||||
await crec.notified.wait()
|
||||
assert intercepted == ["notifications/consumed", "notifications/passed"]
|
||||
assert [method for method, _ in crec.notifications] == ["notifications/passed"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notify_intercept_completes_before_a_later_response_resolves(pair_factory: PairFactory):
|
||||
"""Notifications written before a response are intercepted before it resolves, whatever spawned handlers do."""
|
||||
seen: list[str] = []
|
||||
|
||||
def intercept(method: str, params: Mapping[str, Any] | None) -> bool:
|
||||
seen.append(method)
|
||||
return False
|
||||
|
||||
async def notify_then_answer(
|
||||
ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
||||
) -> dict[str, Any]:
|
||||
await ctx.notify("notifications/first", None)
|
||||
await ctx.notify("notifications/second", None)
|
||||
return {}
|
||||
|
||||
async with running_pair(
|
||||
pair_factory, server_on_request=notify_then_answer, client_on_notify_intercept=intercept
|
||||
) as (client, *_):
|
||||
with anyio.fail_after(5):
|
||||
await client.send_raw_request("burst", None)
|
||||
assert seen == ["notifications/first", "notifications/second"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_a_raising_notify_intercept_is_contained_and_passes_the_frame_through(pair_factory: PairFactory):
|
||||
"""An intercept exception costs only that interception: the frame still reaches `on_notify`, the loop survives."""
|
||||
|
||||
def broken_intercept(method: str, params: Mapping[str, Any] | None) -> bool:
|
||||
raise RuntimeError("intercept exploded")
|
||||
|
||||
async with running_pair(pair_factory, client_on_notify_intercept=broken_intercept) as (
|
||||
_client,
|
||||
server,
|
||||
crec,
|
||||
_srec,
|
||||
):
|
||||
with anyio.fail_after(5):
|
||||
await server.notify("notifications/survives", None)
|
||||
await crec.notified.wait()
|
||||
assert [method for method, _ in crec.notifications] == ["notifications/survives"]
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
_d: Dispatcher[TransportContext] = DirectDispatcher(TransportContext(kind="direct", can_send_request=True))
|
||||
_o: Outbound = _d
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Tests for MCP exception classes."""
|
||||
|
||||
import pytest
|
||||
from mcp_types import URL_ELICITATION_REQUIRED, ElicitRequestURLParams, ErrorData, JSONRPCError
|
||||
|
||||
from mcp.shared.exceptions import MCPError, UrlElicitationRequiredError
|
||||
|
||||
|
||||
def test_url_elicitation_required_error_create_with_single_elicitation() -> None:
|
||||
"""Test creating error with a single elicitation."""
|
||||
elicitation = ElicitRequestURLParams(
|
||||
mode="url",
|
||||
message="Auth required",
|
||||
url="https://example.com/auth",
|
||||
elicitation_id="test-123",
|
||||
)
|
||||
error = UrlElicitationRequiredError([elicitation])
|
||||
|
||||
assert error.error.code == URL_ELICITATION_REQUIRED
|
||||
assert error.error.message == "URL elicitation required"
|
||||
assert len(error.elicitations) == 1
|
||||
assert error.elicitations[0].elicitation_id == "test-123"
|
||||
|
||||
|
||||
def test_url_elicitation_required_error_create_with_multiple_elicitations() -> None:
|
||||
"""Test creating error with multiple elicitations uses plural message."""
|
||||
elicitations = [
|
||||
ElicitRequestURLParams(
|
||||
mode="url",
|
||||
message="Auth 1",
|
||||
url="https://example.com/auth1",
|
||||
elicitation_id="test-1",
|
||||
),
|
||||
ElicitRequestURLParams(
|
||||
mode="url",
|
||||
message="Auth 2",
|
||||
url="https://example.com/auth2",
|
||||
elicitation_id="test-2",
|
||||
),
|
||||
]
|
||||
error = UrlElicitationRequiredError(elicitations)
|
||||
|
||||
assert error.error.message == "URL elicitations required" # Plural
|
||||
assert len(error.elicitations) == 2
|
||||
|
||||
|
||||
def test_url_elicitation_required_error_custom_message() -> None:
|
||||
"""Test creating error with a custom message."""
|
||||
elicitation = ElicitRequestURLParams(
|
||||
mode="url",
|
||||
message="Auth required",
|
||||
url="https://example.com/auth",
|
||||
elicitation_id="test-123",
|
||||
)
|
||||
error = UrlElicitationRequiredError([elicitation], message="Custom message")
|
||||
|
||||
assert error.error.message == "Custom message"
|
||||
|
||||
|
||||
def test_url_elicitation_required_error_from_error_data() -> None:
|
||||
"""Test reconstructing error from ErrorData."""
|
||||
error_data = ErrorData(
|
||||
code=URL_ELICITATION_REQUIRED,
|
||||
message="URL elicitation required",
|
||||
data={
|
||||
"elicitations": [
|
||||
{
|
||||
"mode": "url",
|
||||
"message": "Auth required",
|
||||
"url": "https://example.com/auth",
|
||||
"elicitationId": "test-123",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
error = UrlElicitationRequiredError.from_error(error_data)
|
||||
|
||||
assert len(error.elicitations) == 1
|
||||
assert error.elicitations[0].elicitation_id == "test-123"
|
||||
assert error.elicitations[0].url == "https://example.com/auth"
|
||||
|
||||
|
||||
def test_url_elicitation_required_error_from_error_data_wrong_code() -> None:
|
||||
"""Test that from_error raises ValueError for wrong error code."""
|
||||
error_data = ErrorData(
|
||||
code=-32600, # Wrong code
|
||||
message="Some other error",
|
||||
data={},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Expected error code"):
|
||||
UrlElicitationRequiredError.from_error(error_data)
|
||||
|
||||
|
||||
def test_url_elicitation_required_error_serialization_roundtrip() -> None:
|
||||
"""Test that error can be serialized and reconstructed."""
|
||||
original = UrlElicitationRequiredError(
|
||||
[
|
||||
ElicitRequestURLParams(
|
||||
mode="url",
|
||||
message="Auth required",
|
||||
url="https://example.com/auth",
|
||||
elicitation_id="test-123",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
# Simulate serialization over wire
|
||||
error_data = original.error
|
||||
|
||||
# Reconstruct
|
||||
reconstructed = UrlElicitationRequiredError.from_error(error_data)
|
||||
|
||||
assert reconstructed.elicitations[0].elicitation_id == original.elicitations[0].elicitation_id
|
||||
assert reconstructed.elicitations[0].url == original.elicitations[0].url
|
||||
assert reconstructed.elicitations[0].message == original.elicitations[0].message
|
||||
|
||||
|
||||
def test_url_elicitation_required_error_data_contains_elicitations() -> None:
|
||||
"""Test that error data contains properly serialized elicitations."""
|
||||
elicitation = ElicitRequestURLParams(
|
||||
mode="url",
|
||||
message="Please authenticate",
|
||||
url="https://example.com/oauth",
|
||||
elicitation_id="oauth-flow-1",
|
||||
)
|
||||
error = UrlElicitationRequiredError([elicitation])
|
||||
|
||||
assert error.error.data is not None
|
||||
assert "elicitations" in error.error.data
|
||||
elicit_data = error.error.data["elicitations"][0]
|
||||
assert elicit_data["mode"] == "url"
|
||||
assert elicit_data["message"] == "Please authenticate"
|
||||
assert elicit_data["url"] == "https://example.com/oauth"
|
||||
assert elicit_data["elicitationId"] == "oauth-flow-1"
|
||||
|
||||
|
||||
def test_url_elicitation_required_error_inherits_from_mcp_error() -> None:
|
||||
"""Test that UrlElicitationRequiredError inherits from MCPError."""
|
||||
elicitation = ElicitRequestURLParams(
|
||||
mode="url",
|
||||
message="Auth required",
|
||||
url="https://example.com/auth",
|
||||
elicitation_id="test-123",
|
||||
)
|
||||
error = UrlElicitationRequiredError([elicitation])
|
||||
|
||||
assert isinstance(error, MCPError)
|
||||
assert isinstance(error, Exception)
|
||||
|
||||
|
||||
def test_url_elicitation_required_error_exception_message() -> None:
|
||||
"""Test that exception message is set correctly."""
|
||||
elicitation = ElicitRequestURLParams(
|
||||
mode="url",
|
||||
message="Auth required",
|
||||
url="https://example.com/auth",
|
||||
elicitation_id="test-123",
|
||||
)
|
||||
error = UrlElicitationRequiredError([elicitation])
|
||||
|
||||
# The exception's string representation should match the message
|
||||
assert str(error) == "URL elicitation required"
|
||||
|
||||
|
||||
def test_from_jsonrpc_error_preserves_code_message_and_data() -> None:
|
||||
"""Building an MCPError from a wire JSONRPCError keeps every error field."""
|
||||
wire = JSONRPCError(
|
||||
jsonrpc="2.0",
|
||||
id=3,
|
||||
error=ErrorData(code=URL_ELICITATION_REQUIRED, message="go elsewhere", data={"hint": "y"}),
|
||||
)
|
||||
error = MCPError.from_jsonrpc_error(wire)
|
||||
assert error.error == ErrorData(code=URL_ELICITATION_REQUIRED, message="go elsewhere", data={"hint": "y"})
|
||||
@@ -0,0 +1,56 @@
|
||||
"""The extension-identifier grammar in `mcp.shared.extension`, shared by server and client."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
import mcp.server.extension
|
||||
import mcp.shared.extension
|
||||
from mcp.shared.extension import validate_extension_identifier
|
||||
|
||||
|
||||
def test_server_extension_module_reexports_shared_validator() -> None:
|
||||
"""SDK-defined: `mcp.server.extension` re-exports the shared validator as the same function object."""
|
||||
assert mcp.server.extension.validate_extension_identifier is mcp.shared.extension.validate_extension_identifier
|
||||
|
||||
|
||||
@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_extension_identifiers_are_accepted(identifier: str) -> None:
|
||||
"""Spec `_meta` key grammar: conformant `vendor-prefix/name` identifiers are accepted."""
|
||||
validate_extension_identifier(identifier, owner="T")
|
||||
|
||||
|
||||
@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",
|
||||
"",
|
||||
None,
|
||||
42,
|
||||
],
|
||||
)
|
||||
def test_malformed_extension_identifiers_are_rejected(identifier: Any) -> None:
|
||||
"""Spec `_meta` key grammar: malformed prefixes, malformed names, and non-strings are rejected."""
|
||||
with pytest.raises(TypeError):
|
||||
validate_extension_identifier(identifier, owner="T")
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Tests for httpx utility functions."""
|
||||
|
||||
import httpx
|
||||
|
||||
from mcp.shared._httpx_utils import create_mcp_http_client
|
||||
|
||||
|
||||
def test_default_settings():
|
||||
"""Test that default settings are applied correctly."""
|
||||
client = create_mcp_http_client()
|
||||
|
||||
assert client.follow_redirects is True
|
||||
assert client.timeout.connect == 30.0
|
||||
|
||||
|
||||
def test_custom_parameters():
|
||||
"""Test custom headers and timeout are set correctly."""
|
||||
headers = {"Authorization": "Bearer token"}
|
||||
timeout = httpx.Timeout(60.0)
|
||||
|
||||
client = create_mcp_http_client(headers, timeout)
|
||||
|
||||
assert client.headers["Authorization"] == "Bearer token"
|
||||
assert client.timeout.connect == 60.0
|
||||
@@ -0,0 +1,885 @@
|
||||
"""Pure-function tests of :mod:`mcp.shared.inbound`.
|
||||
|
||||
Independent verifier of the classifier: every ladder rung is exercised
|
||||
pass+fail with no `mcp.server` / transport imports and no inlined error-code
|
||||
or protocol-version literals — all facts are imported from their one source.
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
from collections.abc import Iterator, Mapping
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from mcp_types import (
|
||||
CLIENT_CAPABILITIES_META_KEY,
|
||||
CLIENT_INFO_META_KEY,
|
||||
PROTOCOL_VERSION_META_KEY,
|
||||
)
|
||||
from mcp_types.jsonrpc import (
|
||||
HEADER_MISMATCH,
|
||||
INVALID_PARAMS,
|
||||
INVALID_REQUEST,
|
||||
METHOD_NOT_FOUND,
|
||||
MISSING_REQUIRED_CLIENT_CAPABILITY,
|
||||
PARSE_ERROR,
|
||||
UNSUPPORTED_PROTOCOL_VERSION,
|
||||
)
|
||||
from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION, MODERN_PROTOCOL_VERSIONS
|
||||
|
||||
from mcp.shared.inbound import (
|
||||
_SUBSCHEMA_LIST,
|
||||
_SUBSCHEMA_MAP,
|
||||
_SUBSCHEMA_SINGLE,
|
||||
ERROR_CODE_HTTP_STATUS,
|
||||
MCP_METHOD_HEADER,
|
||||
MCP_NAME_HEADER,
|
||||
MCP_PROTOCOL_VERSION_HEADER,
|
||||
NAME_BEARING_METHODS,
|
||||
InboundLadderRejection,
|
||||
InboundModernRoute,
|
||||
classify_inbound_request,
|
||||
decode_header_value,
|
||||
encode_header_value,
|
||||
find_duplicated_routing_header,
|
||||
find_invalid_x_mcp_header,
|
||||
mcp_param_headers,
|
||||
validate_mcp_param_headers,
|
||||
x_mcp_header_map,
|
||||
)
|
||||
|
||||
CLIENT_INFO = {"name": "t", "version": "0"}
|
||||
CLIENT_CAPS: dict[str, Any] = {}
|
||||
|
||||
|
||||
def envelope(
|
||||
method: str = "tools/list",
|
||||
*,
|
||||
version: str = LATEST_MODERN_VERSION,
|
||||
drop: frozenset[str] = frozenset(),
|
||||
extra_params: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a JSON-RPC body carrying a complete modern `_meta` envelope.
|
||||
|
||||
`drop` removes named envelope keys so rung-1 failures are driven from one
|
||||
table instead of repeating reserved-key constants per call site.
|
||||
"""
|
||||
meta: dict[str, Any] = {
|
||||
PROTOCOL_VERSION_META_KEY: version,
|
||||
CLIENT_INFO_META_KEY: CLIENT_INFO,
|
||||
CLIENT_CAPABILITIES_META_KEY: CLIENT_CAPS,
|
||||
}
|
||||
for key in drop:
|
||||
del meta[key]
|
||||
params: dict[str, Any] = {"_meta": meta}
|
||||
if extra_params:
|
||||
params.update(extra_params)
|
||||
return {"jsonrpc": "2.0", "id": 1, "method": method, "params": params}
|
||||
|
||||
|
||||
def matching_headers(body: dict[str, Any]) -> dict[str, str]:
|
||||
"""The minimal lowercase HTTP header set that agrees with `body` for rung 2."""
|
||||
headers = {
|
||||
MCP_PROTOCOL_VERSION_HEADER: body["params"]["_meta"][PROTOCOL_VERSION_META_KEY],
|
||||
MCP_METHOD_HEADER: body["method"],
|
||||
}
|
||||
name_key = NAME_BEARING_METHODS.get(body["method"])
|
||||
if name_key is not None and name_key in body["params"]:
|
||||
headers[MCP_NAME_HEADER] = encode_header_value(body["params"][name_key])
|
||||
return headers
|
||||
|
||||
|
||||
def assert_rejected(result: object, code: int) -> InboundLadderRejection:
|
||||
assert isinstance(result, InboundLadderRejection)
|
||||
assert result.code == code
|
||||
return result
|
||||
|
||||
|
||||
# --- rung 1: envelope-three-keys -----------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
pytest.param({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, id="no-params"),
|
||||
pytest.param({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}, id="no-meta"),
|
||||
pytest.param(envelope(drop=frozenset({PROTOCOL_VERSION_META_KEY})), id="meta-missing-version"),
|
||||
pytest.param(envelope(drop=frozenset({CLIENT_INFO_META_KEY})), id="meta-missing-client-info"),
|
||||
pytest.param(envelope(drop=frozenset({CLIENT_CAPABILITIES_META_KEY})), id="meta-missing-client-caps"),
|
||||
],
|
||||
)
|
||||
def test_envelope_rung_rejects_missing_keys(body: dict[str, Any]) -> None:
|
||||
"""Spec-mandated: a modern request lacking any of the three reserved `_meta` keys is rejected INVALID_PARAMS."""
|
||||
rejection = assert_rejected(classify_inbound_request(body), INVALID_PARAMS)
|
||||
assert rejection.data is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
pytest.param({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": None}, id="params-none"),
|
||||
pytest.param({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {"_meta": None}}, id="meta-none"),
|
||||
pytest.param(
|
||||
{"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {"_meta": 0}}, id="meta-non-mapping"
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_envelope_rung_rejects_non_mapping_shapes(body: dict[str, Any]) -> None:
|
||||
"""Spec-mandated: non-mapping `params` / `_meta` cannot carry the envelope and reject INVALID_PARAMS."""
|
||||
assert_rejected(classify_inbound_request(body), INVALID_PARAMS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", [7, None, ["2026-07-28"]], ids=["int", "null", "list"])
|
||||
def test_envelope_rung_rejects_non_string_protocol_version(version: Any) -> None:
|
||||
"""A present-but-non-string protocol version is a shape defect, rejected
|
||||
INVALID_PARAMS: it must never become -32022 (the one code auto-negotiating
|
||||
clients do not fall back from), and must not escape as a ValidationError
|
||||
from the version rung's own typed payload (`requested` is a `str` field)."""
|
||||
body = envelope()
|
||||
body["params"]["_meta"][PROTOCOL_VERSION_META_KEY] = version
|
||||
rejection = assert_rejected(classify_inbound_request(body), INVALID_PARAMS)
|
||||
assert "string" in rejection.message
|
||||
|
||||
|
||||
def test_non_string_protocol_version_over_http_still_rejects_at_the_header_rung() -> None:
|
||||
"""SDK-defined: the non-string guard sits after the header rung, so over
|
||||
HTTP a present version header (a string, which can never equal a
|
||||
non-string body value) keeps producing HEADER_MISMATCH - the guard's wire
|
||||
delta is confined to header-less transports."""
|
||||
body = envelope()
|
||||
headers = matching_headers(body)
|
||||
body["params"]["_meta"][PROTOCOL_VERSION_META_KEY] = 7
|
||||
assert_rejected(classify_inbound_request(body, headers=headers), HEADER_MISMATCH)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", [7, None], ids=["int", "null"])
|
||||
def test_absent_version_header_rejects_before_the_string_guard(version: Any) -> None:
|
||||
"""SDK-defined: the version header must be PRESENT, not merely equal - a
|
||||
null body version would otherwise slip the equality check (None == None)
|
||||
- so an absent header is HEADER_MISMATCH for every body value and the
|
||||
string guard stays reachable only on header-less transports."""
|
||||
body = envelope()
|
||||
headers = matching_headers(body)
|
||||
del headers[MCP_PROTOCOL_VERSION_HEADER]
|
||||
body["params"]["_meta"][PROTOCOL_VERSION_META_KEY] = version
|
||||
assert_rejected(classify_inbound_request(body, headers=headers), HEADER_MISMATCH)
|
||||
|
||||
|
||||
# --- rung 2: protocol-version-supported ----------------------------------------
|
||||
|
||||
|
||||
def test_version_rung_rejects_unsupported_with_data_shape() -> None:
|
||||
"""Spec-mandated: an envelope version outside the modern set rejects with the `supported`/`requested` data."""
|
||||
rejection = assert_rejected(
|
||||
classify_inbound_request(envelope(version=LATEST_HANDSHAKE_VERSION)),
|
||||
UNSUPPORTED_PROTOCOL_VERSION,
|
||||
)
|
||||
assert rejection.data == {
|
||||
"supported": list(MODERN_PROTOCOL_VERSIONS),
|
||||
"requested": LATEST_HANDSHAKE_VERSION,
|
||||
}
|
||||
|
||||
|
||||
def test_version_rung_data_reflects_supplied_supported_list() -> None:
|
||||
"""SDK-defined: the caller-supplied `supported_modern_versions` is what rejection `data.supported` echoes."""
|
||||
custom = (LATEST_HANDSHAKE_VERSION,)
|
||||
rejection = assert_rejected(
|
||||
classify_inbound_request(envelope(), supported_modern_versions=custom),
|
||||
UNSUPPORTED_PROTOCOL_VERSION,
|
||||
)
|
||||
assert rejection.data == {"supported": list(custom), "requested": LATEST_MODERN_VERSION}
|
||||
|
||||
|
||||
# --- rung 3: header ↔ envelope agreement ---------------------------------------
|
||||
|
||||
|
||||
def test_header_rung_does_not_reject_when_headers_arg_is_none() -> None:
|
||||
"""SDK-defined: `headers=None` (non-HTTP transports) means rung 3 has nothing to check and the ladder proceeds."""
|
||||
result = classify_inbound_request(envelope(), headers=None)
|
||||
assert isinstance(result, InboundModernRoute)
|
||||
|
||||
|
||||
def test_header_rung_passes_when_header_matches_envelope() -> None:
|
||||
"""Spec-mandated: an HTTP version header equal to the envelope version passes rung 3."""
|
||||
body = envelope()
|
||||
result = classify_inbound_request(body, headers=matching_headers(body))
|
||||
assert isinstance(result, InboundModernRoute)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"headers",
|
||||
[
|
||||
pytest.param({MCP_PROTOCOL_VERSION_HEADER: LATEST_HANDSHAKE_VERSION}, id="mismatch"),
|
||||
pytest.param({}, id="header-absent"),
|
||||
],
|
||||
)
|
||||
def test_header_rung_rejects_on_disagreement(headers: dict[str, str]) -> None:
|
||||
"""Spec-mandated: an absent or mismatched HTTP version header rejects HEADER_MISMATCH."""
|
||||
assert_rejected(classify_inbound_request(envelope(), headers=headers), HEADER_MISMATCH)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"override",
|
||||
[
|
||||
pytest.param({MCP_METHOD_HEADER: "prompts/list"}, id="method-mismatch"),
|
||||
pytest.param({MCP_METHOD_HEADER: "TOOLS/LIST"}, id="method-case-mismatch"),
|
||||
],
|
||||
)
|
||||
def test_header_rung_rejects_method_header_disagreement(override: dict[str, str]) -> None:
|
||||
"""Spec-mandated: `Mcp-Method` must equal `body.method` exactly (case-sensitive) → else HEADER_MISMATCH."""
|
||||
body = envelope()
|
||||
rejection = assert_rejected(
|
||||
classify_inbound_request(body, headers=matching_headers(body) | override), HEADER_MISMATCH
|
||||
)
|
||||
assert MCP_METHOD_HEADER in rejection.message
|
||||
|
||||
|
||||
def test_header_rung_rejects_missing_method_header() -> None:
|
||||
"""Spec-mandated: an HTTP request on the modern path without `Mcp-Method` is HEADER_MISMATCH."""
|
||||
body = envelope()
|
||||
headers = matching_headers(body)
|
||||
del headers[MCP_METHOD_HEADER]
|
||||
assert_rejected(classify_inbound_request(body, headers=headers), HEADER_MISMATCH)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "name_key"),
|
||||
[(m, k) for m, k in NAME_BEARING_METHODS.items()],
|
||||
)
|
||||
def test_header_rung_rejects_missing_or_mismatched_name_header_for_name_bearing_methods(
|
||||
method: str, name_key: str
|
||||
) -> None:
|
||||
"""Spec-mandated: when the body carries the named param, `Mcp-Name` must be present and equal it."""
|
||||
body = envelope(method, extra_params={name_key: "expected"})
|
||||
headers = matching_headers(body)
|
||||
# Mismatch
|
||||
assert_rejected(classify_inbound_request(body, headers=headers | {MCP_NAME_HEADER: "wrong"}), HEADER_MISMATCH)
|
||||
# Absent
|
||||
del headers[MCP_NAME_HEADER]
|
||||
assert_rejected(classify_inbound_request(body, headers=headers), HEADER_MISMATCH)
|
||||
|
||||
|
||||
def test_header_rung_decodes_base64_sentinel_before_comparing_name() -> None:
|
||||
"""Spec-mandated: servers MUST decode the `=?base64?...?=` sentinel before comparing `Mcp-Name`."""
|
||||
body = envelope("tools/call", extra_params={"name": "résumé"})
|
||||
headers = matching_headers(body)
|
||||
assert headers[MCP_NAME_HEADER].startswith("=?base64?")
|
||||
result = classify_inbound_request(body, headers=headers)
|
||||
assert isinstance(result, InboundModernRoute)
|
||||
|
||||
|
||||
def test_header_rung_does_not_require_name_header_for_non_name_bearing_method() -> None:
|
||||
"""SDK-defined: a method outside `NAME_BEARING_METHODS` ignores `Mcp-Name` entirely."""
|
||||
body = envelope("tools/list")
|
||||
result = classify_inbound_request(body, headers=matching_headers(body) | {MCP_NAME_HEADER: "anything"})
|
||||
assert isinstance(result, InboundModernRoute)
|
||||
|
||||
|
||||
def test_header_rung_does_not_require_name_header_when_body_omits_the_named_param() -> None:
|
||||
"""SDK-defined: a name-bearing method whose body lacks the named param skips the `Mcp-Name`
|
||||
check — the param's absence is INVALID_PARAMS later, not HEADER_MISMATCH here."""
|
||||
body = envelope("tools/call")
|
||||
result = classify_inbound_request(body, headers=matching_headers(body))
|
||||
assert isinstance(result, InboundModernRoute)
|
||||
|
||||
|
||||
# --- all rungs pass ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_all_rungs_pass_yields_route() -> None:
|
||||
"""Spec-mandated: a complete envelope at a supported version with agreeing header routes, surfacing the envelope."""
|
||||
body = envelope()
|
||||
result = classify_inbound_request(body, headers=matching_headers(body))
|
||||
assert isinstance(result, InboundModernRoute)
|
||||
assert result.protocol_version == LATEST_MODERN_VERSION
|
||||
assert result.client_info == CLIENT_INFO
|
||||
assert result.client_capabilities == CLIENT_CAPS
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["initialize", "myorg/custom", "does/not/exist"])
|
||||
def test_classifier_passes_unknown_method_through_to_route(method: str) -> None:
|
||||
"""SDK-defined: the classifier does not gate on method — kernel dispatch is the single owner of that decision."""
|
||||
body = envelope(method)
|
||||
result = classify_inbound_request(body, headers=matching_headers(body))
|
||||
assert isinstance(result, InboundModernRoute)
|
||||
|
||||
|
||||
def test_ladder_first_failure_wins() -> None:
|
||||
"""Spec-mandated: rungs evaluate in order — header-mismatch and version-unsupported
|
||||
would both fail; the header rung fires first so an inconsistent client is told it
|
||||
disagrees with itself rather than that its body version is unsupported."""
|
||||
body = envelope(version=LATEST_HANDSHAKE_VERSION)
|
||||
result = classify_inbound_request(body, headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION})
|
||||
assert_rejected(result, HEADER_MISMATCH)
|
||||
|
||||
|
||||
# --- ERROR_CODE_HTTP_STATUS ----------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("code", "status"),
|
||||
[
|
||||
(PARSE_ERROR, 400),
|
||||
(INVALID_REQUEST, 400),
|
||||
(INVALID_PARAMS, 400),
|
||||
(HEADER_MISMATCH, 400),
|
||||
(MISSING_REQUIRED_CLIENT_CAPABILITY, 400),
|
||||
(UNSUPPORTED_PROTOCOL_VERSION, 400),
|
||||
(METHOD_NOT_FOUND, 404),
|
||||
],
|
||||
)
|
||||
def test_error_code_http_status_table(code: int, status: int) -> None:
|
||||
"""SDK-defined: pins the JSON-RPC error code → HTTP status mapping the streamable transport reads."""
|
||||
assert ERROR_CODE_HTTP_STATUS[code] == status
|
||||
|
||||
|
||||
def test_error_code_http_status_covers_every_ladder_code() -> None:
|
||||
"""SDK-defined: every code the ladder can emit has an HTTP-status entry, so the transport never has to default."""
|
||||
ladder_codes = {INVALID_PARAMS, UNSUPPORTED_PROTOCOL_VERSION, HEADER_MISMATCH}
|
||||
assert ladder_codes <= ERROR_CODE_HTTP_STATUS.keys()
|
||||
|
||||
|
||||
# --- shape invariants ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_verdict_dataclasses_are_frozen() -> None:
|
||||
"""SDK-defined: both verdict dataclasses are frozen so a route/rejection cannot be mutated after classification."""
|
||||
route = classify_inbound_request(envelope())
|
||||
assert isinstance(route, InboundModernRoute)
|
||||
rejection = InboundLadderRejection(code=METHOD_NOT_FOUND, message="m")
|
||||
for verdict in (route, rejection):
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
setattr(verdict, "message", "mutated")
|
||||
|
||||
|
||||
# --- header-value codec --------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw",
|
||||
["plain", "with internal space", "", " edge-ws ", "résumé", "a\r\nb", "=?base64?Zm9v?="],
|
||||
)
|
||||
def test_decode_header_value_round_trips_encode(raw: str) -> None:
|
||||
"""SDK-defined: `decode_header_value` is the exact inverse of `encode_header_value` over the full input domain."""
|
||||
assert decode_header_value(encode_header_value(raw)) == raw
|
||||
|
||||
|
||||
def test_decode_header_value_passes_none_and_plain_through() -> None:
|
||||
"""SDK-defined: `None` in → `None` out so callers can pass `headers.get(...)` directly; plain stays verbatim."""
|
||||
assert decode_header_value(None) is None
|
||||
assert decode_header_value("plain") == "plain"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["=?base64?not base64!?=", "=?base64?gA==?="])
|
||||
def test_decode_header_value_returns_none_for_malformed_sentinel(bad: str) -> None:
|
||||
"""SDK-defined: a sentinel with bad base64 or bad UTF-8 decodes to `None`, so it can never match a body value."""
|
||||
assert decode_header_value(bad) is None
|
||||
|
||||
|
||||
# --- NAME_BEARING_METHODS ------------------------------------------------------
|
||||
|
||||
|
||||
def test_name_bearing_methods_table_matches_spec() -> None:
|
||||
"""Spec-mandated: pins the method → name-param table the client emit and server validate share."""
|
||||
assert NAME_BEARING_METHODS == {"tools/call": "name", "prompts/get": "name", "resources/read": "uri"}
|
||||
|
||||
|
||||
# --- find_invalid_x_mcp_header -------------------------------------------------
|
||||
|
||||
|
||||
def _schema(**props: Any) -> dict[str, Any]:
|
||||
return {"type": "object", "properties": props}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"input_schema",
|
||||
[
|
||||
pytest.param(None, id="none"),
|
||||
pytest.param("not-a-mapping", id="non-mapping"),
|
||||
pytest.param({"type": "object"}, id="no-properties"),
|
||||
pytest.param({"type": "object", "properties": "not-a-mapping"}, id="properties-non-mapping"),
|
||||
pytest.param(_schema(a={"type": "string"}), id="no-annotation"),
|
||||
pytest.param(_schema(a={"type": "string", "x-mcp-header": "Region"}), id="valid-string"),
|
||||
pytest.param(_schema(a={"type": "integer", "x-mcp-header": "Count"}), id="valid-integer"),
|
||||
pytest.param(_schema(a={"type": "boolean", "x-mcp-header": "Flag"}), id="valid-boolean"),
|
||||
pytest.param(
|
||||
_schema(a={"type": "string", "x-mcp-header": "A"}, b={"type": "string", "x-mcp-header": "B"}),
|
||||
id="two-distinct",
|
||||
),
|
||||
pytest.param(_schema(a="not-a-mapping", b={"type": "string", "x-mcp-header": "B"}), id="non-mapping-prop"),
|
||||
pytest.param(
|
||||
_schema(outer={"type": "object", "properties": {"r": {"type": "string", "x-mcp-header": "R"}}}),
|
||||
id="nested-on-properties-chain",
|
||||
),
|
||||
pytest.param(
|
||||
_schema(a={"type": "string", "default": {"x-mcp-header": "ignored"}}),
|
||||
id="annotation-lookalike-in-default-is-data",
|
||||
),
|
||||
pytest.param(
|
||||
_schema(a={"type": "string", "examples": [{"x-mcp-header": "ignored"}]}),
|
||||
id="annotation-lookalike-in-examples-is-data",
|
||||
),
|
||||
pytest.param(
|
||||
_schema(a={"type": "string", "const": {"x-mcp-header": "ignored"}}),
|
||||
id="annotation-lookalike-in-const-is-data",
|
||||
),
|
||||
pytest.param(
|
||||
_schema(a={"type": "string", "enum": [{"x-mcp-header": "ignored"}]}),
|
||||
id="annotation-lookalike-in-enum-is-data",
|
||||
),
|
||||
pytest.param(
|
||||
{"properties": {"a": {"type": "string", "x-mcp-header": "R"}}, "$ref": "#/$defs/loop"},
|
||||
id="ref-is-not-dereferenced",
|
||||
),
|
||||
pytest.param(
|
||||
{"type": "object", "allOf": 0, "anyOf": [], "$defs": 0, "patternProperties": {}},
|
||||
id="malformed-or-empty-applicators-ignored",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_find_invalid_x_mcp_header_accepts_valid_or_absent_annotations(input_schema: Any) -> None:
|
||||
"""Spec-mandated: a schema without annotations, or with annotations that are RFC 9110 tokens on
|
||||
integer/string/boolean properties reachable via a pure `properties` chain and case-insensitively
|
||||
unique across the whole schema, is valid."""
|
||||
assert find_invalid_x_mcp_header(input_schema) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"input_schema",
|
||||
[
|
||||
pytest.param(_schema(a={"type": "string", "x-mcp-header": ""}), id="empty"),
|
||||
pytest.param(_schema(a={"type": "string", "x-mcp-header": "My Region"}), id="space"),
|
||||
pytest.param(_schema(a={"type": "string", "x-mcp-header": "Region:Primary"}), id="colon"),
|
||||
pytest.param(_schema(a={"type": "string", "x-mcp-header": "Région"}), id="non-ascii"),
|
||||
pytest.param(_schema(a={"type": "string", "x-mcp-header": "Region\t1"}), id="control-char"),
|
||||
pytest.param(_schema(a={"type": "string", "x-mcp-header": 42}), id="non-string"),
|
||||
pytest.param(_schema(a={"type": "string", "x-mcp-header": 10**5000}), id="oversized-int-header"),
|
||||
pytest.param(_schema(a={"type": "object", "x-mcp-header": "Data"}), id="on-object"),
|
||||
pytest.param(_schema(a={"type": "array", "x-mcp-header": "Items"}), id="on-array"),
|
||||
pytest.param(_schema(a={"type": "null", "x-mcp-header": "Nil"}), id="on-null"),
|
||||
pytest.param(_schema(a={"type": "number", "x-mcp-header": "Ratio"}), id="on-number"),
|
||||
pytest.param(_schema(a={"type": ["string", "null"], "x-mcp-header": "Maybe"}), id="array-type"),
|
||||
pytest.param(_schema(a={"type": {"not": "valid"}, "x-mcp-header": "Bad"}), id="dict-type"),
|
||||
pytest.param(_schema(a={"type": 10**5000, "x-mcp-header": "Big"}), id="oversized-int-type"),
|
||||
pytest.param(_schema(a={"x-mcp-header": "NoType"}), id="missing-type"),
|
||||
pytest.param(
|
||||
_schema(a={"type": "string", "x-mcp-header": "Region"}, b={"type": "string", "x-mcp-header": "Region"}),
|
||||
id="duplicate-same-case",
|
||||
),
|
||||
pytest.param(
|
||||
_schema(a={"type": "string", "x-mcp-header": "MyField"}, b={"type": "string", "x-mcp-header": "myfield"}),
|
||||
id="duplicate-diff-case",
|
||||
),
|
||||
pytest.param(
|
||||
{"allOf": [{"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "X"}}}]},
|
||||
id="properties-chain-not-restored-below-an-applicator",
|
||||
),
|
||||
pytest.param(
|
||||
{"type": "string", "x-mcp-header": "X"},
|
||||
id="on-root-schema",
|
||||
),
|
||||
pytest.param(
|
||||
_schema(
|
||||
a={"type": "string", "x-mcp-header": "Region"},
|
||||
o={"type": "object", "properties": {"b": {"type": "string", "x-mcp-header": "region"}}},
|
||||
),
|
||||
id="duplicate-across-nesting-levels",
|
||||
),
|
||||
pytest.param(
|
||||
_schema(outer={"type": "object", "properties": {"r": {"type": "string", "x-mcp-header": "bad name"}}}),
|
||||
id="nested-bad-token",
|
||||
),
|
||||
pytest.param(
|
||||
_schema(outer={"type": "object", "properties": {"r": {"type": "object", "x-mcp-header": "R"}}}),
|
||||
id="nested-non-primitive",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_find_invalid_x_mcp_header_rejects_malformed_annotations(input_schema: dict[str, Any]) -> None:
|
||||
"""Spec-mandated: empty / non-token / non-primitive / off-chain / duplicate `x-mcp-header`
|
||||
annotations yield a reason string."""
|
||||
assert isinstance(find_invalid_x_mcp_header(input_schema), str)
|
||||
|
||||
|
||||
# Keyword → a value of that keyword's own JSON Schema shape carrying an annotated subschema.
|
||||
# Deliberately a literal table, independent of the `_SUBSCHEMA_*` sets in `inbound.py`:
|
||||
# dropping a keyword from the walk must FAIL its case here, not shrink the parametrization.
|
||||
_ANNOTATED = {"type": "string", "x-mcp-header": "Region"}
|
||||
_APPLICATOR_CASES: dict[str, Any] = {
|
||||
"$defs": {"T": _ANNOTATED},
|
||||
"additionalProperties": _ANNOTATED,
|
||||
"allOf": [_ANNOTATED],
|
||||
"anyOf": [_ANNOTATED],
|
||||
"contains": _ANNOTATED,
|
||||
"contentSchema": _ANNOTATED,
|
||||
"definitions": {"T": _ANNOTATED},
|
||||
"dependentSchemas": {"k": _ANNOTATED},
|
||||
"else": _ANNOTATED,
|
||||
"if": _ANNOTATED,
|
||||
"items": _ANNOTATED,
|
||||
"not": _ANNOTATED,
|
||||
"oneOf": [_ANNOTATED],
|
||||
"patternProperties": {"^a": _ANNOTATED},
|
||||
"prefixItems": [_ANNOTATED],
|
||||
"propertyNames": _ANNOTATED,
|
||||
"then": _ANNOTATED,
|
||||
"unevaluatedItems": _ANNOTATED,
|
||||
"unevaluatedProperties": _ANNOTATED,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("keyword", sorted(_APPLICATOR_CASES))
|
||||
def test_find_invalid_x_mcp_header_rejects_annotations_under_every_non_properties_applicator(keyword: str) -> None:
|
||||
"""Spec-mandated: a property reached through any applicator other than `properties` is not
|
||||
statically reachable, so its annotation invalidates the whole tool definition."""
|
||||
schema = _schema(ok={"type": "string"}) | {keyword: _APPLICATOR_CASES[keyword]}
|
||||
assert isinstance(find_invalid_x_mcp_header(schema), str)
|
||||
|
||||
|
||||
def test_schema_walk_applicator_keywords_match_the_pinned_reject_cases() -> None:
|
||||
"""SDK-defined: a keyword added to the walk must gain a literal reject case above (a removed
|
||||
keyword already fails its case there)."""
|
||||
assert _SUBSCHEMA_LIST | _SUBSCHEMA_MAP | _SUBSCHEMA_SINGLE == set(_APPLICATOR_CASES)
|
||||
|
||||
|
||||
def test_find_invalid_x_mcp_header_reports_dotted_path_for_nested_property() -> None:
|
||||
"""SDK-defined: the reason string names the nested property by its dotted `properties` path."""
|
||||
schema = _schema(outer={"type": "object", "properties": {"r": {"type": "object", "x-mcp-header": "R"}}})
|
||||
reason = find_invalid_x_mcp_header(schema)
|
||||
assert reason is not None and "'outer.r'" in reason
|
||||
|
||||
|
||||
# --- x_mcp_header_map ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_x_mcp_header_map_keys_top_level_and_nested_properties_by_path() -> None:
|
||||
"""Each annotated property maps to its token under its full `properties` path; unannotated props are absent."""
|
||||
schema = _schema(
|
||||
region={"type": "string", "x-mcp-header": "Region"},
|
||||
query={"type": "string"},
|
||||
outer={"type": "object", "properties": {"inner": {"type": "string", "x-mcp-header": "Inner"}}},
|
||||
)
|
||||
assert x_mcp_header_map(schema) == {("region",): "Region", ("outer", "inner"): "Inner"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("input_schema", [None, "not-a-mapping", {"type": "object"}])
|
||||
def test_x_mcp_header_map_empty_for_schemas_without_annotations(input_schema: Any) -> None:
|
||||
assert x_mcp_header_map(input_schema) == {}
|
||||
|
||||
|
||||
# --- mcp_param_headers ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_mcp_param_headers_renders_primitive_types_per_spec() -> None:
|
||||
"""String verbatim, integer as decimal, boolean as lowercase `true`/`false`, header named `Mcp-Param-<token>`."""
|
||||
header_map = {("region",): "Region", ("priority",): "Priority", ("verbose",): "Verbose", ("debug",): "Debug"}
|
||||
arguments = {"region": "us-west1", "priority": 42, "verbose": False, "debug": True}
|
||||
assert mcp_param_headers(header_map, arguments) == {
|
||||
"Mcp-Param-Region": "us-west1",
|
||||
"Mcp-Param-Priority": "42",
|
||||
"Mcp-Param-Verbose": "false",
|
||||
"Mcp-Param-Debug": "true",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "encoded"),
|
||||
[
|
||||
pytest.param("us-west1", "us-west1", id="plain-ascii"),
|
||||
pytest.param("Hello, 世界", "=?base64?SGVsbG8sIOS4lueVjA==?=", id="non-ascii"),
|
||||
pytest.param(" padded ", "=?base64?IHBhZGRlZCA=?=", id="edge-whitespace"),
|
||||
pytest.param("line1\nline2", "=?base64?bGluZTEKbGluZTI=?=", id="control-char"),
|
||||
pytest.param("=?base64?literal?=", "=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?=", id="sentinel-lookalike"),
|
||||
],
|
||||
)
|
||||
def test_mcp_param_headers_base64_wraps_header_unsafe_strings(value: str, encoded: str) -> None:
|
||||
"""Matches the spec's Value Encoding table: a non-header-safe string is base64-sentinel wrapped."""
|
||||
assert mcp_param_headers({("v",): "Val"}, {"v": value}) == {"Mcp-Param-Val": encoded}
|
||||
|
||||
|
||||
def test_mcp_param_headers_omits_absent_or_null_arguments() -> None:
|
||||
"""A path that hits a missing key or a `None` value emits no header (spec: omit when no value is present)."""
|
||||
header_map = {("present",): "Present", ("missing",): "Missing", ("nulled",): "Nulled"}
|
||||
assert mcp_param_headers(header_map, {"present": "x", "nulled": None}) == {"Mcp-Param-Present": "x"}
|
||||
|
||||
|
||||
def test_mcp_param_headers_reads_nested_argument_path() -> None:
|
||||
"""A nested annotated property reads its value at the matching nested `arguments` path."""
|
||||
headers = mcp_param_headers({("outer", "inner"): "Inner"}, {"outer": {"inner": "deep"}})
|
||||
assert headers == {"Mcp-Param-Inner": "deep"}
|
||||
|
||||
|
||||
def test_mcp_param_headers_omits_when_nested_path_is_broken() -> None:
|
||||
"""A nested path through a non-mapping or missing intermediate node yields no header."""
|
||||
header_map = {("outer", "inner"): "Inner"}
|
||||
assert mcp_param_headers(header_map, {"outer": "not-a-mapping"}) == {}
|
||||
assert mcp_param_headers(header_map, {}) == {}
|
||||
|
||||
|
||||
# --- validate_mcp_param_headers --------------------------------------------
|
||||
|
||||
REGION_SCHEMA: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": {"region": {"type": "string", "x-mcp-header": "Region"}},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("argument", "header"),
|
||||
[
|
||||
pytest.param("Hello", "Hello", id="plain-literal"),
|
||||
pytest.param("Hello", "=?base64?SGVsbG8=?=", id="valid-sentinel"),
|
||||
pytest.param("", "=?base64??=", id="empty-sentinel"),
|
||||
pytest.param("SGVsbG8=", "SGVsbG8=", id="missing-prefix-is-literal"),
|
||||
pytest.param("=?base64?SGVsbG8=", "=?base64?SGVsbG8=", id="missing-suffix-is-literal"),
|
||||
],
|
||||
)
|
||||
def test_validate_mcp_param_headers_accepts_agreeing_header_and_argument(argument: str, header: str) -> None:
|
||||
"""Spec Value Encoding: a fully-wrapped sentinel decodes before comparison; anything else is a literal."""
|
||||
assert validate_mcp_param_headers(REGION_SCHEMA, {"region": argument}, {"Mcp-Param-Region": header}) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"header",
|
||||
[
|
||||
pytest.param("=?base64?SGVsbG8?=", id="missing-padding"),
|
||||
pytest.param("=?base64?SGVs!!!bG8=?=", id="non-alphabet-chars"),
|
||||
pytest.param("=?base64?SGVsbG9=?=", id="non-canonical-trailing-bits"),
|
||||
pytest.param("=?base64?gA==?=", id="invalid-utf8"),
|
||||
],
|
||||
)
|
||||
def test_validate_mcp_param_headers_rejects_malformed_sentinel(header: str) -> None:
|
||||
"""Spec: servers MUST reject a recognized header whose sentinel cannot be strictly decoded — not a literal."""
|
||||
rejection = assert_rejected(
|
||||
validate_mcp_param_headers(REGION_SCHEMA, {"region": "Hello"}, {"Mcp-Param-Region": header}),
|
||||
HEADER_MISMATCH,
|
||||
)
|
||||
assert "malformed base64" in rejection.message
|
||||
|
||||
|
||||
def test_validate_mcp_param_headers_rejects_missing_header_for_present_argument() -> None:
|
||||
"""Spec table: client omits the header but the value is in the body → server MUST reject."""
|
||||
rejection = assert_rejected(
|
||||
validate_mcp_param_headers(REGION_SCHEMA, {"region": "test-value"}, {}), HEADER_MISMATCH
|
||||
)
|
||||
assert "missing" in rejection.message
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"arguments",
|
||||
[pytest.param({}, id="absent"), pytest.param({"region": None}, id="null")],
|
||||
)
|
||||
def test_validate_mcp_param_headers_rejects_orphan_header_for_absent_or_null_argument(
|
||||
arguments: dict[str, Any],
|
||||
) -> None:
|
||||
"""SDK-defined posture on a spec gap: an orphan header is the routing-spoof case; go rejects too, ts skips."""
|
||||
rejection = assert_rejected(
|
||||
validate_mcp_param_headers(REGION_SCHEMA, arguments, {"Mcp-Param-Region": "eu"}), HEADER_MISMATCH
|
||||
)
|
||||
assert "absent" in rejection.message
|
||||
|
||||
|
||||
def test_validate_mcp_param_headers_rejects_value_mismatch() -> None:
|
||||
rejection = assert_rejected(
|
||||
validate_mcp_param_headers(REGION_SCHEMA, {"region": "us"}, {"Mcp-Param-Region": "eu"}), HEADER_MISMATCH
|
||||
)
|
||||
assert "does not match" in rejection.message
|
||||
|
||||
|
||||
def test_validate_mcp_param_headers_accepts_absent_argument_with_no_header() -> None:
|
||||
"""Spec table: parameter not in arguments / null → client MUST omit, server MUST NOT expect."""
|
||||
assert validate_mcp_param_headers(REGION_SCHEMA, {}, {}) is None
|
||||
assert validate_mcp_param_headers(REGION_SCHEMA, {"region": None}, {}) is None
|
||||
|
||||
|
||||
def test_validate_mcp_param_headers_matches_header_names_case_insensitively() -> None:
|
||||
"""Spec Case Sensitivity: header-name comparison MUST be case-insensitive."""
|
||||
assert validate_mcp_param_headers(REGION_SCHEMA, {"region": "eu"}, {"MCP-PARAM-REGION": "eu"}) is None
|
||||
rejection = validate_mcp_param_headers(REGION_SCHEMA, {"region": "us"}, {"MCP-PARAM-REGION": "eu"})
|
||||
assert_rejected(rejection, HEADER_MISMATCH)
|
||||
|
||||
|
||||
def test_validate_mcp_param_headers_ignores_undeclared_mcp_param_headers() -> None:
|
||||
"""Spec: an undeclared `Mcp-Param-*` header is unrecognized — forwarded and ignored, never a failure."""
|
||||
headers = {"Mcp-Param-Region": "eu", "Mcp-Param-Undeclared": "=?base64?not even base64?="}
|
||||
assert validate_mcp_param_headers(REGION_SCHEMA, {"region": "eu"}, headers) is None
|
||||
|
||||
|
||||
def test_validate_mcp_param_headers_validates_nothing_for_an_invalid_annotation_schema() -> None:
|
||||
"""Spec gives definition rejection to clients (drop the tool), so an invalid schema recognizes no headers."""
|
||||
invalid = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"region": {"type": "string", "x-mcp-header": "Region"},
|
||||
"dupe": {"type": "string", "x-mcp-header": "region"},
|
||||
},
|
||||
}
|
||||
assert find_invalid_x_mcp_header(invalid) is not None
|
||||
assert validate_mcp_param_headers(invalid, {"region": "us"}, {"Mcp-Param-Region": "eu"}) is None
|
||||
|
||||
|
||||
def test_validate_mcp_param_headers_reads_nested_argument_paths() -> None:
|
||||
"""A nested annotated property compares against the matching nested `arguments` path; a broken path is absent."""
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"outer": {"type": "object", "properties": {"inner": {"type": "string", "x-mcp-header": "Inner"}}}
|
||||
},
|
||||
}
|
||||
assert validate_mcp_param_headers(schema, {"outer": {"inner": "deep"}}, {"Mcp-Param-Inner": "deep"}) is None
|
||||
rejection = validate_mcp_param_headers(schema, {"outer": {"inner": "deep"}}, {"Mcp-Param-Inner": "other"})
|
||||
assert_rejected(rejection, HEADER_MISMATCH)
|
||||
assert validate_mcp_param_headers(schema, {"outer": "not-a-mapping"}, {}) is None
|
||||
|
||||
|
||||
def test_validate_mcp_param_headers_compares_booleans_against_true_false_rendering() -> None:
|
||||
"""Booleans compare against the lowercase `true`/`false` rendering the client emits."""
|
||||
schema = {"type": "object", "properties": {"flag": {"type": "boolean", "x-mcp-header": "Flag"}}}
|
||||
assert validate_mcp_param_headers(schema, {"flag": True}, {"Mcp-Param-Flag": "true"}) is None
|
||||
assert validate_mcp_param_headers(schema, {"flag": False}, {"Mcp-Param-Flag": "false"}) is None
|
||||
rejection = validate_mcp_param_headers(schema, {"flag": True}, {"Mcp-Param-Flag": "True"})
|
||||
assert_rejected(rejection, HEADER_MISMATCH)
|
||||
|
||||
|
||||
INTEGER_SCHEMA: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": {"n": {"type": "integer", "x-mcp-header": "N"}},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("body_value", "header", "matches"),
|
||||
[
|
||||
pytest.param(42, "42", True, id="exact"),
|
||||
pytest.param(42, "42.0", True, id="trailing-zero-fraction"),
|
||||
pytest.param(42, "42.000", True, id="long-zero-fraction"),
|
||||
pytest.param(42, "42.5", False, id="real-fraction"),
|
||||
pytest.param(42, "1e2", False, id="scientific-notation-never-numeric"),
|
||||
pytest.param(42, "43", False, id="different-value"),
|
||||
pytest.param(-7, "-7.0", True, id="negative"),
|
||||
pytest.param(9007199254740993, "9007199254740993", True, id="beyond-ieee754-safe-range-exact"),
|
||||
pytest.param(9007199254740993, "9007199254740992", False, id="beyond-ieee754-safe-range-off-by-one"),
|
||||
],
|
||||
)
|
||||
def test_validate_mcp_param_headers_compares_integers_numerically(body_value: int, header: str, matches: bool) -> None:
|
||||
"""Spec SHOULD: integers compare numerically (`42` == `42.0`) — gated to canonical decimals, compared exactly."""
|
||||
result = validate_mcp_param_headers(INTEGER_SCHEMA, {"n": body_value}, {"Mcp-Param-N": header})
|
||||
if matches:
|
||||
assert result is None
|
||||
else:
|
||||
assert_rejected(result, HEADER_MISMATCH)
|
||||
|
||||
|
||||
def test_validate_mcp_param_headers_non_primitive_body_value_rejects_only_when_a_header_claims_it() -> None:
|
||||
"""A header claiming a non-primitive argument is a mismatch; without one, rejection is params validation's job."""
|
||||
rejection = assert_rejected(
|
||||
validate_mcp_param_headers(REGION_SCHEMA, {"region": {"k": "v"}}, {"Mcp-Param-Region": "x"}),
|
||||
HEADER_MISMATCH,
|
||||
)
|
||||
assert "does not match" in rejection.message
|
||||
assert validate_mcp_param_headers(REGION_SCHEMA, {"region": {"k": "v"}}, {}) is None
|
||||
assert validate_mcp_param_headers(REGION_SCHEMA, {"region": [1, 2]}, {}) is None
|
||||
|
||||
|
||||
class _RepeatedHeaders(Mapping[str, str]):
|
||||
"""A header carrier whose `items()` yields duplicate names, like a raw HTTP header list."""
|
||||
|
||||
def __init__(self, pairs: list[tuple[str, str]]) -> None:
|
||||
self._pairs = pairs
|
||||
|
||||
def __getitem__(self, key: str) -> str:
|
||||
return next(value for name, value in self._pairs if name == key)
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return (name for name, _ in self._pairs)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._pairs)
|
||||
|
||||
def items(self) -> Any:
|
||||
return list(self._pairs)
|
||||
|
||||
|
||||
def test_validate_mcp_param_headers_rejects_a_recognized_header_supplied_more_than_once() -> None:
|
||||
"""Duplicate recognized headers reject even when one matches: first-copy readers and last-wins checks diverge."""
|
||||
headers = _RepeatedHeaders([("Mcp-Param-Region", "spoofed"), ("mcp-param-region", "eu")])
|
||||
# The carrier behaves like a raw header list: first-wins lookup, every line iterated.
|
||||
assert headers["Mcp-Param-Region"] == "spoofed"
|
||||
assert len(headers) == len(list(headers)) == 2
|
||||
rejection = assert_rejected(validate_mcp_param_headers(REGION_SCHEMA, {"region": "eu"}, headers), HEADER_MISMATCH)
|
||||
assert "more than once" in rejection.message
|
||||
noisy = _RepeatedHeaders([("Mcp-Param-Region", "eu"), ("Mcp-Param-Other", "a"), ("mcp-param-other", "b")])
|
||||
assert validate_mcp_param_headers(REGION_SCHEMA, {"region": "eu"}, noisy) is None
|
||||
|
||||
|
||||
def test_validate_mcp_param_headers_rejects_a_header_exceeding_the_int_conversion_limit() -> None:
|
||||
"""A canonical-decimal header beyond CPython's int-conversion digit limit is a clean mismatch, never an error."""
|
||||
rejection = validate_mcp_param_headers(INTEGER_SCHEMA, {"n": 1}, {"Mcp-Param-N": "1" * 5000})
|
||||
assert_rejected(rejection, HEADER_MISMATCH)
|
||||
|
||||
|
||||
def test_validate_mcp_param_headers_compares_integral_float_bodies_numerically() -> None:
|
||||
"""JSON Schema admits `42.0` as an integer; the numeric SHOULD applies in both directions."""
|
||||
assert validate_mcp_param_headers(INTEGER_SCHEMA, {"n": 42.0}, {"Mcp-Param-N": "42"}) is None
|
||||
assert validate_mcp_param_headers(INTEGER_SCHEMA, {"n": 42.0}, {"Mcp-Param-N": "42.0"}) is None
|
||||
assert_rejected(validate_mcp_param_headers(INTEGER_SCHEMA, {"n": 42.0}, {"Mcp-Param-N": "43"}), HEADER_MISMATCH)
|
||||
# A genuinely fractional body value falls back to the exact string rendering.
|
||||
assert validate_mcp_param_headers(INTEGER_SCHEMA, {"n": 42.5}, {"Mcp-Param-N": "42.5"}) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
pytest.param("=?base64?SGVsbG9=?=", id="non-canonical-trailing-bits"),
|
||||
pytest.param("=?base64?SGVsbG8?=", id="missing-padding"),
|
||||
],
|
||||
)
|
||||
def test_decode_header_value_returns_none_for_non_canonical_base64(value: str) -> None:
|
||||
"""Canonical base64 only: a payload that decodes but does not re-encode byte-identically is malformed."""
|
||||
assert decode_header_value(value) is None
|
||||
|
||||
|
||||
def test_validate_mcp_param_headers_union_typed_annotation_invalidates_the_whole_tool() -> None:
|
||||
"""A union-typed annotation fails the integer/string/boolean-only rule, so the whole schema validates nothing."""
|
||||
union_schema = {
|
||||
"type": "object",
|
||||
"properties": {"n": {"type": ["integer", "null"], "x-mcp-header": "N"}},
|
||||
}
|
||||
assert find_invalid_x_mcp_header(union_schema) is not None
|
||||
assert validate_mcp_param_headers(union_schema, {"n": 42}, {"Mcp-Param-N": "999"}) is None
|
||||
assert validate_mcp_param_headers(union_schema, {"n": 42}, {}) is None
|
||||
|
||||
|
||||
def test_validate_mcp_param_headers_accepts_the_clients_own_rendering_of_large_integral_floats() -> None:
|
||||
"""A non-canonical-decimal header falls back to rendered comparison, so the client's own mirroring round-trips."""
|
||||
emitted = mcp_param_headers(x_mcp_header_map(INTEGER_SCHEMA), {"n": 1e16})
|
||||
assert emitted == {"Mcp-Param-N": "1e+16"}
|
||||
assert validate_mcp_param_headers(INTEGER_SCHEMA, {"n": 1e16}, emitted) is None
|
||||
assert_rejected(validate_mcp_param_headers(INTEGER_SCHEMA, {"n": 42}, {"Mcp-Param-N": "1e2"}), HEADER_MISMATCH)
|
||||
|
||||
|
||||
def test_validate_mcp_param_headers_handles_unrenderable_huge_integer_bodies_without_raising() -> None:
|
||||
"""An integer beyond CPython's int-to-str digit limit has no rendering: claimed → mismatch, unclaimed → fine."""
|
||||
huge = 10**5000
|
||||
rejection = validate_mcp_param_headers(REGION_SCHEMA, {"region": huge}, {"Mcp-Param-Region": "x"})
|
||||
assert_rejected(rejection, HEADER_MISMATCH)
|
||||
assert validate_mcp_param_headers(REGION_SCHEMA, {"region": huge}, {}) is None
|
||||
assert validate_mcp_param_headers(INTEGER_SCHEMA, {"n": huge}, {}) is None
|
||||
|
||||
|
||||
def test_mcp_param_headers_omits_values_with_no_scalar_rendering() -> None:
|
||||
"""Objects, arrays, and over-limit integers have no scalar rendering, so the client omits the header."""
|
||||
header_map = {("v",): "Val"}
|
||||
assert mcp_param_headers(header_map, {"v": {"k": 1}}) == {}
|
||||
assert mcp_param_headers(header_map, {"v": [1, 2]}) == {}
|
||||
assert mcp_param_headers(header_map, {"v": 10**5000}) == {}
|
||||
|
||||
|
||||
def test_find_duplicated_routing_header_detects_repeats_of_routing_headers_only() -> None:
|
||||
"""Repeated routing headers report case-insensitively; `Mcp-Param-*` or unrelated repeats are ignored."""
|
||||
assert find_duplicated_routing_header([("Mcp-Name", "a"), ("mcp-name", "b")]) == MCP_NAME_HEADER
|
||||
assert find_duplicated_routing_header([("MCP-Protocol-Version", "x"), ("mcp-protocol-version", "x")]) == (
|
||||
MCP_PROTOCOL_VERSION_HEADER
|
||||
)
|
||||
assert find_duplicated_routing_header([("Mcp-Method", "a"), ("Mcp-Method", "a")]) == MCP_METHOD_HEADER
|
||||
assert find_duplicated_routing_header([("Mcp-Name", "a"), ("Mcp-Param-X", "1"), ("Mcp-Param-X", "2")]) is None
|
||||
assert find_duplicated_routing_header([("accept", "a"), ("accept", "b")]) is None
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import mcp_types as types
|
||||
import pytest
|
||||
from logfire.testing import CaptureLogfire
|
||||
|
||||
from mcp.client.client import Client
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
from mcp.shared._otel import extract_trace_context
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
def test_extract_trace_context_degrades_to_no_parent_on_malformed_traceparent() -> None:
|
||||
"""A non-string `traceparent` makes `extract()` raise; the helper must return `None`, not propagate."""
|
||||
assert extract_trace_context({"traceparent": 123}) is None
|
||||
|
||||
|
||||
async def test_client_and_server_spans(capfire: CaptureLogfire):
|
||||
"""Verify that calling a tool produces client and server spans with correct attributes."""
|
||||
server = MCPServer("test")
|
||||
|
||||
@server.tool()
|
||||
def greet(name: str) -> str:
|
||||
"""Greet someone."""
|
||||
return f"Hello, {name}!"
|
||||
|
||||
async with Client(server, mode="legacy") as client:
|
||||
result = await client.call_tool("greet", {"name": "World"})
|
||||
|
||||
assert isinstance(result.content[0], types.TextContent)
|
||||
assert result.content[0].text == "Hello, World!"
|
||||
|
||||
spans = capfire.exporter.exported_spans_as_dict()
|
||||
span_names = {s["name"] for s in spans}
|
||||
|
||||
assert "MCP send tools/call greet" in span_names
|
||||
assert "tools/call greet" in span_names
|
||||
|
||||
client_span = next(s for s in spans if s["name"] == "MCP send tools/call greet")
|
||||
server_span = next(s for s in spans if s["name"] == "tools/call greet")
|
||||
|
||||
assert client_span["attributes"]["mcp.method.name"] == "tools/call"
|
||||
assert server_span["attributes"]["mcp.method.name"] == "tools/call"
|
||||
|
||||
# Server span should be in the same trace as the client span (context propagation).
|
||||
assert server_span["context"]["trace_id"] == client_span["context"]["trace_id"]
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Tests for filesystem path safety primitives."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp.shared.path_security import (
|
||||
PathEscapeError,
|
||||
contains_path_traversal,
|
||||
is_absolute_path,
|
||||
safe_join,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
# Safe: no traversal
|
||||
("a/b/c", False),
|
||||
("readme.txt", False),
|
||||
("", False),
|
||||
(".", False),
|
||||
("./a/b", False),
|
||||
# Safe: .. balanced by prior descent
|
||||
("a/../b", False),
|
||||
("a/b/../c", False),
|
||||
("a/b/../../c", False),
|
||||
# Unsafe: net escape
|
||||
("..", True),
|
||||
("../etc", True),
|
||||
("../../etc/passwd", True),
|
||||
("a/../../b", True),
|
||||
("./../../etc", True),
|
||||
# .. as substring, not component — safe
|
||||
("1.0..2.0", False),
|
||||
("foo..bar", False),
|
||||
("..foo", False),
|
||||
("foo..", False),
|
||||
# Backslash separator
|
||||
("..\\etc", True),
|
||||
("a\\..\\..\\b", True),
|
||||
("a\\b\\c", False),
|
||||
# Mixed separators
|
||||
("a/..\\..\\b", True),
|
||||
],
|
||||
)
|
||||
def test_contains_path_traversal(value: str, expected: bool):
|
||||
assert contains_path_traversal(value) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
# Relative
|
||||
("relative/path", False),
|
||||
("file.txt", False),
|
||||
("", False),
|
||||
(".", False),
|
||||
("..", False),
|
||||
# POSIX absolute
|
||||
("/", True),
|
||||
("/etc/passwd", True),
|
||||
("/a", True),
|
||||
# Windows drive
|
||||
("C:", True),
|
||||
("C:\\Windows", True),
|
||||
("c:/foo", True),
|
||||
("Z:\\", True),
|
||||
# Windows UNC / backslash-absolute
|
||||
("\\\\server\\share", True),
|
||||
("\\foo", True),
|
||||
# Windows drive-relative — discards the join base when drives differ
|
||||
("C:relative", True),
|
||||
("x:y", True),
|
||||
("a:debug", True),
|
||||
# Not a drive: digit before colon
|
||||
("1:foo", False),
|
||||
# Colon not in position 1
|
||||
("ab:c", False),
|
||||
# Non-ASCII letter is not a drive letter
|
||||
("Ω:namespace", False),
|
||||
("é:foo", False),
|
||||
],
|
||||
)
|
||||
def test_is_absolute_path(value: str, expected: bool):
|
||||
assert is_absolute_path(value) is expected
|
||||
|
||||
|
||||
def test_safe_join_simple(tmp_path: Path):
|
||||
result = safe_join(tmp_path, "docs", "readme.txt")
|
||||
assert result == tmp_path / "docs" / "readme.txt"
|
||||
|
||||
|
||||
def test_safe_join_resolves_relative_base(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
result = safe_join(".", "file.txt")
|
||||
assert result == tmp_path / "file.txt"
|
||||
|
||||
|
||||
def test_safe_join_rejects_dotdot_escape(tmp_path: Path):
|
||||
with pytest.raises(PathEscapeError, match="escapes base"):
|
||||
safe_join(tmp_path, "../../../etc/passwd")
|
||||
|
||||
|
||||
def test_safe_join_rejects_balanced_then_escape(tmp_path: Path):
|
||||
with pytest.raises(PathEscapeError, match="escapes base"):
|
||||
safe_join(tmp_path, "a/../../etc")
|
||||
|
||||
|
||||
def test_safe_join_allows_balanced_dotdot(tmp_path: Path):
|
||||
result = safe_join(tmp_path, "a/../b")
|
||||
assert result == tmp_path / "b"
|
||||
|
||||
|
||||
def test_safe_join_rejects_absolute_part(tmp_path: Path):
|
||||
with pytest.raises(PathEscapeError, match="is absolute"):
|
||||
safe_join(tmp_path, "/etc/passwd")
|
||||
|
||||
|
||||
def test_safe_join_rejects_absolute_in_later_part(tmp_path: Path):
|
||||
with pytest.raises(PathEscapeError, match="is absolute"):
|
||||
safe_join(tmp_path, "docs", "/etc/passwd")
|
||||
|
||||
|
||||
def test_safe_join_rejects_windows_drive(tmp_path: Path):
|
||||
with pytest.raises(PathEscapeError, match="is absolute"):
|
||||
safe_join(tmp_path, "C:\\Windows\\System32")
|
||||
|
||||
|
||||
def test_safe_join_rejects_null_byte(tmp_path: Path):
|
||||
with pytest.raises(PathEscapeError, match="null byte"):
|
||||
safe_join(tmp_path, "file\0.txt")
|
||||
|
||||
|
||||
def test_safe_join_rejects_null_byte_in_later_part(tmp_path: Path):
|
||||
with pytest.raises(PathEscapeError, match="null byte"):
|
||||
safe_join(tmp_path, "docs", "file\0.txt")
|
||||
|
||||
|
||||
def test_safe_join_rejects_symlink_escape(tmp_path: Path):
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
sandbox = tmp_path / "sandbox"
|
||||
sandbox.mkdir()
|
||||
(sandbox / "escape").symlink_to(outside)
|
||||
|
||||
with pytest.raises(PathEscapeError, match="escapes base"):
|
||||
safe_join(sandbox, "escape", "secret.txt")
|
||||
|
||||
|
||||
def test_safe_join_base_equals_target(tmp_path: Path):
|
||||
# Joining nothing (or ".") should return the base itself
|
||||
assert safe_join(tmp_path) == tmp_path
|
||||
assert safe_join(tmp_path, ".") == tmp_path
|
||||
|
||||
|
||||
def test_path_escape_error_is_value_error():
|
||||
with pytest.raises(ValueError):
|
||||
safe_join("/tmp", "/etc")
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Tests for `ClientPeer`.
|
||||
|
||||
Each typed method is tested by wrapping a `DirectDispatcher` in `ClientPeer`,
|
||||
calling it, and asserting (a) the right method+params went out and (b) the
|
||||
return value is the typed result model.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
from mcp_types import (
|
||||
CreateMessageResult,
|
||||
CreateMessageResultWithTools,
|
||||
ElicitResult,
|
||||
ListRootsResult,
|
||||
SamplingMessage,
|
||||
TextContent,
|
||||
Tool,
|
||||
ToolChoice,
|
||||
)
|
||||
|
||||
from mcp.shared.dispatcher import DispatchContext
|
||||
from mcp.shared.exceptions import MCPDeprecationWarning
|
||||
from mcp.shared.peer import ClientPeer, dump_params
|
||||
from mcp.shared.transport_context import TransportContext
|
||||
|
||||
from .conftest import direct_pair
|
||||
from .test_dispatcher import running_pair
|
||||
|
||||
DCtx = DispatchContext[TransportContext]
|
||||
|
||||
|
||||
class _Recorder:
|
||||
def __init__(self, result: dict[str, Any]) -> None:
|
||||
self.result = result
|
||||
self.seen: list[tuple[str, Mapping[str, Any] | None]] = []
|
||||
|
||||
async def on_request(self, ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
self.seen.append((method, params))
|
||||
return self.result
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_peer_sample_sends_create_message_and_returns_typed_result():
|
||||
rec = _Recorder({"role": "assistant", "content": {"type": "text", "text": "hi"}, "model": "m"})
|
||||
async with running_pair(direct_pair, server_on_request=rec.on_request) as (client, *_):
|
||||
peer = ClientPeer(client)
|
||||
with anyio.fail_after(5):
|
||||
result = await peer.sample( # pyright: ignore[reportDeprecated]
|
||||
[SamplingMessage(role="user", content=TextContent(type="text", text="hello"))],
|
||||
max_tokens=10,
|
||||
)
|
||||
method, params = rec.seen[0]
|
||||
assert method == "sampling/createMessage"
|
||||
assert params is not None and params["maxTokens"] == 10
|
||||
assert isinstance(result, CreateMessageResult)
|
||||
assert result.model == "m"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_peer_sample_validates_result_alias_only():
|
||||
"""Peer results validate alias-only; a snake_case key from the wire is
|
||||
ignored as extra, not populated by Python field name."""
|
||||
snake = {"role": "assistant", "content": {"type": "text", "text": "x"}, "model": "m", "stop_reason": "endTurn"}
|
||||
rec = _Recorder(snake)
|
||||
async with running_pair(direct_pair, server_on_request=rec.on_request) as (client, *_):
|
||||
peer = ClientPeer(client)
|
||||
with anyio.fail_after(5):
|
||||
result = await peer.sample( # pyright: ignore[reportDeprecated]
|
||||
[SamplingMessage(role="user", content=TextContent(type="text", text="q"))], max_tokens=1
|
||||
)
|
||||
assert isinstance(result, CreateMessageResult)
|
||||
assert result.stop_reason is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_peer_sample_with_tools_returns_with_tools_result():
|
||||
rec = _Recorder({"role": "assistant", "content": [{"type": "text", "text": "x"}], "model": "m"})
|
||||
async with running_pair(direct_pair, server_on_request=rec.on_request) as (client, *_):
|
||||
peer = ClientPeer(client)
|
||||
with anyio.fail_after(5):
|
||||
result = await peer.sample( # pyright: ignore[reportDeprecated]
|
||||
[SamplingMessage(role="user", content=TextContent(type="text", text="q"))],
|
||||
max_tokens=5,
|
||||
tools=[Tool(name="t", input_schema={"type": "object"})],
|
||||
)
|
||||
method, params = rec.seen[0]
|
||||
assert method == "sampling/createMessage"
|
||||
assert params is not None and params["tools"][0]["name"] == "t"
|
||||
assert isinstance(result, CreateMessageResultWithTools)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_peer_sample_with_tool_choice_only_returns_with_tools_result():
|
||||
# tool_choice alone is tools-mode: the answer may carry array content.
|
||||
rec = _Recorder({"role": "assistant", "content": [{"type": "text", "text": "x"}], "model": "m"})
|
||||
async with running_pair(direct_pair, server_on_request=rec.on_request) as (client, *_):
|
||||
peer = ClientPeer(client)
|
||||
with anyio.fail_after(5):
|
||||
result = await peer.sample( # pyright: ignore[reportDeprecated]
|
||||
[SamplingMessage(role="user", content=TextContent(type="text", text="q"))],
|
||||
max_tokens=5,
|
||||
tool_choice=ToolChoice(mode="none"),
|
||||
)
|
||||
assert isinstance(result, CreateMessageResultWithTools)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_peer_elicit_form_sends_elicitation_create_with_form_params():
|
||||
rec = _Recorder({"action": "accept", "content": {"name": "Max"}})
|
||||
async with running_pair(direct_pair, server_on_request=rec.on_request) as (client, *_):
|
||||
peer = ClientPeer(client)
|
||||
with anyio.fail_after(5):
|
||||
result = await peer.elicit_form("Your name?", requested_schema={"type": "object", "properties": {}})
|
||||
method, params = rec.seen[0]
|
||||
assert method == "elicitation/create"
|
||||
assert params is not None and params["mode"] == "form"
|
||||
assert params["message"] == "Your name?"
|
||||
assert isinstance(result, ElicitResult)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_peer_elicit_url_sends_elicitation_create_with_url_params():
|
||||
rec = _Recorder({"action": "accept"})
|
||||
async with running_pair(direct_pair, server_on_request=rec.on_request) as (client, *_):
|
||||
peer = ClientPeer(client)
|
||||
with anyio.fail_after(5):
|
||||
result = await peer.elicit_url("Auth needed", url="https://example.com/auth", elicitation_id="e1")
|
||||
method, params = rec.seen[0]
|
||||
assert method == "elicitation/create"
|
||||
assert params is not None and params["mode"] == "url"
|
||||
assert params["url"] == "https://example.com/auth"
|
||||
assert isinstance(result, ElicitResult)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_peer_list_roots_sends_roots_list_and_returns_typed_result():
|
||||
rec = _Recorder({"roots": [{"uri": "file:///workspace"}]})
|
||||
async with running_pair(direct_pair, server_on_request=rec.on_request) as (client, *_):
|
||||
peer = ClientPeer(client)
|
||||
with anyio.fail_after(5):
|
||||
result = await peer.list_roots() # pyright: ignore[reportDeprecated]
|
||||
method, _ = rec.seen[0]
|
||||
assert method == "roots/list"
|
||||
assert isinstance(result, ListRootsResult)
|
||||
assert len(result.roots) == 1
|
||||
assert str(result.roots[0].uri) == "file:///workspace"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_peer_list_roots_with_meta_sends_meta_in_params():
|
||||
rec = _Recorder({"roots": []})
|
||||
async with running_pair(direct_pair, server_on_request=rec.on_request) as (client, *_):
|
||||
peer = ClientPeer(client)
|
||||
with anyio.fail_after(5):
|
||||
await peer.list_roots(meta={"traceId": "t1"}) # pyright: ignore[reportDeprecated]
|
||||
method, params = rec.seen[0]
|
||||
assert method == "roots/list"
|
||||
assert params == {"_meta": {"traceId": "t1"}}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_peer_list_roots_is_deprecated_sep_2577():
|
||||
rec = _Recorder({"roots": []})
|
||||
async with running_pair(direct_pair, server_on_request=rec.on_request) as (client, *_):
|
||||
peer = ClientPeer(client)
|
||||
with anyio.fail_after(5):
|
||||
with pytest.warns(
|
||||
MCPDeprecationWarning, match=r"The roots capability is deprecated as of 2026-07-28 \(SEP-2577\)\."
|
||||
):
|
||||
await peer.list_roots() # pyright: ignore[reportDeprecated]
|
||||
assert rec.seen[0][0] == "roots/list"
|
||||
|
||||
|
||||
def test_dump_params_merges_meta_over_model_meta():
|
||||
out = dump_params(None, None)
|
||||
assert out is None
|
||||
out = dump_params(None, {"k": 1})
|
||||
assert out == {"_meta": {"k": 1}}
|
||||
|
||||
|
||||
def test_dump_params_serializes_meta_by_alias():
|
||||
"""`progress_token` (the Python key an inbound `ctx.meta` carries) emits
|
||||
its wire alias `progressToken`; undeclared keys pass through unchanged."""
|
||||
out = dump_params(None, {"progress_token": 7, "traceparent": "00-abc"})
|
||||
assert out == {"_meta": {"progressToken": 7, "traceparent": "00-abc"}}
|
||||
# The wire spelling is already canonical and survives as-is.
|
||||
out = dump_params(None, {"progressToken": "tok"})
|
||||
assert out == {"_meta": {"progressToken": "tok"}}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_peer_notify_forwards_to_wrapped_outbound():
|
||||
sent: list[tuple[str, Mapping[str, Any] | None]] = []
|
||||
|
||||
class _Out:
|
||||
async def send_raw_request(
|
||||
self, method: str, params: Mapping[str, Any] | None, opts: Any = None
|
||||
) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: Any = None) -> None:
|
||||
sent.append((method, params))
|
||||
|
||||
await ClientPeer(_Out()).notify("n", {"x": 1})
|
||||
assert sent == [("n", {"x": 1})]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_peer_ping_sends_ping_and_returns_none():
|
||||
rec = _Recorder({})
|
||||
async with running_pair(direct_pair, server_on_request=rec.on_request) as (client, *_):
|
||||
peer = ClientPeer(client)
|
||||
with anyio.fail_after(5):
|
||||
result = await peer.ping()
|
||||
method, _ = rec.seen[0]
|
||||
assert method == "ping"
|
||||
assert result is None
|
||||
@@ -0,0 +1,482 @@
|
||||
"""Tests for the SSE client and server transports, driven entirely in process."""
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
import mcp_types as types
|
||||
import pytest
|
||||
from httpx_sse import ServerSentEvent
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import (
|
||||
CallToolRequestParams,
|
||||
CallToolResult,
|
||||
EmptyResult,
|
||||
Implementation,
|
||||
InitializeResult,
|
||||
JSONRPCResponse,
|
||||
ListToolsResult,
|
||||
PaginatedRequestParams,
|
||||
ReadResourceRequestParams,
|
||||
ReadResourceResult,
|
||||
ServerCapabilities,
|
||||
TextContent,
|
||||
TextResourceContents,
|
||||
Tool,
|
||||
)
|
||||
from starlette.applications import Starlette
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from starlette.routing import Mount, Route
|
||||
|
||||
import mcp.client.sse
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.client.sse import _extract_session_id_from_endpoint, sse_client
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.server.sse import SseServerTransport
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
from mcp.shared._httpx_utils import McpHttpClientFactory
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from tests.interaction.transports import StreamingASGITransport
|
||||
|
||||
SERVER_NAME = "test_server_for_SSE"
|
||||
|
||||
# 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"
|
||||
|
||||
|
||||
def in_process_client_factory(app: Starlette) -> McpHttpClientFactory:
|
||||
"""An httpx_client_factory for sse_client whose clients are served in process by `app`."""
|
||||
|
||||
def factory(
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: httpx.Timeout | None = None,
|
||||
auth: httpx.Auth | None = None,
|
||||
) -> httpx.AsyncClient:
|
||||
# The SSE GET runs until it observes a disconnect, so the bridge must let the
|
||||
# application drain on close rather than cancelling it. follow_redirects matches
|
||||
# create_mcp_http_client, the factory this one stands in for.
|
||||
return httpx.AsyncClient(
|
||||
transport=StreamingASGITransport(app, cancel_on_close=False),
|
||||
base_url=BASE_URL,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
auth=auth,
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
async def _handle_read_resource(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult:
|
||||
uri = str(params.uri)
|
||||
parsed = urlparse(uri)
|
||||
if parsed.scheme == "foobar":
|
||||
return ReadResourceResult(
|
||||
contents=[TextResourceContents(uri=uri, text=f"Read {parsed.netloc}", mime_type="text/plain")]
|
||||
)
|
||||
raise MCPError(code=404, message="OOPS! no resource with that URI was found")
|
||||
|
||||
|
||||
def make_app(server: Server) -> Starlette:
|
||||
"""Mount `server` on a Starlette app exposing the SSE transport at /sse and /messages/."""
|
||||
# DNS-rebinding protection validates Host/Origin headers against a network attack that cannot
|
||||
# exist for an in-process app; the transport security behaviour itself is pinned by
|
||||
# tests/server/test_sse_security.py.
|
||||
sse = SseServerTransport(
|
||||
"/messages/", security_settings=TransportSecuritySettings(enable_dns_rebinding_protection=False)
|
||||
)
|
||||
|
||||
async def handle_sse(request: Request) -> Response:
|
||||
async with sse.connect_sse(request.scope, request.receive, request._send) as (read_stream, write_stream):
|
||||
await server.run(read_stream, write_stream, server.create_initialization_options())
|
||||
return Response()
|
||||
|
||||
return Starlette(
|
||||
routes=[
|
||||
Route("/sse", endpoint=handle_sse),
|
||||
Mount("/messages/", app=sse.handle_post_message),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def make_server_app() -> Starlette:
|
||||
return make_app(Server(SERVER_NAME, on_read_resource=_handle_read_resource))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_raw_sse_connection() -> None:
|
||||
"""The SSE GET responds 200 with an event-stream content type, announcing the session
|
||||
endpoint as its first event."""
|
||||
http_client = httpx.AsyncClient(
|
||||
transport=StreamingASGITransport(make_server_app(), cancel_on_close=False), base_url=BASE_URL
|
||||
)
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with http_client, http_client.stream("GET", "/sse") as response:
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
|
||||
|
||||
lines = response.aiter_lines()
|
||||
assert await anext(lines) == "event: endpoint"
|
||||
assert (await anext(lines)).startswith("data: /messages/?session_id=")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sse_client_basic_connection() -> None:
|
||||
"""A client initializes against, and pings, a server over the SSE transport."""
|
||||
factory = in_process_client_factory(make_server_app())
|
||||
async with sse_client(f"{BASE_URL}/sse", httpx_client_factory=factory) as streams:
|
||||
async with ClientSession(*streams) as session:
|
||||
result = await session.initialize()
|
||||
assert isinstance(result, InitializeResult)
|
||||
assert result.server_info.name == SERVER_NAME
|
||||
|
||||
ping_result = await session.send_ping()
|
||||
assert isinstance(ping_result, EmptyResult)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sse_client_on_session_created() -> None:
|
||||
"""The session-created callback receives the new session ID before sse_client yields."""
|
||||
factory = in_process_client_factory(make_server_app())
|
||||
captured: list[str] = []
|
||||
|
||||
async with sse_client(
|
||||
f"{BASE_URL}/sse", httpx_client_factory=factory, on_session_created=captured.append
|
||||
) as streams:
|
||||
async with ClientSession(*streams) as session:
|
||||
result = await session.initialize()
|
||||
assert isinstance(result, InitializeResult)
|
||||
# Callback fires when the endpoint event arrives, before sse_client yields.
|
||||
assert len(captured) == 1
|
||||
assert len(captured[0]) > 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"endpoint_url,expected",
|
||||
[
|
||||
("/messages?sessionId=abc123", "abc123"),
|
||||
("/messages?session_id=def456", "def456"),
|
||||
("/messages?sessionId=abc&session_id=def", "abc"),
|
||||
("/messages?other=value", None),
|
||||
("/messages", None),
|
||||
("", None),
|
||||
],
|
||||
)
|
||||
def test_extract_session_id_from_endpoint(endpoint_url: str, expected: str | None) -> None:
|
||||
"""The session ID is read from the endpoint URL's sessionId/session_id query parameters."""
|
||||
assert _extract_session_id_from_endpoint(endpoint_url) == expected
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sse_client_on_session_created_not_called_when_no_session_id(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""No session-created callback fires when the endpoint URL carries no session ID."""
|
||||
factory = in_process_client_factory(make_server_app())
|
||||
callback_mock = Mock()
|
||||
|
||||
def mock_extract(url: str) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(mcp.client.sse, "_extract_session_id_from_endpoint", mock_extract)
|
||||
|
||||
async with sse_client(f"{BASE_URL}/sse", httpx_client_factory=factory, on_session_created=callback_mock) as streams:
|
||||
async with ClientSession(*streams) as session:
|
||||
result = await session.initialize()
|
||||
assert isinstance(result, InitializeResult)
|
||||
# Callback would have fired by now (endpoint event arrives before
|
||||
# sse_client yields); if it hasn't, it won't.
|
||||
callback_mock.assert_not_called()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def initialized_sse_client_session() -> AsyncGenerator[ClientSession, None]:
|
||||
factory = in_process_client_factory(make_server_app())
|
||||
async with sse_client(f"{BASE_URL}/sse", httpx_client_factory=factory) as streams:
|
||||
async with ClientSession(*streams) as session:
|
||||
await session.initialize()
|
||||
yield session
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sse_client_happy_request_and_response(
|
||||
initialized_sse_client_session: ClientSession,
|
||||
) -> None:
|
||||
"""A resource read round-trips its arguments and the handler's content over SSE."""
|
||||
session = initialized_sse_client_session
|
||||
response = await session.read_resource(uri="foobar://should-work")
|
||||
assert len(response.contents) == 1
|
||||
assert isinstance(response.contents[0], TextResourceContents)
|
||||
assert response.contents[0].text == "Read should-work"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sse_client_exception_handling(
|
||||
initialized_sse_client_session: ClientSession,
|
||||
) -> None:
|
||||
"""A server-side MCPError reaches the client with its message intact."""
|
||||
session = initialized_sse_client_session
|
||||
with pytest.raises(MCPError, match="OOPS! no resource with that URI was found"):
|
||||
await session.read_resource(uri="xxx://will-not-work")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sse_client_basic_connection_mounted_app() -> None:
|
||||
"""The SSE transport works unchanged when its app is mounted under a sub-path."""
|
||||
main_app = Starlette(routes=[Mount("/mounted_app", app=make_server_app())])
|
||||
factory = in_process_client_factory(main_app)
|
||||
|
||||
async with sse_client(f"{BASE_URL}/mounted_app/sse", httpx_client_factory=factory) as streams:
|
||||
async with ClientSession(*streams) as session:
|
||||
result = await session.initialize()
|
||||
assert isinstance(result, InitializeResult)
|
||||
assert result.server_info.name == SERVER_NAME
|
||||
|
||||
ping_result = await session.send_ping()
|
||||
assert isinstance(ping_result, EmptyResult)
|
||||
|
||||
|
||||
async def _handle_context_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
|
||||
assert params.name in ("echo_headers", "echo_context")
|
||||
assert ctx.request is not None
|
||||
headers_info = dict(ctx.request.headers)
|
||||
|
||||
if params.name == "echo_headers":
|
||||
return CallToolResult(content=[TextContent(type="text", text=json.dumps(headers_info))])
|
||||
|
||||
assert params.arguments is not None
|
||||
context_data = {
|
||||
"request_id": params.arguments.get("request_id"),
|
||||
"headers": headers_info,
|
||||
}
|
||||
return CallToolResult(content=[TextContent(type="text", text=json.dumps(context_data))])
|
||||
|
||||
|
||||
async def _handle_context_list_tools(
|
||||
ctx: ServerRequestContext, params: PaginatedRequestParams | None
|
||||
) -> ListToolsResult:
|
||||
return ListToolsResult(
|
||||
tools=[
|
||||
Tool(
|
||||
name="echo_headers",
|
||||
description="Echoes request headers",
|
||||
input_schema={"type": "object", "properties": {}},
|
||||
),
|
||||
Tool(
|
||||
name="echo_context",
|
||||
description="Echoes request context",
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {"request_id": {"type": "string"}},
|
||||
"required": ["request_id"],
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def make_context_server_app() -> Starlette:
|
||||
return make_app(
|
||||
Server(
|
||||
"request_context_server",
|
||||
on_call_tool=_handle_context_call_tool,
|
||||
on_list_tools=_handle_context_list_tools,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_request_context_propagation() -> None:
|
||||
"""Custom HTTP headers on the SSE connection are visible to server handlers via ctx.request."""
|
||||
factory = in_process_client_factory(make_context_server_app())
|
||||
|
||||
custom_headers = {
|
||||
"Authorization": "Bearer test-token",
|
||||
"X-Custom-Header": "test-value",
|
||||
"X-Trace-Id": "trace-123",
|
||||
}
|
||||
|
||||
async with sse_client(f"{BASE_URL}/sse", httpx_client_factory=factory, headers=custom_headers) as streams:
|
||||
async with ClientSession(*streams) as session:
|
||||
result = await session.initialize()
|
||||
assert isinstance(result, InitializeResult)
|
||||
|
||||
tool_result = await session.call_tool("echo_headers", {})
|
||||
|
||||
assert len(tool_result.content) == 1
|
||||
content = tool_result.content[0]
|
||||
assert isinstance(content, TextContent)
|
||||
headers_data = json.loads(content.text)
|
||||
|
||||
assert headers_data.get("authorization") == "Bearer test-token"
|
||||
assert headers_data.get("x-custom-header") == "test-value"
|
||||
assert headers_data.get("x-trace-id") == "trace-123"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_request_context_isolation() -> None:
|
||||
"""Each SSE connection's handlers see only that connection's request headers."""
|
||||
factory = in_process_client_factory(make_context_server_app())
|
||||
contexts: list[dict[str, Any]] = []
|
||||
|
||||
# Connect three clients in turn, each with its own headers.
|
||||
for i in range(3):
|
||||
headers = {"X-Request-Id": f"request-{i}", "X-Custom-Value": f"value-{i}"}
|
||||
|
||||
async with sse_client(f"{BASE_URL}/sse", httpx_client_factory=factory, headers=headers) as streams:
|
||||
async with ClientSession(*streams) as session:
|
||||
await session.initialize()
|
||||
|
||||
tool_result = await session.call_tool("echo_context", {"request_id": f"request-{i}"})
|
||||
|
||||
assert len(tool_result.content) == 1
|
||||
content = tool_result.content[0]
|
||||
assert isinstance(content, TextContent)
|
||||
contexts.append(json.loads(content.text))
|
||||
|
||||
assert len(contexts) == 3
|
||||
for i, ctx in enumerate(contexts):
|
||||
assert ctx["request_id"] == f"request-{i}"
|
||||
assert ctx["headers"].get("x-request-id") == f"request-{i}"
|
||||
assert ctx["headers"].get("x-custom-value") == f"value-{i}"
|
||||
|
||||
|
||||
def test_sse_message_id_coercion() -> None:
|
||||
"""Previously, the `RequestId` would coerce a string that looked like an integer into an integer.
|
||||
|
||||
See <https://github.com/modelcontextprotocol/python-sdk/pull/851> for more details.
|
||||
|
||||
As per the JSON-RPC 2.0 specification, the id in the response object needs to be the same type as the id in the
|
||||
request object. In other words, we can't perform the coercion.
|
||||
|
||||
See <https://www.jsonrpc.org/specification#response_object> for more details.
|
||||
"""
|
||||
json_message = '{"jsonrpc": "2.0", "id": "123", "method": "ping", "params": null}'
|
||||
msg = types.JSONRPCRequest.model_validate_json(json_message)
|
||||
assert msg == snapshot(types.JSONRPCRequest(method="ping", jsonrpc="2.0", id="123"))
|
||||
|
||||
json_message = '{"jsonrpc": "2.0", "id": 123, "method": "ping", "params": null}'
|
||||
msg = types.JSONRPCRequest.model_validate_json(json_message)
|
||||
assert msg == snapshot(types.JSONRPCRequest(method="ping", jsonrpc="2.0", id=123))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"endpoint, expected_result",
|
||||
[
|
||||
# Valid endpoints - should normalize and work
|
||||
("/messages/", "/messages/"),
|
||||
("messages/", "/messages/"),
|
||||
("/", "/"),
|
||||
# Invalid endpoints - should raise ValueError
|
||||
("http://example.com/messages/", ValueError),
|
||||
("//example.com/messages/", ValueError),
|
||||
("ftp://example.com/messages/", ValueError),
|
||||
("/messages/?param=value", ValueError),
|
||||
("/messages/#fragment", ValueError),
|
||||
],
|
||||
)
|
||||
def test_sse_server_transport_endpoint_validation(endpoint: str, expected_result: str | type[Exception]) -> None:
|
||||
"""Test that SseServerTransport properly validates and normalizes endpoints."""
|
||||
if isinstance(expected_result, type):
|
||||
# Test invalid endpoints that should raise an exception
|
||||
with pytest.raises(expected_result, match="is not a relative path.*expecting a relative path"):
|
||||
SseServerTransport(endpoint)
|
||||
else:
|
||||
# Test valid endpoints that should normalize correctly
|
||||
sse = SseServerTransport(endpoint)
|
||||
assert sse._endpoint == expected_result
|
||||
assert sse._endpoint.startswith("/")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sse_client_handles_empty_keepalive_pings() -> None:
|
||||
"""Test that SSE client properly handles empty data lines (keep-alive pings).
|
||||
|
||||
Per the MCP spec (Streamable HTTP transport): "The server SHOULD immediately
|
||||
send an SSE event consisting of an event ID and an empty data field in order
|
||||
to prime the client to reconnect."
|
||||
|
||||
This test mocks the SSE event stream to include empty "message" events and
|
||||
verifies the client skips them without crashing.
|
||||
"""
|
||||
# Build a proper JSON-RPC response using types (not hardcoded strings)
|
||||
init_result = InitializeResult(
|
||||
protocol_version="2024-11-05",
|
||||
capabilities=ServerCapabilities(),
|
||||
server_info=Implementation(name="test", version="1.0"),
|
||||
)
|
||||
response = JSONRPCResponse(
|
||||
jsonrpc="2.0",
|
||||
id=1,
|
||||
result=init_result.model_dump(by_alias=True, exclude_none=True),
|
||||
)
|
||||
response_json = response.model_dump_json(by_alias=True, exclude_none=True)
|
||||
|
||||
# Create mock SSE events using httpx_sse's ServerSentEvent
|
||||
async def mock_aiter_sse() -> AsyncGenerator[ServerSentEvent, None]:
|
||||
# First: endpoint event
|
||||
yield ServerSentEvent(event="endpoint", data="/messages/?session_id=abc123")
|
||||
# Empty data keep-alive ping - this is what we're testing
|
||||
yield ServerSentEvent(event="message", data="")
|
||||
# Real JSON-RPC response
|
||||
yield ServerSentEvent(event="message", data=response_json)
|
||||
|
||||
mock_event_source = MagicMock()
|
||||
mock_event_source.aiter_sse.return_value = mock_aiter_sse()
|
||||
mock_event_source.response = MagicMock()
|
||||
mock_event_source.response.raise_for_status = MagicMock()
|
||||
|
||||
mock_aconnect_sse = MagicMock()
|
||||
mock_aconnect_sse.__aenter__ = AsyncMock(return_value=mock_event_source)
|
||||
mock_aconnect_sse.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_client.post = AsyncMock(return_value=MagicMock(status_code=200, raise_for_status=MagicMock()))
|
||||
|
||||
with (
|
||||
patch("mcp.client.sse.create_mcp_http_client", return_value=mock_client),
|
||||
patch("mcp.client.sse.aconnect_sse", return_value=mock_aconnect_sse),
|
||||
):
|
||||
async with sse_client("http://test/sse") as (read_stream, _):
|
||||
# Read the message - should skip the empty one and get the real response
|
||||
msg = await read_stream.receive()
|
||||
# If we get here without error, the empty message was skipped successfully
|
||||
assert not isinstance(msg, Exception)
|
||||
assert isinstance(msg.message, types.JSONRPCResponse)
|
||||
assert msg.message.id == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sse_session_cleanup_on_disconnect() -> None:
|
||||
"""Regression test for https://github.com/modelcontextprotocol/python-sdk/issues/1227
|
||||
|
||||
When a client disconnects, the server should remove the session from
|
||||
_read_stream_writers. Without this cleanup, stale sessions accumulate and
|
||||
POST requests to disconnected sessions return 202 Accepted followed by a
|
||||
ClosedResourceError when the server tries to write to the dead stream.
|
||||
"""
|
||||
factory = in_process_client_factory(make_server_app())
|
||||
captured: list[str] = []
|
||||
|
||||
# Connect a client session, then disconnect
|
||||
async with sse_client(
|
||||
f"{BASE_URL}/sse", httpx_client_factory=factory, on_session_created=captured.append
|
||||
) as streams:
|
||||
async with ClientSession(*streams) as session:
|
||||
await session.initialize()
|
||||
|
||||
# After disconnect, POST to the stale session should return 404
|
||||
# (not 202 as it did before the fix)
|
||||
async with factory() as client:
|
||||
response = await client.post(
|
||||
f"/messages/?session_id={captured[0]}",
|
||||
json={"jsonrpc": "2.0", "method": "ping", "id": 99},
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,213 @@
|
||||
"""Tests for tool name validation utilities (SEP-986)."""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp.shared.tool_name_validation import (
|
||||
issue_tool_name_warning,
|
||||
validate_and_warn_tool_name,
|
||||
validate_tool_name,
|
||||
)
|
||||
|
||||
# Tests for validate_tool_name function - valid names
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tool_name",
|
||||
[
|
||||
"getUser",
|
||||
"get_user_profile",
|
||||
"user-profile-update",
|
||||
"admin.tools.list",
|
||||
"DATA_EXPORT_v2.1",
|
||||
"a",
|
||||
"a" * 128,
|
||||
],
|
||||
ids=[
|
||||
"simple_alphanumeric",
|
||||
"with_underscores",
|
||||
"with_dashes",
|
||||
"with_dots",
|
||||
"mixed_characters",
|
||||
"single_character",
|
||||
"max_length_128",
|
||||
],
|
||||
)
|
||||
def test_validate_tool_name_accepts_valid_names(tool_name: str) -> None:
|
||||
"""Valid tool names should pass validation with no warnings."""
|
||||
result = validate_tool_name(tool_name)
|
||||
assert result.is_valid is True
|
||||
assert result.warnings == []
|
||||
|
||||
|
||||
# Tests for validate_tool_name function - invalid names
|
||||
|
||||
|
||||
def test_validate_tool_name_rejects_empty_name() -> None:
|
||||
"""Empty names should be rejected."""
|
||||
result = validate_tool_name("")
|
||||
assert result.is_valid is False
|
||||
assert "Tool name cannot be empty" in result.warnings
|
||||
|
||||
|
||||
def test_validate_tool_name_rejects_name_exceeding_max_length() -> None:
|
||||
"""Names exceeding 128 characters should be rejected."""
|
||||
result = validate_tool_name("a" * 129)
|
||||
assert result.is_valid is False
|
||||
assert any("exceeds maximum length of 128 characters (current: 129)" in w for w in result.warnings)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tool_name,expected_char",
|
||||
[
|
||||
("get user profile", "' '"),
|
||||
("get,user,profile", "','"),
|
||||
("user/profile/update", "'/'"),
|
||||
("user@domain.com", "'@'"),
|
||||
# a single trailing newline slipped past `$` with re.match
|
||||
("valid_name\n", "'\\n'"),
|
||||
("a" * 127 + "\n", "'\\n'"),
|
||||
],
|
||||
ids=[
|
||||
"with_spaces",
|
||||
"with_commas",
|
||||
"with_slashes",
|
||||
"with_at_symbol",
|
||||
"with_trailing_newline",
|
||||
"max_length_with_trailing_newline",
|
||||
],
|
||||
)
|
||||
def test_validate_tool_name_rejects_invalid_characters(tool_name: str, expected_char: str) -> None:
|
||||
"""Names with invalid characters should be rejected."""
|
||||
result = validate_tool_name(tool_name)
|
||||
assert result.is_valid is False
|
||||
assert any("invalid characters" in w and expected_char in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_validate_tool_name_rejects_multiple_invalid_chars() -> None:
|
||||
"""Names with multiple invalid chars should list all of them."""
|
||||
result = validate_tool_name("user name@domain,com")
|
||||
assert result.is_valid is False
|
||||
warning = next(w for w in result.warnings if "invalid characters" in w)
|
||||
assert "' '" in warning
|
||||
assert "'@'" in warning
|
||||
assert "','" in warning
|
||||
|
||||
|
||||
def test_validate_tool_name_rejects_unicode_characters() -> None:
|
||||
"""Names with unicode characters should be rejected."""
|
||||
result = validate_tool_name("user-\u00f1ame") # n with tilde
|
||||
assert result.is_valid is False
|
||||
|
||||
|
||||
# Tests for validate_tool_name function - warnings for problematic patterns
|
||||
|
||||
|
||||
def test_validate_tool_name_warns_on_leading_dash() -> None:
|
||||
"""Names starting with dash should generate warning but be valid."""
|
||||
result = validate_tool_name("-get-user")
|
||||
assert result.is_valid is True
|
||||
assert any("starts or ends with a dash" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_validate_tool_name_warns_on_trailing_dash() -> None:
|
||||
"""Names ending with dash should generate warning but be valid."""
|
||||
result = validate_tool_name("get-user-")
|
||||
assert result.is_valid is True
|
||||
assert any("starts or ends with a dash" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_validate_tool_name_warns_on_leading_dot() -> None:
|
||||
"""Names starting with dot should generate warning but be valid."""
|
||||
result = validate_tool_name(".get.user")
|
||||
assert result.is_valid is True
|
||||
assert any("starts or ends with a dot" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_validate_tool_name_warns_on_trailing_dot() -> None:
|
||||
"""Names ending with dot should generate warning but be valid."""
|
||||
result = validate_tool_name("get.user.")
|
||||
assert result.is_valid is True
|
||||
assert any("starts or ends with a dot" in w for w in result.warnings)
|
||||
|
||||
|
||||
# Tests for issue_tool_name_warning function
|
||||
|
||||
|
||||
def test_issue_tool_name_warning_logs_warnings(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Warnings should be logged at WARNING level."""
|
||||
warnings = ["Warning 1", "Warning 2"]
|
||||
with caplog.at_level(logging.WARNING):
|
||||
issue_tool_name_warning("test-tool", warnings)
|
||||
|
||||
assert 'Tool name validation warning for "test-tool"' in caplog.text
|
||||
assert "- Warning 1" in caplog.text
|
||||
assert "- Warning 2" in caplog.text
|
||||
assert "Tool registration will proceed" in caplog.text
|
||||
assert "SEP-986" in caplog.text
|
||||
|
||||
|
||||
def test_issue_tool_name_warning_no_logging_for_empty_warnings(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Empty warnings list should not produce any log output."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
issue_tool_name_warning("test-tool", [])
|
||||
|
||||
assert caplog.text == ""
|
||||
|
||||
|
||||
# Tests for validate_and_warn_tool_name function
|
||||
|
||||
|
||||
def test_validate_and_warn_tool_name_returns_true_for_valid_name() -> None:
|
||||
"""Valid names should return True."""
|
||||
assert validate_and_warn_tool_name("valid-tool-name") is True
|
||||
|
||||
|
||||
def test_validate_and_warn_tool_name_returns_false_for_invalid_name() -> None:
|
||||
"""Invalid names should return False."""
|
||||
assert validate_and_warn_tool_name("") is False
|
||||
assert validate_and_warn_tool_name("a" * 129) is False
|
||||
assert validate_and_warn_tool_name("invalid name") is False
|
||||
|
||||
|
||||
def test_validate_and_warn_tool_name_logs_warnings_for_invalid_name(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Invalid names should trigger warning logs."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
validate_and_warn_tool_name("invalid name")
|
||||
|
||||
assert "Tool name validation warning" in caplog.text
|
||||
|
||||
|
||||
def test_validate_and_warn_tool_name_no_warnings_for_clean_valid_name(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Clean valid names should not produce any log output."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = validate_and_warn_tool_name("clean-tool-name")
|
||||
|
||||
assert result is True
|
||||
assert caplog.text == ""
|
||||
|
||||
|
||||
# Tests for edge cases
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tool_name,is_valid,expected_warning_fragment",
|
||||
[
|
||||
("...", True, "starts or ends with a dot"),
|
||||
("---", True, "starts or ends with a dash"),
|
||||
("///", False, "invalid characters"),
|
||||
("user@name123", False, "invalid characters"),
|
||||
],
|
||||
ids=[
|
||||
"only_dots",
|
||||
"only_dashes",
|
||||
"only_slashes",
|
||||
"mixed_valid_invalid",
|
||||
],
|
||||
)
|
||||
def test_edge_cases(tool_name: str, is_valid: bool, expected_warning_fragment: str) -> None:
|
||||
"""Various edge cases should be handled correctly."""
|
||||
result = validate_tool_name(tool_name)
|
||||
assert result.is_valid is is_valid
|
||||
assert any(expected_warning_fragment in w for w in result.warnings)
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user