Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
edea872
feat: add MCP Tasks extension protocol types and client methods
sideeffffect Aug 27, 2026
11e1881
feat: implement server-side task execution for the Tasks extension
sideeffffect Aug 28, 2026
c5ac0d3
refactor: use domain types instead of stringly-typed fields in Tasks
sideeffffect Aug 31, 2026
ed835e7
refactor: model task durations with java.time.Duration
sideeffffect Aug 31, 2026
f322c10
feat: serialize task durations as ISO-8601 strings
sideeffffect Aug 31, 2026
ccf8d9c
refactor: keep FiniteDuration in the domain, java.time.Duration only …
sideeffffect Aug 31, 2026
58aac96
fix: encode task durations as integer milliseconds for spec conformance
sideeffffect Aug 31, 2026
7d785cf
fix: encode task durations as integer milliseconds for spec conformance
sideeffffect Aug 31, 2026
01c2248
feat: run tasks on virtual threads (JDK 21)
sideeffffect Aug 31, 2026
1d41e7c
ci: build the release/publish job on JDK 21
sideeffffect Aug 31, 2026
6250fd7
refactor: derive Codec for task results; name duration fields after w…
sideeffffect Aug 31, 2026
9178a82
refactor: model detailed task state as a sealed TaskOutcome
sideeffffect Aug 31, 2026
1d9e276
refactor: return a sealed ToolCallResponse from callToolWithTasks
sideeffffect Aug 31, 2026
2f2a964
refactor: pass the task body to TaskExecutor.start by name
sideeffffect Aug 31, 2026
8a99738
feat: implement server-initiated input_required task flows
sideeffffect Aug 31, 2026
63203af
Merge remote-tracking branch 'upstream/2026-07-28-protocol-support' i…
sideeffffect Sep 22, 2026
b21bd05
fix: dedupe FiniteDuration codec after merge with 2026-07-28 protocol
sideeffffect Sep 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ jobs:
if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v'))
secrets: inherit
with:
java-version: "21"
java-opts: "-Xmx4G"
sttp-native: 1

Expand All @@ -78,4 +79,4 @@ jobs:
if: github.event.pull_request.user.login == 'softwaremill-ci'
needs: [ build, label ]
uses: softwaremill/github-actions-workflows/.github/workflows/auto-merge.yml@main
secrets: inherit
secrets: inherit
18 changes: 18 additions & 0 deletions client/src/main/scala/chimp/client/McpClient.scala
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,24 @@ trait McpClient[F[_]]:
*/
def sendProgress(token: ProgressToken, progress: Double, total: Option[Double] = None, message: Option[String] = None): F[Unit]

/** Retrieves the current state of a task by its id (MCP Tasks extension, experimental). When a receiver answers `tools/call` with a
* [[chimp.protocol.CreateTaskResult]], the returned `taskId` is polled with this method until the task reaches a terminal state; the
* underlying result is then available in [[chimp.protocol.GetTaskResult.result]].
*/
def getTask(taskId: TaskId): F[GetTaskResult]

/** Requests cancellation of a task by its id (MCP Tasks extension, experimental). */
def cancelTask(taskId: TaskId): F[Unit]

/** Fulfils the input a task is waiting for while it is `InputRequired` (MCP Tasks extension, experimental). */
def updateTask(taskId: TaskId, inputResponses: Map[String, Json]): F[Unit]

/** Invokes a tool, declaring support for the Tasks extension (experimental). The server may answer directly
* ([[chimp.protocol.ToolCallResponse.Immediate]]) or, for a long-running call, defer with a task handle
* ([[chimp.protocol.ToolCallResponse.Deferred]]) that is then driven with [[getTask]] / [[cancelTask]] / [[updateTask]].
*/
def callToolWithTasks(name: String, arguments: Json): F[ToolCallResponse]

/** An [[McpClient]] used over a [[chimp.client.transport.ClientBidirectionalTransport]], which additionally supports server-initiated
* interactions: subscribing to resource updates, notifying the server about changes to the client's roots, and handling notifications
* pushed by the server.
Expand Down
14 changes: 14 additions & 0 deletions client/src/main/scala/chimp/client/McpClientImpl.scala
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,20 @@ object McpClientImpl:
val params = ProgressParams(progressToken = token, progress = progress, total = total, message = message).asJson
sendNotification("notifications/progress", Some(params))

override def getTask(taskId: TaskId): F[GetTaskResult] =
sendRequest[GetTaskResult]("tasks/get", Some(GetTaskParams(taskId).asJson))

override def cancelTask(taskId: TaskId): F[Unit] =
sendRequest[Json]("tasks/cancel", Some(CancelTaskParams(taskId).asJson)).map(_ => ())

override def updateTask(taskId: TaskId, inputResponses: Map[String, Json]): F[Unit] =
sendRequest[Json]("tasks/update", Some(UpdateTaskParams(taskId, inputResponses).asJson)).map(_ => ())

override def callToolWithTasks(name: String, arguments: Json): F[ToolCallResponse] =
requireServerCapability("tools/call", _.tools.isDefined):
val params = CallToolParams(name = name, arguments = arguments, _meta = Some(TasksExtension.clientCapabilityMeta)).asJson
sendRequest[ToolCallResponse]("tools/call", Some(params))

protected def requireServerCapability[A](method: String, present: ServerCapabilities => Boolean)(action: => F[A]): F[A] =
if present(serverCapabilities) then action
else monad.error(McpProtocolException(s"Server did not negotiate the capability required for $method"))
Expand Down
86 changes: 86 additions & 0 deletions client/src/test/scala/chimp/client/TasksClientSpec.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package chimp.client

import chimp.client.transport.ClientHttpTransport
import chimp.protocol.*
import io.circe.syntax.*
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import sttp.client4.testing.SyncBackendStub
import sttp.client4.{GenericRequest, StringBody}
import sttp.model.StatusCode
import sttp.shared.Identity

class TasksClientSpec extends AnyFlatSpec with Matchers:

private val mcpUri = sttp.model.Uri.parse("http://localhost/mcp").toOption.get
private val clientInfo = Implementation(name = "chimp-test", version = "0.0.1")

private def envelopeFor(method: String, request: GenericRequest[?, ?]): Boolean =
request.body match
case StringBody(s, _, _) => s.contains(s"\"$method\"")
case _ => false

private val initEnvelope: String =
val initResult = InitializeResult(
protocolVersion = ProtocolVersion.Latest.name,
capabilities = ServerCapabilities(),
serverInfo = Implementation(name = "test-server", version = "1.0")
)
(JSONRPCMessage.Response(id = RequestId(1), result = initResult.asJson): JSONRPCMessage).asJson.noSpaces

private def client(backend: sttp.client4.testing.SyncBackendStub): McpClient[Identity] =
McpClient[Identity](ClientHttpTransport[Identity](backend, mcpUri), clientInfo, ProtocolVersion.Latest)

it should "poll a task with tasks/get and expose the underlying result" in:
val task = GetTaskResult(
taskId = TaskId("t1"),
outcome = TaskOutcome.Completed(CallToolResult(content = List(ToolContent.Text(text = "done"))).asJson),
resultType = Some("complete")
)
val taskEnvelope = (JSONRPCMessage.Response(id = RequestId(1), result = task.asJson): JSONRPCMessage).asJson.noSpaces
val backend = SyncBackendStub
.whenRequestMatches(envelopeFor("initialize", _))
.thenRespondAdjust(initEnvelope)
.whenRequestMatches(envelopeFor("tasks/get", _))
.thenRespondAdjust(taskEnvelope)
.whenAnyRequest
.thenRespondAdjust("", StatusCode.Accepted)

val res = client(backend).getTask(TaskId("t1"))
res.status shouldBe TaskStatus.Completed
res.outcome match
case TaskOutcome.Completed(result) =>
result.as[CallToolResult].toOption.map(_.content.head) shouldBe Some(ToolContent.Text("text", "done"))
case other => fail(s"expected Completed, got $other")

it should "cancel a task with tasks/cancel" in:
val ack = TaskAck(taskId = Some(TaskId("t1")), status = Some(TaskStatus.Cancelled))
val ackEnvelope = (JSONRPCMessage.Response(id = RequestId(1), result = ack.asJson): JSONRPCMessage).asJson.noSpaces
val backend = SyncBackendStub
.whenRequestMatches(envelopeFor("initialize", _))
.thenRespondAdjust(initEnvelope)
.whenRequestMatches(envelopeFor("tasks/cancel", _))
.thenRespondAdjust(ackEnvelope)
.whenAnyRequest
.thenRespondAdjust("", StatusCode.Accepted)

noException should be thrownBy client(backend).cancelTask(TaskId("t1"))

it should "declare task support and parse a task handle from callToolWithTasks" in:
val created = CreateTaskResult(taskId = TaskId("t9"), status = TaskStatus.Working)
val createdEnvelope = (JSONRPCMessage.Response(id = RequestId(1), result = created.asJson): JSONRPCMessage).asJson.noSpaces
val initResult = InitializeResult(
protocolVersion = ProtocolVersion.Latest.name,
capabilities = ServerCapabilities(tools = Some(ServerToolsCapability())),
serverInfo = Implementation(name = "s", version = "1")
)
val toolsInitEnvelope = (JSONRPCMessage.Response(id = RequestId(1), result = initResult.asJson): JSONRPCMessage).asJson.noSpaces
val backend = SyncBackendStub
.whenRequestMatches(envelopeFor("initialize", _))
.thenRespondAdjust(toolsInitEnvelope)
.whenRequestMatches(req => envelopeFor("tools/call", req) && envelopeFor(TasksExtension.Id, req))
.thenRespondAdjust(createdEnvelope)
.whenAnyRequest
.thenRespondAdjust("", StatusCode.Accepted)

client(backend).callToolWithTasks("slow", io.circe.Json.obj()) shouldBe ToolCallResponse.Deferred(created)
1 change: 1 addition & 0 deletions core/src/main/scala/chimp/protocol/JsonRpc.scala
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,5 @@ enum JSONRPCErrorCodes(val code: Int):
case InternalError extends JSONRPCErrorCodes(-32603)
case InvocationError extends JSONRPCErrorCodes(-32000)
case ResourceNotFound extends JSONRPCErrorCodes(-32002)
case MissingRequiredClientCapability extends JSONRPCErrorCodes(-32003)
case UnsupportedProtocolVersion extends JSONRPCErrorCodes(-32022)
3 changes: 2 additions & 1 deletion core/src/main/scala/chimp/protocol/Lifecycle.scala
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ final case class ServerCapabilities(
completions: Option[Json] = None,
prompts: Option[ServerPromptsCapability] = None,
resources: Option[ServerResourcesCapability] = None,
tools: Option[ServerToolsCapability] = None
tools: Option[ServerToolsCapability] = None,
extensions: Option[Map[String, Json]] = None
) derives Codec

final case class InitializeParams(
Expand Down
182 changes: 182 additions & 0 deletions core/src/main/scala/chimp/protocol/Tasks.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package chimp.protocol

import io.circe.{Codec, Decoder, Encoder, Json}
import io.circe.syntax.*

import java.time.Instant
import scala.concurrent.duration.FiniteDuration

/** Identifier of a task, generated by the receiver with enough entropy to prevent enumeration. */
opaque type TaskId = String
object TaskId:
def apply(value: String): TaskId = value
extension (taskId: TaskId) def value: String = taskId
given Codec[TaskId] = Codec.from(Decoder.decodeString, Encoder.encodeString)

/** The MCP Tasks extension (SEP-2663, identifier `io.modelcontextprotocol/tasks`): durable handles that let a receiver answer a request
* with a task, which the requestor then polls and later collects the result of. Experimental; the wire format follows the reference
* extension and may change.
*/
object TasksExtension:
val Id: String = "io.modelcontextprotocol/tasks"

/** `_meta` key under which a client declares its per-request capabilities. */
val ClientCapabilitiesMetaKey: String = "io.modelcontextprotocol/clientCapabilities"

/** The `_meta` entry a client adds to a request to declare support for the Tasks extension, so the server may answer with a task. */
def clientCapabilityMeta: Map[String, Json] =
Map(ClientCapabilitiesMetaKey -> Json.obj("extensions" -> Json.obj(Id -> Json.obj())))

/** Whether the given request `_meta` declares support for the Tasks extension. */
def declaredIn(meta: Option[Map[String, Json]]): Boolean =
meta
.flatMap(_.get(ClientCapabilitiesMetaKey))
.flatMap(_.hcursor.downField("extensions").downField(Id).focus)
.isDefined

/** State of a task. Terminal states are `Completed`, `Failed` and `Cancelled`. */
enum TaskStatus:
case Working, InputRequired, Completed, Failed, Cancelled

object TaskStatus:
private val toWire: Map[TaskStatus, String] = Map(
Working -> "working",
InputRequired -> "input_required",
Completed -> "completed",
Failed -> "failed",
Cancelled -> "cancelled"
)
private val fromWire: Map[String, TaskStatus] = toWire.map((k, v) => v -> k)

def isTerminal(status: TaskStatus): Boolean = status match
case Completed | Failed | Cancelled => true
case Working | InputRequired => false

given Encoder[TaskStatus] = Encoder.instance(status => Json.fromString(toWire(status)))
given Decoder[TaskStatus] = Decoder.decodeString.emap(s => fromWire.get(s).toRight(s"Unknown task status: $s"))

/** Result returned when a receiver answers a request with a task instead of the request's normal result. `ttlMs` and `pollIntervalMs` carry
* their unit in the name because that is the wire field name; the values are typed as [[scala.concurrent.duration.FiniteDuration]] and
* serialized as integer milliseconds.
*/
final case class CreateTaskResult(
taskId: TaskId,
status: TaskStatus,
createdAt: Option[Instant] = None,
lastUpdatedAt: Option[Instant] = None,
ttlMs: Option[FiniteDuration] = None,
pollIntervalMs: Option[FiniteDuration] = None,
statusMessage: Option[String] = None,
resultType: String = "task",
_meta: Option[Map[String, Json]] = None
) derives Codec

/** The response to a `tools/call` made with task support declared: the receiver either answers immediately with the tool's
* [[CallToolResult]], or defers by returning a [[CreateTaskResult]] task handle to poll.
*/
enum ToolCallResponse:
case Immediate(result: CallToolResult)
case Deferred(task: CreateTaskResult)

object ToolCallResponse:
given Decoder[ToolCallResponse] = Decoder.instance: c =>
c.get[Option[String]]("resultType")
.flatMap:
case Some("task") => c.as[CreateTaskResult].map(ToolCallResponse.Deferred(_))
case _ => c.as[CallToolResult].map(ToolCallResponse.Immediate(_))

final case class GetTaskParams(taskId: TaskId, _meta: Option[Map[String, Json]] = None) derives Codec
final case class GetTaskRequest(method: String = "tasks/get", params: GetTaskParams) derives Codec

/** The status of a task together with the data specific to that status. Modelling it as a sealed type means `result`, `error` and
* `inputRequests` can only appear with the status they belong to. `result` is raw JSON, since its shape depends on the request the task
* stands for.
*/
enum TaskOutcome:
case Working
case InputRequired(inputRequests: Map[String, Json])
case Completed(result: Json)
case Failed(error: JSONRPCErrorObject)
case Cancelled

def status: TaskStatus = this match
case TaskOutcome.Working => TaskStatus.Working
case TaskOutcome.InputRequired(_) => TaskStatus.InputRequired
case TaskOutcome.Completed(_) => TaskStatus.Completed
case TaskOutcome.Failed(_) => TaskStatus.Failed
case TaskOutcome.Cancelled => TaskStatus.Cancelled

/** Detailed task state returned by `tasks/get`. The [[TaskOutcome]] carries the `status` and its status-specific data. On the wire the
* outcome is flattened: `status` plus, where applicable, `result` / `error` / `inputRequests`.
*/
final case class GetTaskResult(
taskId: TaskId,
outcome: TaskOutcome,
createdAt: Option[Instant] = None,
lastUpdatedAt: Option[Instant] = None,
ttlMs: Option[FiniteDuration] = None,
pollIntervalMs: Option[FiniteDuration] = None,
statusMessage: Option[String] = None,
resultType: Option[String] = None,
_meta: Option[Map[String, Json]] = None
):
def status: TaskStatus = outcome.status

object GetTaskResult:
given Encoder[GetTaskResult] = Encoder.instance: task =>
val base = Json.obj(
"taskId" -> task.taskId.asJson,
"status" -> task.status.asJson,
"createdAt" -> task.createdAt.asJson,
"lastUpdatedAt" -> task.lastUpdatedAt.asJson,
"ttlMs" -> task.ttlMs.asJson,
"pollIntervalMs" -> task.pollIntervalMs.asJson,
"statusMessage" -> task.statusMessage.asJson,
"resultType" -> task.resultType.asJson,
"_meta" -> task._meta.asJson
)
val payload = task.outcome match
case TaskOutcome.Completed(result) => Json.obj("result" -> result)
case TaskOutcome.Failed(error) => Json.obj("error" -> error.asJson)
case TaskOutcome.InputRequired(inputRequests) => Json.obj("inputRequests" -> inputRequests.asJson)
case TaskOutcome.Working | TaskOutcome.Cancelled => Json.obj()
base.deepMerge(payload)

given Decoder[GetTaskResult] = Decoder.instance: c =>
for
taskId <- c.get[TaskId]("taskId")
status <- c.get[TaskStatus]("status")
createdAt <- c.get[Option[Instant]]("createdAt")
lastUpdatedAt <- c.get[Option[Instant]]("lastUpdatedAt")
ttlMs <- c.get[Option[FiniteDuration]]("ttlMs")
pollIntervalMs <- c.get[Option[FiniteDuration]]("pollIntervalMs")
statusMessage <- c.get[Option[String]]("statusMessage")
resultType <- c.get[Option[String]]("resultType")
meta <- c.get[Option[Map[String, Json]]]("_meta")
outcome <- status match
case TaskStatus.Working => Right(TaskOutcome.Working)
case TaskStatus.Cancelled => Right(TaskOutcome.Cancelled)
case TaskStatus.Completed => c.get[Json]("result").map(result => TaskOutcome.Completed(result))
case TaskStatus.Failed => c.get[JSONRPCErrorObject]("error").map(error => TaskOutcome.Failed(error))
case TaskStatus.InputRequired => c.get[Map[String, Json]]("inputRequests").map(reqs => TaskOutcome.InputRequired(reqs))
yield GetTaskResult(taskId, outcome, createdAt, lastUpdatedAt, ttlMs, pollIntervalMs, statusMessage, resultType, meta)

final case class CancelTaskParams(taskId: TaskId, _meta: Option[Map[String, Json]] = None) derives Codec
final case class CancelTaskRequest(method: String = "tasks/cancel", params: CancelTaskParams) derives Codec

final case class UpdateTaskParams(taskId: TaskId, inputResponses: Map[String, Json], _meta: Option[Map[String, Json]] = None) derives Codec
final case class UpdateTaskRequest(method: String = "tasks/update", params: UpdateTaskParams) derives Codec

/** Acknowledgement returned by `tasks/cancel` and `tasks/update`. */
final case class TaskAck(
taskId: Option[TaskId] = None,
status: Option[TaskStatus] = None,
resultType: String = "complete",
_meta: Option[Map[String, Json]] = None
) derives Codec

/** Notification pushed by a receiver that supports task subscriptions; carries the same fields as a `tasks/get` result. */
final case class TaskStatusNotification(
method: String = "notifications/tasks",
params: GetTaskResult
) derives Codec
Loading
Loading