Skip to content

Implement MCP Tasks (spec 2025-11-25, experimental) #113

@odrobnik

Description

@odrobnik

Background

MCP spec 2025-11-25 introduces Tasks — a way to turn long-running requests into pollable, durable state machines. A task-augmented request gets an immediate CreateTaskResult (with a taskId); the actual operation result is retrieved later via tasks/result. The spec defines four new methods (tasks/get, tasks/result, tasks/list, tasks/cancel), an optional notifications/tasks/status notification, and a state machine (workinginput_requiredworking → terminal {completed, failed, cancelled}).

This issue tracks adding Tasks support to SwiftMCP. Tasks are marked experimental in the spec; the design and behaviors may evolve.

Scope

In scope (phases 1–3):

  • Server-side task-augmented tools/call (tasks.requests.tools.call)
  • The four new methods and the optional status notification
  • Tool-level opt-in via execution.taskSupport
  • Cooperative cancellation that subsumes the existing notifications/cancelled stub
  • Streamable HTTP transport integration (input_required, side-channel SSE)

Phase 4 / out of scope for first cut:

  • Client-side receiver of task-augmented sampling/createMessage and elicitation/create (server-as-requestor case). Types declared in phase 1, wired up later.
  • Persistent task storage (in-memory only).

Guiding decisions

  • Opt-in per tool, opt-in per server. Default taskSupport is forbidden (spec default). The tasks capability is advertised only when at least one tool opts in (or the server explicitly enables it).
  • In-memory only. Matches existing SessionManager model.
  • Bind tasks to sessions. Sessions are the auth context for task isolation per spec § Security.
  • Unified execution path. Every tools/call runs inside a wrapping Task whose lifecycle is managed by an MCPTask actor. The difference between task-augmented and non-task-augmented is purely a delivery decision at completion time, not a structural fork in dispatch.

Architecture

MCPTask — single source of truth

public actor MCPTask {
    public let requestId: String          // always present
    public let taskId: String?            // nil for non-task-augmented calls
    public let sessionID: UUID
    public let createdAt: Date
    public let ttl: Int?                  // ms; nil = use server default
    public let pollInterval: Int?
    public let progressToken: JSONValue?  // captured for in-tool progress notifications
    public private(set) var status: MCPTaskStatus
    public private(set) var statusMessage: String?
    public private(set) var lastUpdatedAt: Date

    private let work: Task<Void, Never>                                    // the runner
    private var result: JSONRPCMessage?                                    // captured at terminal
    private var pendingResultContinuations: [CheckedContinuation<JSONRPCMessage, Never>] = []

    @TaskLocal internal static var current: MCPTask?
}
  • work runs executeToolCall(...) and calls back into complete(with:).
  • complete(with:):
    • stores result, transitions status (failed if .errorResponse or isError: true, else completed)
    • drains pendingResultContinuations
    • if taskId == nilsession.send(result), then session.removeTask(self)
    • if taskId != nil → retain until ttl
  • transition(to:) is the single point that fires notifications/tasks/status (best-effort, no subscriber list).

Wire-DTO type stays separate (e.g. TaskState) so MCPTask exclusively means the actor.

Storage on Session

Single array, lookups are filters:

extension Session {
    private var mcpTasks: [MCPTask] = []

    func runTask(requestId: JSONRPCID, taskId: String? = nil, ttl: Int? = nil,
                 progressToken: JSONValue? = nil,
                 operation: @Sendable @escaping () async -> JSONRPCMessage) async -> MCPTask
    func task(forRequestId: String) -> MCPTask?
    func task(forTaskId: String) -> MCPTask?
    func removeTask(_ task: MCPTask)
    func cancelAllInFlight()    // sibling of cancelAllWaitingTasks
    var latestTaskExpiry: Date? // computed for SessionManager expiry rules
}

Linear scan is fine — concurrent task count is bounded per session.

Capabilities

ServerCapabilities.swift — add:

public struct TasksCapabilities: Codable, Sendable {
    public var list: EmptyObject?
    public var cancel: EmptyObject?
    public struct Requests: Codable, Sendable {
        public struct Tools: Codable, Sendable { public var call: EmptyObject? }
        public var tools: Tools?
    }
    public var requests: Requests?
}
public var tasks: TasksCapabilities?

Mirror in ClientCapabilities.swift for sampling.createMessage / elicitation.create (declared in phase 1, used in phase 4).

In createInitializeResponse (MCPServer.swift:290): advertise tasks only when the server implements a new MCPTaskSupporting marker protocol or any tool's metadata declares taskSupport != .forbidden. Always advertise tasks.list and tasks.cancel together with tasks.requests.tools.call.

Tool-level negotiation

  • MCPToolMetadata.swift — add var taskSupport: MCPTaskSupport (.forbidden | .optional | .required, default .forbidden).
  • MCPTool.swift — add nested Execution struct, var execution: Execution?; Execution.taskSupport: String? only emits when non-forbidden (per spec: missing == forbidden).
  • @MCPTool(taskSupport: .optional) macro parameter — phase 2.

Request dispatch

In handleToolCall (MCPServer.swift:344):

  1. Detect params["task"].
  2. If taskSupport == .forbidden and task present → -32601.
  3. If taskSupport == .required and task absent → -32601.
  4. Factor existing body into executeToolCall(...) -> JSONRPCMessage. Always call it via Session.runTask.
  5. Task-augmented branch: register MCPTask with non-nil taskId, return CreateTaskResult synchronously.
  6. Non-task-augmented branch: Session.runTask awaits MCPTask.awaitResult() and returns inline (preserves the inline-HTTP-POST-body fast path).

Add four new method handlers to handleRequest:

  • tasks/get → returns task state, never blocks. SHOULD NOT upgrade SSE.
  • tasks/result → blocks until terminal via MCPTask.awaitResult(). MAY use SSE for input_required flow.
  • tasks/list → paginated, opaque base64 cursor over createdAt + taskId, filtered to current session.
  • tasks/cancel-32602 if already terminal; else MCPTask.cancel(), return cancelled task.

For these four, ignore inbound _meta.io.modelcontextprotocol/related-task per spec § 4.5.

Cancellation unification

The current notifications/cancelled handler at MCPServer.swift:161 is a no-op stub — nothing holds a Task reference today. With the unified path, every tools/call has an MCPTask whose work is cancellable.

// notifications/cancelled handler
await Session.current?.task(forRequestId: cancelledId)?.cancel()

// tasks/cancel handler
await Session.current?.task(forTaskId: taskId)?.cancel()

MCPTask.cancel() calls work.cancel(), transitions to cancelled, synthesizes a JSON-RPC error response as the stored result, drains continuations.

Related-task _meta propagation

Add MCPTask.current @TaskLocal for in-tool ergonomics (status updates, progress emission). At outbound message dispatch (Session.send / Session.request / progress notification helpers), if MCPTask.current?.taskId is non-nil and method is not in the tasks/* family, stamp:

{ "_meta": { "io.modelcontextprotocol/related-task": { "taskId": "..." } } }

tasks/result responses MUST carry this metadata since the result envelope itself doesn't contain the task ID.

Session lifetime

Tasks legitimately outlive their connection. Update SessionManager.swift:566 updateSessionExpiry:

session.expiresAt = max(
    lastActivityAt + retentionInterval,
    latestStreamExpiry,
    latestTaskExpiry          // NEW: max(createdAt + ttl) over non-expired tasks
)

Session.cancelAllInFlight() invoked from destroySession.

TTL & cleanup

  • Single retention rule: tasks live until createdAt + ttl; cleanupExpiredState purges them.
  • Cancelled tasks: same rule (spec allows immediate deletion, but holding to ttl gives stable tasks/cancel semantics).
  • Non-task-augmented synthetic MCPTasks: discarded inside complete(with:) — no ttl bookkeeping.
  • tasks/result does NOT mutate the task; results remain available for the full ttl regardless of how many retrievals.
  • After purge, tasks/get / tasks/result / tasks/cancel return -32602 Task not found (spec-compliant per § Error Handling).

Configuration knobs

On HTTPSSETransport:

public var maxTaskTTL: TimeInterval = 60 * 60       // cap requested ttl
public var maxTasksPerSession: Int = 32             // per spec § Resource Management

MCPTask create:

  • Clamps requested ttl to min(requested, maxTaskTTL) (or default if null)
  • Rejects past per-session cap with -32603

Streamable HTTP integration

  • tasks/get: handle inline (don't upgrade to SSE) — client signaled it wants to poll.
  • tasks/result: open SSE if task is non-terminal at receipt OR transitions to input_required, so the receiver can side-channel elicitation/create carrying the same taskId.
  • Status notifications + nested requests use the session's primary general SSE stream (existing Session.sendSSE fall-through at Session.swift:122).
  • Resumable streams via Last-Event-ID cover task notifications with no new plumbing — verify with a test.

Security

In every task-scoped method, enforce task.sessionID == Session.current?.id. Otherwise return -32602 Task not found (don't leak existence). For tasks/list, only return current session's tasks. Document: without auth, isolation is by session ID only.

Phased rollout

Phase 1 — Core

  • MCPTask actor + MCPTaskStatus enum + wire-DTO TaskState + CreateTaskResult
  • Session.runTask, Session.mcpTasks, lookup helpers, cancelAllInFlight
  • ServerCapabilities.TasksCapabilities and conditional advertisement in createInitializeResponse
  • MCPToolMetadata.taskSupport + MCPTool.Execution + tools/list encoding
  • Unified handleToolCall dispatch through Session.runTask; task vs. non-task delivery split
  • tasks/get, tasks/result, tasks/list, tasks/cancel handlers
  • notifications/tasks/status emission on every MCPTask.transition
  • Wire up notifications/cancelled (currently a stub at MCPServer.swift:161)
  • MCPTask.current @TaskLocal + outbound related-task _meta stamping
  • SessionManager.updateSessionExpiry factors in latestTaskExpiry; cleanupExpiredState purges expired tasks
  • HTTPSSETransport config: maxTaskTTL, maxTasksPerSession
  • Streamable HTTP: tasks/get does not upgrade SSE; tasks/result may

Phase 2 — Macro support

  • @MCPTool(taskSupport: .optional) parameter in Sources/SwiftMCPMacros/

Phase 3 — input_required flow

  • In-tool helper, e.g. try await Session.current.requestInput(...) that transitions MCPTask.current to input_required, side-channels the elicitation, transitions back to working

Phase 4 — Client-side

  • Receive task-augmented sampling/createMessage / elicitation/create in Sources/SwiftMCP/Client/

Tests (Swift Testing, Tests/SwiftMCPTests/)

  • MCPTask lifecycle: illegal state transitions rejected, ttl expiry, cross-session isolation, cancel-while-running
  • Capability negotiation: tasks omitted when no tool opts in; advertised when one does
  • tools/call: task: {ttl} returns CreateTaskResult; tasks/get polls working → completed; tasks/result returns original payload with related-task _meta
  • taskSupport: .required without task-32601
  • taskSupport: .forbidden with task-32601
  • tasks/cancel on terminal → -32602; on running → cancels and returns cancelled
  • tasks/list pagination with opaque cursor; isolation across sessions
  • HTTP/SSE: tasks/get does not open SSE; tasks/result may; notifications/tasks/status survives Last-Event-ID resume
  • Failed tool (isError: true) → task failed; tasks/result returns underlying CallToolResult unchanged
  • notifications/cancelled cancels in-flight non-task tools/call
  • Session retention extends to cover live task ttl; reconnecting client can retrieve result after SSE retention window

Files affected

Modified:

New:

  • Sources/SwiftMCP/Models/Tasks/MCPTaskStatus.swift
  • Sources/SwiftMCP/Models/Tasks/TaskState.swift (wire DTO)
  • Sources/SwiftMCP/Models/Tasks/MCPTaskParams.swift (inbound task: {ttl})
  • Sources/SwiftMCP/Models/Tasks/CreateTaskResult.swift
  • Sources/SwiftMCP/Tasks/MCPTask.swift (the actor)
  • Tests under Tests/SwiftMCPTests/Tasks/

Spec references

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions