Building Stateful Workflows on Stateless MCP: Tasks, Async Execution & Pluggable Storage

Stateless MCP dropped sessions and persistent streams. Here's how MCP 2026-07-28 and NitroStack handle long-running tasks, cancellation safety, and distributed TaskStores without pinning connection state.

Hemant JadhavSharvari Raut
Hemant Jadhav & Sharvari Raut
Architectural diagram of stateless MCP task orchestration and distributed TaskStore

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?

  1. Server-Directed Task Creation: A client advertising the io.modelcontextprotocol/tasks extension can invoke a standard tools/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
      }
    }
  2. Standardized Lifecycle Methods: The experimental tasks/list and tasks/result methods 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.
  3. Self-Describing Task Routing: Streamable HTTP requests targeting a task carry standard routing headers:
    • Mcp-Method: tasks/get
    • Mcp-Name: task_9e8a71b2-4f30-4e2a (the taskId) 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 queries TaskManager.getTask(taskId, accessContext).
  • If a request calls tasks/cancel, the adapter triggers the task's internal AbortController.
  • If an older client attempts to invoke tasks/list or tasks/result, the adapter immediately rejects it with a -32601 error, 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:

typescript
export 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:

typescript
export 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:

  1. Active Tasks are Immune: Any task in the working or input_required state is strictly shielded from eviction, regardless of elapsed time.
  2. TTL Measures from Completion: The TTL clock begins ticking only when a task transitions to a terminal state (completed, failed, cancelled), calculated from its lastUpdatedAt timestamp:
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:

typescript
import { 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:

typescript
import { 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?

  1. Client Dispatches Tool: The client issues tools/call for ingest_large_dataset.
  2. Immediate Task Handle: Because taskSupport: 'required' is set, NitroStack intercepts the invocation, creates a new TaskEntry in the TaskStore, and immediately returns HTTP 200 with the task descriptor (status: 'working').
  3. Detached Execution: The tool method executes in the background. As context.task.update() is called, the TaskEntry in TaskStore is updated.
  4. Client Polls via tasks/get: The client (Cursor, Claude, or NitroStudio) polls tasks/get with Mcp-Name: <taskId>. Any server replica queries the TaskStore and returns the latest status message and progress percentage.
  5. Completion or Abort:
    • If the job finishes, the return value is stored in entry.result, status transitions to completed, and the TTL countdown begins.
    • If the user clicks "Cancel" in the client UI, a tasks/cancel request triggers entry.abortController.abort(). On the next iteration of the loop, context.task.throwIfCancelled() aborts execution cleanly, setting status to cancelled.

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:

  1. Auto-Negotiation: When you connect NitroStudio to your server, it checks server/discover for io.modelcontextprotocol/tasks.
  2. 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.
  3. 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: Swap InMemoryTaskStore for a Redis or database backend whenever deploying multiple container replicas behind a load balancer.
  • Set sensible poll intervals: Use pollInterval: 2000 to 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

Hemant Jadhav

Author
Sharvari Raut

Sharvari Raut

Co-Author

A blog poet ✍🏻