Skip to content

add first-class support for tasks extension #6834

Open

Description

@lloydrichards

What is the problem this feature would solve?

Add opt-in support for the io.modelcontextprotocol/tasks MCP extension, providing durable async tool execution without making tasks part of the base, version-neutral MCP schema.

This is distinct from the experimental task API in MCP v2025-11-25, which is not wire-compatible with the extension.

What is the feature you are proposing to solve the problem?

Introduce a small internal MCP extension boundary, then implement
io.modelcontextprotocol/tasks as its first consumer.

This extension must:

  • negotiate support using the extension identifier in the client's per-request capabilities and the server's revision-appropriate capabilities;
  • allow tools/call to return either its normal result or a task result with resultType: "task";
  • implement tasks/get, tasks/update, and tasks/cancel;
  • provide durable task creation before returning a task handle;
  • support polling, cancellation, and input_required task states;
  • expose task-specific schemas and APIs from a dedicated Tasks module, rather than adding them to McpSchema or McpServer.

The design should remain extension-local where core MCP code may expose only the minimal generic seams needed for extension negotiation, RPC registration, and an alternate result. It must not become a generic public task abstraction.


Context

The extension supersedes the experimental v2025-11-25 task design:

  • it is server-directed rather than enabled with params.task;
  • it uses per-request extension capabilities;
  • tasks/list and tasks/result are removed;
  • tasks/get returns the terminal underlying result or JSON-RPC error;
  • tasks/update carries responses to in-progress server input requests.

This work therefore depends on, or should be developed alongside, the stateless MCP protocol/lifecycle work. The current session-initialization model cannot safely stand in for per-request extension negotiation.

Acceptance criteria

  • A client that advertises io.modelcontextprotocol/tasks on an eligible request can receive either the ordinary result or CreateTaskResult.
  • A server never returns a task result to a client that did not advertise the extension on that request.
  • Task creation is durable before its handle is returned.
  • tasks/get returns task state and, once terminal, the original result or JSON-RPC error.
  • tasks/update submits responses for outstanding input requests.
  • tasks/cancel records cooperative cancellation.
  • Task support is opt-in and does not alter behavior for servers or clients that do not configure the extension.
  • Task types, storage, and helpers are exposed from a dedicated Tasks module, not the base McpSchema / McpServer API.
  • Tests cover negotiation, ordinary-result fallback, lifecycle, cancellation, input-required updates, and reconnect/polling behavior.
  • The implementation pins the Tasks extension draft/revision it targets.

Non-goals

  • Implementing the legacy v2025-11-25 experimental Tasks API.
  • General-purpose persistence backends beyond the task-store contract and a
    suitable in-memory implementation.
  • Supporting arbitrary third-party extensions in this issue.

What alternatives have you considered?

This would be part of the work for #6617 and #6706 after resolving #6829

Activity

  1. pullfrog commented on Aug 1, 2026

    @pullfrog
    Contributor
  2. lloydrichards commented on Aug 5, 2026

    @lloydrichards
    ContributorAuthor

    Some explorations into how Task might be implemented as a public facing Schema while behind the scenes is wired separately for v2025-11-25 (experimental) and v2026-06-28 (extensions).

    Concern Experimental 2025-11-25 io.modelcontextprotocol/tasks
    Availability Dated core capability negotiated at initialization Official extension capability carried per request and advertised through discovery.
    Who chooses? Requestor adds params.task Server decides; client merely declares it can handle a task result.
    Initial result { task: Task } resultType: "task" plus Task fields.
    Observation tasks/get, optional notifications. tasks/get, optional subscription notifications.
    Final result Separate blocking tasks/result. Embedded in terminal tasks/get.
    Input round trips Related server-to-client requests and premature tasks/result. inputRequests from get; responses sent by tasks/update.
    Listing tasks/list. Removed to avoid unsafe cross-caller enumeration.
    Cancellation Returns Task state and requires terminal transition. Acknowledges intent; cancellation is cooperative and eventually consistent.
    Time fields ttl, pollInterval. ttlMs, pollIntervalMs.

    Ultimately this is going to need to be something that we can wrap up the current Toolkit with so its possible define what tools can be long lived and how we provide the storage layer. The McpTask should be a shared domain that is agnostic of the wire implementations:

     McpTask         <-  public, serializable state snapshot
     StoredTask      <-  private persistence and authorization record
     TaskTransition  <-  explicit state-change commands
     McpTaskStore    <-  authority that validates and persists transitions
     McpTaskRuntime  <-  supervises Effect fibers and applies transitions

    This could then be used something like:

    // Define the Toolkit like normal
    const DadJoke = Schema.Struct({
      joke: Schema.String
    })
    
    const GetDadJoke = Tool.make("get_dad_joke", {
      description: "Return a dad joke immediately",
      success: DadJoke
    })
    
    const GenerateLongDadJoke = Tool.make("generate_long_dad_joke", {
      description: "Spend some time crafting an elaborate dad joke",
      parameters: Schema.Struct({
        topic: Schema.String
      }),
      success: DadJoke
    }).pipe(
      // Wrap the Tool with the task runtime configuration
      McpTasks.withExecution({
        mode: "task",
        fallback: "inline",
        ttl: "10 minutes",
        pollInterval: "1 second"
      })
    )
    
    const DadJokeToolkit = Toolkit.make(
      GetDadJoke,
      GenerateLongDadJoke
    )
    
    // define the task layer with configuration
    const TasksLive = McpTasks.layer({
      store: McpTasks.memoryStore({
        capacity: 1_000
      })
    })
    
    const ServerLive = McpServer.layerHttp({
      name: "dad-joke-server",
      version: "1.0.0",
      path: "/mcp",
      protocols: [
        McpProtocol.v2026_07_28, // resolves with the stateless extensions wire
        McpProtocol.v2025_11_25 // resolves with the experimental tasks spec
        McpProtocol.v2025_16_18 // fallback to inline
      ]
    })
    
    // Slap it all together
    const DadJokeServerLive = McpServer.toolkit(DadJokeToolkit).pipe(
      Layer.provideMerge(DadJokeHandlers),
      Layer.provide(TasksLive),
      Layer.provide(ServerLive)
    )

    So what we should be aiming for is a version-neutral model for long-running tool calls which can have different wire implementations depending on the protocol version. I think there are still some parts missing for this like adding v2026-06-28 which will introduce the state-less lifecycle as well as a cleaner seam for the extensions.

    Open to other suggestions or recommendations.

    🤖 Generated getting-started report -> 260805_effect-task-architecture.pdf

  3. lloydrichards commented on Oct 17, 2026

    @lloydrichards
    ContributorAuthor

    An update on the direction in #8631. I've moved away from the separate McpTaskStore, TaskTransition, and McpTaskRuntime proposed in my earlier comment. Those would duplicate execution and recovery behavior that Workflow already provides.

    The current approach registers task tools as Workflows and uses the existing MCP toolkit code to decode arguments and encode results. Workflow itself is unchanged.

    flowchart TD
        A["tools/call"] --> B["McpTasks: resolve owner and start Workflow"]
        B --> C["Workflow: run handler and Activities"]
        B --> D["Return handle once identity is readable"]
        E["tasks/get"] --> F["McpTasks: check owner and poll Workflow"]
        F --> G["Return status and tool result"]
        H["tasks/cancel"] --> I["McpTasks: check owner and request Workflow interruption"]
    
    Loading

    What the API looks like

    Tools keep their normal schemas. McpTasks.toolkit takes the handlers and returns a registration layer directly:

    const Reports = Toolkit.make(Tool.make("workspace_report", {
    	parameters: Schema.Struct({ workspaceId: Schema.String }),
    	success: Schema.String
    }))
    
    const ReportsLive = McpTasks.toolkit(Reports, {
    	workspace_report: ({ workspaceId }) =>
    		Activity.make({
    			name: "build-report",
    			success: Schema.String,
    			execute: buildReport(workspaceId)
    		})
    }, {
    	name: "workspace-reports-v1",
    	owner: "shared"
    }).pipe(Layer.provide(WorkflowEngine.layerMemory))
    
    // Provide McpServer when composing the server layers.

    Provide the existing SQL-backed ClusterWorkflowEngine instead for recovery across runner restarts. The toolkit and handlers stay the same. owner: "shared" allows every client to access tasks. Applications can instead supply an Effect that resolves a trusted owner ID.

    Why use Workflow

    Workflow owns execution, results, Activity replay, interruption, and history cleanup. McpTasks adds only two immutable facts through DurableDeferred: the owner and creation time, and a finished timestamp recorded by a Workflow finalizer. There is no separate task database or mutable status record.

    tasks/get reads those facts and polls Workflow. The normal path takes two engine lookups while working and three once terminal, regardless of Activity history size. There is no history scan or background poller.

    On SQL recovery, Workflow replays the handler and reuses completed Activity results. Code outside Activities can run again, so side effects that need replay protection belong in Activities. Original request metadata remains available during replay. Handler changes that cannot replay older executions need a new toolkit name.

    What changes from the earlier proposal

    This first version targets the July io.modelcontextprotocol/tasks extension, without a legacy experimental Tasks adapter. Task tools require extension support. Ordinary inline tools still use McpServer.toolkit, with no automatic fallback.

    Interactive input and input_required are deferred, so this narrows the issue's acceptance criteria. tasks/update checks ownership and acknowledges but ignores input responses. Progress and task-specific expiry are also outside this version.

    Cancellation requests cooperative interruption. Its acknowledgement does not guarantee that execution has stopped. Tasks report ttlMs: null, while the engine's history retention determines how long handles remain usable.

    The SQL tests reconstruct runners to check retained results, ownership, timestamps, Activity replay, and original request metadata. This gives us a smaller Tasks implementation that uses the existing Workflow engines for recovery rather than adding another runtime to maintain.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions