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
5 changed files with 1757 additions and 567 deletions

View file

@ -20,6 +20,7 @@ from mcp import MCPError
from mcp.client import ClientRequestContext
from mcp.server.elicitation import AcceptedElicitation
from mcp.server.mcpserver import Context, MCPServer
from tests._stamp import Unstamp
from tests.interaction._connect import Connect
from tests.interaction._helpers import IncomingMessage
from tests.interaction._requirements import requirement
@ -68,7 +69,7 @@ async def test_context_logging_helpers_send_log_notifications(connect: Connect)
@requirement("mcpserver:context:progress")
async def test_context_report_progress_sends_progress_notifications(connect: Connect) -> None:
async def test_context_report_progress_sends_progress_notifications(connect: Connect, unstamped: Unstamp) -> None:
"""Context.report_progress sends progress notifications correlated to the calling request.
The caller's progress callback receives each report, in order, before the tool call returns.
@ -88,7 +89,7 @@ async def test_context_report_progress_sends_progress_notifications(connect: Con
async with connect(mcp) as client:
result = await client.call_tool("crunch", {}, progress_callback=on_progress)
assert result == snapshot(
assert unstamped(result) == snapshot(
CallToolResult(content=[TextContent(text="crunched")], structured_content={"result": "crunched"})
)
assert received == snapshot([(1.0, 3.0, None), (2.0, 3.0, "halfway there")])
@ -123,7 +124,7 @@ async def test_context_exposes_request_id_and_client_info_to_a_tool(connect: Con
@requirement("mcpserver:context:logging")
@requirement("protocol:progress:no-token")
async def test_report_progress_without_a_progress_token_sends_nothing(connect: Connect) -> None:
async def test_report_progress_without_a_progress_token_sends_nothing(connect: Connect, unstamped: Unstamp) -> None:
"""When the caller supplied no progress callback, Context.report_progress is a silent no-op.
The tool also emits one log message as a sentinel: the message handler receives only that,
@ -145,7 +146,7 @@ async def test_report_progress_without_a_progress_token_sends_nothing(connect: C
async with connect(mcp, message_handler=collect) as client:
result = await client.call_tool("mill", {})
assert result == snapshot(
assert unstamped(result) == snapshot(
CallToolResult(content=[TextContent(text="milled")], structured_content={"result": "milled"})
)
assert received == snapshot(
@ -207,7 +208,7 @@ async def test_context_elicit_returns_typed_result(connect: Connect) -> None:
@requirement("mcpserver:context:read-resource")
async def test_context_read_resource_reads_registered_resource(connect: Connect) -> None:
async def test_context_read_resource_reads_registered_resource(connect: Connect, unstamped: Unstamp) -> None:
"""Context.read_resource lets a tool read a resource registered on the same server.
The tool reports the MIME type and content it read, proving the resource function ran and its
@ -228,7 +229,7 @@ async def test_context_read_resource_reads_registered_resource(connect: Connect)
async with connect(mcp) as client:
result = await client.call_tool("show_config", {})
assert result == snapshot(
assert unstamped(result) == snapshot(
CallToolResult(
content=[TextContent(text="text/plain: 'theme = dark'")],
structured_content={"result": "text/plain: 'theme = dark'"},

View file

@ -15,6 +15,7 @@ from mcp.client import ClaimContext, ClientExtension, ResultClaim, advertise
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
from mcp.server.extension import Extension
from mcp.server.mcpserver import Context, MCPServer, require_client_extension
from tests._stamp import Unstamp
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
@ -79,7 +80,9 @@ def _receipt_shop(issuer: Extension) -> MCPServer:
@requirement("extensions:client:claimed-result-resolved")
async def test_claimed_result_is_finished_by_the_owning_extensions_resolver(connect: Connect) -> None:
async def test_claimed_result_is_finished_by_the_owning_extensions_resolver(
connect: Connect, unstamped: Unstamp
) -> None:
"""The owning extension's claim resolver redeems the substituted `receipt` through
`ctx.session`, and `call_tool` returns the resolver's plain `CallToolResult`."""
received: list[ReceiptResult] = []
@ -92,7 +95,7 @@ async def test_claimed_result_is_finished_by_the_owning_extensions_resolver(conn
result = await client.call_tool("buy", {"item": "lamp"})
assert [claimed.receipt_token for claimed in received] == ["r-117"]
assert result == snapshot(
assert unstamped(result) == snapshot(
CallToolResult(content=[TextContent(text="goods for r-117")], structured_content={"result": "goods for r-117"})
)

View file

@ -14,6 +14,7 @@ from mcp_types import (
from mcp import MCPError
from mcp.server.mcpserver import MCPServer
from tests._stamp import Unstamp
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
@ -21,7 +22,7 @@ pytestmark = pytest.mark.anyio
@requirement("mcpserver:prompt:decorated")
async def test_list_prompts_derives_arguments_from_signature(connect: Connect) -> None:
async def test_list_prompts_derives_arguments_from_signature(connect: Connect, unstamped: Unstamp) -> None:
"""A decorated prompt is listed with arguments derived from the function signature.
Parameters without a default are required; the description comes from the docstring.
@ -36,7 +37,7 @@ async def test_list_prompts_derives_arguments_from_signature(connect: Connect) -
async with connect(mcp) as client:
result = await client.list_prompts()
assert result == snapshot(
assert unstamped(result) == snapshot(
ListPromptsResult(
prompts=[
Prompt(
@ -53,7 +54,7 @@ async def test_list_prompts_derives_arguments_from_signature(connect: Connect) -
@requirement("mcpserver:prompt:decorated")
async def test_get_prompt_renders_function_return(connect: Connect) -> None:
async def test_get_prompt_renders_function_return(connect: Connect, unstamped: Unstamp) -> None:
"""The decorated function's string return value is rendered as a single user message."""
mcp = MCPServer("prompter")
@ -65,7 +66,7 @@ async def test_get_prompt_renders_function_return(connect: Connect) -> None:
async with connect(mcp) as client:
result = await client.get_prompt("greet", {"name": "Ada"})
assert result == snapshot(
assert unstamped(result) == snapshot(
GetPromptResult(
description="A personalised greeting.",
messages=[PromptMessage(role="user", content=TextContent(text="Say hello to Ada."))],
@ -141,7 +142,9 @@ async def test_get_prompt_with_a_wrong_type_argument_is_rejected_before_the_func
@requirement("mcpserver:prompt:optional-args")
async def test_get_prompt_with_an_optional_argument_omitted_uses_the_default(connect: Connect) -> None:
async def test_get_prompt_with_an_optional_argument_omitted_uses_the_default(
connect: Connect, unstamped: Unstamp
) -> None:
"""A prompt rendered without one of its optional arguments uses that parameter's default value."""
mcp = MCPServer("prompter")
@ -153,7 +156,7 @@ async def test_get_prompt_with_an_optional_argument_omitted_uses_the_default(con
async with connect(mcp) as client:
result = await client.get_prompt("review", {"code": "x = 1"})
assert result == snapshot(
assert unstamped(result) == snapshot(
GetPromptResult(
description="Review a snippet of code against a style guide.",
messages=[PromptMessage(role="user", content=TextContent(text="Review x = 1 per pep8."))],
@ -162,7 +165,9 @@ async def test_get_prompt_with_an_optional_argument_omitted_uses_the_default(con
@requirement("mcpserver:prompt:duplicate-name")
async def test_registering_a_duplicate_prompt_name_warns_and_keeps_the_first(connect: Connect) -> None:
async def test_registering_a_duplicate_prompt_name_warns_and_keeps_the_first(
connect: Connect, unstamped: Unstamp
) -> None:
"""Registering a second prompt with an already-used name keeps the first registration.
The intended behaviour is rejection at registration time; MCPServer instead logs a warning
@ -187,7 +192,7 @@ async def test_registering_a_duplicate_prompt_name_warns_and_keeps_the_first(con
result = await client.get_prompt("greet")
assert [prompt.name for prompt in listed.prompts] == ["greet"]
assert result == snapshot(
assert unstamped(result) == snapshot(
GetPromptResult(
description="The first registration; this is the one that wins.",
messages=[PromptMessage(role="user", content=TextContent(text="first"))],

View file

@ -14,6 +14,7 @@ from mcp_types import (
from mcp import MCPError
from mcp.server.mcpserver import MCPServer
from tests._stamp import Unstamp
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
@ -21,7 +22,7 @@ pytestmark = pytest.mark.anyio
@requirement("mcpserver:resource:static")
async def test_read_static_resource(connect: Connect) -> None:
async def test_read_static_resource(connect: Connect, unstamped: Unstamp) -> None:
"""A function registered for a fixed URI is served at that URI with its return value as text."""
mcp = MCPServer("library")
@ -33,7 +34,7 @@ async def test_read_static_resource(connect: Connect) -> None:
async with connect(mcp) as client:
result = await client.read_resource("config://app")
assert result == snapshot(
assert unstamped(result) == snapshot(
ReadResourceResult(
contents=[TextResourceContents(uri="config://app", mime_type="text/plain", text="theme = dark")]
)
@ -41,7 +42,7 @@ async def test_read_static_resource(connect: Connect) -> None:
@requirement("mcpserver:resource:static")
async def test_list_static_and_templated_resources(connect: Connect) -> None:
async def test_list_static_and_templated_resources(connect: Connect, unstamped: Unstamp) -> None:
"""Statically-registered resources appear in resources/list; templated ones only in templates/list.
The name and description are derived from the function name and docstring; the MIME type
@ -63,7 +64,7 @@ async def test_list_static_and_templated_resources(connect: Connect) -> None:
resources = await client.list_resources()
templates = await client.list_resource_templates()
assert resources == snapshot(
assert unstamped(resources) == snapshot(
ListResourcesResult(
resources=[
Resource(
@ -75,7 +76,7 @@ async def test_list_static_and_templated_resources(connect: Connect) -> None:
]
)
)
assert templates == snapshot(
assert unstamped(templates) == snapshot(
ListResourceTemplatesResult(
resource_templates=[
ResourceTemplate(
@ -91,7 +92,7 @@ async def test_list_static_and_templated_resources(connect: Connect) -> None:
@requirement("mcpserver:resource:template")
@requirement("resources:read:template-vars")
async def test_read_templated_resource(connect: Connect) -> None:
async def test_read_templated_resource(connect: Connect, unstamped: Unstamp) -> None:
"""Reading a URI that matches a registered template invokes the function with the extracted parameters."""
mcp = MCPServer("library")
@ -103,7 +104,7 @@ async def test_read_templated_resource(connect: Connect) -> None:
async with connect(mcp) as client:
result = await client.read_resource("users://42/profile")
assert result == snapshot(
assert unstamped(result) == snapshot(
ReadResourceResult(
contents=[TextResourceContents(uri="users://42/profile", mime_type="text/plain", text="profile for 42")]
)
@ -152,7 +153,9 @@ async def test_resource_function_that_raises_is_surfaced_as_a_jsonrpc_error(conn
@requirement("mcpserver:resource:duplicate-name")
async def test_registering_a_duplicate_resource_uri_warns_and_keeps_the_first(connect: Connect) -> None:
async def test_registering_a_duplicate_resource_uri_warns_and_keeps_the_first(
connect: Connect, unstamped: Unstamp
) -> None:
"""Registering a second static resource at an already-used URI keeps the first registration.
The intended behaviour is rejection at registration time; MCPServer instead logs a warning
@ -178,6 +181,6 @@ async def test_registering_a_duplicate_resource_uri_warns_and_keeps_the_first(co
assert [resource.uri for resource in listed.resources] == ["config://app"]
assert listed.resources[0].name == "config_first"
assert result == snapshot(
assert unstamped(result) == snapshot(
ReadResourceResult(contents=[TextResourceContents(uri="config://app", mime_type="text/plain", text="first")])
)

View file

@ -20,6 +20,7 @@ from mcp import MCPError
from mcp.server.mcpserver import Context, MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.shared.exceptions import UrlElicitationRequiredError
from tests._stamp import Unstamp
from tests.interaction._connect import Connect
from tests.interaction._helpers import IncomingMessage
from tests.interaction._requirements import requirement
@ -28,7 +29,7 @@ pytestmark = pytest.mark.anyio
@requirement("tools:call:content:text")
async def test_call_tool_returns_text_content(connect: Connect) -> None:
async def test_call_tool_returns_text_content(connect: Connect, unstamped: Unstamp) -> None:
"""Arguments reach the tool function; its return value comes back as text content.
MCPServer also derives an output schema from the return annotation and attaches the
@ -43,11 +44,15 @@ async def test_call_tool_returns_text_content(connect: Connect) -> None:
async with connect(mcp) as client:
result = await client.call_tool("add", {"a": 2, "b": 3})
assert result == snapshot(CallToolResult(content=[TextContent(text="5")], structured_content={"result": "5"}))
assert unstamped(result) == snapshot(
CallToolResult(content=[TextContent(text="5")], structured_content={"result": "5"})
)
@requirement("mcpserver:tool:schema-variants")
async def test_complex_parameter_types_are_validated_and_coerced_before_the_tool_runs(connect: Connect) -> None:
async def test_complex_parameter_types_are_validated_and_coerced_before_the_tool_runs(
connect: Connect, unstamped: Unstamp
) -> None:
"""Literal, nested-model, and constrained parameters are validated and coerced from the wire arguments.
The string "3" is coerced to `int` and the `point` dict to a `Point` instance before the function
@ -67,7 +72,7 @@ async def test_complex_parameter_types_are_validated_and_coerced_before_the_tool
async with connect(mcp) as client:
result = await client.call_tool("place", {"mode": "fast", "point": {"x": "3", "y": 4}, "count": 5})
assert result == snapshot(
assert unstamped(result) == snapshot(
CallToolResult(
content=[TextContent(text="fast at (3, 4) x5")], structured_content={"result": "fast at (3, 4) x5"}
)
@ -76,7 +81,7 @@ async def test_complex_parameter_types_are_validated_and_coerced_before_the_tool
@requirement("mcpserver:tool:handler-throws")
@requirement("mcpserver:output-schema:skip-on-error")
async def test_call_tool_function_exception_becomes_error_result(connect: Connect) -> None:
async def test_call_tool_function_exception_becomes_error_result(connect: Connect, unstamped: Unstamp) -> None:
"""An exception raised by a tool function is returned as an is_error result, not a JSON-RPC error.
The function's `-> str` annotation gives the tool a derived output schema, but the error
@ -92,13 +97,13 @@ async def test_call_tool_function_exception_becomes_error_result(connect: Connec
async with connect(mcp) as client:
result = await client.call_tool("explode", {})
assert result == snapshot(
assert unstamped(result) == snapshot(
CallToolResult(content=[TextContent(text="Error executing tool explode: boom")], is_error=True)
)
@requirement("mcpserver:tool:handler-throws")
async def test_call_tool_tool_error_becomes_error_result(connect: Connect) -> None:
async def test_call_tool_tool_error_becomes_error_result(connect: Connect, unstamped: Unstamp) -> None:
"""A ToolError raised by a tool function is returned as an is_error result, not a JSON-RPC error."""
mcp = MCPServer("errors")
@ -109,13 +114,13 @@ async def test_call_tool_tool_error_becomes_error_result(connect: Connect) -> No
async with connect(mcp) as client:
result = await client.call_tool("flux", {})
assert result == snapshot(
assert unstamped(result) == snapshot(
CallToolResult(content=[TextContent(text="Error executing tool flux: flux capacitor offline")], is_error=True)
)
@requirement("mcpserver:tool:unknown-name")
async def test_call_tool_unknown_name_returns_error_result(connect: Connect) -> None:
async def test_call_tool_unknown_name_returns_error_result(connect: Connect, unstamped: Unstamp) -> None:
"""Calling a tool name that was never registered is reported as an is_error result.
The spec classifies unknown tools as a protocol error; see the divergence note on the
@ -130,12 +135,14 @@ async def test_call_tool_unknown_name_returns_error_result(connect: Connect) ->
async with connect(mcp) as client:
result = await client.call_tool("nope", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="Unknown tool: nope")], is_error=True))
assert unstamped(result) == snapshot(
CallToolResult(content=[TextContent(text="Unknown tool: nope")], is_error=True)
)
@requirement("mcpserver:tool:output-schema:model")
@requirement("tools:call:structured-content:text-mirror")
async def test_call_tool_model_return_becomes_structured_content(connect: Connect) -> None:
async def test_call_tool_model_return_becomes_structured_content(connect: Connect, unstamped: Unstamp) -> None:
"""A tool returning a pydantic model advertises the model's schema as the tool's output schema
and returns the model's fields as structured content alongside a serialised text block.
"""
@ -164,7 +171,7 @@ async def test_call_tool_model_return_becomes_structured_content(connect: Connec
"type": "object",
}
)
assert result == snapshot(
assert unstamped(result) == snapshot(
CallToolResult(
content=[
TextContent(
@ -182,7 +189,7 @@ async def test_call_tool_model_return_becomes_structured_content(connect: Connec
@requirement("mcpserver:tool:output-schema:wrapped")
async def test_call_tool_list_return_is_wrapped_in_result_key(connect: Connect) -> None:
async def test_call_tool_list_return_is_wrapped_in_result_key(connect: Connect, unstamped: Unstamp) -> None:
"""A tool returning a list wraps the value under a "result" key in both the generated output
schema and the structured content.
"""
@ -204,7 +211,7 @@ async def test_call_tool_list_return_is_wrapped_in_result_key(connect: Connect)
"type": "object",
}
)
assert result == snapshot(
assert unstamped(result) == snapshot(
CallToolResult(
content=[TextContent(text="2"), TextContent(text="3"), TextContent(text="5")],
structured_content={"result": [2, 3, 5]},
@ -279,7 +286,9 @@ async def test_tool_with_output_schema_returning_mismatched_structured_content_i
@requirement("mcpserver:tool:duplicate-name")
async def test_registering_a_duplicate_tool_name_warns_and_keeps_the_first(connect: Connect) -> None:
async def test_registering_a_duplicate_tool_name_warns_and_keeps_the_first(
connect: Connect, unstamped: Unstamp
) -> None:
"""Registering a second tool with an already-used name keeps the first registration.
The intended behaviour is rejection at registration time; MCPServer instead logs a warning
@ -304,14 +313,14 @@ async def test_registering_a_duplicate_tool_name_warns_and_keeps_the_first(conne
result = await client.call_tool("echo", {})
assert [tool.name for tool in listed.tools] == ["echo"]
assert result == snapshot(
assert unstamped(result) == snapshot(
CallToolResult(content=[TextContent(text="first")], structured_content={"result": "first"})
)
@requirement("mcpserver:tool:naming-validation")
async def test_registering_a_tool_with_a_spec_invalid_name_warns_but_does_not_reject(
connect: Connect, caplog: pytest.LogCaptureFixture
connect: Connect, caplog: pytest.LogCaptureFixture, unstamped: Unstamp
) -> None:
"""A tool name that violates the SEP-986 rules logs a warning at registration but is still registered.
@ -340,7 +349,9 @@ async def test_registering_a_tool_with_a_spec_invalid_name_warns_but_does_not_re
result = await client.call_tool("bad name!", {})
assert [tool.name for tool in listed.tools] == ["bad name!"]
assert result == snapshot(CallToolResult(content=[TextContent(text="ok")], structured_content={"result": "ok"}))
assert unstamped(result) == snapshot(
CallToolResult(content=[TextContent(text="ok")], structured_content={"result": "ok"})
)
@requirement("mcpserver:tool:url-elicitation-error")