Soklet Logo

Model Context Protocol

MCP Tasks

Soklet implements the MCP Tasks extension for work that should outlive one HTTP request or one Soklet process. A task-capable tool can return a durable task ID, and the client can retrieve the task later through another connection or another eligible server node.

What Soklet does—and does not—own

Soklet owns capability negotiation, the tasks/* protocol, authorization-preserving manager calls, typed deferred-output validation, notifications, and simulation. Your application owns the durable task repository, job queue, workers, leases, fencing, retries, cleanup, and side-effect idempotency. Soklet does not ship a Redis or JDBC backend and does not promise exactly-once execution.

When to use a task

Use a task when the logical operation must remain recoverable after the initiating connection or serving node is gone—for example, generating a large report, importing a data set, or waiting for an external approval.

A task is not a mechanism for moving a live Java invocation between nodes. The original tool handler creates durable work and returns; an application worker performs that work independently. Any healthy node that shares the same application-owned McpTaskManager authority can later serve tasks/get, tasks/update, or tasks/cancel.

Keep short, naturally request-scoped work inline. Inline completion avoids persistence and polling overhead and can still use request-scoped progress.

Capability negotiation

Tasks require both a manager configured through McpServer.Builder.taskManager(...) and an endpoint whose taskProtocolVersions explicitly includes McpProtocolVersion.V2026_07_28. The @McpServerEndpoint annotation and McpEndpoint.Builder.taskProtocolVersions(...) provide the same endpoint gate. The io.modelcontextprotocol/tasks server extension is advertised only on those endpoints for that revision; sibling endpoints do not inherit it.

Add the task manager to a fresh builder carrying your local MCP server configuration or your application's production settings:

ReportTaskManager taskManager = new ReportTaskManager(database, jobQueue);

McpServer mcpServer = mcpServerBuilder
  .taskManager(taskManager)
  .build();

ReportTaskManager above is application code implementing McpTaskManager, backed by shared durable infrastructure. Soklet never starts or closes it. There is no implicit manager. An endpoint declaring Tasks without a configured manager fails server construction. The annotated endpoint example below supplies the endpoint gate.

A client declares Tasks support in every request that uses the extension:

{
  "_meta": {
    "io.modelcontextprotocol/protocolVersion": "2026-07-28",
    "io.modelcontextprotocol/clientCapabilities": {
      "extensions": {
        "io.modelcontextprotocol/tasks": {}
      }
    }
  }
}

That declaration means “I can consume task results,” not “please run this invocation as a task.” The server remains the sole decision maker. There is no request-level task switch.

An annotated tool returning McpTaskCreatedResult<R> is statically task-required. Soklet rejects a client without the Tasks capability with -32021 after successful application admission and before rate limiting, interception, or handler execution. A programmatic advanced handler that can complete either inline or as a task can inspect McpInvocationFeatures.getTaskCreationContext(); an empty value requires inline completion.

Returning a task handle from a handler or interceptor when that context is absent is an application contract failure. Soklet returns the fixed JSON-RPC internal error (-32603), in HTTP 200 for every supported revision. This happens after the application callback runs; it does not undo application effects or report a missing client capability.

Create durable work from a tool

For a task-required @McpTool on an @McpServerEndpoint, inject McpTaskCreationContext and preserve the eventual Java output type in the return value. Ordinary typed properties remain declared with @McpToolArgument:

@McpServerEndpoint(
  path = "/reports/mcp",
  name = "reports",
  version = "1.0.0",
  protocolVersions = {McpProtocolVersion.V2026_07_28},
  taskProtocolVersions = {McpProtocolVersion.V2026_07_28}
)
public final class ReportEndpoint {
  private final ReportTaskManager reportTasks;

  public ReportEndpoint(ReportTaskManager reportTasks) {
    this.reportTasks = reportTasks;
  }

  @McpTool(
    name = "reports.generate",
    description = "Generates a durable report",
    protocolVersions = {McpProtocolVersion.V2026_07_28}
  )
  public McpTaskCreatedResult<GeneratedReport> generateReport(
      @McpToolArgument(name = "accountId") String accountId,
      McpTaskCreationContext taskCreationContext) throws Exception {
    String taskId = reportTasks.createAndEnqueue(
      accountId,
      taskCreationContext.getRequestContext(),
      taskCreationContext.getTaskOrigin()
    );

    return McpTaskCreatedResult.fromTaskId(taskId);
  }
}

createAndEnqueue(...) is an application transaction. Before it returns, it must make all of these durably recoverable:

  • a high-entropy task ID;
  • the work description and application idempotency key;
  • the task's endpoint, tenant, principal, and other authorization binding;
  • the complete opaque McpTaskOrigin; and
  • an outbox or equivalent worker-dispatch record that cannot permanently orphan acknowledged work.

The handler then creates the public result with McpTaskCreatedResult.fromTaskId(...). Before Soklet returns that handle, it calls the configured manager and requires the task to be readable, authorized, and associated with the same origin. Returning an ID before committing its task therefore fails closed rather than exposing a handle to missing work.

Why persist the task origin?

McpTaskCreationContext.getTaskOrigin() supplies versioned, framework-owned state describing the originating endpoint, operation, validated arguments, declared input capabilities, and typed output contract. Persist it atomically with the job and return it unchanged in every McpTask snapshot.

Treat the origin as opaque and sensitive. Persist McpTaskOrigin.toPersistedString() unchanged in an opaque text or binary field and restore it with McpTaskOrigin.fromPersistedString(...). Do not route it through a JSON/JSONB column, generic JSON serializer, Unicode normalization, trimming, or another transformation. The persistence format preserves distinctions that an ordinary JSON round trip may erase.

getPersistedState() and fromPersistedState(...) have a narrower role: transferring the original immutable JSON value in memory. They are not the database serialization recipe. Apply access controls and retention policies appropriate for the original arguments; never log the origin or send it to a client.

When a completed task is retrieved, Soklet reconstructs the persisted origin contract. A missing or currently inaccessible tool registration fails the detailed read with HTTP 200 and the fixed redacted JSON-RPC internal error (-32603), before sanitization. The persisted task remains unchanged. A later explicit poll can retrieve the result after access is restored or on a node with the origin registration; this error does not promise automatic client retries. Soklet never sends a detailed completed task without its required result.

For an authorized detailed read, Soklet applies the current server's McpToolResultSanitizer to the complete result using the independently admitted polling request and original tool name and raw arguments. Only the returned payload and metadata proceed through ordinary limits and the original typed output schema preserved by the origin. Repeated reads may apply caller-specific redaction without overwriting the persisted result; they cannot bypass the protections applied to inline typed tools.

Validate worker values before storing them

Constructing an McpTask validates its status-specific payload shape. It does not establish that a completed result satisfies the origin tool's output schema or that outstanding input requests match its declarations. The development manager's completeTask(...) and requestTaskInput(...) methods also leave those contract checks to delivery. Validate application values before committing them, including required typed structured content and the tool's declared input requests. Soklet supplies no worker-side API that runs the complete delivery pipeline; keep the framework origin opaque rather than interpreting its persisted members.

An invalid stored payload causes tasks/get to return the fixed internal error (-32603, HTTP 200); invalid notification projections are suppressed. Repeated reads leave durable task state unchanged, publish no state transition, and do not notify the worker that wrote it. A delivery error does not turn COMPLETED into FAILED: current access policy, caller-specific sanitization, and transport limits can prevent delivery even when the underlying work succeeded. A later read can succeed after its delivery conditions are corrected.

The application owns corrections to stored state under its transition rules. In the in-memory manager, terminal tasks cannot be overwritten. Invalid outstanding input on a nonterminal task can be superseded by markTaskWorking(...) before a worker starts a valid input round.

Implement the manager contract

McpTaskManager is a thread-safe application SPI with three authoritative operations:

MethodApplication responsibility
findTask(...)Atomically authorize the current request and return the latest immutable task snapshot.
updateTask(...)Atomically authorize and idempotently accept matching responses to outstanding task input requests.
requestTaskCancelation(...)Atomically authorize and record durable cooperative cancelation intent.

Every call contains a newly admitted McpRequestContext. Possession of a task ID is never authorization. Bind a task to the selected endpoint and the application's principal, tenant, and authorization partition, then compare that binding atomically with the lookup or mutation. An unknown task and an unauthorized task must be indistinguishable: return an empty optional from findTask, or throw the fixed, non-disclosing McpTaskNotFoundException from a mutation.

The manager returns immutable McpTask snapshots. McpTaskStatus has five states:

Java statusWire statusStatus-specific payload
WORKINGworkingnone
INPUT_REQUIREDinput_requiredone or more outstanding input requests
COMPLETEDcompletedone complete MCP result
FAILEDfailedone client-safe JSON-RPC error
CANCELEDcancellednone

Terminal transitions are immutable. Use application transactions or compare-and-set operations so two workers cannot commit contradictory terminal states. The CANCELED Java spelling follows Soklet's naming convention; the wire spelling remains the protocol-required cancelled.

Here is an abridged application-owned manager shape. The repository, transaction, outbox, and publisher adapters are intentionally application types; Soklet does not prescribe their storage technology:

@ThreadSafe
public final class ReportTaskManager implements McpTaskManager {
  private final ReportTaskRepository repository;
  private final McpTaskEventPublisher taskEvents;

  public ReportTaskManager(
      ReportTaskRepository repository,
      McpTaskEventPublisher taskEvents) {
    this.repository = requireNonNull(repository);
    this.taskEvents = requireNonNull(taskEvents);
  }

  public String createAndEnqueue(
      String accountId,
      McpRequestContext requestContext,
      McpTaskOrigin taskOrigin) throws Exception {
    return repository.inTransaction(transaction -> {
      String taskId = UUID.randomUUID().toString();
      Instant now = Instant.now();
      McpTask task = McpTask.withTaskId(
          taskId, taskOrigin, McpTaskStatus.WORKING, now, now)
        .pollInterval(Duration.ofSeconds(2))
        .timeToLive(Duration.ofDays(7))
        .build();

      TaskBinding binding = TaskBinding.from(
        requestContext.getEndpoint(), requestContext.getAdmissionIdentity());
      transaction.insertTask(task, binding);
      transaction.enqueueReportJob(taskId, accountId);
      transaction.enqueueTaskChangedEvent(taskId);
      return taskId;
    });
  }

  @Override
  public Optional<McpTask> findTask(
      McpTaskRequestContext taskRequestContext) throws Exception {
    return repository.findAuthorizedTask(taskRequestContext);
  }

  @Override
  public void updateTask(McpTaskUpdateContext taskUpdateContext) throws Exception {
    repository.acceptAuthorizedInputResponses(taskUpdateContext);
  }

  @Override
  public void requestTaskCancelation(
      McpTaskRequestContext taskRequestContext) throws Exception {
    repository.recordAuthorizedCancelationIntent(taskRequestContext);
  }

  @Override
  public Optional<McpTaskEventPublisher> getTaskEventPublisher() {
    return Optional.of(taskEvents);
  }
}

In a real implementation, the outbox consumer publishes committed change rows through the stable taskEvents instance. Repository mutations use the context-taking manager inputs to authorize and update in one transaction, throwing McpTaskNotFoundException for both unknown and unauthorized mutation targets.

For a complete compiling example, see Task manager example. It configures a typed task tool, implements the application-owned manager and repository boundary, completes work outside the request, recovers the result through a fresh tasks/get, and verifies that another tenant receives the same neutral response as an unknown task. Its synchronized memory repository is explicitly a self-contained test double, not a production backend.

Client task operations

Task operations are independent stateless POST requests. They repeat the protocol version, client capabilities, authentication, and authorization material. The Mcp-Method header names the task operation and Mcp-Name carries the task ID.

OperationMeaning
tasks/getRetrieve the authoritative current snapshot, including a completed result or failure.
tasks/updateSubmit any partial subset of responses to outstanding input requests.
tasks/cancelRecord cancelation intent; it does not promise that a worker stopped or that canceled state won the race.

When an authorized task is INPUT_REQUIRED, Soklet checks every outstanding input request against the polling request's current client capabilities. If any form or URL elicitation mode is unsupported, the whole poll returns HTTP 400 / -32021 with only the missing modes in data.requiredCapabilities. Soklet sends no partial task snapshot or input payload, leaves durable state unchanged, and allows a later capable poll to retrieve all outstanding requests. Capabilities from task creation or earlier polls are not remembered. An empty elicitation: {} capability supports form mode only.

Only outstanding requests require elicitation support on a poll; unused origin declarations, working tasks, and terminal tasks do not. Unknown and unauthorized tasks receive the same neutral error before this capability check. The Tasks extension remains required on every task protocol request. The mode check follows the elicitation capability rules; task input requests retain their ordinary elicitation trust and consent requirements.

Input-response keys are unique for a task's lifetime. The Tasks extension requires a manager to ignore responses for keys that are not outstanding, including unknown, already-consumed, duplicate, and superseded keys. Soklet's manager contract additionally requires ignoring a response whose MCP union variant does not match the known outstanding request. The update acknowledgment can arrive before a worker observes the accepted input.

Task cancelation is separate from request cancelation. CancelationToken and notifications/cancelled concern one HTTP invocation. Only tasks/cancel records durable task intent, and completion or failure can legitimately win a race with that intent.

Obsolete tasks/list and tasks/result methods are not implemented. There is no generic task enumeration API; if a client loses a creation response before learning the task ID, an application-level idempotency key and lookup facility are needed to reconcile that ambiguous submission.

Observability ownership

Soklet observes a task-producing invocation as its ordinary tools/call request. Each admitted tasks/get, tasks/update, and tasks/cancel request receives the normal exactly-once request lifecycle callbacks and metrics, including a bounded method, outcome, and duration. The same behavior applies through the live listener and the off-network simulator.

Those signals describe protocol requests and calls across the manager boundary; they do not describe the durable job's lifecycle. Soklet has no separate public task-created or task-transition observability callback or metrics event, and no built-in counter or gauge for task status changes, active tasks, retries, leases, queue depth or backlog, or worker executions. notifications/tasks is an outbound best-effort projection and has no dedicated task-notification counter, although the containing subscription and stream retain their ordinary metrics.

Instrument durable transitions where your application owns them: the task manager, repository or transactional outbox, queue, and workers. Prefer bounded dimensions such as task kind, transition, and a fixed failure category. Never use task IDs, task contents, status messages, principals, or origins as metric labels. See Observability and Testing for the lifecycle and metric APIs.

Notifications and polling

tasks/get polling is always authoritative. Notifications are an optional low-latency projection of already-durable state.

A manager can expose an application-owned McpTaskEventPublisher through getTaskEventPublisher(). When present, Soklet accepts task IDs in subscriptions/listen.notifications.taskIds and may emit notifications/tasks snapshots. The publisher is a stable manager property: every call must return the same publisher instance for the manager's lifetime, and each built server snapshots it once.

Publish only the changed task ID, and only after the new state is committed and readable. On each event, Soklet performs a fresh manager lookup using the subscription's independently admitted identity and endpoint. It does not trust event payloads as state or authorization.

Once listen capacity is reserved, task-change hints received during initial authorization are retained. After the acknowledgment, Soklet projects only the accepted IDs. This closes the activation gap without sending an initial snapshot or replaying events from before the listen.

Completed notification results use a fresh catalog-access policy with the subscription's current authorized identity, matching polling. When the originating tool is missing or hidden, Soklet suppresses the completed notification and skips its sanitizer. The stored task remains unchanged, and other eligible task notifications continue. A later task-change hint or subscription reconciliation can retry projection after access is restored. Suppression does not consume terminal delivery or start an automatic retry loop. Current input-capability checks apply too. Reconciliation and lease expiry fence queued task frames; a suppressed unwritten terminal can be projected again under a fresh grant. The Tasks schema requires the result in completed polling responses and notifications.

Task-event delivery has broadcast semantics. In a fleet, every Soklet node with potentially connected subscribers must receive the event; a competing-consumer work queue that delivers each event to only one node is insufficient. Duplicate, delayed, or lost events are allowed because clients must still poll. Soklet bounds notification projection and each stream queue; an overloaded or slow subscription can close while ordinary task requests remain available.

Projection work is coalesced per subscription. Distinct accepted task IDs keep first-event order, and repeated hints for one ID collapse to the newest generation without losing an update that races its lookup. Task-manager lookups are serialized within that subscription; each continuation returns behind waiting peers and to the protocol executor's tail.

Each task notification shares a bounded application invocation across manager lookup, catalog policy and result sanitization. Its budget is subscriptionAuthorizationTimeout(...), bounded also by the remaining authorization lease and stream lifetime. Failure or timeout suppresses that generation; a later hint or reauthorization can retry with fresh state. A callback that ignores cancelation retains the subscription's task-projection slot until physical exit, preventing concurrent replacement callbacks. Other subscriptions can progress within the remaining application capacity.

Admission reserves separate coalesced work slots for task, catalog, authorization, and resource work. Pending and active modern subscriptions share an aggregate bound equal to concurrentConnectionLimit, or 8,192 when that transport limit is disabled; the per-partition cap still applies. Capacity rejects new listens before stream opening. Publisher fan-out does not displace already admitted peers.

Maintenance and projection share the protocol and application handler pools. Concurrent jobs are bounded by max(1, min(ownerCapacity, floor(protocolConcurrency / 2), floor(handlerConcurrency / 2))), giving 16 jobs with the defaults and reserving at least half of either pool when its concurrency exceeds one. Measure callback latency, owner count and lease duration together: a higher connection cap does not increase this throughput. Renewals start at half the effective lease plus a per-subscription stagger of up to min(1 second, lease / 20). A conservative simultaneous-wave budget is ownerCount × callbackDuration / maintenanceConcurrency <= lease / 2 - min(1 second, lease / 20), with further room for queueing and competing projection work. For 3,000 owners and concurrency 16, 50-millisecond checks need about 9.4 seconds; 200-millisecond checks need 37.5 seconds and 400-millisecond checks need 75 seconds. The latter two exceed the roughly 29-second conservative window of a 60-second lease. Authorization and catalog callback timeouts begin at dispatch, but queued work cannot extend the previous authorization lease or the stream lifetime. A modern renewal rejected by handler capacity ends the listen even while its earlier lease remains valid; clients must reconnect and reconcile. Per-stream output backpressure can still close a slow subscriber.

After a disconnect:

  1. Open and acknowledge a replacement subscription on a healthy node.
  2. Begin buffering its new task notifications.
  3. Call tasks/get for each task and reconcile the authoritative snapshots with buffered events.

This subscribe-first order permits harmless duplicates but avoids the obvious gap caused by polling before the replacement subscription exists. Soklet does not retain a subscription position or replay missed task events.

Development-only in-memory manager

McpTaskManager.fromInMemoryDefaults() creates a bounded McpInMemoryTaskManager for development, tests, simulation, and deliberately ephemeral single-process applications:

McpInMemoryTaskManager taskManager =
  McpTaskManager.fromInMemoryDefaults();

McpTask task = taskManager.createTask(taskCreationContext);
applicationExecutor.execute(() -> {
  try {
    McpJsonObject resultJson = generateReportJson();
    taskManager.completeTask(
      task.getTaskId(),
      McpCompleteResult.fromToolStructuredContent(resultJson),
      "Completed"
    );
  } catch (McpTaskNotFoundException expired) {
    // The development task expired before the worker completed.
  }
});

return McpTaskCreatedResult.fromTaskId(task.getTaskId());

The executor and generateReportJson() above belong to the application. The generated JSON must satisfy the originating tool's output schema, including its declared Java output type when typed. Text-only completion is appropriate only when successful output does not require structured content. The manager creates no thread and performs no work by itself. It retains at most 1,024 tasks by default, expires them one hour after creation, advertises a one-second polling interval, and removes expired entries opportunistically. Its builder can change those finite development limits.

Never mistake this for a production task backend

The in-memory manager loses all state on restart and cannot share tasks between nodes. It has no durable queue, transaction, outbox, lease, fencing, retry, failover, or crash recovery. Selecting it makes the limitation explicit; Soklet never selects it automatically.

Simulation

The off-network simulator uses the same task manager and task-event publisher instances configured on the imported SokletConfig. A test can call a task-capable tool, issue later tasks/get, tasks/update, and tasks/cancel requests, transition application-owned state between requests, and capture notifications/tasks through the normal production protocol path.

Use SimulatorConfig.Builder.configureMcpServer(...) to replace the production manager with a test-scoped manager when isolation is required. Simulation proves protocol and application behavior without binding a socket; it does not prove cross-process storage, worker leases, crash recovery, TCP disconnect timing, or proxy behavior. See Observability and Testing.

Production checklist

  • Persist the task, exact origin, authorization binding, work description, and recoverable dispatch record before returning a handle.
  • Put a stable application idempotency key in the tool's input and deduplicate ambiguous retries.
  • Make every manager operation independently authorize the current admitted request.
  • Use shared durable storage reachable from every node eligible to serve the endpoint.
  • Use bounded worker leases and fencing when another worker may reclaim abandoned work.
  • Make external side effects idempotent or coordinate them with an outbox or equivalent transaction boundary.
  • Publish task events after commit with fleet-wide broadcast semantics, while treating tasks/get as authoritative.
  • Version application task rows and preserve Soklet's opaque origin unchanged across rolling deployments.
  • Apply finite deadlines to storage calls and respond to interruption so disconnected subscriptions do not retain blocked lookup work.
  • Do not mark durable tasks failed or canceled merely because one Soklet process shuts down.

See Production Readiness for the complete node-loss, ambiguous-outcome, retry, and graceful-drain model.

Previous
Input and Request State