Expose durable work through MCP Tasks
Map workflow tasks to MCP handles with polling, updates, cancellation, subscriptions, retention, and tenant-safe authorization.
The Task protocol
Ordinary MCP tool calls complete within one request. MCP Tasks represent work
that outlives that request. A client creates a Task and receives an opaque
taskId plus current status, polling guidance, retention timestamps, and
eventual result or safe failure detail.
Tasks require negotiated capability and per-request authorization. A server that does not support the extension continues to use normal MCP behavior.
Enable the extension
The Workflow adapter is an opt-in feature on runifold-mcp:
[dependencies]
runifold = { version = "=0.9.0", features = ["mcp", "sqlite-bundled"] }
runifold-mcp = { version = "=0.9.0", features = ["workflow-tasks"] }On the client, advertise Task support during initialization:
let config = McpClientConfig::new(Implementation::new("task-client", "1"))
.with_tasks();
let client = McpClient::new(transport, config);
let initialized = client.initialize().await?;
println!("task capability: {:?}", initialized.capabilities.tasks);If the peer does not negotiate Tasks, call the Tool through the ordinary request/response path or disable long-running mode. Do not assume every Tool call may become a Task.
Workflow mapping
With the workflow-tasks feature, WorkflowTaskAdapter binds one MCP Tool
route to an exact Workflow name, version, and tenant. Queued, leased, Timer, and
Signal waits map to working; durable interrupts map to input_required;
terminal checkpoints map to completed, failed, or cancelled.
Results are reconstructed from immutable terminal history. Route identity must be stable so a Task ID can recover the same contract after server restart.
Client lifecycle
Clients can explicitly:
- read current state with
get_task; - submit keyed input with
update_task; - cooperatively cancel with
cancel_task; - wait under one operation deadline with
wait_task; - receive typed snapshots with
listen_tasks.
Polling intervals are bounded by client policy, remaining deadline, and
retention. A completed Tool result with isError: true is still a completed
Task; protocol failure is a different layer.
Call a Tool and handle both outcomes
use runifold::mcp::{CallToolOutcome, CallToolParams};
let outcome = client
.call_tool_outcome(CallToolParams {
name: "durable_report".into(),
arguments: Some(serde_json::Map::from_iter([
("report_id".into(), serde_json::json!("rpt_42")),
])),
})
.await?;
let result = match outcome {
CallToolOutcome::Complete(result) => result,
CallToolOutcome::Task(task) => {
println!("task={} status={:?}", task.task_id, task.status);
client.wait_task(task).await?
}
};
if result.is_error {
eprintln!("Tool completed with an application error: {:?}", result.content);
} else {
println!("structured={:?}", result.structured_content);
}wait_task polls according to the server recommendation while honoring the
client's operation deadline. Persist the Task ID in application state if the UI
or process may disconnect; after reconnect, call get_task and then wait_task
again. The durable Store—not an in-memory subscription—is the source of truth.
Subscriptions
Task subscriptions emit an immediate durable snapshot, then only changed states, and detach after a terminal snapshot. Reconnection reads current Store state instead of depending on process-local notification replay.
Bound Task IDs per subscription and refresh cadence. Transient Store failures must not invent transitions. Authorization and tenant isolation are re-evaluated whenever state is derived.
Security and retention
Task IDs are identifiers, not bearer credentials. Every lookup stays scoped to the adapter's tenant. Cross-tenant mismatch should not disclose existence. Task input has the same trust level as ordinary MCP elicitation or sampling.
Protocol ttlMs describes handle usability from creation. It is not a
Workflow deadline and does not physically delete active work. Terminal cleanup
is a separate fenced control plane with immutable tombstones.
Bind a Tool route to a Workflow
Construct WorkflowTaskAdapter with the same durable WorkflowStore used by
workers, then register a stable mapping:
let mut tasks = WorkflowTaskAdapter::new(store.clone());
tasks.register_route(WorkflowTaskRoute::new(
"durable_report", // MCP Tool name
"generate-report", // Workflow name
1, // exact Workflow version
WorkflowTenantId::parse("tenant-acme")?,
)?)?;
let server = server.with_task_backend(Arc::new(tasks));The MCP Tool must also be registered and capability-granted on the server. A
separate Workflow worker must register generate-report version 1 and process
the shared Store. Restarting the MCP transport does not cancel or erase the Task.
Task troubleshooting
| Symptom | Check |
|---|---|
| call always completes synchronously | Task capability, route registration, and Tool name match |
DefinitionUnavailable | worker registry contains the exact Workflow name/version |
Task remains working | queue claim, tenant budget, timer, lease, and worker health |
Task is input_required | display keyed requests and submit each key once with update_task |
| reconnect loses progress | use the same durable Store and persisted Task ID |
| cross-tenant lookup reveals existence | bind adapter route and every lookup to authenticated tenant |
completed result has is_error | treat it as application completion, not protocol failure |