Conformance burn-down: server-side InputRequiredResult, Mcp-Method/Name validation, x-mcp-header filter (14 scenarios → green) (#2974)
This commit is contained in:
parent
603342f0fe
commit
587340279e
2 changed files with 1163 additions and 150 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue