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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:10:27 +08:00
commit 49b9bb6724
992 changed files with 161690 additions and 0 deletions
View File
+61
View File
@@ -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"]
+140
View File
@@ -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)
+123
View File
@@ -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
+133
View File
@@ -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)
+20
View File
@@ -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()
+578
View File
@@ -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
+175
View File
@@ -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"})
+56
View File
@@ -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")
+24
View File
@@ -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
+885
View File
@@ -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
+47
View File
@@ -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"]
+159
View File
@@ -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")
+220
View File
@@ -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
+482
View File
@@ -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
+213
View File
@@ -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