""" TaskResultHandler - Integrated handler for tasks/result endpoint. This implements the dequeue-send-wait pattern from the MCP Tasks spec: 1. Dequeue all pending messages for the task 2. Send them to the client via transport with relatedRequestId routing 3. Wait if task is not in terminal state 4. Return final result when task completes This is the core of the task message queue pattern. """ import logging from typing import Any import anyio from mcp.server.session import ServerSession from mcp.shared.exceptions import McpError from mcp.shared.experimental.tasks.helpers import RELATED_TASK_METADATA_KEY, is_terminal from mcp.shared.experimental.tasks.message_queue import TaskMessageQueue from mcp.shared.experimental.tasks.resolver import Resolver from mcp.shared.experimental.tasks.store import TaskStore from mcp.shared.message import ServerMessageMetadata, SessionMessage from mcp.types import ( INVALID_PARAMS, ErrorData, GetTaskPayloadRequest, GetTaskPayloadResult, JSONRPCMessage, RelatedTaskMetadata, RequestId, ) logger = logging.getLogger(__name__) class TaskResultHandler: """ Handler for tasks/result that implements the message queue pattern. This handler: 1. Dequeues pending messages (elicitations, notifications) for the task 2. Sends them to the client via the response stream 3. Waits for responses and resolves them back to callers 4. Blocks until task reaches terminal state 5. Returns the final result Prefer `server.experimental.enable_tasks()`, whose default tasks/result handler wraps `handle()` and only serves tasks created by the requesting session. A custom handler that calls `handle()` directly is responsible for deciding which requestors may access which tasks. Usage: # Create handler with store and queue handler = TaskResultHandler(task_store, message_queue) # Register it with the server @server.experimental.get_task_result() async def handle_task_result(req: GetTaskPayloadRequest) -> GetTaskPayloadResult: ctx = server.request_context return await handler.handle(req, ctx.session, ctx.request_id) """ def __init__( self, store: TaskStore, queue: TaskMessageQueue, ): self._store = store self._queue = queue # Map from internal request ID to resolver for routing responses self._pending_requests: dict[RequestId, Resolver[dict[str, Any]]] = {} async def send_message( self, session: ServerSession, message: SessionMessage, ) -> None: """ Send a message via the session. This is a helper for delivering queued task messages. """ await session.send_message(message) async def handle( self, request: GetTaskPayloadRequest, session: ServerSession, request_id: RequestId, ) -> GetTaskPayloadResult: """ Handle a tasks/result request. This implements the dequeue-send-wait loop: 1. Dequeue all pending messages 2. Send each via transport with relatedRequestId = this request's ID 3. If task not terminal, wait for status change 4. Loop until task is terminal 5. Return final result Args: request: The GetTaskPayloadRequest session: The server session for sending messages request_id: The request ID for relatedRequestId routing Returns: GetTaskPayloadResult with the task's final payload """ task_id = request.params.taskId while True: task = await self._store.get_task(task_id) if task is None: raise McpError( ErrorData( code=INVALID_PARAMS, message=f"Task not found: {task_id}", ) ) await self._deliver_queued_messages(task_id, session, request_id) # If task is terminal, return result if is_terminal(task.status): result = await self._store.get_result(task_id) # GetTaskPayloadResult is a Result with extra="allow" # The stored result contains the actual payload data # Per spec: tasks/result MUST include _meta with related-task metadata related_task = RelatedTaskMetadata(taskId=task_id) related_task_meta: dict[str, Any] = { RELATED_TASK_METADATA_KEY: related_task.model_dump( by_alias=True, mode="json", exclude_none=True, ) } if result is not None: result_data = result.model_dump(by_alias=True, mode="json", exclude_none=True) existing_meta: dict[str, Any] = result_data.get("_meta") or {} result_data["_meta"] = {**existing_meta, **related_task_meta} return GetTaskPayloadResult.model_validate(result_data) return GetTaskPayloadResult.model_validate({"_meta": related_task_meta}) # Wait for task update (status change or new messages) await self._wait_for_task_update(task_id) async def _deliver_queued_messages( self, task_id: str, session: ServerSession, request_id: RequestId, ) -> None: """ Dequeue and send all pending messages for a task. Each message is sent via the session's write stream with relatedRequestId set so responses route back to this stream. """ while True: message = await self._queue.dequeue(task_id) if message is None: break # If this is a request (not notification), wait for response if message.type == "request" and message.resolver is not None: # Store the resolver so we can route the response back original_id = message.original_request_id if original_id is not None: self._pending_requests[original_id] = message.resolver logger.debug("Delivering queued message for task %s: %s", task_id, message.type) # Send the message with relatedRequestId for routing session_message = SessionMessage( message=JSONRPCMessage(message.message), metadata=ServerMessageMetadata(related_request_id=request_id), ) await self.send_message(session, session_message) async def _wait_for_task_update(self, task_id: str) -> None: """ Wait for task to be updated (status change or new message). Races between store update and queue message - first one wins. """ async with anyio.create_task_group() as tg: async def wait_for_store() -> None: try: await self._store.wait_for_update(task_id) except Exception: pass finally: tg.cancel_scope.cancel() async def wait_for_queue() -> None: try: await self._queue.wait_for_message(task_id) except Exception: pass finally: tg.cancel_scope.cancel() tg.start_soon(wait_for_store) tg.start_soon(wait_for_queue) def route_response(self, request_id: RequestId, response: dict[str, Any]) -> bool: """ Route a response back to the waiting resolver. This is called when a response arrives for a queued request. Args: request_id: The request ID from the response response: The response data Returns: True if response was routed, False if no pending request """ resolver = self._pending_requests.pop(request_id, None) if resolver is not None and not resolver.done(): resolver.set_result(response) return True return False def route_error(self, request_id: RequestId, error: ErrorData) -> bool: """ Route an error back to the waiting resolver. Args: request_id: The request ID from the error response error: The error data Returns: True if error was routed, False if no pending request """ resolver = self._pending_requests.pop(request_id, None) if resolver is not None and not resolver.done(): resolver.set_exception(McpError(error)) return True return False