"""SEP-1686 task request handlers. Handles MCP task protocol requests: tasks/get, tasks/result, tasks/list, tasks/cancel. These handlers query and manage existing tasks (contrast with handlers.py which creates tasks). This module requires fastmcp[tasks] (pydocket). It is only imported when docket is available. """ from __future__ import annotations from datetime import datetime, timedelta, timezone from typing import TYPE_CHECKING, Any, Literal import mcp.types from docket.execution import ExecutionState from mcp.shared.exceptions import McpError from mcp.types import ( INTERNAL_ERROR, INVALID_PARAMS, CancelTaskResult, ErrorData, GetTaskResult, ListTasksResult, ) import fastmcp.server.context from fastmcp.exceptions import NotFoundError from fastmcp.prompts.base import Prompt from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate from fastmcp.server.tasks.config import DEFAULT_POLL_INTERVAL_MS, DEFAULT_TTL_MS from fastmcp.server.tasks.context import get_task_scope from fastmcp.server.tasks.keys import parse_task_key, task_redis_prefix from fastmcp.tools.base import Tool from fastmcp.utilities.versions import VersionSpec if TYPE_CHECKING: from fastmcp.server.server import FastMCP # Map Docket execution states to MCP task status strings # Per SEP-1686 final spec (line 381): tasks MUST begin in "working" status DOCKET_TO_MCP_STATE: dict[ExecutionState, str] = { ExecutionState.SCHEDULED: "working", # Initial state per spec ExecutionState.QUEUED: "working", # Initial state per spec ExecutionState.RUNNING: "working", ExecutionState.COMPLETED: "completed", ExecutionState.FAILED: "failed", ExecutionState.CANCELLED: "cancelled", } def _parse_key_version(key_suffix: str) -> tuple[str, str | None]: """Parse a key suffix into (name_or_uri, version). Keys always contain @ as a version delimiter (sentinel pattern): - "add@1.0" → ("add", "1.0") # versioned - "add@" → ("add", None) # unversioned - "user@example.com@1.0" → ("user@example.com", "1.0") # @ in URI Uses rsplit to split on the LAST @ which is always the version delimiter. Falls back to treating the whole string as the name if @ is not present (for backwards compatibility with legacy task keys). """ if "@" not in key_suffix: # Legacy key without version sentinel - treat as unversioned return key_suffix, None name_or_uri, version = key_suffix.rsplit("@", 1) return name_or_uri, version if version else None async def _lookup_task_execution( docket: Any, task_scope: str | None, client_task_id: str, ) -> tuple[Any, str | None, int]: """Look up task execution and metadata from Redis. Consolidates the common pattern of fetching task metadata from Redis, validating it exists, and retrieving the Docket execution. Args: docket: Docket instance task_scope: Authorization scope client_task_id: Client-provided task ID Returns: Tuple of (execution, created_at, poll_interval_ms) Raises: McpError: If task not found or execution not found """ prefix = task_redis_prefix(task_scope) task_meta_key = docket.key(f"{prefix}:{client_task_id}") created_at_key = docket.key(f"{prefix}:{client_task_id}:created_at") poll_interval_key = docket.key(f"{prefix}:{client_task_id}:poll_interval") # Fetch metadata (single round-trip with mget) async with docket.redis() as redis: task_key_bytes, created_at_bytes, poll_interval_bytes = await redis.mget( task_meta_key, created_at_key, poll_interval_key ) # Decode and validate task_key task_key = task_key_bytes.decode("utf-8") if task_key_bytes else None if not task_key: raise McpError( ErrorData(code=INVALID_PARAMS, message=f"Task {client_task_id} not found") ) # Get execution execution = await docket.get_execution(task_key) if not execution: raise McpError( ErrorData( code=INVALID_PARAMS, message=f"Task {client_task_id} execution not found", ) ) # Parse metadata with defaults created_at = created_at_bytes.decode("utf-8") if created_at_bytes else None try: poll_interval_ms = ( int(poll_interval_bytes.decode("utf-8")) if poll_interval_bytes else DEFAULT_POLL_INTERVAL_MS ) except (ValueError, UnicodeDecodeError): poll_interval_ms = DEFAULT_POLL_INTERVAL_MS return execution, created_at, poll_interval_ms async def tasks_get_handler(server: FastMCP, params: dict[str, Any]) -> GetTaskResult: """Handle MCP 'tasks/get' request (SEP-1686). Args: server: FastMCP server instance params: Request params containing taskId Returns: GetTaskResult: Task status response with spec-compliant fields """ async with fastmcp.server.context.Context(fastmcp=server): client_task_id = params.get("taskId") if not client_task_id: raise McpError( ErrorData( code=INVALID_PARAMS, message="Missing required parameter: taskId" ) ) # Get authorization scope for task lookup task_scope = get_task_scope() # Get Docket instance docket = server._docket if docket is None: raise McpError( ErrorData( code=INTERNAL_ERROR, message="Background tasks require Docket", ) ) # Look up task execution and metadata execution, created_at, poll_interval_ms = await _lookup_task_execution( docket, task_scope, client_task_id ) # Sync state from Redis await execution.sync() # Map Docket state to MCP state state_map = DOCKET_TO_MCP_STATE mcp_state: Literal[ "working", "input_required", "completed", "failed", "cancelled" ] = state_map.get(execution.state, "failed") # type: ignore[assignment] # ty:ignore[invalid-assignment] # Build response (use default ttl since we don't track per-task values) # createdAt is REQUIRED per SEP-1686 final spec (line 430) # Per spec lines 447-448: SHOULD NOT include related-task metadata in tasks/get error_message = None status_message = None if execution.state == ExecutionState.FAILED: try: await execution.get_result(timeout=timedelta(seconds=0)) except Exception as error: error_message = str(error) status_message = f"Task failed: {error_message}" elif execution.progress and execution.progress.message: # Extract progress message from Docket if available (spec line 403) status_message = execution.progress.message # createdAt is required per spec, but can be None from Redis # Parse ISO string to datetime, or use current time as fallback if created_at: try: created_at_dt = datetime.fromisoformat( created_at.replace("Z", "+00:00") ) except (ValueError, AttributeError): created_at_dt = datetime.now(timezone.utc) else: created_at_dt = datetime.now(timezone.utc) return GetTaskResult( taskId=client_task_id, status=mcp_state, createdAt=created_at_dt, lastUpdatedAt=datetime.now(timezone.utc), ttl=DEFAULT_TTL_MS, pollInterval=poll_interval_ms, statusMessage=status_message, ) async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any: """Handle MCP 'tasks/result' request (SEP-1686). Converts raw task return values to MCP types based on task type. Args: server: FastMCP server instance params: Request params containing taskId Returns: MCP result (CallToolResult, GetPromptResult, or ReadResourceResult) """ async with fastmcp.server.context.Context(fastmcp=server): client_task_id = params.get("taskId") if not client_task_id: raise McpError( ErrorData( code=INVALID_PARAMS, message="Missing required parameter: taskId" ) ) # Get authorization scope for task lookup task_scope = get_task_scope() # Get execution from Docket (use instance attribute for cross-task access) docket = server._docket if docket is None: raise McpError( ErrorData( code=INTERNAL_ERROR, message="Background tasks require Docket", ) ) # Look up full task key from Redis task_meta_key = docket.key(f"{task_redis_prefix(task_scope)}:{client_task_id}") async with docket.redis() as redis: task_key_bytes = await redis.get(task_meta_key) task_key = None if task_key_bytes is None else task_key_bytes.decode("utf-8") if task_key is None: raise McpError( ErrorData( code=INVALID_PARAMS, message=f"Invalid taskId: {client_task_id} not found", ) ) execution = await docket.get_execution(task_key) if execution is None: raise McpError( ErrorData( code=INVALID_PARAMS, message=f"Invalid taskId: {client_task_id} not found", ) ) # Sync state from Redis await execution.sync() # Check if completed state_map = DOCKET_TO_MCP_STATE if execution.state not in (ExecutionState.COMPLETED, ExecutionState.FAILED): mcp_state = state_map.get(execution.state, "failed") raise McpError( ErrorData( code=INVALID_PARAMS, message=f"Task not completed yet (current state: {mcp_state})", ) ) # Get result from Docket try: raw_value = await execution.get_result(timeout=timedelta(seconds=0)) except Exception as error: # Task failed - return error result return mcp.types.CallToolResult( content=[mcp.types.TextContent(type="text", text=str(error))], isError=True, _meta={ # type: ignore[call-arg] # _meta is Pydantic alias for meta field "io.modelcontextprotocol/related-task": { "taskId": client_task_id, } }, ) # Parse task key to get component key key_parts = parse_task_key(task_key) component_key = key_parts["component_identifier"] # Look up component by its prefixed key (inlined from deleted get_component) component: Tool | Resource | ResourceTemplate | Prompt | None = None try: if component_key.startswith("tool:"): name, version_str = _parse_key_version(component_key[5:]) version = VersionSpec(eq=version_str) if version_str else None component = await server.get_tool(name, version) elif component_key.startswith("resource:"): uri, version_str = _parse_key_version(component_key[9:]) version = VersionSpec(eq=version_str) if version_str else None component = await server.get_resource(uri, version) elif component_key.startswith("template:"): uri, version_str = _parse_key_version(component_key[9:]) version = VersionSpec(eq=version_str) if version_str else None component = await server.get_resource_template(uri, version) elif component_key.startswith("prompt:"): name, version_str = _parse_key_version(component_key[7:]) version = VersionSpec(eq=version_str) if version_str else None component = await server.get_prompt(name, version) except NotFoundError: component = None if component is None: raise McpError( ErrorData( code=INTERNAL_ERROR, message=f"Component not found for task: {component_key}", ) ) # Build related-task metadata related_task_meta = { "io.modelcontextprotocol/related-task": { "taskId": client_task_id, } } # Convert based on component type. # Each branch merges related_task_meta with any existing _meta # (e.g. fastmcp.wrap_result) rather than overwriting it. if isinstance(component, Tool): fastmcp_result = component.convert_result(raw_value) mcp_result = fastmcp_result.to_mcp_result() if isinstance(mcp_result, mcp.types.CallToolResult): merged = {**(mcp_result.meta or {}), **related_task_meta} mcp_result._meta = merged # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] elif isinstance(mcp_result, tuple): content, structured_content = mcp_result mcp_result = mcp.types.CallToolResult( content=content, structuredContent=structured_content, _meta=related_task_meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field ) else: mcp_result = mcp.types.CallToolResult( content=mcp_result, _meta=related_task_meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field ) return mcp_result elif isinstance(component, Prompt): fastmcp_result = component.convert_result(raw_value) mcp_result = fastmcp_result.to_mcp_prompt_result() merged = {**(mcp_result.meta or {}), **related_task_meta} mcp_result._meta = merged # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] return mcp_result elif isinstance(component, ResourceTemplate): fastmcp_result = component.convert_result(raw_value) mcp_result = fastmcp_result.to_mcp_result(component.uri_template) merged = {**(mcp_result.meta or {}), **related_task_meta} mcp_result._meta = merged # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] return mcp_result elif isinstance(component, Resource): fastmcp_result = component.convert_result(raw_value) mcp_result = fastmcp_result.to_mcp_result(str(component.uri)) merged = {**(mcp_result.meta or {}), **related_task_meta} mcp_result._meta = merged # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] return mcp_result else: raise McpError( ErrorData( code=INTERNAL_ERROR, message=f"Internal error: Unknown component type: {type(component).__name__}", ) ) async def tasks_list_handler( server: FastMCP, params: dict[str, Any] ) -> ListTasksResult: """Handle MCP 'tasks/list' request (SEP-1686). Note: With client-side tracking, this returns minimal info. Args: server: FastMCP server instance params: Request params (cursor, limit) Returns: ListTasksResult: Response with tasks list and pagination """ # Return empty list - client tracks tasks locally return ListTasksResult(tasks=[], nextCursor=None) async def tasks_cancel_handler( server: FastMCP, params: dict[str, Any] ) -> CancelTaskResult: """Handle MCP 'tasks/cancel' request (SEP-1686). Cancels a running task, transitioning it to cancelled state. Args: server: FastMCP server instance params: Request params containing taskId Returns: CancelTaskResult: Task status response showing cancelled state """ async with fastmcp.server.context.Context(fastmcp=server): client_task_id = params.get("taskId") if not client_task_id: raise McpError( ErrorData( code=INVALID_PARAMS, message="Missing required parameter: taskId" ) ) # Get authorization scope for task lookup task_scope = get_task_scope() # Get Docket instance docket = server._docket if docket is None: raise McpError( ErrorData( code=INTERNAL_ERROR, message="Background tasks require Docket", ) ) # Look up task execution and metadata execution, created_at, poll_interval_ms = await _lookup_task_execution( docket, task_scope, client_task_id ) # Cancel via Docket (now sets CANCELLED state natively) # Note: We need to get task_key from execution.key for cancellation await docket.cancel(execution.key) # Return task status with cancelled state # createdAt is REQUIRED per SEP-1686 final spec (line 430) # Per spec lines 447-448: SHOULD NOT include related-task metadata in tasks/cancel return CancelTaskResult( taskId=client_task_id, status="cancelled", createdAt=datetime.fromisoformat(created_at) if created_at else datetime.now(timezone.utc), lastUpdatedAt=datetime.now(timezone.utc), ttl=DEFAULT_TTL_MS, pollInterval=poll_interval_ms, statusMessage="Task cancelled", )