Skip to content

WIP: feat: MCP Tasks extension (full: protocol, server execution, client) - #234

Draft
sideeffffect wants to merge 15 commits into
softwaremill:masterfrom
sideeffffect:feat/tasks-extension
Draft

WIP: feat: MCP Tasks extension (full: protocol, server execution, client)#234
sideeffffect wants to merge 15 commits into
softwaremill:masterfrom
sideeffffect:feat/tasks-extension

Conversation

@sideeffffect

@sideeffffect sideeffffect commented Aug 27, 2026

Copy link
Copy Markdown

Complete implementation of the Tasks extension (io.modelcontextprotocol/tasks, SEP-2663) from the MCP roadmap — "call now, fetch later" for long-running requests. Relates to #163.

Protocol (core)

Types matching the 2026-07-28 ext-tasks schema, with domain-typed fields:

  • TaskId (opaque), TaskStatus, and TaskOutcome — a sealed union (Working / InputRequired(inputRequests) / Completed(result) / Failed(error) / Cancelled) so the status-specific data can only exist with its status; GetTaskResult carries it and derives status.
  • CreateTaskResult, tasks/get|cancel|update requests, TaskAck, notifications/tasks, and ToolCallResponse (Immediate / Deferred) for callToolWithTasks.
  • Instant timestamps, FiniteDuration ttlMs/pollIntervalMs (serialized as integer milliseconds per spec), JSONRPCErrorObject errors, -32003, and the _meta capability helpers.

Server

  • TaskStore (in-memory default) + TaskExecutor (virtual-thread-per-task, JDK 21) with best-effort cancellation; TaskSupport attached via .withTasks, with per-tool useTask / requireTask policies.
  • McpHandler answers tools/call with a CreateTaskResult when the client declares the capability, runs the tool in the background, transitions to completed / failed (a late completion never clobbers a cancellation), handles tasks/get / tasks/cancel / tasks/update, returns -32003 for a required-but-undeclared capability, and advertises the extension.
  • input_required: a tool defined with .taskLogic and registered via .addTaskTool gets a TaskContext and can requestInput(key, request) mid-run — moving the task to input_required (surfaced via tasks/get), parking on the virtual-thread worker until the client answers with tasks/update, then resuming. The waiter is registered before input_required is advertised, so a fast tasks/update is never lost.
val server = McpServer()
  .withTasks(TaskSupport(TaskStore.inMemory[Identity], TaskExecutor.threadPool()))
  .addTaskTool(review) // review uses .taskLogic and calls ctx.requestInput(...)

Client

getTask / cancelTask / updateTask, and callToolWithTasks returning ToolCallResponse.

Tests

  • TasksSpec / DurationCodecs behaviour — codec round-trips vs. the spec example, status wire strings, outcome flattening, integer-millis durations.
  • TaskServerSpec — end-to-end via McpHandler + the virtual-thread executor: sync fallback, create→poll→complete, throw→failed, cancel, -32003, capability advertisement, and the full input_required round trip (request → tasks/get shows it → tasks/update answers → completes).
  • TasksClientSpecgetTask / cancelTask / callToolWithTasks.
  • Full local gate + CI green (scalafmt, compile, docs, unit + 2025-11-25 conformance + container integration).

Marked WIP.

🤖 Generated with Claude Code

Add core protocol types for the experimental MCP Tasks extension (SEP-2663,
`io.modelcontextprotocol/tasks`): TaskStatus, CreateTaskResult, GetTaskResult,
the tasks/get, tasks/cancel and tasks/update request/param types, TaskAck and
the notifications/tasks notification, with circe codecs matching the reference
wire format.

Add the requestor-side client methods getTask, cancelTask and updateTask to
McpClient, so a client can poll a durable task handle returned by a
task-supporting server, retrieve its result, cancel it, or answer an input
request.

Server-side task execution (returning a task from tools/call, task storage and
driving) is intentionally left for a follow-up; this establishes the protocol
vocabulary and requestor side first. Tracks softwaremill#163.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@sideeffffect sideeffffect changed the title feat: MCP Tasks extension — protocol types and client methods WIP: feat: MCP Tasks extension — protocol types and client methods Aug 28, 2026
Complete the Tasks extension (io.modelcontextprotocol/tasks) with the
server-side subsystem and client augmentation on top of the protocol types:

Server:
- TaskStore[F] (in-memory default) and TaskExecutor[F] (thread-pool executor
  for synchronous servers) with best-effort cancellation.
- TaskSupport bundle attached via McpServer/StreamingMcpServer.withTasks, with
  per-tool useTask / requireTask policies.
- McpHandler answers tools/call with a CreateTaskResult when the client
  declares the tasks capability in _meta and runs the tool in the background,
  transitioning the task to completed (with the CallToolResult) or failed; a
  late completion never overwrites a cancellation. Handles tasks/get,
  tasks/cancel and tasks/update, returns -32003 when a required task capability
  is absent, and advertises the extension in capabilities.

Client:
- callToolWithTasks declares the capability and returns either a CallToolResult
  or a CreateTaskResult task handle.

Core:
- MissingRequiredClientCapability (-32003), ServerCapabilities.extensions, and
  the client-capability _meta helpers.

Server-initiated input_required flows remain a follow-up. Tracks softwaremill#163.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@sideeffffect sideeffffect changed the title WIP: feat: MCP Tasks extension — protocol types and client methods WIP: feat: MCP Tasks extension (full: protocol, server execution, client) Aug 28, 2026
sideeffffect and others added 13 commits August 31, 2026 19:42
Replace primitive-typed task fields with domain types, keeping the exact wire
format:
- taskId: opaque TaskId instead of String, throughout core, client and server.
- createdAt / lastUpdatedAt: java.time.Instant instead of String (ISO 8601).
- ttl / pollInterval: FiniteDuration instead of Long; still serialized as the
  integer-millisecond wire fields ttlMs / pollIntervalMs via Codec.forProductN.
- GetTaskResult.error: JSONRPCErrorObject instead of raw Json.
- inputRequests / UpdateTaskParams.inputResponses: Map[String, Json] instead of
  raw Json.
- TaskSupport.ttl / pollInterval: FiniteDuration; TaskStore / TaskExecutor keyed
  by TaskId.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Use java.time.Duration for ttl / pollInterval, to match the java.time.Instant
already used for the task timestamps. The Tasks spec fixes ttlMs / pollIntervalMs
as integer milliseconds (not ISO-8601 durations), so the wire encoding is
unchanged: the file-private codec maps Duration to/from milliseconds, verified
by a test asserting the integer-millisecond wire fields.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add reusable ISO-8601 duration codecs (DurationCodecs) for both
java.time.Duration and scala.concurrent.duration.FiniteDuration, the latter
bridging through java.time.Duration (the JDK's ISO-8601 duration type).
Encoding always produces an ISO-8601 string (e.g. "PT1H"); decoding also
accepts a bare JSON number read as milliseconds, so chimp still interoperates
with peers that send the ext-tasks bare-millisecond ttlMs / pollIntervalMs form.

The task ttl / pollInterval fields now serialize as ISO-8601 strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…in codecs

Use scala.concurrent.duration.FiniteDuration for the task ttl / pollInterval
fields and TaskSupport, and expose only a Codec[FiniteDuration]. java.time.Duration
is now confined to DurationCodecs, where it is used solely as the JDK's ISO-8601
duration parser and formatter (Duration.parse / Duration.ofNanos(..).toString).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Tasks extension fixes ttlMs / pollIntervalMs as integer milliseconds, so
revert the wire encoding from ISO-8601 back to integer milliseconds. The domain
type stays scala.concurrent.duration.FiniteDuration; only the codec changes.
Drop the now-unused ISO-8601 DurationCodecs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Tasks extension fixes ttlMs / pollIntervalMs as integer milliseconds, so
revert the wire encoding from ISO-8601 back to integer milliseconds. The domain
type stays scala.concurrent.duration.FiniteDuration; only the codec changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Default TaskExecutor.threadPool to Executors.newVirtualThreadPerTaskExecutor()
(JDK 21+), which fits the blocking tool logic tasks run and scales to many
concurrent tasks. chimp's synchronous stack (ox, tapir-netty-server-sync)
already requires JDK 21 at runtime.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The task executor uses Executors.newVirtualThreadPerTaskExecutor() (JDK 21+),
but the reusable publish workflow otherwise compiles on JDK 11 by default, so
pin the publish job to java-version 21.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ire keys

Replace the hand-written Codec.forProductN for CreateTaskResult and
GetTaskResult with `derives Codec`, naming the duration fields ttlMs /
pollIntervalMs (FiniteDuration) so the derived keys match the spec wire without
a manual codec that could drift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the loose status / result / error / inputRequests optional fields on
GetTaskResult with a sealed TaskOutcome (Working, InputRequired(inputRequests),
Completed(result), Failed(error), Cancelled), mirroring the ext-tasks
DetailedTask union. status is derived from the outcome, so result/error/
inputRequests can only exist with the matching status. A hand-written codec
flattens the outcome onto the wire (status plus result/error/inputRequests) and
rejects, for example, a completed task with no result.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace Either[CallToolResult, CreateTaskResult] with a named sealed type
ToolCallResponse (Immediate(result) / Deferred(task)), decoded from the
resultType discriminator, so call sites match on meaningful cases instead of
Right/Left.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Change start(taskId, body: () => F[Unit]) to start(taskId)(body: => F[Unit]),
so callers pass the task body as a by-name argument rather than an explicit
thunk. Deferral of a synchronous (Identity) body to the worker thread is
preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A tool registered with addTaskTool (defined via Tool.taskLogic) receives a
TaskContext and can call requestInput(key, request) while running as a task.
This moves the task to input_required, surfacing key -> request via tasks/get;
the tool parks (on the virtual-thread executor) until the client answers with
tasks/update, then resumes, transitioning back to working (or completing once
all inputs are resolved).

- TaskContext capability + Tool.taskLogic builder + McpServer/StreamingMcpServer
  addTaskTool and a taskTools list.
- TaskInputCoordinator parks the worker and delivers answers; the waiter is
  registered before input_required is advertised, so a fast tasks/update is
  never lost. Cancelling a task unblocks its waiters.
- tasks/update now delivers inputResponses; task tools are always answered with
  a task and require the client capability (-32003 otherwise). Task tools also
  appear in tools/list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant