Skip to content

Commit 4a8cb8c

Browse files
committed
Widen the MCPServer prompt and resource pipelines to pass InputRequiredResult through
At 2026-07-28, prompts/get and resources/read may answer with an InputRequiredResult (SEP-2322 multi-round-trip requests), but only the tools/call pipeline passed one through from MCPServer. This closed the last waived server conformance scenario, input-required-result-non-tool-request. - Prompt.render and MCPServer.get_prompt pass an InputRequiredResult returned by an @mcp.prompt() function through unchanged; the retry's answers arrive on ctx.input_responses, with ctx.request_state carrying the echoed state. - ResourceTemplate.create_resource and MCPServer.read_resource do the same for @mcp.resource() template functions. Static resource functions can never read the retry (no Context), so FunctionResource.read now rejects an InputRequiredResult loudly instead of JSON-dumping it as content. - Context.read_resource keeps its narrow content type by default and grows ClientSession-style allow_input_required overloads so a handler can opt in and forward a template's InputRequiredResult as its own result. - Era handling is unchanged: the per-version result surfaces already reject the frame toward pre-2026 sessions with -32603, exactly as for tools. - The everything-server gains the test_input_required_result_prompt fixture the conformance scenario drives; both expected-failures baselines are now empty and all three server conformance legs pass with zero waivers. - Docs: a "Beyond tools" section in multi-round-trip.md with a runnable example, the low-level-server.md handler list, and a migration.md entry for the widened return types.
1 parent 533c6a8 commit 4a8cb8c

21 files changed

Lines changed: 516 additions & 34 deletions

File tree

.github/actions/conformance/expected-failures.2026-07-28.yml

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,4 @@
2222

2323
client: []
2424

25-
server:
26-
# SEP-2322 (multi-round-trip requests / IncompleteResult): the prompt pipeline
27-
# cannot return InputRequiredResult from MCPServer yet (tools/call can).
28-
- input-required-result-non-tool-request
25+
server: []

.github/actions/conformance/expected-failures.yml

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,4 @@
1212

1313
client: []
1414

15-
server:
16-
# --- Draft-spec scenarios (in `--suite draft`; the `active` suite is green) ---
17-
# SEP-2322 (multi-round-trip requests / IncompleteResult): the prompt pipeline
18-
# cannot return InputRequiredResult from MCPServer yet (tools/call can).
19-
- input-required-result-non-tool-request
15+
server: []

docs/advanced/low-level-server.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ The handshake belongs to the runner. `server/discover`, `ping`, and every other
181181

182182
Each of these is one idea you now have the vocabulary for; each has its own chapter.
183183

184-
* `on_call_tool` may return an `InputRequiredResult` instead of a `CallToolResult` to pause the call and ask the client for input; see **[Multi-round-trip requests](multi-round-trip.md)**.
184+
* `on_call_tool`, `on_get_prompt`, and `on_read_resource` may return an `InputRequiredResult` instead of their normal result to pause the call and ask the client for input; see **[Multi-round-trip requests](multi-round-trip.md)**.
185185
* `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion` are the same `(ctx, params) -> result` shape for the other primitives.
186186
* `server.streamable_http_app()` returns the same Starlette app `MCPServer`'s does; deploy it the way **[Running your server](../run/index.md)** deploys any other ASGI app. There is no `server.run(transport=...)` down here: `server.run(read_stream, write_stream, server.create_initialization_options())` drives one connection over a pair of streams, and that one line is the whole story.
187187

docs/advanced/multi-round-trip.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,19 @@ On `@mcp.tool()` you rarely build this by hand: declare a dependency that asks t
3131

3232
Everything else in that file (the explicit `input_schema`, the hand-built `CallToolResult`) is the ordinary low-level `Server`, covered in **[The low-level Server](low-level-server.md)**. This page only adds the second return type.
3333

34+
## Beyond tools
35+
36+
`tools/call` is not special: at 2026-07-28 a server may answer `prompts/get` and `resources/read` the same way. On `MCPServer`, an `@mcp.prompt()` function — or an `@mcp.resource()` **template** function — returns the `InputRequiredResult` itself and reads the retry's answers off the context:
37+
38+
```python title="server.py" hl_lines="21 23 25"
39+
--8<-- "docs_src/mrtr/tutorial004.py"
40+
```
41+
42+
* The first round returns the `InputRequiredResult`. On the retry, `ctx.input_responses` holds the answers under the same keys and the function returns its ordinary result — prompt messages here, resource content for a template resource.
43+
* An `@mcp.tool()` function can return the result directly the same way, when the dependency form doesn't fit.
44+
* Static `@mcp.resource()` functions don't participate: they take no `Context`, so they could never read the retry. Only template resources can ask.
45+
* The era rules below apply unchanged: returning an `InputRequiredResult` on a pre-2026 session is the same `-32603` the warning describes.
46+
3447
## The client side
3548

3649
`Client` runs the loop for you.
@@ -94,5 +107,6 @@ Drop to the underlying session, where `allow_input_required=True` hands you the
94107
* `Client` runs the retry loop for you: register `elicitation_callback` / `sampling_callback` / `list_roots_callback` and `call_tool` returns a plain `CallToolResult`. `input_required_max_rounds` (default 10) bounds it.
95108
* To inspect or persist rounds, use `client.session.call_tool(..., allow_input_required=True)` and own the `while isinstance(result, InputRequiredResult)` loop yourself.
96109
* On `@mcp.tool()`, a dependency that asks the user produces this result for you (**[Dependencies](../tutorial/dependencies.md)**); the **low-level** `Server` is the manual form.
110+
* Prompts and resources participate too: an `@mcp.prompt()` or template `@mcp.resource()` function returns the `InputRequiredResult` itself and reads `ctx.input_responses` on the retry.
97111

98112
This is the mechanism that replaces server-initiated sampling and the rest of the push-style back-channel; see **[Deprecated features](deprecated.md)**.

docs/migration.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,24 @@ If you call `MCPServer.call_tool()` directly, read `.content` and
2020
`.structured_content` off the returned `CallToolResult` instead of branching on
2121
the result type.
2222

23+
### `MCPServer.get_prompt()` and `read_resource()` may return `InputRequiredResult`
24+
25+
Like `call_tool()` above, `MCPServer.get_prompt()` now returns
26+
`GetPromptResult | InputRequiredResult` and `MCPServer.read_resource()` returns
27+
`Iterable[ReadResourceContents] | InputRequiredResult`: at 2026-07-28 an
28+
`@mcp.prompt()` function or an `@mcp.resource()` template function may answer
29+
with an `InputRequiredResult` to request client input first (see
30+
[Multi-round-trip requests](advanced/multi-round-trip.md)). If you call these
31+
methods directly, narrow with `isinstance` (or
32+
`assert not isinstance(result, InputRequiredResult)` when your prompt and
33+
resource functions never return one). `Prompt.render()` and
34+
`ResourceTemplate.create_resource()` carry the same union.
35+
36+
`ctx.read_resource()` inside a handler is unchanged by default: it still
37+
returns content and raises `RuntimeError` if the resource requests input; pass
38+
`allow_input_required=True` to receive the `InputRequiredResult` and forward it
39+
as the handler's own result.
40+
2341
### `MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error
2442

2543
Raising `MCPError` (or a subclass such as `UrlElicitationRequiredError`) inside

docs_src/mrtr/tutorial004.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from mcp_types import ElicitRequest, ElicitRequestFormParams, ElicitResult, InputRequiredResult
2+
3+
from mcp.server.mcpserver import Context, MCPServer
4+
from mcp.server.mcpserver.prompts.base import UserMessage
5+
6+
mcp = MCPServer("Briefing")
7+
8+
ASK_AUDIENCE = ElicitRequest(
9+
params=ElicitRequestFormParams(
10+
message="Who is the briefing for?",
11+
requested_schema={
12+
"type": "object",
13+
"properties": {"audience": {"type": "string"}},
14+
"required": ["audience"],
15+
},
16+
)
17+
)
18+
19+
20+
@mcp.prompt()
21+
async def briefing(ctx: Context) -> list[UserMessage] | InputRequiredResult:
22+
"""Draft a briefing tuned to its audience."""
23+
answer = (ctx.input_responses or {}).get("audience")
24+
if not isinstance(answer, ElicitResult) or answer.content is None:
25+
return InputRequiredResult(input_requests={"audience": ASK_AUDIENCE})
26+
return [UserMessage(f"Write a briefing for {answer.content['audience']}.")]

examples/servers/everything-server/mcp_everything_server/server.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -655,6 +655,30 @@ def test_prompt_with_image() -> list[UserMessage]:
655655
]
656656

657657

658+
@mcp.prompt()
659+
async def test_input_required_result_prompt(ctx: Context) -> list[UserMessage] | InputRequiredResult:
660+
"""Tests InputRequiredResult from prompts/get (SEP-2322 non-tool request)"""
661+
responses = ctx.input_responses
662+
if responses and "user_context" in responses:
663+
answer = responses["user_context"]
664+
text = answer.content.get("context", "?") if isinstance(answer, ElicitResult) and answer.content else "?"
665+
return [UserMessage(role="user", content=TextContent(type="text", text=f"Use the following context: {text}"))]
666+
return InputRequiredResult(
667+
input_requests={
668+
"user_context": ElicitRequest(
669+
params=ElicitRequestFormParams(
670+
message="What context should the prompt use?",
671+
requested_schema={
672+
"type": "object",
673+
"properties": {"context": {"type": "string"}},
674+
"required": ["context"],
675+
},
676+
)
677+
)
678+
}
679+
)
680+
681+
658682
# Custom request handlers
659683
# TODO(felix): Add public APIs to MCPServer for subscribe_resource, unsubscribe_resource,
660684
# and set_logging_level to avoid accessing protected _lowlevel_server attribute.

src/mcp/server/mcpserver/context.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
from __future__ import annotations
22

33
from collections.abc import Iterable, Mapping
4-
from typing import TYPE_CHECKING, Any, Generic, cast
4+
from typing import TYPE_CHECKING, Any, Generic, Literal, cast, overload
55

6-
from mcp_types import ClientCapabilities, InputResponseRequestParams, InputResponses, LoggingLevel
6+
from mcp_types import ClientCapabilities, InputRequiredResult, InputResponseRequestParams, InputResponses, LoggingLevel
77
from pydantic import AnyUrl, BaseModel
88
from typing_extensions import deprecated
99

@@ -99,21 +99,49 @@ async def report_progress(self, progress: float, total: float | None = None, mes
9999
"""
100100
await self.request_context.session.report_progress(progress, total, message)
101101

102-
async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContents]:
102+
@overload
103+
async def read_resource(
104+
self, uri: str | AnyUrl, *, allow_input_required: Literal[False] = False
105+
) -> Iterable[ReadResourceContents]: ...
106+
107+
@overload
108+
async def read_resource(
109+
self, uri: str | AnyUrl, *, allow_input_required: bool
110+
) -> Iterable[ReadResourceContents] | InputRequiredResult: ...
111+
112+
async def read_resource(
113+
self, uri: str | AnyUrl, *, allow_input_required: bool = False
114+
) -> Iterable[ReadResourceContents] | InputRequiredResult:
103115
"""Read a resource by URI.
104116
105117
Args:
106118
uri: Resource URI to read
119+
allow_input_required: When `False` (default), an
120+
`InputRequiredResult` returned by a resource template function
121+
(the 2026-07-28 multi-round-trip flow) raises instead of being
122+
returned. Pass `True` to receive it — a handler may forward it
123+
as its own result; the retry's answers then arrive on
124+
`ctx.input_responses`.
107125
108126
Returns:
109-
The resource content as either text or bytes
127+
The resource content as either text or bytes, or — only with
128+
`allow_input_required=True` — the `InputRequiredResult` the
129+
resource template function returned.
110130
111131
Raises:
112132
ResourceNotFoundError: If no resource or template matches the URI.
113133
ResourceError: If template creation or resource reading fails.
134+
RuntimeError: If the resource returned an `InputRequiredResult`
135+
and `allow_input_required` is `False`.
114136
"""
115137
assert self._mcp_server is not None, "Context is not available outside of a request"
116-
return await self._mcp_server.read_resource(uri, self)
138+
result = await self._mcp_server.read_resource(uri, self)
139+
if isinstance(result, InputRequiredResult) and not allow_input_required:
140+
raise RuntimeError(
141+
"Resource returned InputRequiredResult; pass allow_input_required=True to "
142+
"receive it and forward it as this handler's result."
143+
)
144+
return result
117145

118146
async def elicit(
119147
self,

src/mcp/server/mcpserver/prompts/base.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import anyio.to_thread
1010
import pydantic_core
11-
from mcp_types import ContentBlock, Icon, TextContent
11+
from mcp_types import ContentBlock, Icon, InputRequiredResult, TextContent
1212
from pydantic import BaseModel, Field, TypeAdapter, validate_call
1313

1414
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter, inject_context
@@ -52,7 +52,7 @@ def __init__(self, content: str | ContentBlock, **kwargs: Any):
5252

5353
message_validator = TypeAdapter[UserMessage | AssistantMessage](UserMessage | AssistantMessage)
5454

55-
SyncPromptResult = str | Message | dict[str, Any] | Sequence[str | Message | dict[str, Any]]
55+
SyncPromptResult = str | Message | dict[str, Any] | InputRequiredResult | Sequence[str | Message | dict[str, Any]]
5656
PromptResult = SyncPromptResult | Awaitable[SyncPromptResult]
5757

5858

@@ -92,6 +92,8 @@ def from_function(
9292
- A Message object
9393
- A dict (converted to a message)
9494
- A sequence of any of the above
95+
- An InputRequiredResult (passed through unchanged; the 2026-07-28
96+
multi-round-trip flow — read `ctx.input_responses` on the retry)
9597
"""
9698
func_name = name or fn.__name__
9799

@@ -139,9 +141,12 @@ async def render(
139141
self,
140142
arguments: dict[str, Any] | None,
141143
context: Context[LifespanContextT, RequestT],
142-
) -> list[Message]:
144+
) -> list[Message] | InputRequiredResult:
143145
"""Render the prompt with arguments.
144146
147+
An `InputRequiredResult` returned by the prompt function is passed
148+
through unchanged so the multi-round-trip flow reaches the client.
149+
145150
Raises:
146151
ValueError: If required arguments are missing, or if rendering fails.
147152
"""
@@ -163,6 +168,9 @@ async def render(
163168
else:
164169
result = await anyio.to_thread.run_sync(functools.partial(self.fn, **call_args))
165170

171+
if isinstance(result, InputRequiredResult):
172+
return result
173+
166174
# Validate messages
167175
if not isinstance(result, list | tuple):
168176
result = [result]

src/mcp/server/mcpserver/prompts/manager.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
from typing import TYPE_CHECKING, Any
66

7+
from mcp_types import InputRequiredResult
8+
79
from mcp.server.mcpserver.prompts.base import Message, Prompt
810
from mcp.server.mcpserver.utilities.logging import get_logger
911

@@ -50,7 +52,7 @@ async def render_prompt(
5052
name: str,
5153
arguments: dict[str, Any] | None,
5254
context: Context[LifespanContextT, RequestT],
53-
) -> list[Message]:
55+
) -> list[Message] | InputRequiredResult:
5456
"""Render a prompt by name with arguments."""
5557
prompt = self.get_prompt(name)
5658
if not prompt:

0 commit comments

Comments
 (0)