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 (working → input_required ↔ working → 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 == nil → session.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):
- Detect
params["task"].
- If
taskSupport == .forbidden and task present → -32601.
- If
taskSupport == .required and task absent → -32601.
- Factor existing body into
executeToolCall(...) -> JSONRPCMessage. Always call it via Session.runTask.
- Task-augmented branch: register
MCPTask with non-nil taskId, return CreateTaskResult synchronously.
- 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
Phase 2 — Macro support
Phase 3 — input_required flow
Phase 4 — Client-side
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
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 ataskId); the actual operation result is retrieved later viatasks/result. The spec defines four new methods (tasks/get,tasks/result,tasks/list,tasks/cancel), an optionalnotifications/tasks/statusnotification, and a state machine (working→input_required↔working→ 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):
tools/call(tasks.requests.tools.call)execution.taskSupportnotifications/cancelledstubPhase 4 / out of scope for first cut:
sampling/createMessageandelicitation/create(server-as-requestor case). Types declared in phase 1, wired up later.Guiding decisions
taskSupportisforbidden(spec default). Thetaskscapability is advertised only when at least one tool opts in (or the server explicitly enables it).SessionManagermodel.tools/callruns inside a wrappingTaskwhose lifecycle is managed by anMCPTaskactor. 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 truthworkrunsexecuteToolCall(...)and calls back intocomplete(with:).complete(with:):result, transitions status (failedif.errorResponseorisError: true, elsecompleted)pendingResultContinuationstaskId == nil→session.send(result), thensession.removeTask(self)taskId != nil→ retain until ttltransition(to:)is the single point that firesnotifications/tasks/status(best-effort, no subscriber list).Wire-DTO type stays separate (e.g.
TaskState) soMCPTaskexclusively means the actor.Storage on
SessionSingle array, lookups are filters:
Linear scan is fine — concurrent task count is bounded per session.
Capabilities
ServerCapabilities.swift — add:
Mirror in ClientCapabilities.swift for
sampling.createMessage/elicitation.create(declared in phase 1, used in phase 4).In
createInitializeResponse(MCPServer.swift:290): advertisetasksonly when the server implements a newMCPTaskSupportingmarker protocol or any tool's metadata declarestaskSupport != .forbidden. Always advertisetasks.listandtasks.canceltogether withtasks.requests.tools.call.Tool-level negotiation
var taskSupport: MCPTaskSupport(.forbidden | .optional | .required, default.forbidden).Executionstruct,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):params["task"].taskSupport == .forbiddenandtaskpresent →-32601.taskSupport == .requiredandtaskabsent →-32601.executeToolCall(...) -> JSONRPCMessage. Always call it viaSession.runTask.MCPTaskwith non-niltaskId, returnCreateTaskResultsynchronously.Session.runTaskawaitsMCPTask.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 viaMCPTask.awaitResult(). MAY use SSE forinput_requiredflow.tasks/list→ paginated, opaque base64 cursor overcreatedAt + taskId, filtered to current session.tasks/cancel→-32602if already terminal; elseMCPTask.cancel(), return cancelled task.For these four, ignore inbound
_meta.io.modelcontextprotocol/related-taskper spec § 4.5.Cancellation unification
The current
notifications/cancelledhandler at MCPServer.swift:161 is a no-op stub — nothing holds aTaskreference today. With the unified path, everytools/callhas anMCPTaskwhoseworkis cancellable.MCPTask.cancel()callswork.cancel(), transitions tocancelled, synthesizes a JSON-RPC error response as the storedresult, drains continuations.Related-task
_metapropagationAdd
MCPTask.current@TaskLocalfor in-tool ergonomics (status updates, progress emission). At outbound message dispatch (Session.send/Session.request/ progress notification helpers), ifMCPTask.current?.taskIdis non-nil and method is not in thetasks/*family, stamp:{ "_meta": { "io.modelcontextprotocol/related-task": { "taskId": "..." } } }tasks/resultresponses 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.cancelAllInFlight()invoked fromdestroySession.TTL & cleanup
createdAt + ttl;cleanupExpiredStatepurges them.tasks/cancelsemantics).MCPTasks: discarded insidecomplete(with:)— no ttl bookkeeping.tasks/resultdoes NOT mutate the task; results remain available for the full ttl regardless of how many retrievals.tasks/get/tasks/result/tasks/cancelreturn-32602 Task not found(spec-compliant per § Error Handling).Configuration knobs
On
HTTPSSETransport:MCPTaskcreate:min(requested, maxTaskTTL)(or default ifnull)-32603Streamable 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 toinput_required, so the receiver can side-channelelicitation/createcarrying the sametaskId.Session.sendSSEfall-through at Session.swift:122).Last-Event-IDcover 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). Fortasks/list, only return current session's tasks. Document: without auth, isolation is by session ID only.Phased rollout
Phase 1 — Core
MCPTaskactor +MCPTaskStatusenum + wire-DTOTaskState+CreateTaskResultSession.runTask,Session.mcpTasks, lookup helpers,cancelAllInFlightServerCapabilities.TasksCapabilitiesand conditional advertisement increateInitializeResponseMCPToolMetadata.taskSupport+MCPTool.Execution+tools/listencodinghandleToolCalldispatch throughSession.runTask; task vs. non-task delivery splittasks/get,tasks/result,tasks/list,tasks/cancelhandlersnotifications/tasks/statusemission on everyMCPTask.transitionnotifications/cancelled(currently a stub at MCPServer.swift:161)MCPTask.current@TaskLocal+ outbound related-task_metastampingSessionManager.updateSessionExpiryfactors inlatestTaskExpiry;cleanupExpiredStatepurges expired tasksHTTPSSETransportconfig:maxTaskTTL,maxTasksPerSessiontasks/getdoes not upgrade SSE;tasks/resultmayPhase 2 — Macro support
@MCPTool(taskSupport: .optional)parameter inSources/SwiftMCPMacros/Phase 3 — input_required flow
try await Session.current.requestInput(...)that transitionsMCPTask.currenttoinput_required, side-channels the elicitation, transitions back toworkingPhase 4 — Client-side
sampling/createMessage/elicitation/createinSources/SwiftMCP/Client/Tests (Swift Testing,
Tests/SwiftMCPTests/)MCPTasklifecycle: illegal state transitions rejected, ttl expiry, cross-session isolation, cancel-while-runningtasksomitted when no tool opts in; advertised when one doestools/call:task: {ttl}returnsCreateTaskResult;tasks/getpollsworking → completed;tasks/resultreturns original payload with related-task_metataskSupport: .requiredwithouttask→-32601taskSupport: .forbiddenwithtask→-32601tasks/cancelon terminal →-32602; on running → cancels and returnscancelledtasks/listpagination with opaque cursor; isolation across sessionstasks/getdoes not open SSE;tasks/resultmay;notifications/tasks/statussurvivesLast-Event-IDresumeisError: true) → taskfailed;tasks/resultreturns underlyingCallToolResultunchangednotifications/cancelledcancels in-flight non-tasktools/callFiles affected
Modified:
notifications/cancelledwiringtaskSupport/ExecutionrunTask,mcpTasks, status notification helperSources/SwiftMCP/Transport/Routing/MCPRoutes.swift— SSE upgrade rules per methodNew:
Sources/SwiftMCP/Models/Tasks/MCPTaskStatus.swiftSources/SwiftMCP/Models/Tasks/TaskState.swift(wire DTO)Sources/SwiftMCP/Models/Tasks/MCPTaskParams.swift(inboundtask: {ttl})Sources/SwiftMCP/Models/Tasks/CreateTaskResult.swiftSources/SwiftMCP/Tasks/MCPTask.swift(the actor)Tests/SwiftMCPTests/Tasks/Spec references