Conformance burn-down: server-side InputRequiredResult, Mcp-Method/Name validation, x-mcp-header filter (14 scenarios → green) (#2974)
Some checks failed
CI / checks (push) Has been cancelled
CI / all-green (push) Has been cancelled

This commit is contained in:
Max 2026-06-26 09:51:59 +02:00 • committed by GitHub
commit 587340279e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 1163 additions and 150 deletions

View file

@ -10,7 +10,7 @@ from typing import Annotated, Any, Final, NamedTuple, TypedDict
import annotated_types
import pytest
from dirty_equals import IsPartialDict
from mcp_types import CallToolResult
from mcp_types import CallToolResult, InputRequiredResult
from pydantic import BaseModel, Field
from mcp.server.mcpserver.exceptions import InvalidSignature
@ -862,6 +862,29 @@ def test_tool_call_result_annotated_is_structured_and_converted():
assert isinstance(meta.convert_result(func_returning_annotated_tool_call_result()), CallToolResult)
def test_tool_call_result_annotated_unioned_with_input_required_result_is_equivalent_to_the_bare_annotated_form():
"""Stripping `InputRequiredResult` makes the residual behave exactly as if it were the
declared return annotation, including the `Annotated[CallToolResult, Model]` special case
— the schema derives from `Model` and `convert_result` validates `structured_content`
against it instead of wrapping the whole `CallToolResult`."""
class PersonClass(BaseModel):
name: str
def fn_bare() -> Annotated[CallToolResult, PersonClass]:
return CallToolResult(content=[], structured_content={"name": "Brandon"})
def fn_iir() -> Annotated[CallToolResult, PersonClass] | InputRequiredResult:
return CallToolResult(content=[], structured_content={"name": "Brandon"})
bare = func_metadata(fn_bare)
iir = func_metadata(fn_iir)
assert iir.output_schema == bare.output_schema
assert iir.wrap_output == bare.wrap_output
assert isinstance(bare.convert_result(fn_bare()), CallToolResult)
assert isinstance(iir.convert_result(fn_iir()), CallToolResult)
def test_tool_call_result_annotated_is_structured_and_invalid():
class PersonClass(BaseModel):
name: str
@ -1038,7 +1061,9 @@ def test_structured_output_aliases():
# Check that the actual output uses aliases too
result = ModelWithAliases(**{"first": "hello", "second": "world"})
structured_content = meta.convert_result(result).structured_content
converted = meta.convert_result(result)
assert isinstance(converted, CallToolResult)
structured_content = converted.structured_content
assert structured_content is not None
# The structured content should use aliases to match the schema
@ -1051,7 +1076,9 @@ def test_structured_output_aliases():
# Also test the case where we have a model with defaults to ensure aliases work in all cases
result_with_defaults = ModelWithAliases() # Uses default None values
structured_content_defaults = meta.convert_result(result_with_defaults).structured_content
converted_defaults = meta.convert_result(result_with_defaults)
assert isinstance(converted_defaults, CallToolResult)
structured_content_defaults = converted_defaults.structured_content
assert structured_content_defaults is not None
# Even with defaults, should use aliases in output
@ -1191,3 +1218,71 @@ def test_preserves_pydantic_metadata():
assert meta.output_schema is not None
assert meta.output_schema["properties"]["result"] == {"exclusiveMinimum": 1, "title": "Result", "type": "integer"}
def test_convert_result_passes_input_required_result_through_unchanged():
def fn() -> str | InputRequiredResult: ... # pragma: no branch
meta = func_metadata(fn)
irr = InputRequiredResult(request_state="opaque")
assert meta.convert_result(irr) is irr
def test_input_required_result_return_annotation_yields_no_output_schema():
def fn() -> InputRequiredResult: ... # pragma: no branch
meta = func_metadata(fn)
assert meta.output_schema is None
assert meta.output_model is None
def test_union_with_input_required_result_derives_schema_from_residual_arm():
def fn() -> str | InputRequiredResult: ... # pragma: no branch
meta = func_metadata(fn)
assert meta.output_schema is not None
assert meta.output_schema["properties"]["result"]["type"] == "string"
converted = meta.convert_result("hello")
assert isinstance(converted, CallToolResult)
assert converted.structured_content == {"result": "hello"}
irr = InputRequiredResult(request_state="opaque")
assert meta.convert_result(irr) is irr
def test_call_tool_result_unioned_with_input_required_result_is_accepted():
def fn() -> CallToolResult | InputRequiredResult: ... # pragma: no branch
meta = func_metadata(fn)
assert meta.output_schema is None
def test_basemodel_union_input_required_result_derives_model_schema():
class Payload(BaseModel):
x: int
def fn() -> Payload | InputRequiredResult: ... # pragma: no branch
meta = func_metadata(fn)
assert meta.output_model is Payload
assert meta.wrap_output is False
assert meta.output_schema == Payload.model_json_schema()
def test_call_tool_result_in_union_with_input_required_result_is_still_rejected():
def fn() -> CallToolResult | str | InputRequiredResult: ... # pragma: no branch
with pytest.raises(InvalidSignature, match="CallToolResult cannot be used in Union"):
func_metadata(fn)
def test_union_of_only_input_required_subclasses_yields_no_output_schema():
class StepA(InputRequiredResult):
pass
class StepB(InputRequiredResult):
pass
def fn() -> StepA | StepB: ... # pragma: no branch
meta = func_metadata(fn)
assert meta.output_schema is None

View file

@ -3,6 +3,7 @@ from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import anyio
import pytest
from inline_snapshot import snapshot
from mcp_types import (
@ -11,15 +12,21 @@ from mcp_types import (
AudioContent,
BlobResourceContents,
CallToolResult,
ClientCapabilities,
Completion,
CompletionArgument,
CompletionContext,
ContentBlock,
ElicitRequest,
ElicitRequestFormParams,
ElicitResult,
EmbeddedResource,
GetPromptResult,
Icon,
ImageContent,
InputRequiredResult,
ListPromptsResult,
ListRootsRequest,
Prompt,
PromptArgument,
PromptMessage,
@ -1570,3 +1577,98 @@ async def test_read_resource_template_not_found():
assert exc_info.value.error.code == INVALID_PARAMS
assert exc_info.value.error.data == {"uri": "resource://users/999"}
async def test_tool_returning_input_required_result_reaches_client_unchanged():
mcp = MCPServer()
@mcp.tool()
async def ask(ctx: Context) -> str | InputRequiredResult:
return InputRequiredResult(input_requests={"roots": ListRootsRequest()}, request_state="round-1")
with anyio.fail_after(5):
async with Client(mcp, mode="2026-07-28") as client:
result = await client.call_tool("ask", allow_input_required=True)
assert isinstance(result, InputRequiredResult)
assert result.request_state == "round-1"
assert result.input_requests is not None
assert result.input_requests["roots"].method == "roots/list"
async def test_tool_reads_input_responses_and_request_state_from_context_on_retry():
mcp = MCPServer()
@mcp.tool()
async def greet(ctx: Context) -> str | InputRequiredResult:
responses = ctx.input_responses
if responses and "who" in responses:
who = responses["who"]
assert isinstance(who, ElicitResult) and who.content is not None
return f"Hello, {who.content['name']}! (state={ctx.request_state})"
return InputRequiredResult(
input_requests={
"who": ElicitRequest(
params=ElicitRequestFormParams(
message="What is your name?",
requested_schema={
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
},
)
)
},
request_state="r1",
)
with anyio.fail_after(5):
async with Client(mcp, mode="2026-07-28") as client:
r1 = await client.call_tool("greet", allow_input_required=True)
assert isinstance(r1, InputRequiredResult)
assert r1.input_requests is not None and "who" in r1.input_requests
r2 = await client.call_tool(
"greet",
input_responses={"who": ElicitResult(action="accept", content={"name": "Alice"})},
request_state=r1.request_state,
allow_input_required=True,
)
assert isinstance(r2, CallToolResult)
block = r2.content[0]
assert isinstance(block, TextContent)
assert block.text == "Hello, Alice! (state=r1)"
async def test_context_exposes_client_capabilities_from_connection():
mcp = MCPServer()
seen: list[ClientCapabilities | None] = []
@mcp.tool()
async def probe(ctx: Context) -> str:
seen.append(ctx.client_capabilities)
return "ok"
with anyio.fail_after(5):
async with Client(mcp, mode="2026-07-28") as client:
await client.call_tool("probe")
assert len(seen) == 1
assert isinstance(seen[0], ClientCapabilities)
async def test_context_input_responses_and_request_state_are_none_on_initial_round():
mcp = MCPServer()
captured: dict[str, Any] = {}
@mcp.tool()
async def probe(ctx: Context) -> str:
captured["responses"] = ctx.input_responses
captured["state"] = ctx.request_state
return "ok"
with anyio.fail_after(5):
async with Client(mcp, mode="2026-07-28") as client:
await client.call_tool("probe")
assert captured == {"responses": None, "state": None}