Align with spec #3002: optional clientInfo, serverInfo in result _meta (#3143)

This commit is contained in:
Max 2026-07-23 12:00:36 +01:00 • committed by GitHub
commit 837ef904f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 1757 additions and 567 deletions

View file

@ -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:

View file

@ -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(

View file

@ -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