
Building Stateful Workflows on Stateless MCP: Tasks, Async Execution & Pluggable Storage
When the Model Context Protocol (MCP) released its 2026-07-28 specification, the headline was unambiguous: MCP is now stateless by default. The initialize handshake was deleted, Mcp-Session-Id headers were eliminated, and servers were unshackled from sticky TCP/SSE connections.
For developers building high-throughput API endpoints, this was cause for celebration. But for teams building autonomous AI agents, document processing pipelines, data migrations, or multi-step analysis jobs, it raised an immediate architectural dilemma:
"If MCP no longer holds sessions or open server push streams, how can a tool perform long-running background work without timing out the client's HTTP request?"
In the 2025 era of MCP, long-running execution was tightly coupled to persistent connection state. If an SSE connection severed or a load balancer recycled an idle TCP socket, your background job vanished into the ether.
With MCP 2026-07-28 and the newly standardized Tasks Extension (SEP-2663), stateful background execution has finally been decoupled from connection transport. In this deep dive, we explore how the new task lifecycle works, how NitroStack implements it via a pluggable TaskStore engine, and how you can run production-grade background tasks that survive replica restarts and horizontal scaling.
text┌─────────────────────────────────────────────────────────────────────────────┐ │ 2025 SESSIONFUL MODEL VS 2026 STATELESS TASK MODEL │ ├─────────────────────────────────────────────────────────────────────────────┤ │ 2025 Era (Session-Bound): │ │ Client ──────(initialize)─────► Replica 1 [Holds Session & Push Stream] │ │ Client ──────(tools/call)──────► Replica 1 [In-memory state pinned] │ │ * Replica 1 crashes or LB flips to Replica 2 ──► Session Lost / Broken │ ├─────────────────────────────────────────────────────────────────────────────┤ │ 2026 Era (Stateless + Pluggable Store): │ │ Client ──────(tools/call)──────► Replica 1 ──► Writes Task to Shared Store│ │ (Redis / Postgres / Dynamo)│ │ Client ──────(tasks/get)───────► Replica 2 ◄── Reads State from Store │ │ * Any replica answers any phase. Zero sticky routing required. │ └─────────────────────────────────────────────────────────────────────────────┘
1. The Anatomy of SEP-2663: Tasks as an Extension
In the legacy 2025 specification, tasks were an experimental, loosely defined feature. Clients had to pass an ad-hoc task: {} envelope, poll non-standard endpoints (tasks/list, tasks/result), and rely on server push notifications that frequently broke behind corporate proxies.
In MCP 2026-07-28, tasks have graduated into a first-class, independently versioned extension: io.modelcontextprotocol/tasks.
What Changed at the Protocol Wire?
- Server-Directed Task Creation: A client advertising the
io.modelcontextprotocol/tasksextension can invoke a standardtools/call. If the tool executes long-running logic, the server can immediately return a task handle without waiting for completion:json{ "task": { "taskId": "task_9e8a71b2-4f30-4e2a", "status": "working", "statusMessage": "Ingesting 150,000 PDF documents...", "createdAt": "2026-10-06T10:15:00.000Z", "lastUpdatedAt": "2026-10-06T10:15:00.000Z", "ttl": 300000, "pollInterval": 2500 } } - Standardized Lifecycle Methods: The experimental
tasks/listandtasks/resultmethods were completely removed. In modern stateless MCP, era-mismatched calls to these methods are rejected with-32601(Method not found). The client drives execution through:tasks/get: Polls the status and metadata of a specific task.tasks/update: Delivers mid-flight input or updates (supersedes ad-hoc input requests).tasks/cancel: Explicitly signals the server to abort the task.
- Self-Describing Task Routing: Streamable HTTP requests targeting a task carry standard routing headers:
Mcp-Method: tasks/getMcp-Name: task_9e8a71b2-4f30-4e2a(thetaskId) Gateways and proxies can route status checks directly to specific cluster queues without inspecting JSON-RPC payloads.
text┌─────────────────────────────────────────────────────────────────────────────┐ │ MCP TASK LIFECYCLE STATES │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌───────────────────────┐ │ │ │ working │◄─────────────────┐ │ │ └───────────┬───────────┘ │ │ │ │ │ │ │ needs user ▼ user answered │ │ │ input ┌───────────┐ │ │ │ │input_req'd├────────────────────────┘ │ │ └─────┬─────┘ │ │ │ │ │ ┌───────────────────┼───────────────────┐ │ │ │ job finished │ unhandled error │ client aborted │ │ ▼ ▼ ▼ │ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ │ │ completed │ │ failed │ │ cancelled │ ◄── TERMINAL STATES │ │ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ (No transitions) │ │ │ │ │ │ │ └───────────────────┴───────────────────┘ │ │ │ │ │ TTL countdown starts │ │ ▼ │ │ [ Evicted from TaskStore ] │ │ │ └─────────────────────────────────────────────────────────────────────────────┘
2. NitroStack's Task Architecture
To support SEP-2663 without burdening application developers with raw JSON-RPC boilerplate, @nitrostack/core introduces a decoupled task orchestration pipeline built around four core constructs:
text┌─────────────────────────────────────────────────────────────────────────────┐ │ NITROSTACK TASK SUBSYSTEM ARCHITECTURE │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ MCP Client (Cursor / Claude / NitroStudio) │ │ │ │ │ │ POST /mcp (Streamable HTTP, Mcp-Method: tasks/*, Mcp-Name: taskId) │ │ ▼ │ │ ┌───────────────────────────────────────────────────────────────────────┐ │ │ │ ModernProtocolAdapter (v2) │ │ │ │ - Pre-dispatch Wire Interception │ │ │ │ - Validates client extension support (io.modelcontextprotocol/tasks)│ │ │ │ - Dispatches tasks/get, tasks/update, tasks/cancel │ │ │ └───────────────────────────────────┬───────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌───────────────────────────────────────────────────────────────────────┐ │ │ │ TaskManager (Core Engine) │ │ │ │ - Enforces TaskAccessContext (Multi-Tenant isolation) │ │ │ │ - Manages AbortController & throwIfCancelled() │ │ │ │ - Emits status change events to NotificationRouter │ │ │ └───────────────────┬───────────────────────────────┬───────────────────┘ │ │ │ │ │ │ ▼ ▼ │ │ ┌─────────────────────────────────┐ ┌───────────────────────────────┐ │ │ │ Pluggable TaskStore │ │ Multi-Channel Notification │ │ │ │ - InMemoryTaskStore (Default) │ │ Router │ │ │ │ - RedisTaskStore (Distributed) │ │ - Modern Subscriptions Bus │ │ │ │ - DynamoDB / Postgres Store │ │ - Legacy SSE / STDIO │ │ │ └─────────────────────────────────┘ └───────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────────────┘
1. Wire Interception in ModernProtocolAdapter
Incoming requests hit ModernProtocolAdapter. Before passing a request to a user handler, the adapter intercepts task lifecycle methods:
- If a request calls
tasks/get, the adapter queriesTaskManager.getTask(taskId, accessContext). - If a request calls
tasks/cancel, the adapter triggers the task's internalAbortController. - If an older client attempts to invoke
tasks/listortasks/result, the adapter immediately rejects it with a-32601error, upholding modern protocol compliance.
2. Multi-Tenant Authorization via TaskAccessContext
In a shared enterprise cluster, one tenant must never be able to inspect or cancel another tenant's background tasks. NitroStack binds caller identity to task state using TaskAccessContext:
typescriptexport interface TaskAccessContext { userId?: string; tenantId?: string; sessionId?: string; }
When a user calls tasks/get or tasks/cancel, the TaskManager compares the caller's verified JWT claims (ctx.auth.sub, ctx.auth.tenantId) against the task's recorded ownerId and tenantId. If a mismatch occurs, NitroStack throws a TaskUnauthorizedError, returning a -32001 protocol error before any task metadata is exposed.
3. The Pluggable TaskStore Abstraction
In local development, holding tasks in a Node.js process Map is convenient. But in production Kubernetes pods or serverless instances, in-memory state is an anti-pattern: pod autoscaling or node rollouts would instantly kill pending jobs.
NitroStack solves this with the TaskStore interface:
typescriptexport interface TaskStore { get(taskId: string): TaskEntry | undefined; set(taskId: string, entry: TaskEntry): void; delete(taskId: string): boolean; has(taskId: string): boolean; list(): TaskEntry[]; cleanupExpired?(now: number): number; destroy?(): void; }
Eviction Protection: The Active vs. Terminal Rule
A subtle bug in naive task storage engines is premature eviction. If a task is scheduled with a 5-minute TTL, and data ingestion takes 6 minutes, a background cleanup sweep based on createdAt would delete the task while it is still actively computing!
NitroStack implements strict TTL Eviction Protection:
- Active Tasks are Immune: Any task in the
workingorinput_requiredstate is strictly shielded from eviction, regardless of elapsed time. - TTL Measures from Completion: The TTL clock begins ticking only when a task transitions to a terminal state (
completed,failed,cancelled), calculated from itslastUpdatedAttimestamp:
typescript// Inside InMemoryTaskStore.cleanupExpired(): cleanupExpired(now: number): number { let count = 0; for (const [taskId, entry] of this.tasks.entries()) { if (entry.data.ttl === null) continue; // Active tasks are never evicted! if (!isTerminalStatus(entry.data.status)) continue; // TTL countdown starts strictly from completion timestamp const terminalTime = new Date(entry.data.lastUpdatedAt).getTime(); if (now - terminalTime > entry.data.ttl) { this.tasks.delete(taskId); count++; } } return count; }
Configuring a Distributed Task Store
To run multiple replicas of your MCP server behind an ALB or Kubernetes Service, implement TaskStore backed by Redis or your distributed cache of choice:
typescriptimport { Server, TaskStore, TaskEntry } from '@nitrostack/core'; import { createClient } from 'redis'; export class RedisTaskStore implements TaskStore { private client = createClient({ url: process.env.REDIS_URL }); async init() { await this.client.connect(); } get(taskId: string): TaskEntry | undefined { // Fetch serialized task entry from Redis // ... } set(taskId: string, entry: TaskEntry): void { // Store task entry with Redis EXPIRE set to entry.data.ttl // ... } delete(taskId: string): boolean { // Remove task key // ... } has(taskId: string): boolean { // Check key existence // ... } list(): TaskEntry[] { // Scan keys for active tenant // ... } } // Inject your store into the NitroStack Server instance: const redisStore = new RedisTaskStore(); await redisStore.init(); const server = new Server({ /* ... */ }); server.setTaskStore(redisStore);
4. Writing Task-Augmented Tools with NitroStack
Building a background task in NitroStack requires zero protocol-level plumbing. You declare your tool with the @Tool decorator, specify taskSupport: 'required' (or 'optional'), and interact with context.task.
Here is a complete, production-ready example of a large dataset ingestion tool:
typescriptimport { Tool, ExecutionContext, JsonValue } from '@nitrostack/core'; import { z } from 'zod'; const IngestDataSchema = z.object({ sourceUrl: z.string().url().describe('The URL of the remote dataset to process'), batchSize: z.number().int().min(100).max(5000).default(1000), }); export class DataMigrationService { @Tool({ name: 'ingest_large_dataset', description: 'Asynchronously fetches and indexes an enterprise dataset with real-time task tracking', schema: IngestDataSchema, taskSupport: 'required', // Enforces SEP-2663 task handling }) async ingestDataset( input: z.infer<typeof IngestDataSchema>, context: ExecutionContext ): Promise<JsonValue> { context.logger.info(`Starting async ingestion from: ${input.sourceUrl}`); // 1. Configure the task's initial polling interval context.task.update({ status: 'working', statusMessage: 'Downloading dataset archive...', pollInterval: 1500, // Hint to the client to poll every 1.5 seconds }); const totalBatches = 50; for (let batch = 1; batch <= totalBatches; batch++) { // 2. Cancellation Check: Did the client abort via tasks/cancel? context.task.throwIfCancelled(); // Simulate batch processing await new Promise((resolve) => setTimeout(resolve, 300)); const percent = Math.round((batch / totalBatches) * 100); // 3. Update Progress context.task.update({ status: 'working', statusMessage: `Indexed batch ${batch} of ${totalBatches} (${percent}%)`, }); } // 4. Return Final Result // NitroStack automatically sets the task status to 'completed' and resolves the result. return { success: true, recordsProcessed: totalBatches * input.batchSize, completedAt: new Date().toISOString(), }; } }
What Happens at Runtime?
- Client Dispatches Tool: The client issues
tools/callforingest_large_dataset. - Immediate Task Handle: Because
taskSupport: 'required'is set, NitroStack intercepts the invocation, creates a newTaskEntryin theTaskStore, and immediately returns HTTP 200 with thetaskdescriptor (status: 'working'). - Detached Execution: The tool method executes in the background. As
context.task.update()is called, theTaskEntryinTaskStoreis updated. - Client Polls via
tasks/get: The client (Cursor, Claude, or NitroStudio) pollstasks/getwithMcp-Name: <taskId>. Any server replica queries theTaskStoreand returns the latest status message and progress percentage. - Completion or Abort:
- If the job finishes, the return value is stored in
entry.result, status transitions tocompleted, and the TTL countdown begins. - If the user clicks "Cancel" in the client UI, a
tasks/cancelrequest triggersentry.abortController.abort(). On the next iteration of the loop,context.task.throwIfCancelled()aborts execution cleanly, setting status tocancelled.
- If the job finishes, the return value is stored in
5. Observing Tasks in NitroStudio
During local development, debugging asynchronous background tasks used to require grepping terminal stdout logs. With the updated NitroStudio Desktop Inspector:
- Auto-Negotiation: When you connect NitroStudio to your server, it checks
server/discoverforio.modelcontextprotocol/tasks. - Visual Task Timeline: Whenever a task-augmented tool executes, NitroStudio opens an interactive Task Inspector showing:
- Current status chip (
working,completed,cancelled). - Live stream of status messages and timestamps.
- Elapsed execution time vs. TTL expiry.
- Interactive "Cancel Task" button linked to
tasks/cancel.
- Current status chip (
- Backwards Compatibility: If connected to a legacy 2025 server, NitroStudio falls back to legacy SSE listeners gracefully without breaking your development workflow.
Summary & Best Practices
Stateless MCP does not mean sacrificing asynchronous execution. By separating task state from connection state, MCP 2026-07-28 delivers a resilient, cloud-native foundation for AI workflows.
Production Checklist for MCP Tasks:
- Always check for cancellation: Call
context.task.throwIfCancelled()inside intensive loops to prevent orphan background processes from consuming CPU. - Use a distributed
TaskStore: SwapInMemoryTaskStorefor a Redis or database backend whenever deploying multiple container replicas behind a load balancer. - Set sensible poll intervals: Use
pollInterval: 2000to prevent aggressive client polling loops from saturating your HTTP ingress. - Rely on TTL Eviction Protection: Remember that terminal tasks (
completed,failed,cancelled) are automatically purged after their TTL expires. Keep your database clean by sizing TTLs according to client retrieval windows (e.g., 5 to 15 minutes).
In the next post in our stateless MCP series, we will examine Authentication & Security: dive into how MCP 2026-07-28 replaces Dynamic Client Registration with SSRF-hardened Client ID Metadata Documents (CIMD) and enforces RFC 9207 anti-mixup defenses.
About the Authors

Hemant Jadhav
Author
Sharvari Raut
Co-AuthorA blog poet ✍🏻