ext/tasks

SEP-2663 v2 tasks extension — separate go.mod sub-module.

ext/tasks, SEP-2663 Tasks Extension

Go module for the v2 task surface defined by SEP-2663 (merged Final 2026-05-15). Provides server-directed async task execution registered as a protocol extension under capabilities.extensions["io.modelcontextprotocol/tasks"].

Looking for a guided walk-through? Read docs/TASKS_TUTORIAL.md, which covers when to use tasks vs sync vs MRTR, the GoAsync sentinel + middleware peek, lifecycle, the in-task input flow (TaskElicit / TaskSample), notifications + the G6 filter, cancellation semantics, and the multi-tenancy caveat on stateless. For the sibling MRTR surface (SEP-2322 multi-round-trip requests), see docs/MRTR_TUTORIAL.md. This README is the API reference; the tutorials are the conceptual walk-throughs.

Why a sub-module

SEP-2663 defines tasks as an extension, not a top-level capability. ext/tasks mirrors the same pattern ext/auth and ext/ui use: a separate go.mod consumed by servers that opt into the extension. v1 (the legacy capabilities.tasks surface) stays in server/ as RegisterTasksV1 and continues to ship until decommissioned.

Quickstart

import (
    "github.com/panyam/mcpkit/server"
    "github.com/panyam/mcpkit/ext/tasks"
)

srv := server.NewServer(info)

srv.RegisterTool(
    core.ToolDef{
        Name:        "slow_compute",
        Description: "...",
        Execution:   &core.ToolExecution{TaskSupport: core.TaskSupportOptional},
    },
    func(ctx core.ToolContext, req core.ToolRequest) (core.ToolResponse, error) {
        // Handlers whose work is genuinely async opt into the continuation
        // goroutine by returning core.GoAsyncResult{} — see "Handler pattern" below.
        if tasks.GetTaskContext(ctx) == nil {
            return core.GoAsyncResult{}, nil
        }
        // Real work runs here, with TaskContext available for TaskElicit /
        // TaskSample / SetStatus / progress emission under the G6 filter.
        return core.TextResult("done"), nil
    },
)

tasks.Register(tasks.Config{Server: srv})

tasks.Register installs the v2 middleware that intercepts tools/call for task-eligible tools, registers the tasks/get / tasks/update / tasks/cancel method handlers (all gated on the client declaring the extension), and advertises the extension in the initialize response.

ToolHandler returns the sealed core.ToolResponse interface; the middleware dispatches on the concrete variant, one of ToolResult, InputRequiredResult, CreateTaskResult, or GoAsyncResult.

Handler pattern

mcpkit’s v2 middleware runs the handler synchronously first and then dispatches on the concrete ToolResponse variant it returned. The handler chooses one of three shapes:

Handler returns Middleware does When to use
core.InputRequiredResult via ctx.RequestInput(...) Passes through unchanged; no task created SEP-2322 MRTR round - gather input before deciding whether to escalate
core.GoAsyncResult{} Mints a task, spawns continuation goroutine that re-invokes the handler with TaskContext attached, returns CreateTaskResult Slow / blocking work, TaskElicit / TaskSample calls, progress emission that should be filtered
core.ToolResult{...} (a regular sync result) Wraps as a born-terminal task (status: completed, result stored, one notifications/tasks event fired), returns CreateTaskResult Sync work that finishes immediately on a TaskSupport=optional/required tool

The continuation goroutine re-invokes the same handler with a TaskContext accessible via tasks.GetTaskContext(ctx). The handler typically branches on that:

func myHandler(ctx core.ToolContext, req core.ToolRequest) (core.ToolResponse, error) {
    if tasks.GetTaskContext(ctx) != nil {
        // Continuation: do the real work. TaskElicit / TaskSample / SetStatus
        // are all available; emissions are filtered per SEP-2663 G6.
        return doRealWork(ctx, req)
    }

    // Optional MRTR phase: gather input via ctx.RequestInput first.
    // ctx.RequestInput returns (core.InputRequiredResult, error) directly;
    // InputRequiredResult satisfies ToolResponse.
    if ctx.InputResponse("user_name") == nil {
        return ctx.RequestInput(core.InputRequests{
            "user_name": core.InputRequest{Method: "elicitation/create", Params: ...},
        })
    }

    // MRTR complete; defer the rest to the continuation goroutine.
    return core.GoAsyncResult{}, nil
}

The examples/mrtr reference fixture’s test_tool_with_task walks this exact pattern end-to-end (drives the matching mrtr-tasks-composition conformance scenario). For the full conceptual walk-through (MRTR as a stateless continuation primitive, capabilities across wires, progressToken, the G6 filter replacement table, MRTR vs push vs task-input-flow), see docs/MRTR_TUTORIAL.md.

G6 filter scope: the SEP-2663 G6 session-notify filter (notifications/progress and notifications/message MUST NOT be sent on tasks) is installed only on the continuation goroutine’s bgCtx. A sync-returning handler runs on the unfiltered POST ctx and is responsible for not leaking those notifications itself.

Surface

Symbol Purpose
tasks.Register(tasks.Config) Canonical entry point.
tasks.Config Holds Server, Store, DefaultTTLMs, DefaultPollMs, TracerProvider.
tasks.TaskContext Typed-context for tool handlers running as v2 tasks. Exposes TaskID(), ProgressToken(), SetStatus(status), TaskElicit(req), TaskSample(req).
tasks.WithTaskContext / tasks.GetTaskContext Context wiring (mirrors core’s typed-context pattern).

Tracing (SEP-414 P6, issue 659)

Optional. Set Config.TracerProvider to opt the runtime into span-link instrumentation of the async task lifecycle:

tasks.Register(tasks.Config{
    Server:         srv,
    TracerProvider: mcpotel.NewProvider(otelTP), // or any core.TracerProvider
})

What gets emitted:

  • task.execute span on the GoAsync path - a NEW root trace (not a child of the create span, since the work outlives the tools/call dispatch span) carrying a Link back to the originating tools/call create span. Attributes: mcp.task.id (at start), mcp.task.status (stamped at End from the final stored status, one of completed / failed / cancelled / input_required). RecordError fires on protocol-level failures (mwErr, resp.Error, unexpected result shape, panic recover); handler-returned errors map to completed with IsError=true per SEP-2663 semantics.
  • AddLink on each tasks/get / tasks/update / tasks/cancel dispatch span - points back to the originating create span so a backend can pivot from any poll into the whole lifecycle.

Nil or core.NoopTracerProvider{} (the default) skips the install, with zero overhead and zero allocation. ext/tasks depends on core only; no compile-time dep on ext/otel. The contract details (core.WithNewRootSpan, core.LinkedTracerProvider, core.Link) live in docs/SEP_414_OTEL.md § Span links and § New-root-span marker.

The wire types (CreateTaskResult, DetailedTask, UpdateTaskRequest, TaskInfoV2, etc.) live in core/task_v2.go since they’re consumed by both server and client.

Relationship to server/

  • ext/tasks uses server.Server, server.Middleware, server.MethodHandler, server.Registry, server.TaskStore, server.NewInMemoryStore. These are the generic server primitives any extension consumes.
  • ext/tasks duplicates v2-flavoured pieces (TaskContext, activeTask, generateTaskID) rather than sharing with v1’s versions in server/. Rationale: v1 is frozen and decommed-bound; once it retires, deletion in server/ is total without unwinding cross-package shared types.
  • The previous RegisterTasksHybrid (a single helper installing both v1 and v2 on one server) was dropped during the move. See docs/TASKS_V2_MIGRATION.md for the recommended two-call pattern.

Where the conformance suite lives

panyam/mcpconformance on the feat/tasks-mrtr-extension branch. Run via just testconf-tasks-v2 in the root repo, which spawns examples/tasks-v2/tasks-v2 --serve as the reference implementation fixture.

V1-RETIREMENT markers

Code that should be reconsidered when v1 retires is tagged V1-RETIREMENT: in comments. git grep V1-RETIREMENT enumerates the cleanup list at retirement time.