Pass InputRequiredResult through the MCPServer prompt and resource pipelines (#3020)
This commit is contained in:
parent
8f2c97b769
commit
8d0f928e40
21 changed files with 627 additions and 32 deletions
|
|
@ -22,7 +22,4 @@
|
||||||
|
|
||||||
client: []
|
client: []
|
||||||
|
|
||||||
server:
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,4 @@
|
||||||
|
|
||||||
client: []
|
client: []
|
||||||
|
|
||||||
server:
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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.
|
* `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.
|
* `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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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
|
## The client side
|
||||||
|
|
||||||
`Client` runs the loop for you.
|
`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.
|
* `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.
|
* 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.
|
* 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)**.
|
This is the mechanism that replaces server-initiated sampling and the rest of the push-style back-channel; see **[Deprecated features](deprecated.md)**.
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,26 @@ If you call `MCPServer.call_tool()` directly, read `.content` and
|
||||||
`.structured_content` off the returned `CallToolResult` instead of branching on
|
`.structured_content` off the returned `CallToolResult` instead of branching on
|
||||||
the result type.
|
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
|
### `MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error
|
||||||
|
|
||||||
Raising `MCPError` (or a subclass such as `UrlElicitationRequiredError`) inside
|
Raising `MCPError` (or a subclass such as `UrlElicitationRequiredError`) inside
|
||||||
|
|
|
||||||
26
docs_src/mrtr/tutorial004.py
Normal file
26
docs_src/mrtr/tutorial004.py
Normal file
|
|
@ -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']}.")]
|
||||||
|
|
@ -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
|
# Custom request handlers
|
||||||
# TODO(felix): Add public APIs to MCPServer for subscribe_resource, unsubscribe_resource,
|
# TODO(felix): Add public APIs to MCPServer for subscribe_resource, unsubscribe_resource,
|
||||||
# and set_logging_level to avoid accessing protected _lowlevel_server attribute.
|
# and set_logging_level to avoid accessing protected _lowlevel_server attribute.
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
||||||
from collections.abc import Iterable, Mapping
|
from collections.abc import Iterable, Mapping
|
||||||
from typing import TYPE_CHECKING, Any, Generic, cast
|
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 pydantic import AnyUrl, BaseModel
|
||||||
from typing_extensions import deprecated
|
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")
|
raise ValueError("Context is not available outside of a request")
|
||||||
return self._request_context
|
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:
|
async def report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
|
||||||
"""Report progress for the current operation.
|
"""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]:
|
async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContents]:
|
||||||
"""Read a resource by URI.
|
"""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:
|
Args:
|
||||||
uri: Resource URI to read
|
uri: Resource URI to read
|
||||||
|
|
||||||
|
|
@ -111,9 +132,16 @@ class Context(BaseModel, Generic[LifespanContextT, RequestT]):
|
||||||
Raises:
|
Raises:
|
||||||
ResourceNotFoundError: If no resource or template matches the URI.
|
ResourceNotFoundError: If no resource or template matches the URI.
|
||||||
ResourceError: If template creation or resource reading fails.
|
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"
|
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(
|
async def elicit(
|
||||||
self,
|
self,
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,13 @@ from typing import TYPE_CHECKING, Any, Literal
|
||||||
|
|
||||||
import anyio.to_thread
|
import anyio.to_thread
|
||||||
import pydantic_core
|
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 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.context_injection import find_context_parameter, inject_context
|
||||||
from mcp.server.mcpserver.utilities.func_metadata import func_metadata
|
from mcp.server.mcpserver.utilities.func_metadata import func_metadata
|
||||||
from mcp.shared._callable_inspection import is_async_callable
|
from mcp.shared._callable_inspection import is_async_callable
|
||||||
|
from mcp.shared.exceptions import MCPError
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from mcp.server.context import LifespanContextT, RequestT
|
from mcp.server.context import LifespanContextT, RequestT
|
||||||
|
|
@ -52,7 +53,7 @@ class AssistantMessage(Message):
|
||||||
|
|
||||||
message_validator = TypeAdapter[UserMessage | AssistantMessage](UserMessage | AssistantMessage)
|
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]
|
PromptResult = SyncPromptResult | Awaitable[SyncPromptResult]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -92,6 +93,8 @@ class Prompt(BaseModel):
|
||||||
- A Message object
|
- A Message object
|
||||||
- A dict (converted to a message)
|
- A dict (converted to a message)
|
||||||
- A sequence of any of the above
|
- 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__
|
func_name = name or fn.__name__
|
||||||
|
|
||||||
|
|
@ -139,9 +142,12 @@ class Prompt(BaseModel):
|
||||||
self,
|
self,
|
||||||
arguments: dict[str, Any] | None,
|
arguments: dict[str, Any] | None,
|
||||||
context: Context[LifespanContextT, RequestT],
|
context: Context[LifespanContextT, RequestT],
|
||||||
) -> list[Message]:
|
) -> list[Message] | InputRequiredResult:
|
||||||
"""Render the prompt with arguments.
|
"""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:
|
Raises:
|
||||||
ValueError: If required arguments are missing, or if rendering fails.
|
ValueError: If required arguments are missing, or if rendering fails.
|
||||||
"""
|
"""
|
||||||
|
|
@ -163,6 +169,9 @@ class Prompt(BaseModel):
|
||||||
else:
|
else:
|
||||||
result = await anyio.to_thread.run_sync(functools.partial(self.fn, **call_args))
|
result = await anyio.to_thread.run_sync(functools.partial(self.fn, **call_args))
|
||||||
|
|
||||||
|
if isinstance(result, InputRequiredResult):
|
||||||
|
return result
|
||||||
|
|
||||||
# Validate messages
|
# Validate messages
|
||||||
if not isinstance(result, list | tuple):
|
if not isinstance(result, list | tuple):
|
||||||
result = [result]
|
result = [result]
|
||||||
|
|
@ -185,5 +194,7 @@ class Prompt(BaseModel):
|
||||||
raise ValueError(f"Could not convert prompt result to message: {msg}")
|
raise ValueError(f"Could not convert prompt result to message: {msg}")
|
||||||
|
|
||||||
return messages
|
return messages
|
||||||
|
except MCPError:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"Error rendering prompt {self.name}: {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 typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from mcp_types import InputRequiredResult
|
||||||
|
|
||||||
from mcp.server.mcpserver.prompts.base import Message, Prompt
|
from mcp.server.mcpserver.prompts.base import Message, Prompt
|
||||||
from mcp.server.mcpserver.utilities.logging import get_logger
|
from mcp.server.mcpserver.utilities.logging import get_logger
|
||||||
|
|
||||||
|
|
@ -50,7 +52,7 @@ class PromptManager:
|
||||||
name: str,
|
name: str,
|
||||||
arguments: dict[str, Any] | None,
|
arguments: dict[str, Any] | None,
|
||||||
context: Context[LifespanContextT, RequestT],
|
context: Context[LifespanContextT, RequestT],
|
||||||
) -> list[Message]:
|
) -> list[Message] | InputRequiredResult:
|
||||||
"""Render a prompt by name with arguments."""
|
"""Render a prompt by name with arguments."""
|
||||||
prompt = self.get_prompt(name)
|
prompt = self.get_prompt(name)
|
||||||
if not prompt:
|
if not prompt:
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from mcp_types import Annotations, Icon
|
from mcp_types import Annotations, Icon, InputRequiredResult
|
||||||
from pydantic import AnyUrl
|
from pydantic import AnyUrl
|
||||||
|
|
||||||
from mcp.server.mcpserver.exceptions import ResourceNotFoundError
|
from mcp.server.mcpserver.exceptions import ResourceNotFoundError
|
||||||
|
|
@ -86,9 +86,15 @@ class ResourceManager:
|
||||||
self._templates[template.uri_template] = template
|
self._templates[template.uri_template] = template
|
||||||
return 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.
|
"""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:
|
Raises:
|
||||||
ResourceNotFoundError: If no resource or template matches the URI.
|
ResourceNotFoundError: If no resource or template matches the URI.
|
||||||
ResourceError: If a matching template fails to create the resource.
|
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
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import anyio.to_thread
|
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 pydantic import BaseModel, Field, validate_call
|
||||||
|
|
||||||
from mcp.server.mcpserver.exceptions import ResourceError
|
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.func_metadata import func_metadata
|
||||||
from mcp.server.mcpserver.utilities.logging import get_logger
|
from mcp.server.mcpserver.utilities.logging import get_logger
|
||||||
from mcp.shared._callable_inspection import is_async_callable
|
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.path_security import contains_path_traversal, is_absolute_path
|
||||||
from mcp.shared.uri_template import UriTemplate
|
from mcp.shared.uri_template import UriTemplate
|
||||||
|
|
||||||
|
|
@ -208,9 +209,14 @@ class ResourceTemplate(BaseModel):
|
||||||
uri: str,
|
uri: str,
|
||||||
params: dict[str, Any],
|
params: dict[str, Any],
|
||||||
context: Context[LifespanContextT, RequestT],
|
context: Context[LifespanContextT, RequestT],
|
||||||
) -> Resource:
|
) -> Resource | InputRequiredResult:
|
||||||
"""Create a resource from the template with the given parameters.
|
"""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:
|
Raises:
|
||||||
ResourceError: If creating the resource fails.
|
ResourceError: If creating the resource fails.
|
||||||
"""
|
"""
|
||||||
|
|
@ -224,6 +230,9 @@ class ResourceTemplate(BaseModel):
|
||||||
else:
|
else:
|
||||||
result = await anyio.to_thread.run_sync(functools.partial(self.fn, **params))
|
result = await anyio.to_thread.run_sync(functools.partial(self.fn, **params))
|
||||||
|
|
||||||
|
if isinstance(result, InputRequiredResult):
|
||||||
|
return result
|
||||||
|
|
||||||
return FunctionResource(
|
return FunctionResource(
|
||||||
uri=uri, # type: ignore
|
uri=uri, # type: ignore
|
||||||
name=self.name,
|
name=self.name,
|
||||||
|
|
@ -235,7 +244,7 @@ class ResourceTemplate(BaseModel):
|
||||||
meta=self.meta,
|
meta=self.meta,
|
||||||
fn=lambda: result, # Capture result in closure
|
fn=lambda: result, # Capture result in closure
|
||||||
)
|
)
|
||||||
except ResourceError:
|
except (ResourceError, MCPError):
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception(f"Error creating resource from template {uri}")
|
logger.exception(f"Error creating resource from template {uri}")
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,12 @@ import anyio.to_thread
|
||||||
import httpx
|
import httpx
|
||||||
import pydantic
|
import pydantic
|
||||||
import pydantic_core
|
import pydantic_core
|
||||||
from mcp_types import Annotations, Icon
|
from mcp_types import Annotations, Icon, InputRequiredResult
|
||||||
from pydantic import Field, ValidationInfo, validate_call
|
from pydantic import Field, ValidationInfo, validate_call
|
||||||
|
|
||||||
from mcp.server.mcpserver.resources.base import Resource
|
from mcp.server.mcpserver.resources.base import Resource
|
||||||
from mcp.shared._callable_inspection import is_async_callable
|
from mcp.shared._callable_inspection import is_async_callable
|
||||||
|
from mcp.shared.exceptions import MCPError
|
||||||
|
|
||||||
|
|
||||||
class TextResource(Resource):
|
class TextResource(Resource):
|
||||||
|
|
@ -63,6 +64,14 @@ class FunctionResource(Resource):
|
||||||
else:
|
else:
|
||||||
result = await anyio.to_thread.run_sync(self.fn)
|
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
|
if isinstance(result, Resource): # pragma: no cover
|
||||||
return await result.read()
|
return await result.read()
|
||||||
elif isinstance(result, bytes):
|
elif isinstance(result, bytes):
|
||||||
|
|
@ -71,6 +80,8 @@ class FunctionResource(Resource):
|
||||||
return result
|
return result
|
||||||
else:
|
else:
|
||||||
return pydantic_core.to_json(result, fallback=str, indent=2).decode()
|
return pydantic_core.to_json(result, fallback=str, indent=2).decode()
|
||||||
|
except MCPError:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"Error reading resource {self.uri}: {e}")
|
raise ValueError(f"Error reading resource {self.uri}: {e}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -389,7 +389,7 @@ class MCPServer(Generic[LifespanResultT]):
|
||||||
|
|
||||||
async def _handle_read_resource(
|
async def _handle_read_resource(
|
||||||
self, ctx: ServerRequestContext[LifespanResultT], params: ReadResourceRequestParams
|
self, ctx: ServerRequestContext[LifespanResultT], params: ReadResourceRequestParams
|
||||||
) -> ReadResourceResult:
|
) -> ReadResourceResult | InputRequiredResult:
|
||||||
context = Context(request_context=ctx, mcp_server=self, input_params=params)
|
context = Context(request_context=ctx, mcp_server=self, input_params=params)
|
||||||
try:
|
try:
|
||||||
results = await self.read_resource(params.uri, context)
|
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)})
|
raise MCPError(code=INVALID_PARAMS, message=str(err), data={"uri": str(params.uri)})
|
||||||
except ResourceError as err:
|
except ResourceError as err:
|
||||||
raise MCPError(code=INTERNAL_ERROR, message=str(err), data={"uri": str(params.uri)})
|
raise MCPError(code=INTERNAL_ERROR, message=str(err), data={"uri": str(params.uri)})
|
||||||
|
if isinstance(results, InputRequiredResult):
|
||||||
|
return results
|
||||||
contents: list[TextResourceContents | BlobResourceContents] = []
|
contents: list[TextResourceContents | BlobResourceContents] = []
|
||||||
for item in results:
|
for item in results:
|
||||||
if isinstance(item.content, bytes):
|
if isinstance(item.content, bytes):
|
||||||
|
|
@ -431,7 +433,7 @@ class MCPServer(Generic[LifespanResultT]):
|
||||||
|
|
||||||
async def _handle_get_prompt(
|
async def _handle_get_prompt(
|
||||||
self, ctx: ServerRequestContext[LifespanResultT], params: GetPromptRequestParams
|
self, ctx: ServerRequestContext[LifespanResultT], params: GetPromptRequestParams
|
||||||
) -> GetPromptResult:
|
) -> GetPromptResult | InputRequiredResult:
|
||||||
context = Context(request_context=ctx, mcp_server=self, input_params=params)
|
context = Context(request_context=ctx, mcp_server=self, input_params=params)
|
||||||
return await self.get_prompt(params.name, params.arguments, context)
|
return await self.get_prompt(params.name, params.arguments, context)
|
||||||
|
|
||||||
|
|
@ -496,9 +498,14 @@ class MCPServer(Generic[LifespanResultT]):
|
||||||
|
|
||||||
async def read_resource(
|
async def read_resource(
|
||||||
self, uri: AnyUrl | str, context: Context[LifespanResultT, Any] | None = None
|
self, uri: AnyUrl | str, context: Context[LifespanResultT, Any] | None = None
|
||||||
) -> Iterable[ReadResourceContents]:
|
) -> Iterable[ReadResourceContents] | InputRequiredResult:
|
||||||
"""Read a resource by URI.
|
"""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:
|
Raises:
|
||||||
ResourceNotFoundError: If no resource or template matches the URI.
|
ResourceNotFoundError: If no resource or template matches the URI.
|
||||||
ResourceError: If template creation or resource reading fails.
|
ResourceError: If template creation or resource reading fails.
|
||||||
|
|
@ -506,10 +513,14 @@ class MCPServer(Generic[LifespanResultT]):
|
||||||
if context is None:
|
if context is None:
|
||||||
context = Context(mcp_server=self)
|
context = Context(mcp_server=self)
|
||||||
resource = await self._resource_manager.get_resource(uri, context)
|
resource = await self._resource_manager.get_resource(uri, context)
|
||||||
|
if isinstance(resource, InputRequiredResult):
|
||||||
|
return resource
|
||||||
|
|
||||||
try:
|
try:
|
||||||
content = await resource.read()
|
content = await resource.read()
|
||||||
return [ReadResourceContents(content=content, mime_type=resource.mime_type, meta=resource.meta)]
|
return [ReadResourceContents(content=content, mime_type=resource.mime_type, meta=resource.meta)]
|
||||||
|
except MCPError:
|
||||||
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception(f"Error getting resource {uri}")
|
logger.exception(f"Error getting resource {uri}")
|
||||||
# If an exception happens when reading the resource, we should not leak the exception to the client.
|
# 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:
|
The function can return:
|
||||||
- str for text content
|
- str for text content
|
||||||
- bytes for binary 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
|
- other types will be converted to JSON
|
||||||
|
|
||||||
If the URI contains parameters (e.g. "resource://{param}"), it is
|
If the URI contains parameters (e.g. "resource://{param}"), it is
|
||||||
|
|
@ -852,6 +866,11 @@ class MCPServer(Generic[LifespanResultT]):
|
||||||
) -> Callable[[_CallableT], _CallableT]:
|
) -> Callable[[_CallableT], _CallableT]:
|
||||||
"""Decorator to register a prompt.
|
"""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:
|
Args:
|
||||||
name: Optional name for the prompt (defaults to function name)
|
name: Optional name for the prompt (defaults to function name)
|
||||||
title: Optional human-readable title for the prompt
|
title: Optional human-readable title for the prompt
|
||||||
|
|
@ -1192,8 +1211,14 @@ class MCPServer(Generic[LifespanResultT]):
|
||||||
|
|
||||||
async def get_prompt(
|
async def get_prompt(
|
||||||
self, name: str, arguments: dict[str, Any] | None = None, context: Context[LifespanResultT, Any] | None = None
|
self, name: str, arguments: dict[str, Any] | None = None, context: Context[LifespanResultT, Any] | None = None
|
||||||
) -> GetPromptResult:
|
) -> GetPromptResult | InputRequiredResult:
|
||||||
"""Get a prompt by name with arguments."""
|
"""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:
|
if context is None:
|
||||||
context = Context(mcp_server=self)
|
context = Context(mcp_server=self)
|
||||||
try:
|
try:
|
||||||
|
|
@ -1201,12 +1226,16 @@ class MCPServer(Generic[LifespanResultT]):
|
||||||
if not prompt:
|
if not prompt:
|
||||||
raise ValueError(f"Unknown prompt: {name}")
|
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(
|
return GetPromptResult(
|
||||||
description=prompt.description,
|
description=prompt.description,
|
||||||
messages=pydantic_core.to_jsonable_python(messages),
|
messages=pydantic_core.to_jsonable_python(rendered),
|
||||||
)
|
)
|
||||||
|
except MCPError:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Error getting prompt {name}")
|
logger.exception(f"Error getting prompt {name}")
|
||||||
raise ValueError(str(e)) from e
|
raise ValueError(str(e)) from e
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,17 @@ from mcp_types import (
|
||||||
CreateMessageRequestParams,
|
CreateMessageRequestParams,
|
||||||
ElicitRequest,
|
ElicitRequest,
|
||||||
ElicitRequestFormParams,
|
ElicitRequestFormParams,
|
||||||
|
ElicitRequestParams,
|
||||||
ElicitResult,
|
ElicitResult,
|
||||||
|
GetPromptResult,
|
||||||
InputRequiredResult,
|
InputRequiredResult,
|
||||||
|
PromptMessage,
|
||||||
TextContent,
|
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 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.
|
# 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")]
|
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))
|
request = CreateMessageRequest(params=CreateMessageRequestParams(messages=[], max_tokens=64))
|
||||||
with pytest.raises(NotImplementedError, match="sampling/createMessage"):
|
with pytest.raises(NotImplementedError, match="sampling/createMessage"):
|
||||||
tutorial002.fulfil(request)
|
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."),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import pytest
|
import pytest
|
||||||
from mcp_types import (
|
from mcp_types import (
|
||||||
|
InputRequiredResult,
|
||||||
ListResourceTemplatesResult,
|
ListResourceTemplatesResult,
|
||||||
TextResourceContents,
|
TextResourceContents,
|
||||||
)
|
)
|
||||||
|
|
@ -49,6 +50,7 @@ async def test_resource_template_edge_cases():
|
||||||
|
|
||||||
# Verify valid template works
|
# Verify valid template works
|
||||||
result = await mcp.read_resource("resource://users/123/posts/456")
|
result = await mcp.read_resource("resource://users/123/posts/456")
|
||||||
|
assert not isinstance(result, InputRequiredResult)
|
||||||
result_list = list(result)
|
result_list = list(result)
|
||||||
assert len(result_list) == 1
|
assert len(result_list) == 1
|
||||||
assert result_list[0].content == "Post 456 by user 123"
|
assert result_list[0].content == "Post 456 by user 123"
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,14 @@ import threading
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
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 import Context
|
||||||
from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, Prompt, UserMessage
|
from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, Prompt, UserMessage
|
||||||
|
|
@ -209,3 +216,30 @@ async def test_sync_fn_runs_in_worker_thread():
|
||||||
|
|
||||||
assert messages == [UserMessage(content=TextContent(type="text", text="hello"))]
|
assert messages == [UserMessage(content=TextContent(type="text", text="hello"))]
|
||||||
assert fn_thread[0] != main_thread
|
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
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import threading
|
||||||
import anyio
|
import anyio
|
||||||
import anyio.from_thread
|
import anyio.from_thread
|
||||||
import pytest
|
import pytest
|
||||||
|
from inline_snapshot import snapshot
|
||||||
|
from mcp_types import InputRequiredResult
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from mcp.server.mcpserver.resources import FunctionResource
|
from mcp.server.mcpserver.resources import FunctionResource
|
||||||
|
|
@ -242,3 +244,20 @@ async def test_sync_fn_does_not_block_event_loop():
|
||||||
release.set()
|
release.set()
|
||||||
|
|
||||||
assert result == ["done"]
|
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"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import threading
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from mcp_types import Annotations
|
from mcp_types import Annotations, ElicitRequest, ElicitRequestFormParams, InputRequiredResult
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from mcp.server.mcpserver import Context, MCPServer
|
from mcp.server.mcpserver import Context, MCPServer
|
||||||
|
|
@ -403,6 +403,7 @@ class TestResourceTemplateAnnotations:
|
||||||
|
|
||||||
# Create a resource from the template
|
# Create a resource from the template
|
||||||
resource = await template.create_resource("resource://items/123", {"item_id": "123"}, Context())
|
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
|
# The resource should inherit the template's annotations
|
||||||
assert resource.annotations is not None
|
assert resource.annotations is not None
|
||||||
|
|
@ -477,3 +478,30 @@ async def test_sync_fn_runs_in_worker_thread():
|
||||||
assert isinstance(resource, FunctionResource)
|
assert isinstance(resource, FunctionResource)
|
||||||
assert await resource.read() == "hello world"
|
assert await resource.read() == "hello world"
|
||||||
assert fn_thread[0] != main_thread
|
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
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from mcp_types import InputRequiredResult
|
||||||
|
|
||||||
from mcp.server.mcpserver import MCPServer
|
from mcp.server.mcpserver import MCPServer
|
||||||
|
|
||||||
|
|
@ -89,6 +90,7 @@ async def test_list_resources(mcp: MCPServer):
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_read_resource_dir(mcp: MCPServer):
|
async def test_read_resource_dir(mcp: MCPServer):
|
||||||
res_iter = await mcp.read_resource("dir://test_dir")
|
res_iter = await mcp.read_resource("dir://test_dir")
|
||||||
|
assert not isinstance(res_iter, InputRequiredResult)
|
||||||
res_list = list(res_iter)
|
res_list = list(res_iter)
|
||||||
assert len(res_list) == 1
|
assert len(res_list) == 1
|
||||||
res = res_list[0]
|
res = res_list[0]
|
||||||
|
|
@ -106,6 +108,7 @@ async def test_read_resource_dir(mcp: MCPServer):
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_read_resource_file(mcp: MCPServer):
|
async def test_read_resource_file(mcp: MCPServer):
|
||||||
res_iter = await mcp.read_resource("file://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)
|
res_list = list(res_iter)
|
||||||
assert len(res_list) == 1
|
assert len(res_list) == 1
|
||||||
res = res_list[0]
|
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):
|
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")})
|
await mcp.call_tool("delete_file", arguments={"path": str(test_dir / "example.py")})
|
||||||
res_iter = await mcp.read_resource("file://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)
|
res_list = list(res_iter)
|
||||||
assert len(res_list) == 1
|
assert len(res_list) == 1
|
||||||
res = res_list[0]
|
res = res_list[0]
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue