Pass InputRequiredResult through the MCPServer prompt and resource pipelines (#3020)

This commit is contained in:
Max 2026-06-29 17:50:58 +02:00 • committed by GitHub
commit 8d0f928e40
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 627 additions and 32 deletions

View file

@ -10,13 +10,17 @@ from mcp_types import (
CreateMessageRequestParams,
ElicitRequest,
ElicitRequestFormParams,
ElicitRequestParams,
ElicitResult,
GetPromptResult,
InputRequiredResult,
PromptMessage,
TextContent,
)
from docs_src.mrtr import tutorial001, tutorial002, tutorial003
from docs_src.mrtr import tutorial001, tutorial002, tutorial003, tutorial004
from mcp import Client, MCPError
from mcp.client import ClientRequestContext
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
@ -109,3 +113,51 @@ def test_fulfil_refuses_a_request_it_cannot_answer() -> None:
request = CreateMessageRequest(params=CreateMessageRequestParams(messages=[], max_tokens=64))
with pytest.raises(NotImplementedError, match="sampling/createMessage"):
tutorial002.fulfil(request)
async def test_a_prompt_returns_an_input_required_result_on_the_first_round() -> None:
"""tutorial004: `prompts/get` participates in the same flow — the `@mcp.prompt()` function
returns the `InputRequiredResult` itself."""
async with Client(tutorial004.mcp) as client:
result = await client.session.get_prompt("briefing", allow_input_required=True)
assert result == snapshot(
InputRequiredResult(
result_type="input_required",
input_requests={
"audience": ElicitRequest(
method="elicitation/create",
params=ElicitRequestFormParams(
mode="form",
message="Who is the briefing for?",
requested_schema={
"type": "object",
"properties": {"audience": {"type": "string"}},
"required": ["audience"],
},
),
)
},
)
)
async def _answer_audience(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
return ElicitResult(action="accept", content={"audience": "the board"})
async def test_the_prompt_auto_loop_returns_the_final_messages() -> None:
"""tutorial004 + the page's client-side claim: `get_prompt` drives the same loop, so the
caller sees only the complete `GetPromptResult`."""
async with Client(tutorial004.mcp, elicitation_callback=_answer_audience) as client:
result = await client.get_prompt("briefing")
assert result == snapshot(
GetPromptResult(
description="Draft a briefing tuned to its audience.",
messages=[
PromptMessage(
role="user",
content=TextContent(type="text", text="Write a briefing for the board."),
)
],
)
)

View file

@ -1,5 +1,6 @@
import pytest
from mcp_types import (
InputRequiredResult,
ListResourceTemplatesResult,
TextResourceContents,
)
@ -49,6 +50,7 @@ async def test_resource_template_edge_cases():
# Verify valid template works
result = await mcp.read_resource("resource://users/123/posts/456")
assert not isinstance(result, InputRequiredResult)
result_list = list(result)
assert len(result_list) == 1
assert result_list[0].content == "Post 456 by user 123"

View file

@ -2,7 +2,14 @@ import threading
from typing import Any
import pytest
from mcp_types import EmbeddedResource, TextContent, TextResourceContents
from mcp_types import (
ElicitRequest,
ElicitRequestFormParams,
EmbeddedResource,
InputRequiredResult,
TextContent,
TextResourceContents,
)
from mcp.server.mcpserver import Context
from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, Prompt, UserMessage
@ -209,3 +216,30 @@ async def test_sync_fn_runs_in_worker_thread():
assert messages == [UserMessage(content=TextContent(type="text", text="hello"))]
assert fn_thread[0] != main_thread
@pytest.mark.anyio
async def test_render_passes_input_required_result_through_unchanged():
"""Prompt.render returns the InputRequiredResult the function returned, bypassing
message conversion entirely (SEP-2322 multi-round-trip pass-through)."""
sentinel = InputRequiredResult(
input_requests={
"who": ElicitRequest(
params=ElicitRequestFormParams(
message="Who is this for?",
requested_schema={
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
},
)
)
}
)
def asking_prompt() -> InputRequiredResult:
return sentinel
prompt = Prompt.from_function(asking_prompt)
result = await prompt.render(None, Context())
assert result is sentinel

View file

@ -3,6 +3,8 @@ import threading
import anyio
import anyio.from_thread
import pytest
from inline_snapshot import snapshot
from mcp_types import InputRequiredResult
from pydantic import BaseModel
from mcp.server.mcpserver.resources import FunctionResource
@ -242,3 +244,20 @@ async def test_sync_fn_does_not_block_event_loop():
release.set()
assert result == ["done"]
@pytest.mark.anyio
async def test_read_rejects_an_input_required_result_from_a_static_function():
"""A static resource function returning an InputRequiredResult is a mistake (it can
never read the retry's input_responses), so read() raises instead of JSON-dumping it."""
def ask() -> InputRequiredResult:
return InputRequiredResult(request_state="round-1")
resource = FunctionResource(uri="resource://ask", name="ask", fn=ask)
with pytest.raises(ValueError) as exc:
await resource.read()
assert str(exc.value) == snapshot(
"Error reading resource resource://ask: static resources cannot return "
"InputRequiredResult; only resource template functions participate in the multi-round-trip flow"
)

View file

@ -3,7 +3,7 @@ import threading
from typing import Any
import pytest
from mcp_types import Annotations
from mcp_types import Annotations, ElicitRequest, ElicitRequestFormParams, InputRequiredResult
from pydantic import BaseModel
from mcp.server.mcpserver import Context, MCPServer
@ -403,6 +403,7 @@ class TestResourceTemplateAnnotations:
# Create a resource from the template
resource = await template.create_resource("resource://items/123", {"item_id": "123"}, Context())
assert not isinstance(resource, InputRequiredResult)
# The resource should inherit the template's annotations
assert resource.annotations is not None
@ -477,3 +478,30 @@ async def test_sync_fn_runs_in_worker_thread():
assert isinstance(resource, FunctionResource)
assert await resource.read() == "hello world"
assert fn_thread[0] != main_thread
@pytest.mark.anyio
async def test_create_resource_passes_input_required_result_through_unchanged():
"""create_resource returns the InputRequiredResult the template function returned
instead of wrapping it in a FunctionResource (SEP-2322 multi-round-trip pass-through)."""
sentinel = InputRequiredResult(
input_requests={
"who": ElicitRequest(
params=ElicitRequestFormParams(
message="Who is this for?",
requested_schema={
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
},
)
)
}
)
def ask(topic: str) -> InputRequiredResult:
return sentinel
template = ResourceTemplate.from_function(fn=ask, uri_template="ask://{topic}")
result = await template.create_resource("ask://databases", {"topic": "databases"}, Context())
assert result is sentinel

View file

@ -2,6 +2,7 @@ import json
from pathlib import Path
import pytest
from mcp_types import InputRequiredResult
from mcp.server.mcpserver import MCPServer
@ -89,6 +90,7 @@ async def test_list_resources(mcp: MCPServer):
@pytest.mark.anyio
async def test_read_resource_dir(mcp: MCPServer):
res_iter = await mcp.read_resource("dir://test_dir")
assert not isinstance(res_iter, InputRequiredResult)
res_list = list(res_iter)
assert len(res_list) == 1
res = res_list[0]
@ -106,6 +108,7 @@ async def test_read_resource_dir(mcp: MCPServer):
@pytest.mark.anyio
async def test_read_resource_file(mcp: MCPServer):
res_iter = await mcp.read_resource("file://test_dir/example.py")
assert not isinstance(res_iter, InputRequiredResult)
res_list = list(res_iter)
assert len(res_list) == 1
res = res_list[0]
@ -122,6 +125,7 @@ async def test_delete_file(mcp: MCPServer, test_dir: Path):
async def test_delete_file_and_check_resources(mcp: MCPServer, test_dir: Path):
await mcp.call_tool("delete_file", arguments={"path": str(test_dir / "example.py")})
res_iter = await mcp.read_resource("file://test_dir/example.py")
assert not isinstance(res_iter, InputRequiredResult)
res_list = list(res_iter)
assert len(res_list) == 1
res = res_list[0]

View file

@ -10,6 +10,7 @@ from inline_snapshot import snapshot
from mcp_types import (
INTERNAL_ERROR,
INVALID_PARAMS,
MISSING_REQUIRED_CLIENT_CAPABILITY,
AudioContent,
BlobResourceContents,
CallToolResult,
@ -26,6 +27,7 @@ from mcp_types import (
Icon,
ImageContent,
InputRequiredResult,
InputResponses,
ListPromptsResult,
ListRootsRequest,
Prompt,
@ -1311,6 +1313,7 @@ class TestServerPrompts:
return "Hello, world!"
result = await mcp.get_prompt("fn")
assert not isinstance(result, InputRequiredResult)
content = result.messages[0].content
assert isinstance(content, TextContent)
assert content.text == "Hello, world!"
@ -1328,6 +1331,7 @@ class TestServerPrompts:
assert prompts[0].name == "fn"
# Don't compare functions directly since validate_call wraps them
content = await prompts[0].render(None, Context())
assert not isinstance(content, InputRequiredResult)
assert isinstance(content[0].content, TextContent)
assert content[0].content.text == "Hello, world!"
@ -1343,6 +1347,7 @@ class TestServerPrompts:
assert len(prompts) == 1
assert prompts[0].name == "custom_name"
content = await prompts[0].render(None, Context())
assert not isinstance(content, InputRequiredResult)
assert isinstance(content[0].content, TextContent)
assert content[0].content.text == "Hello, world!"
@ -1358,6 +1363,7 @@ class TestServerPrompts:
assert len(prompts) == 1
assert prompts[0].description == "A custom description"
content = await prompts[0].render(None, Context())
assert not isinstance(content, InputRequiredResult)
assert isinstance(content[0].content, TextContent)
assert content[0].content.text == "Hello, world!"
@ -1921,6 +1927,283 @@ async def test_tool_reads_input_responses_and_request_state_from_context_on_retr
assert block.text == "Hello, Alice! (state=r1)"
def _ask_who() -> ElicitRequest:
return ElicitRequest(
params=ElicitRequestFormParams(
message="Who is this for?",
requested_schema={
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
},
)
)
async def test_prompt_returning_input_required_result_reaches_client_unchanged():
"""A prompt function may return an InputRequiredResult and the pipeline passes it
through to the client (spec-mandated: SEP-2322 allows it on prompts/get)."""
mcp = MCPServer()
@mcp.prompt()
async def briefing(ctx: Context) -> list[UserMessage] | InputRequiredResult:
return InputRequiredResult(input_requests={"who": _ask_who()}, request_state="round-1")
with anyio.fail_after(5):
async with Client(mcp, mode="2026-07-28") as client:
result = await client.session.get_prompt("briefing", 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["who"].method == "elicitation/create"
async def test_prompt_reads_input_responses_and_request_state_from_context_on_retry():
"""The prompts/get retry carries input_responses and request_state to the prompt
function via the Context, completing the SEP-2322 multi-round-trip flow."""
mcp = MCPServer()
@mcp.prompt()
async def briefing(ctx: Context) -> list[UserMessage] | 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 [UserMessage(content=f"Brief {who.content['name']} (state={ctx.request_state})")]
return InputRequiredResult(input_requests={"who": _ask_who()}, request_state="r1")
with anyio.fail_after(5):
async with Client(mcp, mode="2026-07-28") as client:
r1 = await client.session.get_prompt("briefing", allow_input_required=True)
assert isinstance(r1, InputRequiredResult)
assert r1.input_requests is not None and "who" in r1.input_requests
r2 = await client.session.get_prompt(
"briefing",
input_responses={"who": ElicitResult(action="accept", content={"name": "Alice"})},
request_state=r1.request_state,
allow_input_required=True,
)
assert isinstance(r2, GetPromptResult)
block = r2.messages[0].content
assert isinstance(block, TextContent)
assert block.text == "Brief Alice (state=r1)"
async def test_prompt_input_required_result_on_legacy_session_is_a_serialization_error():
"""Pins the shared era gate: a pre-2026 session has no input_required vocabulary, so
the runner rejects the frame with -32603 — the same posture the tools path has."""
mcp = MCPServer()
@mcp.prompt()
async def briefing(ctx: Context) -> list[UserMessage] | InputRequiredResult:
return InputRequiredResult(input_requests={"who": _ask_who()})
async with Client(mcp, mode="legacy") as client:
with pytest.raises(MCPError) as exc:
await client.get_prompt("briefing")
assert exc.value.error.code == INTERNAL_ERROR
assert exc.value.error.message == "Handler returned an invalid result"
async def test_resource_template_input_required_result_on_legacy_session_is_a_serialization_error():
"""Pins the shared era gate for resources/read: a pre-2026 session has no
input_required vocabulary, so the runner rejects the frame with -32603."""
mcp = MCPServer()
@mcp.resource("ask://{topic}")
async def ask(topic: str, ctx: Context) -> str | InputRequiredResult:
return InputRequiredResult(input_requests={"who": _ask_who()})
async with Client(mcp, mode="legacy") as client:
with pytest.raises(MCPError) as exc:
await client.read_resource("ask://databases")
assert exc.value.error.code == INTERNAL_ERROR
assert exc.value.error.message == "Handler returned an invalid result"
async def test_resource_template_returning_input_required_result_reaches_client_unchanged():
"""A resource template function may return an InputRequiredResult and the pipeline
passes it through to the client (spec-mandated: SEP-2322 allows it on resources/read)."""
mcp = MCPServer()
@mcp.resource("ask://{topic}")
async def ask(topic: str, ctx: Context) -> str | InputRequiredResult:
return InputRequiredResult(input_requests={"who": _ask_who()}, request_state="round-1")
with anyio.fail_after(5):
async with Client(mcp, mode="2026-07-28") as client:
result = await client.session.read_resource("ask://databases", 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["who"].method == "elicitation/create"
async def test_resource_template_reads_input_responses_from_context_on_retry():
"""The resources/read retry carries input_responses to the template function via the
Context, completing the SEP-2322 multi-round-trip flow."""
mcp = MCPServer()
@mcp.resource("ask://{topic}")
async def ask(topic: str, 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"{topic} notes for {who.content['name']}"
return InputRequiredResult(input_requests={"who": _ask_who()})
with anyio.fail_after(5):
async with Client(mcp, mode="2026-07-28") as client:
r1 = await client.session.read_resource("ask://databases", allow_input_required=True)
assert isinstance(r1, InputRequiredResult)
assert r1.input_requests is not None and "who" in r1.input_requests
r2 = await client.session.read_resource(
"ask://databases",
input_responses={"who": ElicitResult(action="accept", content={"name": "Alice"})},
allow_input_required=True,
)
assert isinstance(r2, ReadResourceResult)
contents = r2.contents[0]
assert isinstance(contents, TextResourceContents)
assert contents.text == "databases notes for Alice"
async def test_context_read_resource_raises_on_input_required_result():
"""ctx.read_resource is a content reader: an InputRequiredResult from the template
raises with a pointer at the forwarding path instead of widening every caller."""
mcp = MCPServer()
@mcp.resource("ask://{topic}")
async def ask(topic: str, ctx: Context) -> str | InputRequiredResult:
return InputRequiredResult(input_requests={"who": _ask_who()})
context = Context(mcp_server=mcp)
with pytest.raises(RuntimeError) as exc:
await context.read_resource("ask://databases")
assert str(exc.value) == snapshot(
"Resource returned InputRequiredResult; ctx.read_resource() only returns "
"content — use MCPServer.read_resource(uri, context) to receive and forward it."
)
async def test_mcpserver_read_resource_returns_input_required_result_for_handler_forwarding():
"""MCPServer.read_resource hands the template's InputRequiredResult to a direct caller
unchanged — the composition path for a handler that forwards it as its own result."""
mcp = MCPServer()
sentinel = InputRequiredResult(input_requests={"who": _ask_who()})
@mcp.resource("ask://{topic}")
async def ask(topic: str, ctx: Context) -> str | InputRequiredResult:
return sentinel
context = Context(mcp_server=mcp)
result = await mcp.read_resource("ask://databases", context)
assert result is sentinel
async def test_context_read_resource_keeps_outer_input_responses_from_the_nested_template():
"""ctx.read_resource never participates in the multi-round-trip flow, so the nested
template must not see the outer request's input_responses/request_state — a colliding
key would otherwise consume an answer meant for the outer handler's own question."""
mcp = MCPServer()
seen_responses: list[InputResponses | None] = []
seen_state: list[str | None] = []
@mcp.resource("ask://{topic}")
async def ask(topic: str, ctx: Context) -> str:
seen_responses.append(ctx.input_responses)
seen_state.append(ctx.request_state)
return f"{topic} content"
@mcp.tool()
async def outer(ctx: Context) -> str:
contents = list(await ctx.read_resource("ask://databases"))
assert isinstance(contents[0].content, str)
return contents[0].content
with anyio.fail_after(5):
async with Client(mcp, mode="2026-07-28") as client:
result = await client.session.call_tool(
"outer",
input_responses={"who": ElicitResult(action="accept", content={"name": "Alice"})},
request_state="outer-state",
)
assert isinstance(result, CallToolResult)
block = result.content[0]
assert isinstance(block, TextContent)
assert block.text == "databases content"
assert seen_responses == [None]
assert seen_state == [None]
async def test_prompt_raising_mcp_error_surfaces_code_and_data_to_client():
"""A handler-raised MCPError keeps its code and data through the prompt pipeline —
the same parity tools/call has, needed for self-service capability rejection."""
mcp = MCPServer()
@mcp.prompt()
async def briefing(ctx: Context) -> str:
raise MCPError(
code=MISSING_REQUIRED_CLIENT_CAPABILITY,
message="needs elicitation",
data={"requiredCapabilities": ["elicitation"]},
)
async with Client(mcp) as client:
with pytest.raises(MCPError) as exc:
await client.get_prompt("briefing")
assert exc.value.error.code == MISSING_REQUIRED_CLIENT_CAPABILITY
assert exc.value.error.message == "needs elicitation"
assert exc.value.error.data == {"requiredCapabilities": ["elicitation"]}
async def test_resource_template_raising_mcp_error_surfaces_code_and_data_to_client():
"""A handler-raised MCPError keeps its code and data through the resource template
pipeline instead of being wrapped into a generic ResourceError."""
mcp = MCPServer()
@mcp.resource("ask://{topic}")
async def ask(topic: str, ctx: Context) -> str:
raise MCPError(
code=MISSING_REQUIRED_CLIENT_CAPABILITY,
message="needs elicitation",
data={"requiredCapabilities": ["elicitation"]},
)
async with Client(mcp) as client:
with pytest.raises(MCPError) as exc:
await client.read_resource("ask://databases")
assert exc.value.error.code == MISSING_REQUIRED_CLIENT_CAPABILITY
assert exc.value.error.message == "needs elicitation"
assert exc.value.error.data == {"requiredCapabilities": ["elicitation"]}
async def test_static_resource_raising_mcp_error_surfaces_code_and_data_to_client():
"""A handler-raised MCPError keeps its code and data through the static resource
read path too — parity with the template path above."""
mcp = MCPServer()
@mcp.resource("static://thing")
def thing() -> str:
raise MCPError(
code=MISSING_REQUIRED_CLIENT_CAPABILITY,
message="needs elicitation",
data={"requiredCapabilities": ["elicitation"]},
)
async with Client(mcp) as client:
with pytest.raises(MCPError) as exc:
await client.read_resource("static://thing")
assert exc.value.error.code == MISSING_REQUIRED_CLIENT_CAPABILITY
assert exc.value.error.message == "needs elicitation"
assert exc.value.error.data == {"requiredCapabilities": ["elicitation"]}
async def test_context_exposes_client_capabilities_from_connection():
mcp = MCPServer()
seen: list[ClientCapabilities | None] = []