Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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)**.
20 changes: 20 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,26 @@ 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: it still returns content,
and raises `RuntimeError` if the resource requests input. A handler that wants
to receive the `InputRequiredResult` and forward it as its own result calls
`MCPServer.read_resource(uri, context)` directly — but not from a tool whose
dependencies elicit via `Resolve(...)`: the resolver owns that tool's
`request_state` channel, and a forwarded result's state would clobber it.

### `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
32 changes: 30 additions & 2 deletions src/mcp/server/mcpserver/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from collections.abc import Iterable, Mapping
from typing import TYPE_CHECKING, Any, Generic, cast

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 @@ -89,6 +89,16 @@ def request_context(self) -> ServerRequestContext[LifespanContextT, RequestT]:
raise ValueError("Context is not available outside of a request")
return self._request_context

def _nested_invocation(self) -> Context[LifespanContextT, RequestT]:
"""A Context for invoking another handler's function from inside this request.

Shares the request infrastructure (session, request metadata, lifespan) but
carries no `input_responses`/`request_state`: those are addressed to the wire
request's own target — their keys are ones that handler minted — so a nested
invocation always starts on round one.
"""
return Context(request_context=self._request_context, mcp_server=self._mcp_server)

async def report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
"""Report progress for the current operation.

Expand All @@ -102,6 +112,17 @@ async def report_progress(self, progress: float, total: float | None = None, mes
async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContents]:
"""Read a resource by URI.

This is a content reader: an `InputRequiredResult` returned by a
resource template function (the 2026-07-28 multi-round-trip flow)
raises here, and the nested template never sees this request's
`input_responses`/`request_state` — those answer the outer handler's
own questions, so the template always behaves as round one. A handler
that wants to receive and forward an `InputRequiredResult` as its own
result calls `MCPServer.read_resource(uri, context)` instead — but
not from a tool whose dependencies elicit via `Resolve(...)`: the
resolver owns that tool's `request_state` channel, and a forwarded
result's state would clobber it.

Args:
uri: Resource URI to read

Expand All @@ -111,9 +132,16 @@ async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContent
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`.
"""
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._nested_invocation())
if isinstance(result, InputRequiredResult):
raise RuntimeError(
"Resource returned InputRequiredResult; ctx.read_resource() only returns "
"content — use MCPServer.read_resource(uri, context) to receive and forward it."
)
return result

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

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
from mcp.server.mcpserver.utilities.func_metadata import func_metadata
from mcp.shared._callable_inspection import is_async_callable
from mcp.shared.exceptions import MCPError

if TYPE_CHECKING:
from mcp.server.context import LifespanContextT, RequestT
Expand Down Expand Up @@ -52,7 +53,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 +93,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 +142,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 +169,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 All @@ -185,5 +194,7 @@ async def render(
raise ValueError(f"Could not convert prompt result to message: {msg}")

return messages
except MCPError:
raise
except Exception as e:
raise ValueError(f"Error rendering prompt {self.name}: {e}")
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
15 changes: 12 additions & 3 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 All @@ -17,6 +17,7 @@
from mcp.server.mcpserver.utilities.func_metadata import func_metadata
from mcp.server.mcpserver.utilities.logging import get_logger
from mcp.shared._callable_inspection import is_async_callable
from mcp.shared.exceptions import MCPError
from mcp.shared.path_security import contains_path_traversal, is_absolute_path
from mcp.shared.uri_template import UriTemplate

Expand Down Expand Up @@ -208,9 +209,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 +230,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 All @@ -235,7 +244,7 @@ async def create_resource(
meta=self.meta,
fn=lambda: result, # Capture result in closure
)
except ResourceError:
except (ResourceError, MCPError):
raise
except Exception as exc:
logger.exception(f"Error creating resource from template {uri}")
Expand Down
Loading
Loading