Pass InputRequiredResult through the MCPServer prompt and resource pipelines (#3020)
This commit is contained in:
parent
8f2c97b769
commit
8d0f928e40
7 changed files with 627 additions and 32 deletions
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
|||
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
|
||||
|
||||
|
|
@ -89,6 +89,16 @@ class Context(BaseModel, Generic[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.
|
||||
|
||||
|
|
@ -102,6 +112,17 @@ class Context(BaseModel, Generic[LifespanContextT, RequestT]):
|
|||
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
|
||||
|
||||
|
|
@ -111,9 +132,16 @@ class Context(BaseModel, Generic[LifespanContextT, RequestT]):
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -8,12 +8,13 @@ from typing import TYPE_CHECKING, Any, Literal
|
|||
|
||||
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
|
||||
|
|
@ -52,7 +53,7 @@ class AssistantMessage(Message):
|
|||
|
||||
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 +93,8 @@ class Prompt(BaseModel):
|
|||
- 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 +142,12 @@ class Prompt(BaseModel):
|
|||
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 +169,9 @@ class Prompt(BaseModel):
|
|||
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]
|
||||
|
|
@ -185,5 +194,7 @@ class Prompt(BaseModel):
|
|||
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,6 +4,8 @@ from __future__ import annotations
|
|||
|
||||
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 @@ class PromptManager:
|
|||
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:
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
|||
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 @@ class ResourceManager:
|
|||
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.
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from dataclasses import dataclass, field
|
|||
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
|
||||
|
|
@ -17,6 +17,7 @@ from mcp.server.mcpserver.utilities.context_injection import find_context_parame
|
|||
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
|
||||
|
||||
|
|
@ -208,9 +209,14 @@ class ResourceTemplate(BaseModel):
|
|||
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 +230,9 @@ class ResourceTemplate(BaseModel):
|
|||
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,
|
||||
|
|
@ -235,7 +244,7 @@ class ResourceTemplate(BaseModel):
|
|||
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}")
|
||||
|
|
|
|||
|
|
@ -12,11 +12,12 @@ import anyio.to_thread
|
|||
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
|
||||
from mcp.shared._callable_inspection import is_async_callable
|
||||
from mcp.shared.exceptions import MCPError
|
||||
|
||||
|
||||
class TextResource(Resource):
|
||||
|
|
@ -63,6 +64,14 @@ class FunctionResource(Resource):
|
|||
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):
|
||||
|
|
@ -71,6 +80,8 @@ class FunctionResource(Resource):
|
|||
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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -389,7 +389,7 @@ class MCPServer(Generic[LifespanResultT]):
|
|||
|
||||
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 @@ class MCPServer(Generic[LifespanResultT]):
|
|||
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 @@ class MCPServer(Generic[LifespanResultT]):
|
|||
|
||||
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 @@ class MCPServer(Generic[LifespanResultT]):
|
|||
|
||||
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,10 +513,14 @@ class MCPServer(Generic[LifespanResultT]):
|
|||
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()
|
||||
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.
|
||||
|
|
@ -696,6 +707,9 @@ class MCPServer(Generic[LifespanResultT]):
|
|||
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 +866,11 @@ class MCPServer(Generic[LifespanResultT]):
|
|||
) -> 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 +1211,14 @@ class MCPServer(Generic[LifespanResultT]):
|
|||
|
||||
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,12 +1226,16 @@ class MCPServer(Generic[LifespanResultT]):
|
|||
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 MCPError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"Error getting prompt {name}")
|
||||
raise ValueError(str(e)) from e
|
||||
|
|
|
|||
Loading…
Reference in a new issue