Skip to content

Commit 8d0f928

Browse files
authored
Pass InputRequiredResult through the MCPServer prompt and resource pipelines (#3020)
1 parent 8f2c97b commit 8d0f928

21 files changed

Lines changed: 627 additions & 32 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: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,26 @@ 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: it still returns content,
37+
and raises `RuntimeError` if the resource requests input. A handler that wants
38+
to receive the `InputRequiredResult` and forward it as its own result calls
39+
`MCPServer.read_resource(uri, context)` directly — but not from a tool whose
40+
dependencies elicit via `Resolve(...)`: the resolver owns that tool's
41+
`request_state` channel, and a forwarded result's state would clobber it.
42+
2343
### `MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error
2444

2545
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: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from collections.abc import Iterable, Mapping
44
from typing import TYPE_CHECKING, Any, Generic, cast
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

@@ -89,6 +89,16 @@ def request_context(self) -> ServerRequestContext[LifespanContextT, RequestT]:
8989
raise ValueError("Context is not available outside of a request")
9090
return self._request_context
9191

92+
def _nested_invocation(self) -> Context[LifespanContextT, RequestT]:
93+
"""A Context for invoking another handler's function from inside this request.
94+
95+
Shares the request infrastructure (session, request metadata, lifespan) but
96+
carries no `input_responses`/`request_state`: those are addressed to the wire
97+
request's own target — their keys are ones that handler minted — so a nested
98+
invocation always starts on round one.
99+
"""
100+
return Context(request_context=self._request_context, mcp_server=self._mcp_server)
101+
92102
async def report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
93103
"""Report progress for the current operation.
94104
@@ -102,6 +112,17 @@ async def report_progress(self, progress: float, total: float | None = None, mes
102112
async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContents]:
103113
"""Read a resource by URI.
104114
115+
This is a content reader: an `InputRequiredResult` returned by a
116+
resource template function (the 2026-07-28 multi-round-trip flow)
117+
raises here, and the nested template never sees this request's
118+
`input_responses`/`request_state` — those answer the outer handler's
119+
own questions, so the template always behaves as round one. A handler
120+
that wants to receive and forward an `InputRequiredResult` as its own
121+
result calls `MCPServer.read_resource(uri, context)` instead — but
122+
not from a tool whose dependencies elicit via `Resolve(...)`: the
123+
resolver owns that tool's `request_state` channel, and a forwarded
124+
result's state would clobber it.
125+
105126
Args:
106127
uri: Resource URI to read
107128
@@ -111,9 +132,16 @@ async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContent
111132
Raises:
112133
ResourceNotFoundError: If no resource or template matches the URI.
113134
ResourceError: If template creation or resource reading fails.
135+
RuntimeError: If the resource returned an `InputRequiredResult`.
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._nested_invocation())
139+
if isinstance(result, InputRequiredResult):
140+
raise RuntimeError(
141+
"Resource returned InputRequiredResult; ctx.read_resource() only returns "
142+
"content — use MCPServer.read_resource(uri, context) to receive and forward it."
143+
)
144+
return result
117145

118146
async def elicit(
119147
self,

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

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@
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
1515
from mcp.server.mcpserver.utilities.func_metadata import func_metadata
1616
from mcp.shared._callable_inspection import is_async_callable
17+
from mcp.shared.exceptions import MCPError
1718

1819
if TYPE_CHECKING:
1920
from mcp.server.context import LifespanContextT, RequestT
@@ -52,7 +53,7 @@ def __init__(self, content: str | ContentBlock, **kwargs: Any):
5253

5354
message_validator = TypeAdapter[UserMessage | AssistantMessage](UserMessage | AssistantMessage)
5455

55-
SyncPromptResult = str | Message | dict[str, Any] | Sequence[str | Message | dict[str, Any]]
56+
SyncPromptResult = str | Message | dict[str, Any] | InputRequiredResult | Sequence[str | Message | dict[str, Any]]
5657
PromptResult = SyncPromptResult | Awaitable[SyncPromptResult]
5758

5859

@@ -92,6 +93,8 @@ def from_function(
9293
- A Message object
9394
- A dict (converted to a message)
9495
- A sequence of any of the above
96+
- An InputRequiredResult (passed through unchanged; the 2026-07-28
97+
multi-round-trip flow — read `ctx.input_responses` on the retry)
9598
"""
9699
func_name = name or fn.__name__
97100

@@ -139,9 +142,12 @@ async def render(
139142
self,
140143
arguments: dict[str, Any] | None,
141144
context: Context[LifespanContextT, RequestT],
142-
) -> list[Message]:
145+
) -> list[Message] | InputRequiredResult:
143146
"""Render the prompt with arguments.
144147
148+
An `InputRequiredResult` returned by the prompt function is passed
149+
through unchanged so the multi-round-trip flow reaches the client.
150+
145151
Raises:
146152
ValueError: If required arguments are missing, or if rendering fails.
147153
"""
@@ -163,6 +169,9 @@ async def render(
163169
else:
164170
result = await anyio.to_thread.run_sync(functools.partial(self.fn, **call_args))
165171

172+
if isinstance(result, InputRequiredResult):
173+
return result
174+
166175
# Validate messages
167176
if not isinstance(result, list | tuple):
168177
result = [result]
@@ -185,5 +194,7 @@ async def render(
185194
raise ValueError(f"Could not convert prompt result to message: {msg}")
186195

187196
return messages
197+
except MCPError:
198+
raise
188199
except Exception as e:
189200
raise ValueError(f"Error rendering prompt {self.name}: {e}")

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)