Distributed Task Queues (QueueTaskExecutor)¶
When litestar-mcp[queues] is installed and litestar-queues QueuePlugin is registered on your Litestar application, LitestarMCP automatically distributes background MCP Tasks across litestar-queues workers via QueueTaskExecutor.
Zero-Config Auto-Enabling¶
Registering QueuePlugin and LitestarMCP on the same Litestar application is all that is required:
"""Snippet: distributed MCP Tasks using litestar-queues."""
from litestar import Litestar
from litestar_queues import QueuePlugin
from litestar_mcp import LitestarMCP
def build() -> Litestar:
"""Build a Litestar application whose MCP Tasks run on litestar-queues workers."""
return Litestar(
plugins=[
QueuePlugin(),
LitestarMCP(),
],
)
During on_app_init, LitestarMCP._configure_task_runtime() inspects app_config.plugins:
- If a
QueuePluginis present andMCPConfig.tasksis not explicitlyFalse,LitestarMCPenablesMCPTaskConfigautomatically. - When
MCPTaskConfig.executorisNone,LitestarMCPinstantiatesQueueTaskExecutor()and callsexecutor.configure(app_config), which attachesQueueTaskExecutor.resolve_task_dependenciestoQueuePluginthroughTaskDependencyChain. - If
MCPTaskConfig.storeisNone,MCPTaskStorebinds to the"litestar_mcp_tasks"store entry in the application'sStoreRegistry(Litestar(stores=...)). - On application startup (
on_startup),LitestarMCPbindsMCPTaskRuntime(app=app, task_store=self._task_store, run_attempt=service.run_task_attempt)ontoQueueTaskExecutor.
Explicit QueueTaskExecutor Configuration¶
To customize the default queue name, per-attempt execution timeout, or custom principal loader callback, pass an explicit QueueTaskExecutor to MCPTaskConfig(executor=...):
from typing import Any
from litestar import Litestar
from litestar_queues import QueuePlugin
from litestar_mcp import LitestarMCP, MCPConfig, MCPTaskConfig
from litestar_mcp.queues import QueueTaskExecutor
async def load_user_from_db(owner_id: str) -> dict[str, Any]:
"""Load an application user model from the task owner identifier."""
raw_id = owner_id.removeprefix("user:")
return {"id": raw_id, "roles": ["analyst"]}
app = Litestar(
plugins=[
QueuePlugin(),
LitestarMCP(
MCPConfig(
tasks=MCPTaskConfig(
executor=QueueTaskExecutor(
queue="mcp_default",
timeout=1800,
principal_loader=load_user_from_db,
),
),
),
),
],
)
| Parameter | Type | Default | Description |
|---|---|---|---|
queue |
str |
"mcp" |
Default litestar-queues queue lane for run_mcp_tool_task (litestar_mcp.mcp.run_tool_task) attempts when not overridden on the controller or route. |
timeout |
int |
3600 |
Maximum duration in seconds allowed for a single tool execution attempt before the worker times out the job. |
principal_loader |
Callable[[str], Awaitable[Any]] or None |
None |
Optional async callback receiving ToolTaskCall.owner_id and returning the user object to bind to request.scope["user"] in the worker. When omitted, LitestarMCP rehydrates user, auth, and litestar_security automatically from the captured snapshot and route dependencies. |
Plugin, Controller & Route Overrides¶
You can control task execution and queue routing at every layer of your Litestar application:
| Layer | Configuration | Effect |
|---|---|---|
| Plugin (Disable Tasks) | MCPConfig(tasks=False) |
Disables the io.modelcontextprotocol/tasks extension completely, even when QueuePlugin is registered on the application. |
| Plugin (Force In-Process) | MCPConfig(tasks=MCPTaskConfig(executor="in_process")) (or executor=False) |
Keeps io.modelcontextprotocol/tasks enabled using process-local asyncio.Task runners instead of auto-wiring QueueTaskExecutor. |
Router / Controller (opt) |
Controller.opt = {"mcp_task": True, "mcp_queue": "heavy"} |
Enables task promotion and routes all tools on the controller to the "heavy" queue lane unless overridden on a specific handler. |
| Route / Decorator (Custom Queue) | @mcp_tool(task=True, queue="heavy") or @post(..., mcp_task=True, mcp_queue="heavy") |
Routes task attempts for this specific tool to the "heavy" queue lane (ToolTaskCall.queue = "heavy"). |
| Route / Decorator (Force In-Process) | @mcp_tool(task=True, queue=False) or @mcp_tool(task=True, queue="in_process") |
Forces this specific task-capable tool to run in-process within the web worker even when QueueTaskExecutor is active globally. |
from litestar import Controller, post
from litestar_mcp import mcp_tool
class AnalyticsController(Controller):
"""Controller routing background MCP tasks to the heavy worker lane by default."""
path = "/analytics"
opt = {"mcp_task": True, "mcp_queue": "heavy"}
@post("/backfill", mcp_tool="run_backfill")
async def run_backfill(self, dataset: str) -> dict[str, str]:
"""Run on the controller's default 'heavy' queue."""
return {"dataset": dataset, "status": "backfilled"}
@mcp_tool("quick_ping", task=True, queue=False)
@post("/ping")
async def quick_ping(self) -> dict[str, str]:
"""Override the controller queue to run as an in-process background task."""
return {"status": "ok"}
Automatic Security, Principal & Dependency Rehydration in Workers¶
When a task-augmented tools/call runs in a separate litestar-queues worker process (litestar workers run), the original HTTP connection is no longer present. LitestarMCP and QueueTaskExecutor automatically capture the caller's security context at enqueue time and reconstruct an internal ASGI request scope inside the worker:
flowchart LR
Call["POST /mcp (tools/call)"] --> Snap["extract_principal_snapshot (ToolTaskCall)"]
Snap --> Queue["litestar-queues (mcp-task:{id}:{attempt})"]
Queue --> Scope["Worker blank_http_scope + rehydrate_security_scope"]
Scope --> Handler["Route Guards + Provide + Dishka Scope.REQUEST"]
1. Principal & litestar-security SecurityContext Snapshot¶
Before submitting a ToolTaskCall to QueueTaskExecutor, MCPHandlerService calls extract_principal_snapshot(scope.get("user"), scope.get("auth"), scope.get("litestar_security"), context.owner_id):
- User Attributes & Typed Structs: Extracts standard identity fields (
id,sub,user_id,account_id,username,name,email,is_active,roles,scopes,permissions,hives,tenant,tenant_id) or serializesmsgspec.Structand@dataclassuser models alongside a"__user_type__": "module:QualName"tag. litestar-securitySecurityContext: Whenlitestar-securityis active on the request, serializesPrincipal(account_id,subject,mechanisms,attributes,restrictions),AuthorizationSnapshot(roles,permissions,scopes,attributes,tenant_roles), andCredentialRestrictions(scopes,tenant_ids,roles) under"__litestar_security__".- Standard
request.auth: When custom Litestar authentication middleware populatesscope["auth"], serializes the claims payload under"__auth__".
2. Worker Scope Rehydration (run_task_attempt)¶
When the worker claims run_mcp_tool_task (RUN_TOOL_TASK = "litestar_mcp.mcp.run_tool_task"):
MCPHandlerService.run_task_attempt()creates a synthesized HTTP scope viablank_http_scope(app).rehydrate_security_scope(scope, call.principal, call.owner_id)restores:scope["user"]: Reconstructed as the originalmsgspec.Struct/@dataclassclass (when__user_type__is importable), alitestar-securityPrincipalwrapper, or aPrincipalProxysupporting both dictionary (request.user["id"]) and attribute (request.user.id) lookups.scope["auth"]andscope["litestar_security"]: Reconstructed asSecurityContext(principal=..., authorization=AuthorizationSnapshot(...), restrictions=(CredentialRestrictions(...), ...))(or fallback value objects whenlitestar-securityis not installed).- Existing
ProvideUser Dependencies: Ifprincipal_loaderwas not supplied onQueueTaskExecutor,_rehydrate_user_from_handler_dependencies()inspects the target route handler's Litestardependenciesfor a provider namedcurrent_user,user, orprincipal. If found, it invokes that provider with any matching parameters (request,connection,state,scope,owner_id,user_id,principal_id) and binds the resolved domain user ontorequest.scope["user"]. - Litestar
Provide& DishkaScope.REQUESTContainers:execute_toolruns the synthesized request through the full Litestar handler pipeline. Route guards,Providedependencies, and DishkaFromDishka[T]request-scoped containers resolve identically to a live HTTP request.
Note: Standalone
litestar-queuesworkers (WorkerConfig(placement="external")) must keepWorkerConfig.run_app_lifespan=True(the default inlitestar-queues) soLitestarMCP.on_startupbindsMCPTaskRuntimeand initializes application lifespan resources (such as database pools and Dishka containers) before the worker claims tasks.
Internal Job Keys, Cooperative Cancellation & Cross-Replica Resume¶
QueueTaskExecutor coordinates multi-attempt execution and cancellation across stateless web and worker replicas through MCPTaskStore and QueueService:
Job Dispatch Keys¶
- Task Record Key:
mcp-task:{task_id}stores the JSON-encodedTaskRecord, includingdispatchInternal(the serializedToolTaskCallenvelope) andrequestStateInternal. - Queue Attempt Key: Each attempt is enqueued on
litestar-queueswithkey=f"mcp-task:{call.task_id}:{call.attempt}", ensuring idempotent scheduling per attempt number (attemptstarts at1). - Active Job Pointer Key: After
QueueService.enqueue()returns a queue job UUID,QueueTaskExecutor._enqueue_attempt()persists that UUID undermcp-task:{task_id}:jobinMCPTaskStorewith the task's TTL (expires_in).
Cooperative Cancellation (cancel_task(include_running=True))¶
When ANY web replica receives tasks/cancel for taskId:
MCPHandlerService.tasks_canceldelegates toQueueTaskExecutor.cancel(record).QueueTaskExecutor.cancelreads the active queue job UUID fromMCPTaskStore.get_job_id(record.task_id)(mcp-task:{task_id}:job).- It invokes
await service.cancel_task(UUID(job_id), include_running=True)onlitestar-queues. - Inside the worker executing
run_mcp_tool_task, a background watcher coroutine (_cancel_on_signal) awaitsget_current_task_context().wait_cancelled(). When the durable cancellation signal arrives,_cancel_on_signalcancels the AnyIOCancelScopewrappingrun_attempt. MCPTaskStore.mark_cancelled(record.task_id)transitions the task record tostatus="cancelled"and emitsnotifications/tasks.
Cross-Replica input_required Resumption¶
Because QueueTaskExecutor does not hold open an idle worker slot while waiting for human input:
- When a tool returns
MCPInputRequiredResult, the attempt finishes cleanly (retries=0) after persistingstatus="input_required",inputRequests,requestStateInternal, anddispatchInternal(ToolTaskCallwithattempt=1) in the sharedMCPTaskStore. - When the client sends
tasks/updateto any web replica,MCPHandlerService.tasks_updatevalidates the responses viatask_store.update(), loads the persistedToolTaskCallfromtask_store.get_dispatch(task_id), mergesinput_responses, setsrequest_state=record.request_state, incrementsattempt=dispatch.attempt + 1, saves the updatedToolTaskCall, and callsawait executor.resume(call). QueueTaskExecutor.resume(call)enqueues a fresh job underkey=f"mcp-task:{call.task_id}:{call.attempt}"and updatesmcp-task:{task_id}:job. Any available worker replica claims the resumed attempt withget_mcp_request_context().input_responsesandrequest_statepopulated.