From 4a8cb8ce7e82c170eb7fa77d11f98e9e854ac5e8 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:23:08 +0000 Subject: [PATCH 1/4] 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. --- .../expected-failures.2026-07-28.yml | 5 +- .../actions/conformance/expected-failures.yml | 6 +- docs/advanced/low-level-server.md | 2 +- docs/advanced/multi-round-trip.md | 14 ++ docs/migration.md | 18 ++ docs_src/mrtr/tutorial004.py | 26 +++ .../mcp_everything_server/server.py | 24 +++ src/mcp/server/mcpserver/context.py | 38 +++- src/mcp/server/mcpserver/prompts/base.py | 14 +- src/mcp/server/mcpserver/prompts/manager.py | 4 +- .../mcpserver/resources/resource_manager.py | 10 +- .../server/mcpserver/resources/templates.py | 12 +- src/mcp/server/mcpserver/resources/types.py | 10 +- src/mcp/server/mcpserver/server.py | 39 +++- tests/docs_src/test_mrtr.py | 54 +++++- tests/issues/test_141_resource_templates.py | 2 + tests/server/mcpserver/prompts/test_base.py | 36 +++- .../resources/test_function_resources.py | 19 ++ .../resources/test_resource_template.py | 30 ++- .../mcpserver/servers/test_file_server.py | 4 + tests/server/mcpserver/test_server.py | 183 ++++++++++++++++++ 21 files changed, 516 insertions(+), 34 deletions(-) create mode 100644 docs_src/mrtr/tutorial004.py diff --git a/.github/actions/conformance/expected-failures.2026-07-28.yml b/.github/actions/conformance/expected-failures.2026-07-28.yml index 82300dfeaf..504b463856 100644 --- a/.github/actions/conformance/expected-failures.2026-07-28.yml +++ b/.github/actions/conformance/expected-failures.2026-07-28.yml @@ -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: [] diff --git a/.github/actions/conformance/expected-failures.yml b/.github/actions/conformance/expected-failures.yml index 006f4e2ece..4ad4123d02 100644 --- a/.github/actions/conformance/expected-failures.yml +++ b/.github/actions/conformance/expected-failures.yml @@ -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: [] diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md index 2220151db5..12c4532949 100644 --- a/docs/advanced/low-level-server.md +++ b/docs/advanced/low-level-server.md @@ -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. diff --git a/docs/advanced/multi-round-trip.md b/docs/advanced/multi-round-trip.md index 665808a5dc..ee8d0188a1 100644 --- a/docs/advanced/multi-round-trip.md +++ b/docs/advanced/multi-round-trip.md @@ -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. @@ -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)**. diff --git a/docs/migration.md b/docs/migration.md index 68155560d9..bd75e8c985 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -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 diff --git a/docs_src/mrtr/tutorial004.py b/docs_src/mrtr/tutorial004.py new file mode 100644 index 0000000000..05b945935f --- /dev/null +++ b/docs_src/mrtr/tutorial004.py @@ -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']}.")] diff --git a/examples/servers/everything-server/mcp_everything_server/server.py b/examples/servers/everything-server/mcp_everything_server/server.py index f622aac7a3..e4f5db84f6 100644 --- a/examples/servers/everything-server/mcp_everything_server/server.py +++ b/examples/servers/everything-server/mcp_everything_server/server.py @@ -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. diff --git a/src/mcp/server/mcpserver/context.py b/src/mcp/server/mcpserver/context.py index 82a6fa2b6e..99a0eb77b3 100644 --- a/src/mcp/server/mcpserver/context.py +++ b/src/mcp/server/mcpserver/context.py @@ -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 @@ -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, diff --git a/src/mcp/server/mcpserver/prompts/base.py b/src/mcp/server/mcpserver/prompts/base.py index 338cb1f870..d8e3b421e1 100644 --- a/src/mcp/server/mcpserver/prompts/base.py +++ b/src/mcp/server/mcpserver/prompts/base.py @@ -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 @@ -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] @@ -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__ @@ -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. """ @@ -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] diff --git a/src/mcp/server/mcpserver/prompts/manager.py b/src/mcp/server/mcpserver/prompts/manager.py index 28a7a6e98c..7e7f350787 100644 --- a/src/mcp/server/mcpserver/prompts/manager.py +++ b/src/mcp/server/mcpserver/prompts/manager.py @@ -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 @@ -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: diff --git a/src/mcp/server/mcpserver/resources/resource_manager.py b/src/mcp/server/mcpserver/resources/resource_manager.py index 41d3d7bb37..e56e3ba177 100644 --- a/src/mcp/server/mcpserver/resources/resource_manager.py +++ b/src/mcp/server/mcpserver/resources/resource_manager.py @@ -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 @@ -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. diff --git a/src/mcp/server/mcpserver/resources/templates.py b/src/mcp/server/mcpserver/resources/templates.py index f78b5ec666..9c7100632e 100644 --- a/src/mcp/server/mcpserver/resources/templates.py +++ b/src/mcp/server/mcpserver/resources/templates.py @@ -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 @@ -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. """ @@ -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, diff --git a/src/mcp/server/mcpserver/resources/types.py b/src/mcp/server/mcpserver/resources/types.py index e295e21e02..fcca97035c 100644 --- a/src/mcp/server/mcpserver/resources/types.py +++ b/src/mcp/server/mcpserver/resources/types.py @@ -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 @@ -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): diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 888eae6541..d8fd3d66b3 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -389,7 +389,7 @@ async def _handle_list_resources( async def _handle_read_resource( self, ctx: ServerRequestContext[LifespanResultT], params: ReadResourceRequestParams - ) -> ReadResourceResult: + ) -> ReadResourceResult | InputRequiredResult: context = Context(request_context=ctx, mcp_server=self, input_params=params) try: results = await self.read_resource(params.uri, context) @@ -397,6 +397,8 @@ async def _handle_read_resource( raise MCPError(code=INVALID_PARAMS, message=str(err), data={"uri": str(params.uri)}) except ResourceError as err: raise MCPError(code=INTERNAL_ERROR, message=str(err), data={"uri": str(params.uri)}) + if isinstance(results, InputRequiredResult): + return results contents: list[TextResourceContents | BlobResourceContents] = [] for item in results: if isinstance(item.content, bytes): @@ -431,7 +433,7 @@ async def _handle_list_prompts( async def _handle_get_prompt( self, ctx: ServerRequestContext[LifespanResultT], params: GetPromptRequestParams - ) -> GetPromptResult: + ) -> GetPromptResult | InputRequiredResult: context = Context(request_context=ctx, mcp_server=self, input_params=params) return await self.get_prompt(params.name, params.arguments, context) @@ -496,9 +498,14 @@ async def list_resource_templates(self) -> list[MCPResourceTemplate]: async def read_resource( self, uri: AnyUrl | str, context: Context[LifespanResultT, Any] | None = None - ) -> Iterable[ReadResourceContents]: + ) -> Iterable[ReadResourceContents] | InputRequiredResult: """Read a resource by URI. + An `InputRequiredResult` returned by a resource 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: ResourceNotFoundError: If no resource or template matches the URI. ResourceError: If template creation or resource reading fails. @@ -506,6 +513,8 @@ async def read_resource( if context is None: context = Context(mcp_server=self) resource = await self._resource_manager.get_resource(uri, context) + if isinstance(resource, InputRequiredResult): + return resource try: content = await resource.read() @@ -696,6 +705,9 @@ def resource( The function can return: - str for text content - bytes for binary content + - an InputRequiredResult (template resources only; passed through + unchanged for the 2026-07-28 multi-round-trip flow — read + `ctx.input_responses` on the retry) - other types will be converted to JSON If the URI contains parameters (e.g. "resource://{param}"), it is @@ -852,6 +864,11 @@ def prompt( ) -> Callable[[_CallableT], _CallableT]: """Decorator to register a prompt. + The function returns the prompt messages (a string, `Message`, dict, + or a sequence of these), or an `InputRequiredResult` to request + client input first (the 2026-07-28 multi-round-trip flow — read + `ctx.input_responses` on the retry). + Args: name: Optional name for the prompt (defaults to function name) title: Optional human-readable title for the prompt @@ -1192,8 +1209,14 @@ async def list_prompts(self) -> list[MCPPrompt]: async def get_prompt( self, name: str, arguments: dict[str, Any] | None = None, context: Context[LifespanResultT, Any] | None = None - ) -> GetPromptResult: - """Get a prompt by name with arguments.""" + ) -> GetPromptResult | InputRequiredResult: + """Get a prompt by name with arguments. + + An `InputRequiredResult` returned by the prompt 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. + """ if context is None: context = Context(mcp_server=self) try: @@ -1201,11 +1224,13 @@ async def get_prompt( if not prompt: raise ValueError(f"Unknown prompt: {name}") - messages = await prompt.render(arguments, context) + rendered = await prompt.render(arguments, context) + if isinstance(rendered, InputRequiredResult): + return rendered return GetPromptResult( description=prompt.description, - messages=pydantic_core.to_jsonable_python(messages), + messages=pydantic_core.to_jsonable_python(rendered), ) except Exception as e: logger.exception(f"Error getting prompt {name}") diff --git a/tests/docs_src/test_mrtr.py b/tests/docs_src/test_mrtr.py index 4be449edc0..110bd8f781 100644 --- a/tests/docs_src/test_mrtr.py +++ b/tests/docs_src/test_mrtr.py @@ -10,13 +10,17 @@ 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."), + ) + ], + ) + ) diff --git a/tests/issues/test_141_resource_templates.py b/tests/issues/test_141_resource_templates.py index e955ab0afb..9ec4f94a3e 100644 --- a/tests/issues/test_141_resource_templates.py +++ b/tests/issues/test_141_resource_templates.py @@ -1,5 +1,6 @@ import pytest from mcp_types import ( + InputRequiredResult, ListResourceTemplatesResult, TextResourceContents, ) @@ -49,6 +50,7 @@ def get_user_profile_missing(user_id: str) -> str: # pragma: no cover # 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" diff --git a/tests/server/mcpserver/prompts/test_base.py b/tests/server/mcpserver/prompts/test_base.py index ef795777ea..e88a096ba8 100644 --- a/tests/server/mcpserver/prompts/test_base.py +++ b/tests/server/mcpserver/prompts/test_base.py @@ -2,7 +2,14 @@ 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 @@ def blocking_fn() -> str: 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 diff --git a/tests/server/mcpserver/resources/test_function_resources.py b/tests/server/mcpserver/resources/test_function_resources.py index c1ff960617..5a5c5c48dd 100644 --- a/tests/server/mcpserver/resources/test_function_resources.py +++ b/tests/server/mcpserver/resources/test_function_resources.py @@ -3,6 +3,8 @@ 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 run() -> None: 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" + ) diff --git a/tests/server/mcpserver/resources/test_resource_template.py b/tests/server/mcpserver/resources/test_resource_template.py index 58c072ae32..42a1099537 100644 --- a/tests/server/mcpserver/resources/test_resource_template.py +++ b/tests/server/mcpserver/resources/test_resource_template.py @@ -3,7 +3,7 @@ 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 @@ def get_item(item_id: str) -> str: # 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 @@ def blocking_fn(name: str) -> str: 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 diff --git a/tests/server/mcpserver/servers/test_file_server.py b/tests/server/mcpserver/servers/test_file_server.py index 9c3fe265c2..e06d0bfe2e 100644 --- a/tests/server/mcpserver/servers/test_file_server.py +++ b/tests/server/mcpserver/servers/test_file_server.py @@ -2,6 +2,7 @@ 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] diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index d92ed5eaad..a98485b2e9 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -1311,6 +1311,7 @@ def fn() -> str: 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 +1329,7 @@ def fn() -> str: 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 +1345,7 @@ def fn() -> str: 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 +1361,7 @@ def fn() -> str: 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 +1925,185 @@ async def greet(ctx: Context) -> str | InputRequiredResult: 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_by_default(): + """ctx.read_resource is a content reader: an InputRequiredResult from the template + raises with the opt-in hint instead of widening every caller (mirrors ClientSession).""" + 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; pass allow_input_required=True to receive it " + "and forward it as this handler's result." + ) + + +async def test_context_read_resource_with_allow_input_required_forwards_the_result(): + """With allow_input_required=True the handler receives the template's + InputRequiredResult unchanged and may forward 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 context.read_resource("ask://databases", allow_input_required=True) + assert result is sentinel + + async def test_context_exposes_client_capabilities_from_connection(): mcp = MCPServer() seen: list[ClientCapabilities | None] = [] From f689d8f94953d8114d90473bf649365ed77a4eb9 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:57:19 +0000 Subject: [PATCH 2/4] Make Context.read_resource raise-only on InputRequiredResult Drop the allow_input_required overloads: ctx.read_resource is a content reader, so an InputRequiredResult from a resource template always raises, with the error pointing at the forwarding path. A handler that wants to receive and forward the result as its own calls MCPServer.read_resource(uri, context) directly, which carries the union. --- docs/migration.md | 8 +++--- src/mcp/server/mcpserver/context.py | 40 ++++++++------------------- tests/server/mcpserver/test_server.py | 16 +++++------ 3 files changed, 24 insertions(+), 40 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index bd75e8c985..e493f75795 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -33,10 +33,10 @@ methods directly, narrow with `isinstance` (or 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. +`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. ### `MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error diff --git a/src/mcp/server/mcpserver/context.py b/src/mcp/server/mcpserver/context.py index 99a0eb77b3..a98b1601cf 100644 --- a/src/mcp/server/mcpserver/context.py +++ b/src/mcp/server/mcpserver/context.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Iterable, Mapping -from typing import TYPE_CHECKING, Any, Generic, Literal, cast, overload +from typing import TYPE_CHECKING, Any, Generic, cast from mcp_types import ClientCapabilities, InputRequiredResult, InputResponseRequestParams, InputResponses, LoggingLevel from pydantic import AnyUrl, BaseModel @@ -99,47 +99,31 @@ async def report_progress(self, progress: float, total: float | None = None, mes """ await self.request_context.session.report_progress(progress, total, message) - @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: + 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. A handler that wants to receive and forward one as its + own result calls `MCPServer.read_resource(uri, context)` instead. + 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, or — only with - `allow_input_required=True` — the `InputRequiredResult` the - resource template function returned. + The resource content as either text or bytes 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`. + RuntimeError: If the resource returned an `InputRequiredResult`. """ assert self._mcp_server is not None, "Context is not available outside of a request" result = await self._mcp_server.read_resource(uri, self) - if isinstance(result, InputRequiredResult) and not allow_input_required: + if isinstance(result, InputRequiredResult): raise RuntimeError( - "Resource returned InputRequiredResult; pass allow_input_required=True to " - "receive it and forward it as this handler's result." + "Resource returned InputRequiredResult; ctx.read_resource() only returns " + "content — use MCPServer.read_resource(uri, context) to receive and forward it." ) return result diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index a98485b2e9..5a0f4a0951 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -2071,9 +2071,9 @@ async def ask(topic: str, ctx: Context) -> str | InputRequiredResult: assert contents.text == "databases notes for Alice" -async def test_context_read_resource_raises_on_input_required_result_by_default(): +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 the opt-in hint instead of widening every caller (mirrors ClientSession).""" + raises with a pointer at the forwarding path instead of widening every caller.""" mcp = MCPServer() @mcp.resource("ask://{topic}") @@ -2084,14 +2084,14 @@ async def ask(topic: str, ctx: Context) -> str | InputRequiredResult: with pytest.raises(RuntimeError) as exc: await context.read_resource("ask://databases") assert str(exc.value) == snapshot( - "Resource returned InputRequiredResult; pass allow_input_required=True to receive it " - "and forward it as this handler's result." + "Resource returned InputRequiredResult; ctx.read_resource() only returns " + "content — use MCPServer.read_resource(uri, context) to receive and forward it." ) -async def test_context_read_resource_with_allow_input_required_forwards_the_result(): - """With allow_input_required=True the handler receives the template's - InputRequiredResult unchanged and may forward it as its own result.""" +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()}) @@ -2100,7 +2100,7 @@ async def ask(topic: str, ctx: Context) -> str | InputRequiredResult: return sentinel context = Context(mcp_server=mcp) - result = await context.read_resource("ask://databases", allow_input_required=True) + result = await mcp.read_resource("ask://databases", context) assert result is sentinel From 273818156a5720214fca9bd09405ae52c909e464 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:07:21 +0000 Subject: [PATCH 3/4] Let handler-raised MCPError keep its code and data in the prompt and resource pipelines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tools/call already re-raises MCPError so a handler can answer with a typed protocol error (e.g. a missing-client-capability rejection with data.requiredCapabilities); the prompt and resource pipelines wrapped it into generic internal errors, destroying the code and data. Add the same carve-out at the five wrap sites (Prompt.render, MCPServer.get_prompt, ResourceTemplate.create_resource, FunctionResource.read, MCPServer.read_resource) — this matters now that prompt and template functions participate in the multi-round-trip flow and must be able to reject a request themselves. Also document that a tool whose dependencies elicit owns its request_state channel, so handlers should not forward another result's InputRequiredResult from such a tool. --- docs/migration.md | 4 +- src/mcp/server/mcpserver/context.py | 5 +- src/mcp/server/mcpserver/prompts/base.py | 3 + .../server/mcpserver/resources/templates.py | 3 +- src/mcp/server/mcpserver/resources/types.py | 3 + src/mcp/server/mcpserver/server.py | 4 ++ tests/server/mcpserver/test_server.py | 64 +++++++++++++++++++ 7 files changed, 83 insertions(+), 3 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index e493f75795..516cd8b18a 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -36,7 +36,9 @@ resource functions never return one). `Prompt.render()` and `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. +`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 diff --git a/src/mcp/server/mcpserver/context.py b/src/mcp/server/mcpserver/context.py index a98b1601cf..c7d8c66e9f 100644 --- a/src/mcp/server/mcpserver/context.py +++ b/src/mcp/server/mcpserver/context.py @@ -105,7 +105,10 @@ async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContent This is a content reader: an `InputRequiredResult` returned by a resource template function (the 2026-07-28 multi-round-trip flow) raises here. A handler that wants to receive and forward one as its - own result calls `MCPServer.read_resource(uri, context)` instead. + 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 diff --git a/src/mcp/server/mcpserver/prompts/base.py b/src/mcp/server/mcpserver/prompts/base.py index d8e3b421e1..0a010de7d2 100644 --- a/src/mcp/server/mcpserver/prompts/base.py +++ b/src/mcp/server/mcpserver/prompts/base.py @@ -14,6 +14,7 @@ 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 @@ -193,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}") diff --git a/src/mcp/server/mcpserver/resources/templates.py b/src/mcp/server/mcpserver/resources/templates.py index 9c7100632e..096e821d81 100644 --- a/src/mcp/server/mcpserver/resources/templates.py +++ b/src/mcp/server/mcpserver/resources/templates.py @@ -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 @@ -243,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}") diff --git a/src/mcp/server/mcpserver/resources/types.py b/src/mcp/server/mcpserver/resources/types.py index fcca97035c..689e0ff6fc 100644 --- a/src/mcp/server/mcpserver/resources/types.py +++ b/src/mcp/server/mcpserver/resources/types.py @@ -17,6 +17,7 @@ from mcp.server.mcpserver.resources.base import Resource from mcp.shared._callable_inspection import is_async_callable +from mcp.shared.exceptions import MCPError class TextResource(Resource): @@ -79,6 +80,8 @@ async def read(self) -> str | bytes: return result else: return pydantic_core.to_json(result, fallback=str, indent=2).decode() + except MCPError: + raise except Exception as e: raise ValueError(f"Error reading resource {self.uri}: {e}") diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index d8fd3d66b3..6764709806 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -519,6 +519,8 @@ async def read_resource( try: content = await resource.read() return [ReadResourceContents(content=content, mime_type=resource.mime_type, meta=resource.meta)] + except MCPError: + raise except Exception as exc: logger.exception(f"Error getting resource {uri}") # If an exception happens when reading the resource, we should not leak the exception to the client. @@ -1232,6 +1234,8 @@ async def get_prompt( description=prompt.description, messages=pydantic_core.to_jsonable_python(rendered), ) + except MCPError: + raise except Exception as e: logger.exception(f"Error getting prompt {name}") raise ValueError(str(e)) from e diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 5a0f4a0951..54abea9c24 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -10,6 +10,7 @@ from mcp_types import ( INTERNAL_ERROR, INVALID_PARAMS, + MISSING_REQUIRED_CLIENT_CAPABILITY, AudioContent, BlobResourceContents, CallToolResult, @@ -2104,6 +2105,69 @@ async def ask(topic: str, ctx: Context) -> str | InputRequiredResult: assert result is sentinel +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] = [] From 2a381dad481bc5246cdff16f433425343faaf1e7 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:32:57 +0000 Subject: [PATCH 4/4] Keep the outer request's input_responses out of nested resource reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ctx.read_resource passed the handler's own Context into the nested resource template, so on the outer request's retry the template saw input_responses and request_state addressed to the outer handler — with a colliding key a write-once template would silently consume an answer to a different question. The continuation payload belongs to the wire request's target dispatch only, so nested invocations now use Context._nested_invocation(): same request infrastructure, no input_responses/request_state — a nested template always behaves as round one, making ctx.read_resource's raise-on-input-required contract deterministic across rounds. The explicit forwarding path (MCPServer.read_resource(uri, context)) is unchanged: there the caller deliberately re-addresses the payload. --- src/mcp/server/mcpserver/context.py | 25 ++++++++++++++----- tests/server/mcpserver/test_server.py | 36 +++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/mcp/server/mcpserver/context.py b/src/mcp/server/mcpserver/context.py index c7d8c66e9f..6640467411 100644 --- a/src/mcp/server/mcpserver/context.py +++ b/src/mcp/server/mcpserver/context.py @@ -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. @@ -104,11 +114,14 @@ async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContent This is a content reader: an `InputRequiredResult` returned by a resource template function (the 2026-07-28 multi-round-trip flow) - raises here. A handler that wants to receive and forward one 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. + 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 @@ -122,7 +135,7 @@ async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContent RuntimeError: If the resource returned an `InputRequiredResult`. """ assert self._mcp_server is not None, "Context is not available outside of a request" - result = 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 " diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 54abea9c24..b4a1184580 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -27,6 +27,7 @@ Icon, ImageContent, InputRequiredResult, + InputResponses, ListPromptsResult, ListRootsRequest, Prompt, @@ -2105,6 +2106,41 @@ async def ask(topic: str, ctx: Context) -> str | InputRequiredResult: 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."""