This commit is contained in:
parent
3a6f2996cd
commit
837ef904f8
10 changed files with 1757 additions and 567 deletions
|
|
@ -2,17 +2,27 @@
|
|||
|
||||
These call the registered handler via the public `Server.get_request_handler`
|
||||
accessor without spinning up a `ServerRunner` or any transport, so they verify
|
||||
the handler's contract in isolation from the dispatch pipeline.
|
||||
the handler's contract in isolation from the dispatch pipeline. The exception
|
||||
is the server-identity pair: the serverInfo `_meta` stamp is applied by the
|
||||
runner (spec 2026-07-28, #3002), not the handler, so those two drive one
|
||||
request through `serve_one` to observe it.
|
||||
"""
|
||||
|
||||
import importlib.metadata
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, cast
|
||||
|
||||
import anyio
|
||||
import mcp_types as types
|
||||
import pytest
|
||||
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
|
||||
|
||||
from mcp.server import NotificationOptions, Server, ServerRequestContext
|
||||
from mcp.server.connection import Connection
|
||||
from mcp.server.runner import serve_one
|
||||
from mcp.shared.dispatcher import CallOptions
|
||||
from mcp.shared.message import MessageMetadata
|
||||
from mcp.shared.transport_context import TransportContext
|
||||
|
||||
|
||||
# `Server._handle_discover` reads only `ctx.protocol_version` (capabilities are
|
||||
|
|
@ -36,6 +46,47 @@ async def _discover(server: Server[Any], protocol_version: str = MODERN_PROTOCOL
|
|||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class _StubDispatchContext:
|
||||
"""Minimal `DispatchContext` for the `serve_one`-driven identity tests.
|
||||
|
||||
Satisfies the protocol structurally; the discover handler never touches
|
||||
the back-channel.
|
||||
"""
|
||||
|
||||
request_id: int | str | None = 1
|
||||
transport: TransportContext = field(default_factory=lambda: TransportContext(kind="direct", can_send_request=False))
|
||||
message_metadata: MessageMetadata = None
|
||||
cancel_requested: anyio.Event = field(default_factory=anyio.Event)
|
||||
can_send_request: bool = False
|
||||
|
||||
async def send_raw_request(
|
||||
self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None
|
||||
) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
async def _discover_over_runner(server: Server[Any]) -> dict[str, Any]:
|
||||
"""Serve one `server/discover` through the runner - the layer that stamps
|
||||
server identity into the result `_meta`."""
|
||||
connection = Connection.from_envelope(MODERN_PROTOCOL_VERSIONS[0], None, None)
|
||||
params: dict[str, Any] = {
|
||||
"_meta": {
|
||||
types.PROTOCOL_VERSION_META_KEY: MODERN_PROTOCOL_VERSIONS[0],
|
||||
types.CLIENT_CAPABILITIES_META_KEY: {},
|
||||
}
|
||||
}
|
||||
return await serve_one(
|
||||
server, _StubDispatchContext(), "server/discover", params, connection=connection, lifespan_state={}
|
||||
)
|
||||
|
||||
|
||||
def test_registered_by_default() -> None:
|
||||
"""SDK-defined: a bare `Server` registers a `server/discover` handler out of
|
||||
the box, typed for the base `RequestParams`."""
|
||||
|
|
@ -55,7 +106,8 @@ async def test_supported_versions_is_modern_set() -> None:
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_server_info_reflects_constructor_fields() -> None:
|
||||
"""SDK-defined: `serverInfo` is built field-for-field from the `Server`
|
||||
"""Server identity travels as the discover result's `_meta` serverInfo
|
||||
stamp (spec 2026-07-28, #3002), built field-for-field from the `Server`
|
||||
constructor arguments."""
|
||||
icons = [types.Icon(src="https://example.test/icon.png")]
|
||||
server = Server(
|
||||
|
|
@ -66,8 +118,9 @@ async def test_server_info_reflects_constructor_fields() -> None:
|
|||
website_url="https://example.test",
|
||||
icons=icons,
|
||||
)
|
||||
result = await _discover(server)
|
||||
assert result.server_info == types.Implementation(
|
||||
result = await _discover_over_runner(server)
|
||||
stamp = result["_meta"][types.SERVER_INFO_META_KEY]
|
||||
assert types.Implementation.model_validate(stamp) == types.Implementation(
|
||||
name="info-server",
|
||||
version="9.9.9",
|
||||
title="Info Server",
|
||||
|
|
@ -78,11 +131,12 @@ async def test_server_info_reflects_constructor_fields() -> None:
|
|||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_server_info_version_falls_back_to_package() -> None:
|
||||
"""SDK-defined: when no explicit version is supplied, `serverInfo.version`
|
||||
falls back to the installed `mcp` package version."""
|
||||
result = await _discover(Server("unversioned"))
|
||||
assert result.server_info.version == importlib.metadata.version("mcp")
|
||||
async def test_an_unversioned_server_reports_an_empty_version() -> None:
|
||||
"""SDK-defined: when no explicit version is supplied, the stamped
|
||||
`serverInfo` version is an empty string - the SDK never substitutes its
|
||||
own package version for the server's."""
|
||||
result = await _discover_over_runner(Server("unversioned"))
|
||||
assert result["_meta"][types.SERVER_INFO_META_KEY] == {"name": "unversioned", "version": ""}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
|
@ -143,7 +197,6 @@ async def test_overridable_via_add_request_handler() -> None:
|
|||
custom = types.DiscoverResult(
|
||||
supported_versions=list(MODERN_PROTOCOL_VERSIONS),
|
||||
capabilities=types.ServerCapabilities(),
|
||||
server_info=types.Implementation(name="custom-server", version="1.0.0"),
|
||||
instructions="overridden",
|
||||
ttl_ms=60_000,
|
||||
cache_scope="public",
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@
|
|||
|
||||
These exercise the closed set of extension contribution kinds - tools,
|
||||
resources, request methods, and the single `tools/call` interceptor - through
|
||||
the highest-level public surface (in-memory `Client`), plus the
|
||||
`compose_tool_call_interceptor` helper directly.
|
||||
the highest-level public surface (in-memory `Client`).
|
||||
"""
|
||||
|
||||
from typing import Any, Literal, cast
|
||||
from dataclasses import replace
|
||||
from typing import Any, Literal
|
||||
|
||||
import mcp_types as types
|
||||
import pytest
|
||||
|
|
@ -14,6 +14,7 @@ from inline_snapshot import snapshot
|
|||
from mcp_types import (
|
||||
METHOD_NOT_FOUND,
|
||||
MISSING_REQUIRED_CLIENT_CAPABILITY,
|
||||
SERVER_INFO_META_KEY,
|
||||
CallToolResult,
|
||||
TextContent,
|
||||
)
|
||||
|
|
@ -26,7 +27,6 @@ from mcp.server.extension import (
|
|||
MethodBinding,
|
||||
ResourceBinding,
|
||||
ToolBinding,
|
||||
compose_tool_call_interceptor,
|
||||
)
|
||||
from mcp.server.mcpserver import Context, MCPServer, require_client_extension
|
||||
from mcp.server.mcpserver.resources import TextResource
|
||||
|
|
@ -150,10 +150,17 @@ async def test_additive_extension_registers_its_tool_and_resource() -> None:
|
|||
|
||||
assert [t.name for t in tools.tools] == ["ping"]
|
||||
assert tools.tools[0].meta == _TOOL_META
|
||||
assert called == snapshot(CallToolResult(content=[TextContent(text="pong")], structured_content={"result": "pong"}))
|
||||
assert called == snapshot(
|
||||
CallToolResult(
|
||||
_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
|
||||
content=[TextContent(text="pong")],
|
||||
structured_content={"result": "pong"},
|
||||
)
|
||||
)
|
||||
assert resources == snapshot(
|
||||
types.ListResourcesResult(
|
||||
resources=[types.Resource(name="greeting", uri="ext://greeting", mime_type="text/plain")]
|
||||
_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
|
||||
resources=[types.Resource(name="greeting", uri="ext://greeting", mime_type="text/plain")],
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -195,7 +202,9 @@ async def test_extension_method_reachable_via_session_send_request() -> None:
|
|||
request = _PingRequest(params=_PingParams())
|
||||
result = await client.session.send_request(request, _PingResult)
|
||||
|
||||
assert result == snapshot(_PingResult(pong=True))
|
||||
assert result == snapshot(
|
||||
_PingResult(_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, pong=True)
|
||||
)
|
||||
|
||||
|
||||
async def test_pass_through_interceptor_leaves_tool_result_unchanged() -> None:
|
||||
|
|
@ -207,7 +216,13 @@ async def test_pass_through_interceptor_leaves_tool_result_unchanged() -> None:
|
|||
async with Client(server) as client:
|
||||
result = await client.call_tool("echo", {"value": "hi"})
|
||||
|
||||
assert result == snapshot(CallToolResult(content=[TextContent(text="hi")], structured_content={"result": "hi"}))
|
||||
assert result == snapshot(
|
||||
CallToolResult(
|
||||
_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
|
||||
content=[TextContent(text="hi")],
|
||||
structured_content={"result": "hi"},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def test_short_circuiting_interceptor_replaces_tool_result() -> None:
|
||||
|
|
@ -219,26 +234,36 @@ async def test_short_circuiting_interceptor_replaces_tool_result() -> None:
|
|||
async with Client(server) as client:
|
||||
result = await client.call_tool("echo", {"value": "hi"})
|
||||
|
||||
assert result == snapshot(CallToolResult(content=[TextContent(text="intercepted")]))
|
||||
assert result == snapshot(
|
||||
CallToolResult(
|
||||
_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
|
||||
content=[TextContent(text="intercepted")],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_plain_extension_installs_no_tool_call_interceptor() -> None:
|
||||
"""SDK-defined: an extension that does not override `intercept_tool_call` adds no
|
||||
middleware - the composed interceptor exists only when at least one extension
|
||||
overrides it."""
|
||||
baseline = len(MCPServer("test")._lowlevel_server.middleware)
|
||||
def test_plain_extension_leaves_the_tool_call_handler_bare() -> None:
|
||||
"""SDK-defined: an extension that does not override `intercept_tool_call` leaves
|
||||
`tools/call` registered as the server's own handler - the interceptor chain is
|
||||
composed only when at least one extension overrides it."""
|
||||
server = MCPServer("test", extensions=[_AdditiveExt()])
|
||||
|
||||
assert len(server._lowlevel_server.middleware) == baseline
|
||||
entry = server._lowlevel_server.get_request_handler("tools/call")
|
||||
assert entry is not None
|
||||
assert entry.handler == server._handle_call_tool
|
||||
|
||||
|
||||
def test_overriding_extension_installs_one_tool_call_interceptor() -> None:
|
||||
"""SDK-defined: an extension that overrides `intercept_tool_call` composes exactly
|
||||
one additional `tools/call` middleware."""
|
||||
def test_overriding_extension_wraps_the_tool_call_handler() -> None:
|
||||
"""SDK-defined: an extension that overrides `intercept_tool_call` re-registers
|
||||
`tools/call` with the interceptor chain wrapped around the server's own handler,
|
||||
and installs no middleware."""
|
||||
baseline = len(MCPServer("test")._lowlevel_server.middleware)
|
||||
server = MCPServer("test", extensions=[_ReplacingExt()])
|
||||
|
||||
assert len(server._lowlevel_server.middleware) == baseline + 1
|
||||
entry = server._lowlevel_server.get_request_handler("tools/call")
|
||||
assert entry is not None
|
||||
assert entry.handler != server._handle_call_tool
|
||||
assert len(server._lowlevel_server.middleware) == baseline
|
||||
|
||||
|
||||
async def test_default_interceptor_passes_through_alongside_an_overriding_one() -> None:
|
||||
|
|
@ -251,11 +276,17 @@ async def test_default_interceptor_passes_through_alongside_an_overriding_one()
|
|||
async with Client(server) as client:
|
||||
result = await client.call_tool("echo", {"value": "hi"})
|
||||
|
||||
assert result == snapshot(CallToolResult(content=[TextContent(text="hi")], structured_content={"result": "hi"}))
|
||||
assert result == snapshot(
|
||||
CallToolResult(
|
||||
_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
|
||||
content=[TextContent(text="hi")],
|
||||
structured_content={"result": "hi"},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def test_interceptors_run_in_registration_order_with_threaded_params() -> None:
|
||||
"""SDK-defined: `compose_tool_call_interceptor` nests extensions first-outermost, so
|
||||
"""SDK-defined: `compose_tool_call_handler` nests extensions first-outermost, so
|
||||
two passing-through interceptors record in registration order, each seeing the
|
||||
validated `tools/call` params (the real tool name)."""
|
||||
log: list[tuple[str, str]] = []
|
||||
|
|
@ -271,26 +302,48 @@ async def test_interceptors_run_in_registration_order_with_threaded_params() ->
|
|||
assert log == [("com.example/first", "echo"), ("com.example/second", "echo")]
|
||||
|
||||
|
||||
async def test_compose_tool_call_interceptor_passes_through_non_tools_call() -> None:
|
||||
"""SDK-defined: the composed middleware is a no-op for any method other than
|
||||
`tools/call` - it forwards to `call_next` without touching the interceptors."""
|
||||
sentinel = types.EmptyResult()
|
||||
async def test_an_interceptor_context_rewrite_does_not_change_the_tool_invocation() -> None:
|
||||
"""SDK-defined: the validated `params` argument is authoritative - an
|
||||
interceptor passing a rewritten context through `call_next` adjusts what
|
||||
the handler observes on `ctx`, not which tool call runs. Wire-level
|
||||
request rewriting belongs to `Server.middleware`, above params validation."""
|
||||
|
||||
async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult:
|
||||
return sentinel
|
||||
class _RewritingExt(Extension):
|
||||
identifier = "com.example/rewriting"
|
||||
|
||||
middleware = compose_tool_call_interceptor([_ReplacingExt()])
|
||||
ctx = ServerRequestContext(
|
||||
session=cast("Any", None),
|
||||
lifespan_context={},
|
||||
protocol_version="2026-07-28",
|
||||
method="tasks/get",
|
||||
params={"taskId": "t-1"},
|
||||
async def intercept_tool_call(
|
||||
self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
|
||||
) -> HandlerResult:
|
||||
rewritten = {**(ctx.params or {}), "arguments": {"value": "rewritten"}}
|
||||
return await call_next(replace(ctx, params=rewritten))
|
||||
|
||||
server = MCPServer("test", extensions=[_RewritingExt()])
|
||||
server.tool(name="echo")(_echo)
|
||||
|
||||
async with Client(server) as client:
|
||||
result = await client.call_tool("echo", {"value": "original"})
|
||||
|
||||
assert result == snapshot(
|
||||
CallToolResult(
|
||||
_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
|
||||
content=[TextContent(text="original")],
|
||||
structured_content={"result": "original"},
|
||||
)
|
||||
)
|
||||
|
||||
result = await middleware(ctx, call_next)
|
||||
|
||||
assert result is sentinel
|
||||
async def test_short_circuited_interceptor_result_carries_the_server_info_stamp() -> None:
|
||||
"""Spec-mandated (2026-07-28, #3002): an interceptor that answers without running
|
||||
the tool still produces a stamped result - interception happens at the handler
|
||||
layer, below the runner's outbound envelope pass."""
|
||||
server = MCPServer("test", extensions=[_ReplacingExt()])
|
||||
server.tool(name="echo", structured_output=False)(_echo)
|
||||
|
||||
async with Client(server) as client:
|
||||
result = await client.call_tool("echo", {"value": "hi"})
|
||||
|
||||
assert result.content == [TextContent(text="intercepted")]
|
||||
assert result.meta == {SERVER_INFO_META_KEY: {"name": "test", "version": ""}}
|
||||
|
||||
|
||||
def test_extension_subclass_without_prefixed_identifier_is_rejected_at_definition() -> None:
|
||||
|
|
@ -345,7 +398,9 @@ async def test_version_pinned_method_is_served_at_an_allowed_version() -> None:
|
|||
request = _VersionPinnedRequest(params=_VersionPinnedParams())
|
||||
result = await client.session.send_request(request, _VersionPinnedResult)
|
||||
|
||||
assert result == snapshot(_VersionPinnedResult(ok=True))
|
||||
assert result == snapshot(
|
||||
_VersionPinnedResult(_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, ok=True)
|
||||
)
|
||||
|
||||
|
||||
async def test_version_pinned_method_is_method_not_found_at_a_disallowed_version() -> None:
|
||||
|
|
@ -425,7 +480,13 @@ async def test_require_client_extension_passes_when_client_declared_it() -> None
|
|||
async with Client(server, extensions=[advertise(_NEEDS_EXT)]) as client:
|
||||
result = await client.call_tool("guarded", {})
|
||||
|
||||
assert result == snapshot(CallToolResult(content=[TextContent(text="ok")], structured_content={"result": "ok"}))
|
||||
assert result == snapshot(
|
||||
CallToolResult(
|
||||
_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
|
||||
content=[TextContent(text="ok")],
|
||||
structured_content={"result": "ok"},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def test_require_client_extension_raises_minus_32021_when_client_did_not_declare_it() -> None:
|
||||
|
|
|
|||
|
|
@ -998,7 +998,8 @@ class TestServerResourceTemplates:
|
|||
result = await client.read_resource("resource://bob/csv")
|
||||
assert result == snapshot(
|
||||
ReadResourceResult(
|
||||
contents=[TextResourceContents(uri="resource://bob/csv", mime_type="text/csv", text="csv for bob")]
|
||||
_meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}},
|
||||
contents=[TextResourceContents(uri="resource://bob/csv", mime_type="text/csv", text="csv for bob")],
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -1065,6 +1066,7 @@ class TestServerResourceMetadata:
|
|||
result = await client.read_resource("resource://data")
|
||||
assert result == snapshot(
|
||||
ReadResourceResult(
|
||||
_meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}},
|
||||
contents=[
|
||||
TextResourceContents(
|
||||
uri="resource://data",
|
||||
|
|
@ -1072,7 +1074,7 @@ class TestServerResourceMetadata:
|
|||
meta={"version": "1.0", "category": "config"}, # type: ignore[reportUnknownMemberType]
|
||||
text="test data",
|
||||
)
|
||||
]
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -1234,11 +1236,12 @@ class TestContextInjection:
|
|||
result = await client.read_resource("resource://nocontext/test")
|
||||
assert result == snapshot(
|
||||
ReadResourceResult(
|
||||
_meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}},
|
||||
contents=[
|
||||
TextResourceContents(
|
||||
uri="resource://nocontext/test", mime_type="text/plain", text="Resource test works"
|
||||
)
|
||||
]
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -1262,11 +1265,12 @@ class TestContextInjection:
|
|||
result = await client.read_resource("resource://custom/123")
|
||||
assert result == snapshot(
|
||||
ReadResourceResult(
|
||||
_meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}},
|
||||
contents=[
|
||||
TextResourceContents(
|
||||
uri="resource://custom/123", mime_type="text/plain", text="Resource 123 with context"
|
||||
)
|
||||
]
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -1394,6 +1398,7 @@ class TestServerPrompts:
|
|||
result = await client.list_prompts()
|
||||
assert result == snapshot(
|
||||
ListPromptsResult(
|
||||
_meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}},
|
||||
prompts=[
|
||||
Prompt(
|
||||
name="fn",
|
||||
|
|
@ -1403,7 +1408,7 @@ class TestServerPrompts:
|
|||
PromptArgument(name="optional", required=False),
|
||||
],
|
||||
)
|
||||
]
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -1419,6 +1424,7 @@ class TestServerPrompts:
|
|||
result = await client.get_prompt("fn", {"name": "World"})
|
||||
assert result == snapshot(
|
||||
GetPromptResult(
|
||||
_meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}},
|
||||
description="",
|
||||
messages=[PromptMessage(role="user", content=TextContent(text="Hello, World!"))],
|
||||
)
|
||||
|
|
@ -1449,6 +1455,7 @@ class TestServerPrompts:
|
|||
result = await client.get_prompt("fn", {"name": "World"})
|
||||
assert result == snapshot(
|
||||
GetPromptResult(
|
||||
_meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}},
|
||||
description="This is the function docstring.",
|
||||
messages=[PromptMessage(role="user", content=TextContent(text="Hello, World!"))],
|
||||
)
|
||||
|
|
@ -1471,6 +1478,7 @@ class TestServerPrompts:
|
|||
result = await client.get_prompt("fn")
|
||||
assert result == snapshot(
|
||||
GetPromptResult(
|
||||
_meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}},
|
||||
description="",
|
||||
messages=[
|
||||
PromptMessage(
|
||||
|
|
|
|||
|
|
@ -25,10 +25,12 @@ async def test_server_name_title_description_version():
|
|||
|
||||
# Start server and connect client
|
||||
async with Client(mcp) as client:
|
||||
assert client.server_info.name == "TestServer"
|
||||
assert client.server_info.title == "Test Server Title"
|
||||
assert client.server_info.description == "This is a test server description."
|
||||
assert client.server_info.version == "1.0"
|
||||
server_info = client.server_info
|
||||
assert server_info is not None
|
||||
assert server_info.name == "TestServer"
|
||||
assert server_info.title == "Test Server Title"
|
||||
assert server_info.description == "This is a test server description."
|
||||
assert server_info.version == "1.0"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
|
|
|||
|
|
@ -62,13 +62,14 @@ async def test_add_html_resource_serves_ui_resource_at_app_mime_type() -> None:
|
|||
result = await client.read_resource("ui://clock/app.html")
|
||||
assert result == snapshot(
|
||||
ReadResourceResult(
|
||||
_meta={"io.modelcontextprotocol/serverInfo": {"name": "clock", "version": ""}},
|
||||
contents=[
|
||||
TextResourceContents(
|
||||
uri="ui://clock/app.html",
|
||||
mime_type="text/html;profile=mcp-app",
|
||||
text="<title>Clock</title>",
|
||||
)
|
||||
]
|
||||
],
|
||||
)
|
||||
)
|
||||
assert isinstance(result.contents[0], TextResourceContents)
|
||||
|
|
|
|||
|
|
@ -133,19 +133,26 @@ async def test_a_dict_returning_handler_leaks_no_hint_fields_to_a_2025_session()
|
|||
assert "cache_scope" not in result.model_fields_set
|
||||
|
||||
|
||||
async def test_an_input_required_shaped_dict_is_never_stamped() -> None:
|
||||
async def test_an_input_required_shaped_dict_never_gets_cache_hints() -> None:
|
||||
"""Spec carve-out: interim `input_required` results carry no cache hints, even on a hinted method."""
|
||||
|
||||
async def read_resource(ctx: ServerRequestContext[Any], params: ReadResourceRequestParams) -> dict[str, Any]:
|
||||
return {"resultType": "input_required", "requestState": "s1"}
|
||||
|
||||
server = Server("srv", cache_hints={"resources/read": CacheHint(ttl_ms=60_000, scope="public")})
|
||||
server = Server(
|
||||
"srv",
|
||||
cache_hints={"resources/read": CacheHint(ttl_ms=60_000, scope="public")},
|
||||
)
|
||||
server.add_request_handler("resources/read", ReadResourceRequestParams, read_resource)
|
||||
async with Client(server) as client:
|
||||
result = await client.session.read_resource("res://x", allow_input_required=True)
|
||||
assert isinstance(result, InputRequiredResult)
|
||||
assert result.model_dump(by_alias=True, exclude_none=True) == snapshot(
|
||||
{"resultType": "input_required", "requestState": "s1"}
|
||||
{
|
||||
"_meta": {"io.modelcontextprotocol/serverInfo": {"name": "srv", "version": ""}},
|
||||
"resultType": "input_required",
|
||||
"requestState": "s1",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from mcp_types import (
|
|||
ElicitationCapability,
|
||||
EmptyResult,
|
||||
Implementation,
|
||||
InitializeRequestParams,
|
||||
ListRootsRequest,
|
||||
ListRootsResult,
|
||||
PingRequest,
|
||||
|
|
@ -321,8 +322,8 @@ async def test_connection_send_tool_list_changed_with_meta_includes_meta_only_pa
|
|||
# --- check_capability ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_connection_check_capability_false_when_no_client_params_recorded():
|
||||
"""SDK-defined: `check_capability` returns False when no `client_params`
|
||||
def test_connection_check_capability_false_when_no_capabilities_recorded():
|
||||
"""SDK-defined: `check_capability` returns False when no capabilities
|
||||
were recorded, regardless of which factory built the connection."""
|
||||
conn = Connection.for_loop(StubOutbound())
|
||||
assert conn.check_capability(ClientCapabilities(sampling=SamplingCapability())) is False
|
||||
|
|
@ -330,6 +331,33 @@ def test_connection_check_capability_false_when_no_client_params_recorded():
|
|||
assert Connection.from_envelope(LATEST_MODERN_VERSION, None, None).check_capability(ClientCapabilities()) is False
|
||||
|
||||
|
||||
def test_from_envelope_records_capabilities_without_client_info():
|
||||
"""Spec-mandated (spec PR #3002): the envelope requires capabilities but not
|
||||
client info, so a pair-only request still gets working capability checks -
|
||||
`client_capabilities` is recorded on its own while `client_params` stays
|
||||
`None`."""
|
||||
caps = ClientCapabilities(sampling=SamplingCapability())
|
||||
conn = Connection.from_envelope(LATEST_MODERN_VERSION, None, caps)
|
||||
assert conn.client_params is None
|
||||
assert conn.client_capabilities == caps
|
||||
assert conn.check_capability(ClientCapabilities(sampling=SamplingCapability())) is True
|
||||
|
||||
|
||||
def test_client_params_assignment_keeps_capabilities_in_lockstep():
|
||||
"""SDK-defined: recording `client_params` (the loop path's handshake
|
||||
commit) is the sync point that also records `client_capabilities`, so the
|
||||
two facts cannot drift."""
|
||||
conn = Connection.for_loop(StubOutbound())
|
||||
assert conn.client_capabilities is None
|
||||
conn.client_params = InitializeRequestParams(
|
||||
protocol_version=LATEST_HANDSHAKE_VERSION,
|
||||
capabilities=ClientCapabilities(roots=RootsCapability()),
|
||||
client_info=Implementation(name="c", version="0"),
|
||||
)
|
||||
assert conn.client_capabilities == ClientCapabilities(roots=RootsCapability())
|
||||
assert conn.check_capability(ClientCapabilities(roots=RootsCapability())) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("have", "want", "expected"),
|
||||
[
|
||||
|
|
|
|||
|
|
@ -25,9 +25,13 @@ from mcp_types import (
|
|||
LATEST_PROTOCOL_VERSION,
|
||||
METHOD_NOT_FOUND,
|
||||
PROTOCOL_VERSION_META_KEY,
|
||||
SERVER_INFO_META_KEY,
|
||||
UNSUPPORTED_PROTOCOL_VERSION,
|
||||
CallToolRequestParams,
|
||||
ClientCapabilities,
|
||||
EmptyResult,
|
||||
ErrorData,
|
||||
Icon,
|
||||
Implementation,
|
||||
InitializeRequestParams,
|
||||
JSONRPCRequest,
|
||||
|
|
@ -953,7 +957,14 @@ async def test_on_request_dispatches_custom_method_registered_via_add_request_ha
|
|||
born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None)
|
||||
async with connected_runner(server, initialized=False, connection=born_ready) as (client, _):
|
||||
result = await client.send_raw_request("myorg/echo", None)
|
||||
assert result == {"echoed": True}
|
||||
# Custom-method results served at a modern version carry the required
|
||||
# `resultType` discriminator and the serverInfo `_meta` stamp like any
|
||||
# other result (spec 2026-07-28, #3002).
|
||||
assert result == {
|
||||
"echoed": True,
|
||||
"resultType": "complete",
|
||||
"_meta": {SERVER_INFO_META_KEY: {"name": "test-server", "version": "0.0.1"}},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
|
@ -987,6 +998,188 @@ async def test_runner_custom_method_result_is_not_surface_validated(server: SrvT
|
|||
assert result == {"anything": "goes"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_modern_short_circuit_middleware_owns_its_result_envelope(server: SrvT):
|
||||
"""SDK-defined: a middleware that answers without calling `call_next` is
|
||||
trusted to return its own well-formed result, response envelope included -
|
||||
the outbound pipeline (and its serverInfo stamp) never patches it up."""
|
||||
|
||||
async def short_circuit(ctx: Ctx, call_next: Any) -> Any:
|
||||
return {"ok": True}
|
||||
|
||||
server.middleware.append(short_circuit)
|
||||
born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None)
|
||||
async with connected_runner(server, initialized=False, connection=born_ready) as (client, _):
|
||||
result = await client.send_raw_request("myorg/anything", None)
|
||||
assert result == {"ok": True}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_a_handler_authored_server_info_stamp_is_not_overwritten(server: SrvT):
|
||||
"""SDK-defined: a handler that stamps its own serverInfo `_meta` value owns
|
||||
it; the runner fills the key only when it is absent (spec 2026-07-28, #3002)."""
|
||||
|
||||
async def custom(ctx: Ctx, params: RequestParams) -> dict[str, Any]:
|
||||
return {"ok": True, "_meta": {SERVER_INFO_META_KEY: {"name": "authored", "version": "9"}}}
|
||||
|
||||
server.add_request_handler("myorg/authored", RequestParams, custom)
|
||||
born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None)
|
||||
async with connected_runner(server, initialized=False, connection=born_ready) as (client, _):
|
||||
result = await client.send_raw_request("myorg/authored", None)
|
||||
assert result["_meta"][SERVER_INFO_META_KEY] == {"name": "authored", "version": "9"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_a_non_mapping_custom_result_meta_is_left_alone(server: SrvT):
|
||||
"""SDK-defined: a custom-method handler that returns a non-mapping `_meta`
|
||||
owns that shape; the stamp neither clobbers it nor fails the request."""
|
||||
|
||||
async def custom(ctx: Ctx, params: RequestParams) -> dict[str, Any]:
|
||||
return {"_meta": "not-a-mapping"}
|
||||
|
||||
server.add_request_handler("myorg/odd-meta", RequestParams, custom)
|
||||
born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None)
|
||||
async with connected_runner(server, initialized=False, connection=born_ready) as (client, _):
|
||||
result = await client.send_raw_request("myorg/odd-meta", None)
|
||||
assert result == {"_meta": "not-a-mapping", "resultType": "complete"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stamping_never_mutates_a_handler_retained_result_dict(server: SrvT):
|
||||
"""SDK-defined: the outbound pass dumps the handler's dict to a copy
|
||||
(`_dump_result`) and the stamp writes into that copy, so a dict the handler
|
||||
retains (module-level, cached, shared) is never mutated underneath it."""
|
||||
|
||||
retained: dict[str, Any] = {"ok": True}
|
||||
|
||||
async def custom(ctx: Ctx, params: RequestParams) -> dict[str, Any]:
|
||||
return retained
|
||||
|
||||
server.add_request_handler("myorg/cached", RequestParams, custom)
|
||||
born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None)
|
||||
async with connected_runner(server, initialized=False, connection=born_ready) as (client, _):
|
||||
result = await client.send_raw_request("myorg/cached", None)
|
||||
assert result["_meta"][SERVER_INFO_META_KEY] == {"name": "test-server", "version": "0.0.1"}
|
||||
assert retained == {"ok": True}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mutating_a_stamped_response_never_corrupts_later_stamps():
|
||||
"""SDK-defined: every response gets a fresh stamp dict, nested values
|
||||
included - a caller mutating one stamped response (a middleware, or an
|
||||
application holding an in-memory result) cannot corrupt the identity
|
||||
stamped into later responses."""
|
||||
|
||||
async def custom(ctx: Ctx, params: RequestParams) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
server: SrvT = Server(name="test-server", version="0.0.1", icons=[Icon(src="https://example.com/icon.png")])
|
||||
server.add_request_handler("myorg/empty", RequestParams, custom)
|
||||
born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None)
|
||||
async with connected_runner(server, initialized=False, connection=born_ready) as (client, _):
|
||||
first = await client.send_raw_request("myorg/empty", None)
|
||||
first["_meta"][SERVER_INFO_META_KEY]["icons"][0]["src"] = "https://evil.example/pwned.png"
|
||||
second = await client.send_raw_request("myorg/empty", None)
|
||||
assert second["_meta"][SERVER_INFO_META_KEY] == {
|
||||
"name": "test-server",
|
||||
"version": "0.0.1",
|
||||
"icons": [{"src": "https://example.com/icon.png"}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_a_claimed_result_type_on_a_legacy_session_is_sieved_not_leaked(server: SrvT):
|
||||
"""SDK-defined: claimed extension shapes are 2026-era vocabulary, so on a
|
||||
handshake-era session the per-version sieve still applies - a claimed
|
||||
shape (not a valid legacy result) surfaces as INTERNAL_ERROR instead of
|
||||
leaking a shape the client cannot resolve."""
|
||||
|
||||
async def custom(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]:
|
||||
return {"resultType": "voucher", "voucherCode": "v-42"}
|
||||
|
||||
server.add_request_handler("tools/call", CallToolRequestParams, custom)
|
||||
async with connected_runner(server) as (client, _):
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await client.send_raw_request("tools/call", {"name": "issue"})
|
||||
assert exc.value.error.code == INTERNAL_ERROR
|
||||
assert exc.value.error.message == "Handler returned an invalid result"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_a_handshake_era_custom_result_gains_no_discriminator(server: SrvT):
|
||||
"""The `resultType` fill is modern-only: handshake-era results keep the
|
||||
handler's exact shape (the field does not exist pre-2026)."""
|
||||
|
||||
async def echo(ctx: Ctx, params: RequestParams) -> dict[str, Any]:
|
||||
return {"echoed": True}
|
||||
|
||||
server.add_request_handler("myorg/echo", RequestParams, echo)
|
||||
async with connected_runner(server) as (client, _):
|
||||
result = await client.send_raw_request("myorg/echo", None)
|
||||
assert result == {"echoed": True}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_an_explicit_null_result_type_is_filled_on_the_modern_path(server: SrvT):
|
||||
"""A handler-authored `"resultType": null` reads as absent (null is not a
|
||||
valid discriminator) and is filled, mirroring the null posture of the
|
||||
identity stamp."""
|
||||
|
||||
async def custom(ctx: Ctx, params: RequestParams) -> dict[str, Any]:
|
||||
return {"echoed": True, "resultType": None}
|
||||
|
||||
server.add_request_handler("myorg/echo", RequestParams, custom)
|
||||
born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None)
|
||||
async with connected_runner(server, initialized=False, connection=born_ready) as (client, _):
|
||||
result = await client.send_raw_request("myorg/echo", None)
|
||||
assert result == {
|
||||
"echoed": True,
|
||||
"resultType": "complete",
|
||||
"_meta": {SERVER_INFO_META_KEY: {"name": "test-server", "version": "0.0.1"}},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_a_claimed_extension_result_type_bypasses_the_sieve_and_is_stamped(server: SrvT):
|
||||
"""SDK-defined: a spec-method result carrying an extension `resultType` is a
|
||||
claimed shape the extension owns - the per-version sieve would strip its
|
||||
vendor fields, so it applies to core-vocabulary results only. The identity
|
||||
stamp still lands: claimed shapes are results like any other."""
|
||||
|
||||
async def custom(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]:
|
||||
return {"resultType": "voucher", "voucherCode": "v-42"}
|
||||
|
||||
server.add_request_handler("tools/call", CallToolRequestParams, custom)
|
||||
born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None)
|
||||
async with connected_runner(server, initialized=False, connection=born_ready) as (client, _):
|
||||
result = await client.send_raw_request("tools/call", _modern_params(name="issue"))
|
||||
assert result == {
|
||||
"resultType": "voucher",
|
||||
"voucherCode": "v-42",
|
||||
"_meta": {SERVER_INFO_META_KEY: {"name": "test-server", "version": "0.0.1"}},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_an_empty_result_on_the_modern_path_carries_the_discriminator_and_stamp(server: SrvT):
|
||||
"""Spec-mandated (2026-07-28): `resultType` is required on every result a
|
||||
modern server sends (the absent-means-complete bridge is for clients of
|
||||
older servers only), and serverInfo is stamped into every result too - so
|
||||
a result that dumps as `{}` goes on the modern wire with both."""
|
||||
|
||||
async def custom(ctx: Ctx, params: RequestParams) -> EmptyResult:
|
||||
return EmptyResult()
|
||||
|
||||
server.add_request_handler("myorg/empty", RequestParams, custom)
|
||||
born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None)
|
||||
async with connected_runner(server, initialized=False, connection=born_ready) as (client, _):
|
||||
result = await client.send_raw_request("myorg/empty", None)
|
||||
assert result == {
|
||||
"resultType": "complete",
|
||||
"_meta": {SERVER_INFO_META_KEY: {"name": "test-server", "version": "0.0.1"}},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runner_initialize_result_reflects_init_options():
|
||||
async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToolsResult:
|
||||
|
|
@ -1447,6 +1640,44 @@ async def test_dual_era_loop_malformed_envelope_content_never_locks(server: SrvT
|
|||
assert exc_info.value.error.code != UNSUPPORTED_PROTOCOL_VERSION
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dual_era_loop_pair_only_envelope_serves_modern_and_locks(server: SrvT):
|
||||
"""Spec-mandated (spec PR #3002): the required envelope pair (protocol version
|
||||
+ client capabilities) without the optional clientInfo is a complete
|
||||
modern request - it is served, records the declared capabilities without
|
||||
client params, locks the era modern, and a later legacy `initialize` is
|
||||
rejected with -32022."""
|
||||
params = _modern_params()
|
||||
del params["_meta"][CLIENT_INFO_META_KEY]
|
||||
async with dual_era_client(server) as (client, _):
|
||||
result = await client.send_raw_request("tools/list", params)
|
||||
assert result["tools"][0]["name"] == "t"
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.send_raw_request("initialize", _initialize_params())
|
||||
assert exc_info.value.error.code == UNSUPPORTED_PROTOCOL_VERSION
|
||||
ctx = _seen_ctx[-1]
|
||||
assert ctx.session.client_params is None
|
||||
assert ctx.session.client_capabilities == ClientCapabilities()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dual_era_loop_version_without_capabilities_rejects_naming_the_key_and_never_locks(server: SrvT):
|
||||
"""A `_meta` declaring the protocol version but missing the required
|
||||
client-capabilities key routes modern - never the legacy path with its
|
||||
generic 'Invalid request parameters' - and is rejected INVALID_PARAMS
|
||||
naming the missing key; like every failed classification it locks no era,
|
||||
so the legacy handshake stays available."""
|
||||
params = _modern_params()
|
||||
del params["_meta"][CLIENT_CAPABILITIES_META_KEY]
|
||||
async with dual_era_client(server) as (client, _):
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.send_raw_request("tools/list", params)
|
||||
init = await client.send_raw_request("initialize", _initialize_params())
|
||||
assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION
|
||||
assert exc_info.value.error.code == INVALID_PARAMS
|
||||
assert CLIENT_CAPABILITIES_META_KEY in exc_info.value.error.message
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dual_era_loop_failed_modern_request_never_locks(server: SrvT):
|
||||
"""A well-formed modern request for an unknown method fails without
|
||||
|
|
@ -1688,17 +1919,27 @@ async def test_dual_era_loop_custom_method_with_mis_shaped_envelope_values_still
|
|||
params["_meta"][CLIENT_INFO_META_KEY] = "not-an-object"
|
||||
async with dual_era_client(greeter) as (client, _):
|
||||
result = await client.send_raw_request("custom/greet", params)
|
||||
assert result == {"ok": True}
|
||||
assert result == {
|
||||
"ok": True,
|
||||
"resultType": "complete",
|
||||
"_meta": {SERVER_INFO_META_KEY: {"name": "greeter-server", "version": "0.0.1"}},
|
||||
}
|
||||
assert seen == [None]
|
||||
|
||||
|
||||
def test_has_modern_envelope_requires_the_full_key_triple():
|
||||
def test_has_modern_envelope_keys_on_the_protocol_version_key():
|
||||
"""Era evidence is the reserved protocol-version `_meta` key: legacy traffic
|
||||
never mints it (bare `_meta` / `progressToken` is not evidence), and a
|
||||
half-built envelope still routes modern so the classifier - not the legacy
|
||||
path - names its missing required key."""
|
||||
assert not _has_modern_envelope(None)
|
||||
assert not _has_modern_envelope({})
|
||||
assert not _has_modern_envelope({"_meta": None})
|
||||
assert not _has_modern_envelope({"_meta": {"progressToken": 1}})
|
||||
partial_meta = {k: v for k, v in _modern_envelope().items() if k != CLIENT_CAPABILITIES_META_KEY}
|
||||
assert not _has_modern_envelope({"_meta": partial_meta})
|
||||
version_only = {PROTOCOL_VERSION_META_KEY: LATEST_MODERN_VERSION}
|
||||
assert _has_modern_envelope({"_meta": version_only})
|
||||
pair_only = {k: v for k, v in _modern_envelope().items() if k != CLIENT_INFO_META_KEY}
|
||||
assert _has_modern_envelope({"_meta": pair_only})
|
||||
assert _has_modern_envelope(_modern_params())
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from mcp_types import (
|
|||
CLIENT_CAPABILITIES_META_KEY,
|
||||
CLIENT_INFO_META_KEY,
|
||||
PROTOCOL_VERSION_META_KEY,
|
||||
SERVER_INFO_META_KEY,
|
||||
JSONRPCMessage,
|
||||
JSONRPCRequest,
|
||||
JSONRPCResponse,
|
||||
|
|
@ -264,11 +265,14 @@ def test_mcpserver_run_stdio_serves_a_modern_connection(monkeypatch: pytest.Monk
|
|||
discover = JSONRPCRequest(jsonrpc="2.0", id=1, method="server/discover", params={"_meta": envelope})
|
||||
tools = JSONRPCRequest(jsonrpc="2.0", id=2, method="tools/list", params={"_meta": envelope})
|
||||
|
||||
responses = _serve_stdio_and_collect(monkeypatch, MCPServer(name="ModernStdioServer"), [discover, tools], 2)
|
||||
server = MCPServer(name="ModernStdioServer", version="1.2.3")
|
||||
responses = _serve_stdio_and_collect(monkeypatch, server, [discover, tools], 2)
|
||||
|
||||
assert isinstance(responses[0], JSONRPCResponse) and responses[0].id == 1
|
||||
assert "2026-07-28" in responses[0].result["supportedVersions"]
|
||||
assert responses[0].result["serverInfo"]["name"] == "ModernStdioServer"
|
||||
# Server identity travels as the result `_meta` stamp, not a DiscoverResult
|
||||
# body field (spec 2026-07-28, #3002).
|
||||
assert responses[0].result["_meta"][SERVER_INFO_META_KEY] == {"name": "ModernStdioServer", "version": "1.2.3"}
|
||||
assert isinstance(responses[1], JSONRPCResponse) and responses[1].id == 2
|
||||
# `resultType` is the modern-only wire field: its presence proves the
|
||||
# request was served at the discovered version, not the handshake era.
|
||||
|
|
|
|||
|
|
@ -24,8 +24,10 @@ from mcp_types import (
|
|||
METHOD_NOT_FOUND,
|
||||
PARSE_ERROR,
|
||||
PROTOCOL_VERSION_META_KEY,
|
||||
SERVER_INFO_META_KEY,
|
||||
CallToolRequestParams,
|
||||
CallToolResult,
|
||||
ClientCapabilities,
|
||||
ErrorData,
|
||||
JSONRPCError,
|
||||
JSONRPCResponse,
|
||||
|
|
@ -158,6 +160,40 @@ def _list_tools_body() -> dict[str, Any]:
|
|||
return {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {"_meta": meta}}
|
||||
|
||||
|
||||
async def test_handle_modern_request_serves_pair_only_envelope_without_client_info() -> None:
|
||||
"""Spec-mandated (spec PR #3002): `clientInfo` is optional - a request whose
|
||||
`_meta` carries only the protocol-version + client-capabilities pair is
|
||||
served, with the declared capabilities recorded and `client_params` None."""
|
||||
seen: list[tuple[object, object]] = []
|
||||
|
||||
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
|
||||
seen.append((ctx.session.client_params, ctx.session.client_capabilities))
|
||||
return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public")
|
||||
|
||||
server: Server[Any] = Server("test", on_list_tools=list_tools)
|
||||
body = _list_tools_body()
|
||||
del body["params"]["_meta"][CLIENT_INFO_META_KEY]
|
||||
async with _asgi_client(server) as http:
|
||||
response = await http.post("/mcp", json=body, headers={MCP_METHOD_HEADER: "tools/list"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["result"]["tools"] == []
|
||||
assert seen == [(None, ClientCapabilities())]
|
||||
|
||||
|
||||
async def test_handle_modern_request_missing_capabilities_rejects_naming_the_key() -> None:
|
||||
"""Spec-mandated (basic/index.mdx): the protocol version without the
|
||||
required client-capabilities key is malformed - INVALID_PARAMS (HTTP 400)
|
||||
with a message naming the missing key."""
|
||||
body = _list_tools_body()
|
||||
del body["params"]["_meta"][CLIENT_CAPABILITIES_META_KEY]
|
||||
async with _asgi_client(Server("test")) as http:
|
||||
response = await http.post("/mcp", json=body, headers={MCP_METHOD_HEADER: "tools/list"})
|
||||
assert response.status_code == 400
|
||||
error = response.json()["error"]
|
||||
assert error["code"] == INVALID_PARAMS
|
||||
assert CLIENT_CAPABILITIES_META_KEY in error["message"]
|
||||
|
||||
|
||||
async def test_handle_modern_request_routes_with_mis_shaped_envelope_client_info() -> None:
|
||||
"""SDK-defined: a mis-shaped ``clientInfo`` envelope value is treated as not supplied —
|
||||
the request still routes (200 + result) and the handler observes ``client_params is None``
|
||||
|
|
@ -178,7 +214,12 @@ async def test_handle_modern_request_routes_with_mis_shaped_envelope_client_info
|
|||
async with _asgi_client(server) as http:
|
||||
response = await http.post("/mcp", json=body, headers={MCP_METHOD_HEADER: "custom/greet"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["result"] == {"ok": True}
|
||||
result = response.json()["result"]
|
||||
assert result == {
|
||||
"_meta": {SERVER_INFO_META_KEY: {"name": "test", "version": ""}},
|
||||
"ok": True,
|
||||
"resultType": "complete",
|
||||
}
|
||||
assert seen == [None]
|
||||
|
||||
|
||||
|
|
@ -709,6 +750,20 @@ async def test_modern_tools_call_accepts_matching_mcp_param_header() -> None:
|
|||
assert response.json()["result"]["content"] == []
|
||||
|
||||
|
||||
async def test_modern_tools_call_validates_mcp_param_headers_for_a_pair_only_envelope() -> None:
|
||||
"""The schema-resolving `tools/list` walk builds its synthetic envelope from the caller's:
|
||||
a pair-only caller (spec PR #3002, no clientInfo) omits the optional key rather than sending
|
||||
null, so header validation still runs and a mismatched header is still rejected."""
|
||||
body = _tool_call_body({"region": "east"})
|
||||
del body["params"]["_meta"][CLIENT_INFO_META_KEY]
|
||||
async with _asgi_client(_x_mcp_server()) as http:
|
||||
matched = await http.post("/mcp", json=body, headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "east"})
|
||||
mismatched = await http.post("/mcp", json=body, headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "west"})
|
||||
assert matched.status_code == 200
|
||||
assert mismatched.status_code == 400
|
||||
assert mismatched.json()["error"]["code"] == HEADER_MISMATCH
|
||||
|
||||
|
||||
@pytest.mark.parametrize("json_response", [True, False])
|
||||
async def test_modern_tools_call_rejects_mcp_param_mismatch_with_400_and_header_mismatch(
|
||||
json_response: bool,
|
||||
|
|
@ -1063,7 +1118,7 @@ async def test_json_response_mode_still_streams_subscriptions_listen() -> None:
|
|||
the SSE path, acks first, and ends with the stamped result on close()."""
|
||||
bus = _OpenSignalBus()
|
||||
handler = ListenHandler(bus)
|
||||
server = Server("test", on_subscriptions_listen=handler)
|
||||
server = Server("test", version="1.2.3", on_subscriptions_listen=handler)
|
||||
body = _listen_body()
|
||||
|
||||
responses: list[httpx2.Response] = []
|
||||
|
|
@ -1086,4 +1141,9 @@ async def test_json_response_mode_still_streams_subscriptions_listen() -> None:
|
|||
events = _sse_payloads(response.text)
|
||||
assert events[0]["method"] == "notifications/subscriptions/acknowledged"
|
||||
assert events[1]["id"] == 9
|
||||
assert events[1]["result"]["_meta"] == {"io.modelcontextprotocol/subscriptionId": 9}
|
||||
# The terminal listen result is a modern-era result like any other, so it
|
||||
# carries the serverInfo stamp alongside the subscription id.
|
||||
assert events[1]["result"]["_meta"] == {
|
||||
"io.modelcontextprotocol/subscriptionId": 9,
|
||||
SERVER_INFO_META_KEY: {"name": "test", "version": "1.2.3"},
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue