Merge pull request #3136 from jlowin/task-elicitation-relay

Relay task elicitation through standard MCP protocol
This commit is contained in:
Chris Guidry 2026-02-10 16:06:37 -05:00 committed by GitHub
commit efcc12bc76
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 629 additions and 204 deletions

View file

@ -189,12 +189,16 @@ report_progress(self, progress: float, total: float | None = None, message: str
Report progress for the current operation.
Works in both foreground (MCP progress notifications) and background
(Docket task execution) contexts.
**Args:**
- `progress`: Current progress value e.g. 24
- `total`: Optional total value e.g. 100
- `message`: Optional status message describing current progress
#### `list_resources` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L390" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `list_resources` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L426" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
list_resources(self) -> list[SDKResource]
@ -206,7 +210,7 @@ List all available resources from the server.
- List of Resource objects available on the server
#### `list_prompts` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L406" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `list_prompts` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L442" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
list_prompts(self) -> list[SDKPrompt]
@ -218,7 +222,7 @@ List all available prompts from the server.
- List of Prompt objects available on the server
#### `get_prompt` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L422" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `get_prompt` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L458" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> GetPromptResult
@ -234,7 +238,7 @@ Get a prompt by name with optional arguments.
- The prompt result
#### `read_resource` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L441" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `read_resource` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L477" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
read_resource(self, uri: str | AnyUrl) -> ResourceResult
@ -249,7 +253,7 @@ Read a resource by URI.
- ResourceResult with contents
#### `log` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L457" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `log` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L493" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
log(self, message: str, level: LoggingLevel | None = None, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None
@ -267,7 +271,7 @@ Messages sent to Clients are also logged to the `fastmcp.server.context.to_clien
- `extra`: Optional mapping for additional arguments
#### `transport` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L486" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `transport` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L522" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
transport(self) -> TransportType | None
@ -279,7 +283,7 @@ Returns the transport type used to run this server: "stdio", "sse",
or "streamable-http". Returns None if called outside of a server context.
#### `client_supports_extension` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L494" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `client_supports_extension` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L530" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
client_supports_extension(self, extension_id: str) -> bool
@ -304,7 +308,7 @@ Example::
return "text-only client"
#### `client_id` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L522" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `client_id` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L558" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
client_id(self) -> str | None
@ -313,7 +317,7 @@ client_id(self) -> str | None
Get the client ID if available.
#### `request_id` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L531" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `request_id` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L567" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
request_id(self) -> str
@ -324,7 +328,7 @@ Get the unique ID for this request.
Raises RuntimeError if MCP request context is not available.
#### `session_id` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L544" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `session_id` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L580" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
session_id(self) -> str
@ -341,7 +345,7 @@ the same client session.
- for other transports.
#### `session` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L601" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `session` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L637" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
session(self) -> ServerSession
@ -355,7 +359,7 @@ In background task mode: Returns the session stored at Context creation.
Raises RuntimeError if no session is available.
#### `debug` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L627" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `debug` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L663" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
debug(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None
@ -366,7 +370,7 @@ Send a `DEBUG`-level message to the connected MCP Client.
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.
#### `info` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L643" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `info` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L679" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
info(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None
@ -377,7 +381,7 @@ Send a `INFO`-level message to the connected MCP Client.
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.
#### `warning` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L659" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `warning` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L695" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
warning(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None
@ -388,7 +392,7 @@ Send a `WARNING`-level message to the connected MCP Client.
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.
#### `error` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L675" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `error` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L711" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
error(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None
@ -399,7 +403,7 @@ Send a `ERROR`-level message to the connected MCP Client.
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.
#### `list_roots` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L691" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `list_roots` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L727" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
list_roots(self) -> list[Root]
@ -408,7 +412,7 @@ list_roots(self) -> list[Root]
List the roots available to the server, as indicated by the client.
#### `send_notification` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L696" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `send_notification` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L732" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
send_notification(self, notification: mcp.types.ServerNotificationType) -> None
@ -420,7 +424,7 @@ Send a notification to the client immediately.
- `notification`: An MCP notification instance (e.g., ToolListChangedNotification())
#### `close_sse_stream` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L706" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `close_sse_stream` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L742" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
close_sse_stream(self) -> None
@ -438,7 +442,7 @@ Instead of holding a connection open for minutes, you can periodically close
and let the client reconnect.
#### `sample_step` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L745" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `sample_step` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L781" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
sample_step(self, messages: str | Sequence[str | SamplingMessage]) -> SampleStep
@ -481,7 +485,7 @@ regardless of this setting.
- - .text: The text content (if any)
#### `sample` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L824" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `sample` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L860" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ResultT]
@ -490,7 +494,7 @@ sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[
Overload: With result_type, returns SamplingResult[ResultT].
#### `sample` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L840" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `sample` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L876" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[str]
@ -499,7 +503,7 @@ sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[
Overload: Without result_type, returns SamplingResult[str].
#### `sample` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L855" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `sample` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L891" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ResultT] | SamplingResult[str]
@ -547,43 +551,43 @@ regardless of this setting.
- - .history: All messages exchanged during sampling
#### `elicit` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L930" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `elicit` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L966" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
elicit(self, message: str, response_type: None) -> AcceptedElicitation[dict[str, Any]] | DeclinedElicitation | CancelledElicitation
```
#### `elicit` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L942" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `elicit` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L978" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
elicit(self, message: str, response_type: type[T]) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation
```
#### `elicit` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L952" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `elicit` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L988" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
elicit(self, message: str, response_type: list[str]) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation
```
#### `elicit` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L962" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `elicit` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L998" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
elicit(self, message: str, response_type: dict[str, dict[str, str]]) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation
```
#### `elicit` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L972" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `elicit` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L1008" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
elicit(self, message: str, response_type: list[list[str]]) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation
```
#### `elicit` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L984" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `elicit` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L1020" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
elicit(self, message: str, response_type: list[dict[str, dict[str, str]]]) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation
```
#### `elicit` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L996" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `elicit` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L1032" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
elicit(self, message: str, response_type: type[T] | list[str] | dict[str, dict[str, str]] | list[list[str]] | list[dict[str, dict[str, str]]] | None = None) -> AcceptedElicitation[T] | AcceptedElicitation[dict[str, Any]] | AcceptedElicitation[str] | AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation
@ -612,7 +616,7 @@ type or dataclass or BaseModel. If it is a primitive type, an
object schema with a single "value" field will be generated.
#### `set_state` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L1110" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `set_state` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L1146" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
set_state(self, key: str, value: Any) -> None
@ -625,7 +629,7 @@ The key is automatically prefixed with the session identifier.
State expires after 1 day to prevent unbounded memory growth.
#### `get_state` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L1124" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `get_state` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L1160" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_state(self, key: str) -> Any
@ -636,7 +640,7 @@ Get a value from the session-scoped state store.
Returns None if the key is not found.
#### `delete_state` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L1133" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `delete_state` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L1169" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
delete_state(self, key: str) -> None
@ -645,7 +649,7 @@ delete_state(self, key: str) -> None
Delete a value from the session-scoped state store.
#### `enable_components` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L1150" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `enable_components` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L1186" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
enable_components(self) -> None
@ -669,7 +673,7 @@ ResourceListChangedNotification, and PromptListChangedNotification.
- `match_all`: If True, matches all components regardless of other criteria.
#### `disable_components` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L1188" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `disable_components` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L1224" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
disable_components(self) -> None
@ -693,7 +697,7 @@ ResourceListChangedNotification, and PromptListChangedNotification.
- `match_all`: If True, matches all components regardless of other criteria.
#### `reset_visibility` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L1226" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `reset_visibility` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L1262" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
reset_visibility(self) -> None

View file

@ -13,7 +13,7 @@ in Docket workers. Unlike regular MCP requests, background tasks don't have
an active request context, so elicitation requires special handling:
1. Set task status to "input_required" via Redis
2. Send notifications/tasks/updated with elicitation metadata
2. Send notifications/tasks/status with elicitation metadata
3. Wait for client to send input via tasks/sendInput
4. Resume task execution with the provided input
@ -26,7 +26,7 @@ internal APIs for background task coordination.
### `elicit_for_task` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/tasks/elicitation.py#L42" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
elicit_for_task(task_id: str, session: ServerSession, message: str, schema: dict[str, Any], fastmcp: FastMCP) -> mcp.types.ElicitResult
elicit_for_task(task_id: str, session: ServerSession | None, message: str, schema: dict[str, Any], fastmcp: FastMCP) -> mcp.types.ElicitResult
```
@ -50,7 +50,29 @@ in a Docket worker context where there's no active MCP request.
- `McpError`: If the elicitation request fails
### `handle_task_input` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/tasks/elicitation.py#L176" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `relay_elicitation` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/tasks/elicitation.py#L234" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
relay_elicitation(session: ServerSession, session_id: str, task_id: str, elicitation: dict[str, Any], fastmcp: FastMCP) -> None
```
Relay elicitation from a background task worker to the client.
Called by the notification subscriber when it detects an input_required
notification with elicitation metadata. Sends a standard elicitation/create
request to the client session, then uses handle_task_input() to push the
response to Redis so the blocked worker can resume.
**Args:**
- `session`: MCP ServerSession
- `session_id`: Session identifier
- `task_id`: Background task ID
- `elicitation`: Elicitation metadata (message, requestedSchema)
- `fastmcp`: FastMCP server instance
### `handle_task_input` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/tasks/elicitation.py#L290" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
handle_task_input(task_id: str, session_id: str, action: str, content: dict[str, Any] | None, fastmcp: FastMCP) -> bool

View file

@ -13,7 +13,7 @@ Handles queuing tool/prompt/resource executions to Docket as background tasks.
## Functions
### `submit_to_docket` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/tasks/handlers.py#L31" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `submit_to_docket` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/tasks/handlers.py#L34" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
submit_to_docket(task_type: Literal['tool', 'resource', 'template', 'prompt'], key: str, component: Tool | Resource | ResourceTemplate | Prompt, arguments: dict[str, Any] | None = None, task_meta: TaskMeta | None = None) -> mcp.types.CreateTaskResult

View file

@ -0,0 +1,113 @@
---
title: notifications
sidebarTitle: notifications
---
# `fastmcp.server.tasks.notifications`
Distributed notification queue for background task events (SEP-1686).
Enables distributed Docket workers to send MCP notifications to clients
without holding session references. Workers push to a Redis queue,
the MCP server process subscribes and forwards to the client's session.
Pattern: Fire-and-forward with retry
- One queue per session_id
- LPUSH/BRPOP for reliable ordered delivery
- Retry up to 3 times on delivery failure, then discard
- TTL-based expiration for stale messages
Note: Docket's execution.subscribe() handles task state/progress events via
Redis Pub/Sub. This module handles elicitation-specific notifications that
require reliable delivery (input_required prompts, cancel signals).
## Functions
### `push_notification` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/tasks/notifications.py#L48" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
push_notification(session_id: str, notification: dict[str, Any], docket: Docket) -> None
```
Push notification to session's queue (called from Docket worker).
Used for elicitation-specific notifications (input_required, cancel)
that need reliable delivery across distributed processes.
**Args:**
- `session_id`: Target session's identifier
- `notification`: MCP notification dict (method, params, _meta)
- `docket`: Docket instance for Redis access
### `notification_subscriber_loop` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/tasks/notifications.py#L76" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
notification_subscriber_loop(session_id: str, session: ServerSession, docket: Docket, fastmcp: FastMCP) -> None
```
Subscribe to notification queue and forward to session.
Runs in the MCP server process. Bridges distributed workers to clients.
This loop:
1. Maintains a heartbeat (active subscriber marker for debugging)
2. Blocks on BRPOP waiting for notifications
3. Forwards notifications to the client's session
4. Retries failed deliveries, then discards (no dead-letter queue)
**Args:**
- `session_id`: Session identifier to subscribe to
- `session`: MCP ServerSession for sending notifications
- `docket`: Docket instance for Redis access
- `fastmcp`: FastMCP server instance (for elicitation relay)
### `ensure_subscriber_running` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/tasks/notifications.py#L238" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
ensure_subscriber_running(session_id: str, session: ServerSession, docket: Docket, fastmcp: FastMCP) -> None
```
Start notification subscriber if not already running (idempotent).
Subscriber is created on first task submission and cleaned up on disconnect.
Safe to call multiple times for the same session.
**Args:**
- `session_id`: Session identifier
- `session`: MCP ServerSession
- `docket`: Docket instance
- `fastmcp`: FastMCP server instance (for elicitation relay)
### `stop_subscriber` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/tasks/notifications.py#L278" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
stop_subscriber(session_id: str) -> None
```
Stop notification subscriber for a session.
Called when session disconnects. Pending messages remain in queue
for delivery if client reconnects (with TTL expiration).
**Args:**
- `session_id`: Session identifier
### `get_subscriber_count` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/tasks/notifications.py#L298" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_subscriber_count() -> int
```
Get number of active subscribers (for monitoring).

View file

@ -0,0 +1,82 @@
"""
Background task elicitation demo.
A background task (Docket) that pauses mid-execution to ask the user a
question, waits for the answer, then resumes and finishes.
Works with both in-memory and Redis backends:
# In-memory (single process, no Redis needed)
FASTMCP_DOCKET_URL=memory:// uv run python examples/task_elicitation.py
# Redis (distributed, needs a worker running separately)
# Terminal 1: docker compose -f examples/tasks/docker-compose.yml up -d
# Terminal 2: FASTMCP_DOCKET_URL=redis://localhost:24242/0 \
# uv run fastmcp tasks worker examples/task_elicitation.py
# Terminal 3: FASTMCP_DOCKET_URL=redis://localhost:24242/0 \
# uv run python examples/task_elicitation.py
Requires the `docket` extra (included in dev dependencies).
"""
import asyncio
from dataclasses import dataclass
from mcp.types import TextContent
from fastmcp import Context, FastMCP
from fastmcp.client import Client
from fastmcp.server.elicitation import AcceptedElicitation
mcp = FastMCP("Task Elicitation Demo")
@dataclass
class DinnerPrefs:
cuisine: str
vegetarian: bool
@mcp.tool(task=True)
async def plan_dinner(ctx: Context) -> str:
"""Plan a dinner menu, asking the user what they're in the mood for."""
await ctx.report_progress(0, 2, "Asking what you'd like...")
result = await ctx.elicit(
"What kind of dinner are you in the mood for?",
response_type=DinnerPrefs,
)
if not isinstance(result, AcceptedElicitation):
return "Dinner cancelled!"
prefs = result.data
await ctx.report_progress(1, 2, "Planning your menu...")
await asyncio.sleep(1)
await ctx.report_progress(2, 2, "Done!")
veg = "vegetarian " if prefs.vegetarian else ""
return f"Tonight's menu: a lovely {veg}{prefs.cuisine} dinner!"
async def handle_elicitation(message, response_type, params, context):
"""Handle elicitation requests from background tasks."""
print(f" Server asks: {message}")
print(" Responding with: cuisine=Thai, vegetarian=True")
return DinnerPrefs(cuisine="Thai", vegetarian=True)
async def main():
async with Client(mcp, elicitation_handler=handle_elicitation) as client:
print("Starting background task...")
task = await client.call_tool("plan_dinner", {}, task=True)
print(f" task_id = {task.task_id}\n")
result = await task.result()
assert isinstance(result.content[0], TextContent)
print(f"\nResult: {result.content[0].text}")
if __name__ == "__main__":
asyncio.run(main())

View file

@ -1133,7 +1133,7 @@ class Context:
return await elicit_for_task(
task_id=self._task_id, # type: ignore[arg-type]
session=self.session,
session=self._session,
message=message,
schema=schema,
fastmcp=self.fastmcp,

View file

@ -5,7 +5,11 @@ This module implements protocol-level background task execution for MCP servers.
from fastmcp.server.tasks.capabilities import get_task_capabilities
from fastmcp.server.tasks.config import TaskConfig, TaskMeta, TaskMode
from fastmcp.server.tasks.elicitation import elicit_for_task, handle_task_input
from fastmcp.server.tasks.elicitation import (
elicit_for_task,
handle_task_input,
relay_elicitation,
)
from fastmcp.server.tasks.keys import (
build_task_key,
get_client_task_id_from_key,
@ -29,5 +33,6 @@ __all__ = [
"handle_task_input",
"parse_task_key",
"push_notification",
"relay_elicitation",
"stop_subscriber",
]

View file

@ -41,7 +41,7 @@ ELICIT_TTL_SECONDS = 3600
async def elicit_for_task(
task_id: str,
session: ServerSession,
session: ServerSession | None,
message: str,
schema: dict[str, Any],
fastmcp: FastMCP,
@ -134,7 +134,7 @@ async def elicit_for_task(
"ttl": ELICIT_TTL_SECONDS * 1000,
},
"_meta": {
"modelcontextprotocol.io/related-task": {
"io.modelcontextprotocol/related-task": {
"taskId": task_id,
"status": "input_required",
"statusMessage": message,
@ -231,6 +231,62 @@ async def elicit_for_task(
return mcp.types.ElicitResult(action="cancel", content=None)
async def relay_elicitation(
session: ServerSession,
session_id: str,
task_id: str,
elicitation: dict[str, Any],
fastmcp: FastMCP,
) -> None:
"""Relay elicitation from a background task worker to the client.
Called by the notification subscriber when it detects an input_required
notification with elicitation metadata. Sends a standard elicitation/create
request to the client session, then uses handle_task_input() to push the
response to Redis so the blocked worker can resume.
Args:
session: MCP ServerSession
session_id: Session identifier
task_id: Background task ID
elicitation: Elicitation metadata (message, requestedSchema)
fastmcp: FastMCP server instance
"""
try:
result = await session.elicit(
message=elicitation["message"],
requestedSchema=elicitation["requestedSchema"],
)
await handle_task_input(
task_id=task_id,
session_id=session_id,
action=result.action,
content=result.content,
fastmcp=fastmcp,
)
logger.debug(
"Relayed elicitation response for task %s (action=%s)",
task_id,
result.action,
)
except Exception as e:
logger.warning("Failed to relay elicitation for task %s: %s", task_id, e)
# Push a cancel response so the worker's BLPOP doesn't block forever
success = await handle_task_input(
task_id=task_id,
session_id=session_id,
action="cancel",
content=None,
fastmcp=fastmcp,
)
if not success:
logger.warning(
"Failed to push cancel response for task %s "
"(worker may block until TTL)",
task_id,
)
async def handle_task_input(
task_id: str,
session_id: str,

View file

@ -127,7 +127,7 @@ async def submit_to_docket(
"pollInterval": poll_interval_ms,
},
"_meta": {
"modelcontextprotocol.io/related-task": {
"io.modelcontextprotocol/related-task": {
"taskId": server_task_id,
}
},
@ -173,7 +173,7 @@ async def submit_to_docket(
)
try:
await ensure_subscriber_running(session_id, ctx.session, docket)
await ensure_subscriber_running(session_id, ctx.session, docket, ctx.fastmcp)
# Register cleanup callback on session exit (once per session)
# This ensures subscriber is stopped when the session disconnects

View file

@ -31,6 +31,8 @@ if TYPE_CHECKING:
from docket import Docket
from mcp.server.session import ServerSession
from fastmcp.server.server import FastMCP
logger = logging.getLogger(__name__)
# Redis key patterns
@ -75,6 +77,7 @@ async def notification_subscriber_loop(
session_id: str,
session: ServerSession,
docket: Docket,
fastmcp: FastMCP,
) -> None:
"""Subscribe to notification queue and forward to session.
@ -90,6 +93,7 @@ async def notification_subscriber_loop(
session_id: Session identifier to subscribe to
session: MCP ServerSession for sending notifications
docket: Docket instance for Redis access
fastmcp: FastMCP server instance (for elicitation relay)
"""
queue_key = docket.key(NOTIFICATION_QUEUE_KEY.format(session_id=session_id))
active_key = docket.key(NOTIFICATION_ACTIVE_KEY.format(session_id=session_id))
@ -117,7 +121,9 @@ async def notification_subscriber_loop(
try:
# Reconstruct and send MCP notification
await _send_mcp_notification(session, notification_dict)
await _send_mcp_notification(
session, notification_dict, session_id, docket, fastmcp
)
logger.debug(
"Delivered notification to session %s (attempt %d)",
session_id,
@ -159,12 +165,22 @@ async def notification_subscriber_loop(
async def _send_mcp_notification(
session: ServerSession,
notification_dict: dict[str, Any],
session_id: str,
docket: Docket,
fastmcp: FastMCP,
) -> None:
"""Reconstruct MCP notification from dict and send to session.
For input_required notifications with elicitation metadata, also sends
a standard elicitation/create request to the client and relays the
response back to the worker via Redis.
Args:
session: MCP ServerSession
notification_dict: Notification as dict (method, params, _meta)
session_id: Session identifier (for elicitation relay)
docket: Docket instance (for notification delivery)
fastmcp: FastMCP server instance (for elicitation relay)
"""
method = notification_dict.get("method", "notifications/tasks/status")
if method != "notifications/tasks/status":
@ -181,11 +197,37 @@ async def _send_mcp_notification(
await session.send_notification(server_notification)
# If this is an input_required notification with elicitation metadata,
# relay the elicitation to the client via standard elicitation/create
params = notification_dict.get("params", {})
if params.get("status") == "input_required":
meta = notification_dict.get("_meta", {})
related_task = meta.get("io.modelcontextprotocol/related-task", {})
elicitation = related_task.get("elicitation")
if elicitation:
task_id = params.get("taskId")
if not task_id:
logger.warning(
"input_required notification missing taskId, skipping relay"
)
return
from fastmcp.server.tasks.elicitation import relay_elicitation
task = asyncio.create_task(
relay_elicitation(session, session_id, task_id, elicitation, fastmcp),
name=f"elicitation-relay-{task_id[:8]}",
)
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
# =============================================================================
# Subscriber Management
# =============================================================================
# Strong references to fire-and-forget relay tasks (prevent GC mid-flight)
_background_tasks: set[asyncio.Task[None]] = set()
# Registry of active subscribers per session (prevents duplicates)
# Uses weakref to session to detect disconnects
_active_subscribers: dict[
@ -197,6 +239,7 @@ async def ensure_subscriber_running(
session_id: str,
session: ServerSession,
docket: Docket,
fastmcp: FastMCP,
) -> None:
"""Start notification subscriber if not already running (idempotent).
@ -207,6 +250,7 @@ async def ensure_subscriber_running(
session_id: Session identifier
session: MCP ServerSession
docket: Docket instance
fastmcp: FastMCP server instance (for elicitation relay)
"""
# Check if subscriber already running for this session
if session_id in _active_subscribers:
@ -224,7 +268,7 @@ async def ensure_subscriber_running(
# Start new subscriber task
task = asyncio.create_task(
notification_subscriber_loop(session_id, session, docket),
notification_subscriber_loop(session_id, session, docket, fastmcp),
name=f"notification-subscriber-{session_id[:8]}",
)
_active_subscribers[session_id] = (task, weakref.ref(session))

View file

@ -300,7 +300,7 @@ async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any:
content=[mcp.types.TextContent(type="text", text=str(error))],
isError=True,
_meta={ # type: ignore[call-arg] # _meta is Pydantic alias for meta field
"modelcontextprotocol.io/related-task": {
"io.modelcontextprotocol/related-task": {
"taskId": client_task_id,
}
},
@ -342,7 +342,7 @@ async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any:
# Build related-task metadata
related_task_meta = {
"modelcontextprotocol.io/related-task": {
"io.modelcontextprotocol/related-task": {
"taskId": client_task_id,
}
}

View file

@ -13,6 +13,7 @@ from mcp import ServerSession
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.client.elicitation import ElicitResult
from fastmcp.server.context import Context
from fastmcp.server.elicitation import AcceptedElicitation, DeclinedElicitation
from fastmcp.server.tasks.elicitation import handle_task_input
@ -227,67 +228,31 @@ class TestBackgroundTaskIntegration:
assert captured["is_background"] is True
async def test_elicit_accept_flow(self):
"""E2E: tool elicits input, client accepts, tool receives value.
Flow:
1. Tool calls ctx.elicit("name?", str) blocks waiting for input
2. Client polls handle_task_input(action="accept", content={"value":"Bob"})
3. Tool resumes with AcceptedElicitation(data="Bob")
"""
"""E2E: tool elicits input, client accepts via elicitation_handler."""
mcp = FastMCP("elicit-accept-test")
elicit_started = asyncio.Event()
captured: dict[str, str | None] = {"task_id": None, "session_id": None}
@mcp.tool(task=True)
async def ask_name(ctx: Context) -> str:
captured["task_id"] = ctx.task_id
captured["session_id"] = ctx.session_id
elicit_started.set()
result = await ctx.elicit("What is your name?", str)
if isinstance(result, AcceptedElicitation):
return f"Hello, {result.data}!"
return "No name provided"
async with Client(mcp) as client:
async def handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content={"value": "Bob"})
async with Client(mcp, elicitation_handler=handler) as client:
task = await client.call_tool("ask_name", {}, task=True)
await asyncio.wait_for(elicit_started.wait(), timeout=5.0)
assert captured["task_id"] is not None
assert captured["session_id"] is not None
# Poll until the "waiting" status is stored in Redis
success = False
for _ in range(40):
success = await handle_task_input(
task_id=captured["task_id"],
session_id=captured["session_id"],
action="accept",
content={"value": "Bob"},
fastmcp=mcp,
)
if success:
break
await asyncio.sleep(0.05)
assert success is True, "handle_task_input should succeed within 2s"
await task.wait(timeout=10.0)
result = await task.result()
assert result.data == "Hello, Bob!"
async def test_elicit_decline_flow(self):
"""E2E: tool elicits input, client declines, tool gets DeclinedElicitation."""
"""E2E: tool elicits input, client declines via elicitation_handler."""
mcp = FastMCP("elicit-decline-test")
elicit_started = asyncio.Event()
captured: dict[str, str | None] = {"task_id": None, "session_id": None}
@mcp.tool(task=True)
async def optional_input(ctx: Context) -> str:
captured["task_id"] = ctx.task_id
captured["session_id"] = ctx.session_id
elicit_started.set()
result = await ctx.elicit("Want to provide a name?", str)
if isinstance(result, DeclinedElicitation):
return "User declined"
@ -295,34 +260,17 @@ class TestBackgroundTaskIntegration:
return f"Got: {result.data}"
return "Cancelled"
async with Client(mcp) as client:
async def handler(message, response_type, params, ctx):
return ElicitResult(action="decline")
async with Client(mcp, elicitation_handler=handler) as client:
task = await client.call_tool("optional_input", {}, task=True)
await asyncio.wait_for(elicit_started.wait(), timeout=5.0)
assert captured["task_id"] is not None
assert captured["session_id"] is not None
success = False
for _ in range(40):
success = await handle_task_input(
task_id=captured["task_id"],
session_id=captured["session_id"],
action="decline",
content=None,
fastmcp=mcp,
)
if success:
break
await asyncio.sleep(0.05)
assert success is True
await task.wait(timeout=10.0)
result = await task.result()
assert result.data == "User declined"
async def test_elicit_with_pydantic_model(self):
"""E2E: tool elicits structured Pydantic input, data round-trips correctly."""
"""E2E: tool elicits structured Pydantic input via elicitation_handler."""
from pydantic import BaseModel
class UserInfo(BaseModel):
@ -330,43 +278,20 @@ class TestBackgroundTaskIntegration:
age: int
mcp = FastMCP("elicit-pydantic-test")
elicit_started = asyncio.Event()
captured: dict[str, str | None] = {"task_id": None, "session_id": None}
@mcp.tool(task=True)
async def get_user_info(ctx: Context) -> str:
captured["task_id"] = ctx.task_id
captured["session_id"] = ctx.session_id
elicit_started.set()
result = await ctx.elicit("Provide user info", UserInfo)
if isinstance(result, AcceptedElicitation):
assert isinstance(result.data, UserInfo)
return f"{result.data.name} is {result.data.age}"
return "No info"
async with Client(mcp) as client:
async def handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content={"name": "Alice", "age": 30})
async with Client(mcp, elicitation_handler=handler) as client:
task = await client.call_tool("get_user_info", {}, task=True)
await asyncio.wait_for(elicit_started.wait(), timeout=5.0)
assert captured["task_id"] is not None
assert captured["session_id"] is not None
success = False
for _ in range(40):
success = await handle_task_input(
task_id=captured["task_id"],
session_id=captured["session_id"],
action="accept",
content={"name": "Alice", "age": 30},
fastmcp=mcp,
)
if success:
break
await asyncio.sleep(0.05)
assert success is True
await task.wait(timeout=10.0)
result = await task.result()
assert result.data == "Alice is 30"

View file

@ -7,14 +7,14 @@ No mocking of Redis, sessions, or Docket internals.
import asyncio
import mcp.types
import mcp.types as mcp_types
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.client.elicitation import ElicitResult
from fastmcp.client.messages import MessageHandler
from fastmcp.server.context import Context
from fastmcp.server.elicitation import AcceptedElicitation
from fastmcp.server.tasks.elicitation import handle_task_input
from fastmcp.server.tasks.notifications import (
get_subscriber_count,
)
@ -25,12 +25,12 @@ class NotificationCaptureHandler(MessageHandler):
def __init__(self) -> None:
super().__init__()
self.notifications: list[mcp.types.ServerNotification] = []
self.notifications: list[mcp_types.ServerNotification] = []
async def on_notification(self, message: mcp.types.ServerNotification) -> None:
async def on_notification(self, message: mcp_types.ServerNotification) -> None:
self.notifications.append(message)
def for_method(self, method: str) -> list[mcp.types.ServerNotification]:
def for_method(self, method: str) -> list[mcp_types.ServerNotification]:
return [
notification
for notification in self.notifications
@ -41,66 +41,68 @@ class NotificationCaptureHandler(MessageHandler):
class TestNotificationIntegration:
"""Integration tests for the notification queue using real Docket memory backend.
The elicitation flow implicitly validates the full notification pipeline:
1. Tool calls ctx.elicit() stores request in Redis pushes notification
2. Subscriber picks up notification sends MCP notification to client
3. Client calls handle_task_input() LPUSH response BLPOP wakes tool
The elicitation flow validates the full notification pipeline:
1. Tool calls ctx.elicit() -> stores request in Redis -> pushes notification
2. Subscriber picks up notification -> sends MCP notification to client
3. Subscriber relays elicitation/create to client -> handler responds
4. Relay pushes response to Redis -> BLPOP wakes tool
"""
async def test_notification_delivered_during_elicitation(self):
"""Full E2E: notification queue delivers input_required metadata to client."""
"""Full E2E: notification queue delivers input_required metadata to client.
The elicitation relay handles the response via the client's
elicitation_handler. We verify both the notification metadata
structure and the end-to-end elicitation flow.
"""
mcp = FastMCP("notification-test")
notification_handler = NotificationCaptureHandler()
elicit_started = asyncio.Event()
captured: dict[str, str | None] = {"task_id": None, "session_id": None}
@mcp.tool(task=True)
async def elicit_tool(ctx: Context) -> str:
captured["task_id"] = ctx.task_id
captured["session_id"] = ctx.session_id
elicit_started.set()
result = await ctx.elicit("Enter value", str)
if isinstance(result, AcceptedElicitation):
return f"got: {result.data}"
return "no value"
async with Client(mcp, message_handler=notification_handler) as client:
async def elicitation_handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content={"value": "hello"})
async with Client(
mcp,
message_handler=notification_handler,
elicitation_handler=elicitation_handler,
) as client:
task = await client.call_tool("elicit_tool", {}, task=True)
await asyncio.wait_for(elicit_started.wait(), timeout=5.0)
assert captured["task_id"] is not None
assert captured["session_id"] is not None
await task.wait(timeout=10.0)
result = await task.result()
assert result.data == "got: hello"
notification: mcp.types.ServerNotification | None = None
for _ in range(40):
candidates = notification_handler.for_method(
"notifications/tasks/status"
# Verify the input_required notification was delivered with metadata
notification: mcp_types.ServerNotification | None = None
candidates = notification_handler.for_method("notifications/tasks/status")
for candidate in reversed(candidates):
candidate_meta = getattr(candidate.root, "_meta", None)
related_task = (
candidate_meta.get("io.modelcontextprotocol/related-task")
if isinstance(candidate_meta, dict)
else None
)
for candidate in reversed(candidates):
candidate_meta = getattr(candidate.root, "_meta", None)
related_task = (
candidate_meta.get("modelcontextprotocol.io/related-task")
if isinstance(candidate_meta, dict)
else None
)
if (
isinstance(related_task, dict)
and related_task.get("status") == "input_required"
):
notification = candidate
break
if notification is not None:
if (
isinstance(related_task, dict)
and related_task.get("status") == "input_required"
):
notification = candidate
break
await asyncio.sleep(0.05)
assert notification is not None, "expected notifications/tasks/status"
task_meta = getattr(notification.root, "_meta", None)
assert isinstance(task_meta, dict)
related_task = task_meta.get("modelcontextprotocol.io/related-task")
related_task = task_meta.get("io.modelcontextprotocol/related-task")
assert isinstance(related_task, dict)
assert related_task.get("taskId") == captured["task_id"]
assert related_task.get("taskId") == task.task_id
assert related_task.get("status") == "input_required"
elicitation = related_task.get("elicitation")
@ -109,25 +111,6 @@ class TestNotificationIntegration:
assert isinstance(elicitation.get("requestId"), str)
assert isinstance(elicitation.get("requestedSchema"), dict)
success = False
for _ in range(40):
success = await handle_task_input(
task_id=captured["task_id"],
session_id=captured["session_id"],
action="accept",
content={"value": "hello"},
fastmcp=mcp,
)
if success:
break
await asyncio.sleep(0.05)
assert success is True
await task.wait(timeout=10.0)
result = await task.result()
assert result.data == "got: hello"
async def test_subscriber_started_and_cleaned_up(self):
"""Subscriber starts during background task and stops when client disconnects."""
mcp = FastMCP("subscriber-test")

View file

@ -0,0 +1,191 @@
"""Tests for background task elicitation relay (notifications.py).
The relay bridges distributed background tasks to clients via the standard
MCP elicitation/create protocol. When a worker calls ctx.elicit(), the
notification subscriber detects the input_required notification and sends
an elicitation/create request to the client session. The client's
elicitation_handler fires, and the relay pushes the response to Redis
for the blocked worker.
These tests use Client(mcp) with the real memory:// Docket backend.
"""
import asyncio
from dataclasses import dataclass
from pydantic import BaseModel
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.client.elicitation import ElicitResult
from fastmcp.server.context import Context
from fastmcp.server.elicitation import (
AcceptedElicitation,
CancelledElicitation,
DeclinedElicitation,
)
class TestElicitationRelay:
"""E2E tests for elicitation flowing through the standard MCP protocol."""
async def test_accept_via_elicitation_handler(self):
"""Tool elicits, client handler accepts, tool gets the value."""
mcp = FastMCP("relay-accept")
@mcp.tool(task=True)
async def ask_name(ctx: Context) -> str:
result = await ctx.elicit("What is your name?", str)
if isinstance(result, AcceptedElicitation):
return f"Hello, {result.data}!"
return "No name"
async def handler(message, response_type, params, ctx):
assert message == "What is your name?"
return ElicitResult(action="accept", content={"value": "Alice"})
async with Client(mcp, elicitation_handler=handler) as client:
task = await client.call_tool("ask_name", {}, task=True)
result = await task.result()
assert result.data == "Hello, Alice!"
async def test_decline_via_elicitation_handler(self):
"""Tool elicits, client handler declines, tool gets DeclinedElicitation."""
mcp = FastMCP("relay-decline")
@mcp.tool(task=True)
async def optional_input(ctx: Context) -> str:
result = await ctx.elicit("Provide a name?", str)
if isinstance(result, DeclinedElicitation):
return "User declined"
if isinstance(result, AcceptedElicitation):
return f"Got: {result.data}"
return "Cancelled"
async def handler(message, response_type, params, ctx):
return ElicitResult(action="decline")
async with Client(mcp, elicitation_handler=handler) as client:
task = await client.call_tool("optional_input", {}, task=True)
result = await task.result()
assert result.data == "User declined"
async def test_cancel_via_elicitation_handler(self):
"""Tool elicits, client handler cancels, tool gets CancelledElicitation."""
mcp = FastMCP("relay-cancel")
@mcp.tool(task=True)
async def cancellable(ctx: Context) -> str:
result = await ctx.elicit("Input?", str)
if isinstance(result, CancelledElicitation):
return "Cancelled"
return "Not cancelled"
async def handler(message, response_type, params, ctx):
return ElicitResult(action="cancel")
async with Client(mcp, elicitation_handler=handler) as client:
task = await client.call_tool("cancellable", {}, task=True)
result = await task.result()
assert result.data == "Cancelled"
async def test_dataclass_round_trips_through_relay(self):
"""Structured dataclass type round-trips through the relay."""
mcp = FastMCP("relay-dataclass")
@dataclass
class UserInfo:
name: str
age: int
@mcp.tool(task=True)
async def get_user(ctx: Context) -> str:
result = await ctx.elicit("Provide user info", UserInfo)
if isinstance(result, AcceptedElicitation):
assert isinstance(result.data, UserInfo)
return f"{result.data.name} is {result.data.age}"
return "No info"
async def handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content={"name": "Bob", "age": 30})
async with Client(mcp, elicitation_handler=handler) as client:
task = await client.call_tool("get_user", {}, task=True)
result = await task.result()
assert result.data == "Bob is 30"
async def test_pydantic_model_round_trips_through_relay(self):
"""Structured Pydantic model round-trips through the relay."""
mcp = FastMCP("relay-pydantic")
class Config(BaseModel):
host: str
port: int
@mcp.tool(task=True)
async def get_config(ctx: Context) -> str:
result = await ctx.elicit("Server config?", Config)
if isinstance(result, AcceptedElicitation):
assert isinstance(result.data, Config)
return f"{result.data.host}:{result.data.port}"
return "No config"
async def handler(message, response_type, params, ctx):
return ElicitResult(
action="accept", content={"host": "localhost", "port": 8080}
)
async with Client(mcp, elicitation_handler=handler) as client:
task = await client.call_tool("get_config", {}, task=True)
result = await task.result()
assert result.data == "localhost:8080"
async def test_multiple_sequential_elicitations(self):
"""Tool calls ctx.elicit() twice, both go through the relay."""
mcp = FastMCP("relay-multi")
@mcp.tool(task=True)
async def two_questions(ctx: Context) -> str:
r1 = await ctx.elicit("First name?", str)
r2 = await ctx.elicit("Last name?", str)
if isinstance(r1, AcceptedElicitation) and isinstance(
r2, AcceptedElicitation
):
return f"{r1.data} {r2.data}"
return "Incomplete"
call_count = 0
async def handler(message, response_type, params, ctx):
nonlocal call_count
call_count += 1
if call_count == 1:
assert message == "First name?"
return ElicitResult(action="accept", content={"value": "Jane"})
else:
assert message == "Last name?"
return ElicitResult(action="accept", content={"value": "Doe"})
async with Client(mcp, elicitation_handler=handler) as client:
task = await client.call_tool("two_questions", {}, task=True)
result = await task.result()
assert result.data == "Jane Doe"
assert call_count == 2
async def test_no_elicitation_handler_returns_cancel(self):
"""Without an elicitation_handler, the relay fails and task gets cancel."""
mcp = FastMCP("relay-no-handler")
@mcp.tool(task=True)
async def needs_input(ctx: Context) -> str:
result = await ctx.elicit("Input?", str)
if isinstance(result, CancelledElicitation):
return "Cancelled as expected"
if isinstance(result, AcceptedElicitation):
return f"Got: {result.data}"
return "Other"
async with Client(mcp) as client:
task = await client.call_tool("needs_input", {}, task=True)
result = await asyncio.wait_for(task.result(), timeout=15.0)
assert result.data == "Cancelled as expected"

View file

@ -2,7 +2,7 @@
Tests for SEP-1686 related-task metadata in protocol responses.
Per the spec, all task-related responses MUST include
modelcontextprotocol.io/related-task in _meta.
io.modelcontextprotocol/related-task in _meta.
"""
import pytest
@ -24,7 +24,7 @@ async def metadata_server():
async def test_tasks_get_includes_related_task_metadata(metadata_server: FastMCP):
"""tasks/get response includes modelcontextprotocol.io/related-task in _meta."""
"""tasks/get response includes io.modelcontextprotocol/related-task in _meta."""
async with Client(metadata_server) as client:
# Submit a task
task = await client.call_tool("test_tool", {"value": 5}, task=True)
@ -40,7 +40,7 @@ async def test_tasks_get_includes_related_task_metadata(metadata_server: FastMCP
async def test_tasks_result_includes_related_task_metadata(metadata_server: FastMCP):
"""tasks/result response includes modelcontextprotocol.io/related-task in _meta."""
"""tasks/result response includes io.modelcontextprotocol/related-task in _meta."""
async with Client(metadata_server) as client:
# Submit and complete a task
task = await client.call_tool("test_tool", {"value": 7}, task=True)
@ -53,7 +53,7 @@ async def test_tasks_result_includes_related_task_metadata(metadata_server: Fast
async def test_tasks_list_includes_related_task_metadata(metadata_server: FastMCP):
"""tasks/list response includes modelcontextprotocol.io/related-task in _meta."""
"""tasks/list response includes io.modelcontextprotocol/related-task in _meta."""
async with Client(metadata_server) as client:
# List tasks via client (which uses protocol properly)
result = await client.list_tasks()