Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions .github/actions/conformance/expected-failures.2026-07-28.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,4 @@

client: []

server:
# SEP-2322 (multi-round-trip requests / IncompleteResult): the prompt pipeline
# cannot return InputRequiredResult from MCPServer yet (tools/call can).
- input-required-result-non-tool-request
server: []
6 changes: 1 addition & 5 deletions .github/actions/conformance/expected-failures.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,4 @@

client: []

server:
# --- Draft-spec scenarios (in `--suite draft`; the `active` suite is green) ---
# SEP-2322 (multi-round-trip requests / IncompleteResult): the prompt pipeline
# cannot return InputRequiredResult from MCPServer yet (tools/call can).
- input-required-result-non-tool-request
server: []
2 changes: 1 addition & 1 deletion docs/advanced/low-level-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ The handshake belongs to the runner. `server/discover`, `ping`, and every other

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

* `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)**.
* `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)**.
* `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.
* `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.

Expand Down
14 changes: 14 additions & 0 deletions docs/advanced/multi-round-trip.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,19 @@ On `@mcp.tool()` you rarely build this by hand: declare a dependency that asks t

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.

## Beyond tools

`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:

```python title="server.py" hl_lines="21 23 25"
--8<-- "docs_src/mrtr/tutorial004.py"
```

* 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.
* An `@mcp.tool()` function can return the result directly the same way, when the dependency form doesn't fit.
* Static `@mcp.resource()` functions don't participate: they take no `Context`, so they could never read the retry. Only template resources can ask.
* The era rules below apply unchanged: returning an `InputRequiredResult` on a pre-2026 session is the same `-32603` the warning describes.

## The client side

`Client` runs the loop for you.
Expand Down Expand Up @@ -94,5 +107,6 @@ Drop to the underlying session, where `allow_input_required=True` hands you the
* `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.
* To inspect or persist rounds, use `client.session.call_tool(..., allow_input_required=True)` and own the `while isinstance(result, InputRequiredResult)` loop yourself.
* 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.
* 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.

This is the mechanism that replaces server-initiated sampling and the rest of the push-style back-channel; see **[Deprecated features](deprecated.md)**.
18 changes: 18 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,24 @@ If you call `MCPServer.call_tool()` directly, read `.content` and
`.structured_content` off the returned `CallToolResult` instead of branching on
the result type.

### `MCPServer.get_prompt()` and `read_resource()` may return `InputRequiredResult`

Like `call_tool()` above, `MCPServer.get_prompt()` now returns
`GetPromptResult | InputRequiredResult` and `MCPServer.read_resource()` returns
`Iterable[ReadResourceContents] | InputRequiredResult`: at 2026-07-28 an
`@mcp.prompt()` function or an `@mcp.resource()` template function may answer
with an `InputRequiredResult` to request client input first (see
[Multi-round-trip requests](advanced/multi-round-trip.md)). If you call these
methods directly, narrow with `isinstance` (or
`assert not isinstance(result, InputRequiredResult)` when your prompt and
resource functions never return one). `Prompt.render()` and
`ResourceTemplate.create_resource()` carry the same union.

`ctx.read_resource()` inside a handler is unchanged by default: it still
returns content and raises `RuntimeError` if the resource requests input; pass
`allow_input_required=True` to receive the `InputRequiredResult` and forward it
as the handler's own result.

### `MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error

Raising `MCPError` (or a subclass such as `UrlElicitationRequiredError`) inside
Expand Down
26 changes: 26 additions & 0 deletions docs_src/mrtr/tutorial004.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from mcp_types import ElicitRequest, ElicitRequestFormParams, ElicitResult, InputRequiredResult

from mcp.server.mcpserver import Context, MCPServer
from mcp.server.mcpserver.prompts.base import UserMessage

mcp = MCPServer("Briefing")

ASK_AUDIENCE = ElicitRequest(
params=ElicitRequestFormParams(
message="Who is the briefing for?",
requested_schema={
"type": "object",
"properties": {"audience": {"type": "string"}},
"required": ["audience"],
},
)
)


@mcp.prompt()
async def briefing(ctx: Context) -> list[UserMessage] | InputRequiredResult:
"""Draft a briefing tuned to its audience."""
answer = (ctx.input_responses or {}).get("audience")
if not isinstance(answer, ElicitResult) or answer.content is None:
return InputRequiredResult(input_requests={"audience": ASK_AUDIENCE})
return [UserMessage(f"Write a briefing for {answer.content['audience']}.")]
24 changes: 24 additions & 0 deletions examples/servers/everything-server/mcp_everything_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,30 @@ def test_prompt_with_image() -> list[UserMessage]:
]


@mcp.prompt()
async def test_input_required_result_prompt(ctx: Context) -> list[UserMessage] | InputRequiredResult:
"""Tests InputRequiredResult from prompts/get (SEP-2322 non-tool request)"""
responses = ctx.input_responses
if responses and "user_context" in responses:
answer = responses["user_context"]
text = answer.content.get("context", "?") if isinstance(answer, ElicitResult) and answer.content else "?"
return [UserMessage(role="user", content=TextContent(type="text", text=f"Use the following context: {text}"))]
return InputRequiredResult(
input_requests={
"user_context": ElicitRequest(
params=ElicitRequestFormParams(
message="What context should the prompt use?",
requested_schema={
"type": "object",
"properties": {"context": {"type": "string"}},
"required": ["context"],
},
)
)
}
)


# Custom request handlers
# TODO(felix): Add public APIs to MCPServer for subscribe_resource, unsubscribe_resource,
# and set_logging_level to avoid accessing protected _lowlevel_server attribute.
Expand Down
38 changes: 33 additions & 5 deletions src/mcp/server/mcpserver/context.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from __future__ import annotations

from collections.abc import Iterable, Mapping
from typing import TYPE_CHECKING, Any, Generic, cast
from typing import TYPE_CHECKING, Any, Generic, Literal, cast, overload

from mcp_types import ClientCapabilities, InputResponseRequestParams, InputResponses, LoggingLevel
from mcp_types import ClientCapabilities, InputRequiredResult, InputResponseRequestParams, InputResponses, LoggingLevel
from pydantic import AnyUrl, BaseModel
from typing_extensions import deprecated

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

async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContents]:
@overload
async def read_resource(
self, uri: str | AnyUrl, *, allow_input_required: Literal[False] = False
) -> Iterable[ReadResourceContents]: ...

@overload
async def read_resource(
self, uri: str | AnyUrl, *, allow_input_required: bool
) -> Iterable[ReadResourceContents] | InputRequiredResult: ...

async def read_resource(
self, uri: str | AnyUrl, *, allow_input_required: bool = False
) -> Iterable[ReadResourceContents] | InputRequiredResult:
"""Read a resource by URI.

Args:
uri: Resource URI to read
allow_input_required: When `False` (default), an
`InputRequiredResult` returned by a resource template function
(the 2026-07-28 multi-round-trip flow) raises instead of being
returned. Pass `True` to receive it — a handler may forward it
as its own result; the retry's answers then arrive on
`ctx.input_responses`.

Returns:
The resource content as either text or bytes
The resource content as either text or bytes, or — only with
`allow_input_required=True` — the `InputRequiredResult` the
resource template function returned.

Raises:
ResourceNotFoundError: If no resource or template matches the URI.
ResourceError: If template creation or resource reading fails.
RuntimeError: If the resource returned an `InputRequiredResult`
and `allow_input_required` is `False`.
"""
assert self._mcp_server is not None, "Context is not available outside of a request"
return await self._mcp_server.read_resource(uri, self)
result = await self._mcp_server.read_resource(uri, self)
if isinstance(result, InputRequiredResult) and not allow_input_required:
raise RuntimeError(
"Resource returned InputRequiredResult; pass allow_input_required=True to "
"receive it and forward it as this handler's result."
)
return result

async def elicit(
self,
Expand Down
14 changes: 11 additions & 3 deletions src/mcp/server/mcpserver/prompts/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import anyio.to_thread
import pydantic_core
from mcp_types import ContentBlock, Icon, TextContent
from mcp_types import ContentBlock, Icon, InputRequiredResult, TextContent
from pydantic import BaseModel, Field, TypeAdapter, validate_call

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

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

SyncPromptResult = str | Message | dict[str, Any] | Sequence[str | Message | dict[str, Any]]
SyncPromptResult = str | Message | dict[str, Any] | InputRequiredResult | Sequence[str | Message | dict[str, Any]]
PromptResult = SyncPromptResult | Awaitable[SyncPromptResult]


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

Expand Down Expand Up @@ -139,9 +141,12 @@ async def render(
self,
arguments: dict[str, Any] | None,
context: Context[LifespanContextT, RequestT],
) -> list[Message]:
) -> list[Message] | InputRequiredResult:
"""Render the prompt with arguments.

An `InputRequiredResult` returned by the prompt function is passed
through unchanged so the multi-round-trip flow reaches the client.

Raises:
ValueError: If required arguments are missing, or if rendering fails.
"""
Expand All @@ -163,6 +168,9 @@ async def render(
else:
result = await anyio.to_thread.run_sync(functools.partial(self.fn, **call_args))

if isinstance(result, InputRequiredResult):
return result

# Validate messages
if not isinstance(result, list | tuple):
result = [result]
Expand Down
4 changes: 3 additions & 1 deletion src/mcp/server/mcpserver/prompts/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

from typing import TYPE_CHECKING, Any

from mcp_types import InputRequiredResult

from mcp.server.mcpserver.prompts.base import Message, Prompt
from mcp.server.mcpserver.utilities.logging import get_logger

Expand Down Expand Up @@ -50,7 +52,7 @@ async def render_prompt(
name: str,
arguments: dict[str, Any] | None,
context: Context[LifespanContextT, RequestT],
) -> list[Message]:
) -> list[Message] | InputRequiredResult:
"""Render a prompt by name with arguments."""
prompt = self.get_prompt(name)
if not prompt:
Expand Down
10 changes: 8 additions & 2 deletions src/mcp/server/mcpserver/resources/resource_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from collections.abc import Callable
from typing import TYPE_CHECKING, Any

from mcp_types import Annotations, Icon
from mcp_types import Annotations, Icon, InputRequiredResult
from pydantic import AnyUrl

from mcp.server.mcpserver.exceptions import ResourceNotFoundError
Expand Down Expand Up @@ -86,9 +86,15 @@ def add_template(
self._templates[template.uri_template] = template
return template

async def get_resource(self, uri: AnyUrl | str, context: Context[LifespanContextT, RequestT]) -> Resource:
async def get_resource(
self, uri: AnyUrl | str, context: Context[LifespanContextT, RequestT]
) -> Resource | InputRequiredResult:
"""Get resource by URI, checking concrete resources first, then templates.

A template function may return an `InputRequiredResult` instead of
resource content (the 2026-07-28 multi-round-trip flow); it is passed
through unchanged.

Raises:
ResourceNotFoundError: If no resource or template matches the URI.
ResourceError: If a matching template fails to create the resource.
Expand Down
12 changes: 10 additions & 2 deletions src/mcp/server/mcpserver/resources/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from typing import TYPE_CHECKING, Any

import anyio.to_thread
from mcp_types import Annotations, Icon
from mcp_types import Annotations, Icon, InputRequiredResult
from pydantic import BaseModel, Field, validate_call

from mcp.server.mcpserver.exceptions import ResourceError
Expand Down Expand Up @@ -208,9 +208,14 @@ async def create_resource(
uri: str,
params: dict[str, Any],
context: Context[LifespanContextT, RequestT],
) -> Resource:
) -> Resource | InputRequiredResult:
"""Create a resource from the template with the given parameters.

An `InputRequiredResult` returned by the template function is passed
through unchanged (the 2026-07-28 multi-round-trip flow); the retry's
answers arrive on `ctx.input_responses`, with `ctx.request_state`
carrying the echoed opaque state.

Raises:
ResourceError: If creating the resource fails.
"""
Expand All @@ -224,6 +229,9 @@ async def create_resource(
else:
result = await anyio.to_thread.run_sync(functools.partial(self.fn, **params))

if isinstance(result, InputRequiredResult):
return result

return FunctionResource(
uri=uri, # type: ignore
name=self.name,
Expand Down
10 changes: 9 additions & 1 deletion src/mcp/server/mcpserver/resources/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import httpx
import pydantic
import pydantic_core
from mcp_types import Annotations, Icon
from mcp_types import Annotations, Icon, InputRequiredResult
from pydantic import Field, ValidationInfo, validate_call

from mcp.server.mcpserver.resources.base import Resource
Expand Down Expand Up @@ -63,6 +63,14 @@ async def read(self) -> str | bytes:
else:
result = await anyio.to_thread.run_sync(self.fn)

if isinstance(result, InputRequiredResult):
# A static resource function can never read the retry's
# input_responses (it takes no Context), so this can only be a
# mistake — reject it instead of JSON-dumping it as content.
raise ValueError(
"static resources cannot return InputRequiredResult; only resource "
"template functions participate in the multi-round-trip flow"
)
if isinstance(result, Resource): # pragma: no cover
return await result.read()
elif isinstance(result, bytes):
Expand Down
Loading
Loading