feat: add tasks models (#566)

## Motivation and Context


closes #421 
#406 

## How Has This Been Tested?
Unit tests

## Breaking Changes
NaN

## Types of changes
- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update

## Checklist
- [x] I have read the [MCP
Documentation](https://modelcontextprotocol.io)
- [x] My code follows the repository's style guidelines
- [x] New and existing tests pass locally
- [x] I have added appropriate error handling
- [x] I have added or updated documentation as needed
This commit is contained in:
Pavel Gorgulov 2026-03-20 18:07:32 +01:00 • committed by GitHub
commit fd3a8583b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1904 additions and 0 deletions

View file

@ -32,6 +32,7 @@ public sealed interface Method {
NotificationsToolsListChanged("notifications/tools/list_changed"),
NotificationsRootsListChanged("notifications/roots/list_changed"),
NotificationsPromptsListChanged("notifications/prompts/list_changed"),
NotificationsTasksStatus("notifications/tasks/status"),
ToolsList("tools/list"),
ToolsCall("tools/call"),
LoggingSetLevel("logging/setLevel"),
@ -39,6 +40,10 @@ public sealed interface Method {
CompletionComplete("completion/complete"),
RootsList("roots/list"),
ElicitationCreate("elicitation/create"),
TasksGet("tasks/get"),
TasksResult("tasks/result"),
TasksList("tasks/list"),
TasksCancel("tasks/cancel"),
}
/**

View file

@ -348,3 +348,48 @@ public data class ToolListChangedNotification(override val params: BaseNotificat
@EncodeDefault
override val method: Method = Method.Defined.NotificationsToolsListChanged
}
// ============================================================================
// Task Status Notification
// ============================================================================
/**
* An optional notification from the receiver to the requestor, informing them that a task’s status has changed.
* Receivers are not required to send these notifications.
*
* This notification can be sent by either side (both [ClientNotification] and [ServerNotification]).
*
* @property params The task status notification parameters containing the current task state.
*/
@Serializable
public data class TaskStatusNotification(override val params: TaskStatusNotificationParams? = null) :
ClientNotification,
ServerNotification {
@EncodeDefault
override val method: Method = Method.Defined.NotificationsTasksStatus
}
/**
* Parameters for a notifications/tasks/status notification.
*
* @property taskId The task identifier.
* @property status Current task state.
* @property statusMessage Optional human-readable message describing the current task state.
* @property createdAt ISO 8601 timestamp when the task was created.
* @property lastUpdatedAt ISO 8601 timestamp when the task was last updated.
* @property ttl Actual retention duration from creation in milliseconds, null for unlimited.
* @property pollInterval Suggested polling interval in milliseconds.
* @property meta Optional metadata for this notification.
*/
@Serializable
public data class TaskStatusNotificationParams(
override val taskId: String,
override val status: TaskStatus,
override val statusMessage: String? = null,
override val createdAt: String,
override val lastUpdatedAt: String,
override val ttl: Long?,
override val pollInterval: Long? = null,
@SerialName("_meta") override val meta: JsonObject? = null,
) : NotificationParams,
TaskFields

View file

@ -6,6 +6,7 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.long
import kotlinx.serialization.json.longOrNull
import kotlin.jvm.JvmInline
@ -22,6 +23,17 @@ public value class RequestMeta(public val json: JsonObject) {
}
}
/**
* The related task metadata, if this request is associated with a task.
*
* @see RelatedTaskMetadata
* @see RELATED_TASK_META_KEY
*/
public val relatedTask: RelatedTaskMetadata?
get() = json[RELATED_TASK_META_KEY]?.let { element ->
McpJson.decodeFromJsonElement(element)
}
/**
* Retrieves the value associated with the specified key from the JSON object.
*

View file

@ -159,6 +159,10 @@ private val clientRequestDeserializers: Map<String, DeserializationStrategy<Clie
Method.Defined.ResourcesTemplatesList.value to ListResourceTemplatesRequest.serializer(),
Method.Defined.ToolsCall.value to CallToolRequest.serializer(),
Method.Defined.ToolsList.value to ListToolsRequest.serializer(),
Method.Defined.TasksGet.value to GetTaskRequest.serializer(),
Method.Defined.TasksResult.value to GetTaskPayloadRequest.serializer(),
Method.Defined.TasksList.value to ListTasksRequest.serializer(),
Method.Defined.TasksCancel.value to CancelTaskRequest.serializer(),
)
}
@ -175,6 +179,10 @@ private val serverRequestDeserializers: Map<String, DeserializationStrategy<Serv
Method.Defined.Ping.value to PingRequest.serializer(),
Method.Defined.RootsList.value to ListRootsRequest.serializer(),
Method.Defined.SamplingCreateMessage.value to CreateMessageRequest.serializer(),
Method.Defined.TasksGet.value to GetTaskRequest.serializer(),
Method.Defined.TasksResult.value to GetTaskPayloadRequest.serializer(),
Method.Defined.TasksList.value to ListTasksRequest.serializer(),
Method.Defined.TasksCancel.value to CancelTaskRequest.serializer(),
)
}
@ -212,6 +220,7 @@ private val clientNotificationDeserializers: Map<String, DeserializationStrategy
Method.Defined.NotificationsProgress.value to ProgressNotification.serializer(),
Method.Defined.NotificationsInitialized.value to InitializedNotification.serializer(),
Method.Defined.NotificationsRootsListChanged.value to RootsListChangedNotification.serializer(),
Method.Defined.NotificationsTasksStatus.value to TaskStatusNotification.serializer(),
)
}
@ -231,6 +240,7 @@ private val serverNotificationDeserializers: Map<String, DeserializationStrategy
Method.Defined.NotificationsResourcesListChanged.value to ResourceListChangedNotification.serializer(),
Method.Defined.NotificationsToolsListChanged.value to ToolListChangedNotification.serializer(),
Method.Defined.NotificationsPromptsListChanged.value to PromptListChangedNotification.serializer(),
Method.Defined.NotificationsTasksStatus.value to TaskStatusNotification.serializer(),
)
}
@ -301,6 +311,9 @@ private fun selectClientResultDeserializer(element: JsonElement): Deserializatio
"model" in jsonObject && "role" in jsonObject -> CreateMessageResult.serializer()
"roots" in jsonObject -> ListRootsResult.serializer()
"action" in jsonObject -> ElicitResult.serializer()
"task" in jsonObject -> CreateTaskResult.serializer()
"tasks" in jsonObject -> ListTasksResult.serializer()
"taskId" in jsonObject -> GetTaskResult.serializer()
else -> null
}
}
@ -309,6 +322,7 @@ private fun selectClientResultDeserializer(element: JsonElement): Deserializatio
* Selects the appropriate deserializer for server results based on JSON content.
* Returns null if the structure doesn't match any known server result type.
*/
@Suppress("CyclomaticComplexMethod")
private fun selectServerResultDeserializer(element: JsonElement): DeserializationStrategy<ServerResult>? {
val jsonObject = element.jsonObject
return when {
@ -321,6 +335,9 @@ private fun selectServerResultDeserializer(element: JsonElement): Deserializatio
"messages" in jsonObject -> GetPromptResult.serializer()
"contents" in jsonObject -> ReadResourceResult.serializer()
"content" in jsonObject -> CallToolResult.serializer()
"task" in jsonObject -> CreateTaskResult.serializer()
"tasks" in jsonObject -> ListTasksResult.serializer()
"taskId" in jsonObject -> GetTaskResult.serializer()
else -> null
}
}

View file

@ -0,0 +1,347 @@
@file:OptIn(ExperimentalSerializationApi::class)
package io.modelcontextprotocol.kotlin.sdk.types
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlin.jvm.JvmInline
/**
* The key used in `_meta` to associate a message with a task.
*/
public const val RELATED_TASK_META_KEY: String = "io.modelcontextprotocol/related-task"
/**
* Common fields shared by all types representing the task state.
*/
public sealed interface TaskFields {
public val taskId: String
public val status: TaskStatus
public val statusMessage: String?
public val createdAt: String
public val lastUpdatedAt: String
public val ttl: Long?
public val pollInterval: Long?
}
/**
* Data associated with a task.
*
* @property taskId The task identifier.
* @property status Current task state.
* @property statusMessage Optional human-readable message describing the current task state.
* This can provide context for any status, including reasons for "cancelled" status,
* summaries for "completed" status, or diagnostic information for "failed" status.
* @property createdAt ISO 8601 timestamp when the task was created.
* @property lastUpdatedAt ISO 8601 timestamp when the task was last updated.
* @property ttl Actual retention duration from creation in milliseconds, null for unlimited.
* @property pollInterval Suggested polling interval in milliseconds.
*/
@Serializable
public data class Task(
override val taskId: String,
override val status: TaskStatus,
override val statusMessage: String? = null,
override val createdAt: String,
override val lastUpdatedAt: String,
override val ttl: Long?,
override val pollInterval: Long? = null,
) : TaskFields
/**
* Metadata for augmenting a request with task execution.
* Include this in the `task` field of the request parameters.
*
* @property ttl Requested duration in milliseconds to retain task from creation.
*/
@Serializable
public data class TaskMetadata(val ttl: Long? = null)
/**
* The status of a task.
*/
@Serializable
public enum class TaskStatus {
@SerialName("working")
Working,
@SerialName("input_required")
InputRequired,
@SerialName("completed")
Completed,
@SerialName("failed")
Failed,
@SerialName("cancelled")
Cancelled,
}
/**
* Metadata for associating messages with a task.
* Include this in the `_meta` field under the key `io.modelcontextprotocol/related-task`.
*
* @property taskId The task identifier this message is associated with.
*/
@Serializable
public data class RelatedTaskMetadata(val taskId: String)
/**
* A response to a task-augmented request.
*
* @property task The task data associated with the response.
* @property meta Optional metadata for this response.
*/
@Serializable
public data class CreateTaskResult(
val task: Task,
@SerialName("_meta")
override val meta: JsonObject? = null,
) : ClientResult,
ServerResult
// ============================================================================
// tasks/get
// ============================================================================
/**
* A request to retrieve the state of a task.
*
* This request can be sent by either side (both [ClientRequest] and [ServerRequest]).
*
* @property params The parameters specifying which task to retrieve.
*/
@Serializable
public data class GetTaskRequest(override val params: GetTaskRequestParams) :
ClientRequest,
ServerRequest {
@EncodeDefault
override val method: Method = Method.Defined.TasksGet
/**
* The task identifier to query.
*/
public val taskId: String
get() = params.taskId
/**
* Metadata for this request. May include a progressToken for out-of-band progress notifications.
*/
public val meta: RequestMeta?
get() = params.meta
}
/**
* Parameters for a tasks/get request.
*
* @property taskId The task identifier to query.
* @property meta Optional metadata for this request.
*/
@Serializable
public data class GetTaskRequestParams(
val taskId: String,
@SerialName("_meta")
override val meta: RequestMeta? = null,
) : RequestParams
/**
* The response to a tasks/get request.
*
* Contains all [Task] fields flattened into the result (intersection of Result and Task).
*
* @property taskId The task identifier.
* @property status Current task state.
* @property statusMessage Optional human-readable message describing the current task state.
* @property createdAt ISO 8601 timestamp when the task was created.
* @property lastUpdatedAt ISO 8601 timestamp when the task was last updated.
* @property ttl Actual retention duration from creation in milliseconds, null for unlimited.
* @property pollInterval Suggested polling interval in milliseconds.
* @property meta Optional metadata for this response.
*/
@Serializable
public data class GetTaskResult(
override val taskId: String,
override val status: TaskStatus,
override val statusMessage: String? = null,
override val createdAt: String,
override val lastUpdatedAt: String,
override val ttl: Long?,
override val pollInterval: Long? = null,
@SerialName("_meta")
override val meta: JsonObject? = null,
) : ClientResult,
ServerResult,
TaskFields
// ============================================================================
// tasks/result
// ============================================================================
/**
* A request to retrieve the result of a completed task.
*
* This request can be sent by either side (both [ClientRequest] and [ServerRequest]).
*
* @property params The parameters specifying which task to retrieve results for.
*/
@Serializable
public data class GetTaskPayloadRequest(override val params: GetTaskPayloadRequestParams) :
ClientRequest,
ServerRequest {
@EncodeDefault
override val method: Method = Method.Defined.TasksResult
/**
* The task identifier to retrieve results for.
*/
public val taskId: String
get() = params.taskId
/**
* Metadata for this request. May include a progressToken for out-of-band progress notifications.
*/
public val meta: RequestMeta?
get() = params.meta
}
/**
* Parameters for a tasks/result request.
*
* @property taskId The task identifier to retrieve results for.
* @property meta Optional metadata for this request.
*/
@Serializable
public data class GetTaskPayloadRequestParams(
val taskId: String,
@SerialName("_meta")
override val meta: RequestMeta? = null,
) : RequestParams
/**
* The response to a tasks/result request.
*
* The structure matches the result type of the original request.
* For example, a tools/call task would return the [CallToolResult] structure.
*
* @property meta Optional metadata for this response.
*/
@JvmInline
@Serializable
public value class GetTaskPayloadResult(public val json: JsonObject) :
ClientResult,
ServerResult {
override val meta: JsonObject?
get() = json["_meta"]?.takeIf { it is JsonObject } as? JsonObject
/**
* Retrieves the value associated with the specified key from the result payload.
*
* @param key the key whose corresponding value is to be returned
* @return the [JsonElement] associated with the specified key, or null if the key does not exist
*/
public operator fun get(key: String): JsonElement? = json[key]
}
// ============================================================================
// tasks/list
// ============================================================================
/**
* A request to retrieve a list of tasks.
*
* This request can be sent by either side (both [ClientRequest] and [ServerRequest]).
* Supports pagination through the cursor parameter.
*
* @property params Optional pagination parameters to control which page of results to return.
*/
@Serializable
public data class ListTasksRequest(override val params: PaginatedRequestParams? = null) :
ClientRequest,
ServerRequest,
PaginatedRequest {
@EncodeDefault
override val method: Method = Method.Defined.TasksList
}
/**
* The response to a tasks/list request.
*
* @property tasks The list of tasks.
* @property nextCursor An opaque token representing the pagination position after the last returned result.
* If present, there may be more results available.
* @property meta Optional metadata for this response.
*/
@Serializable
public data class ListTasksResult(
val tasks: List<Task>,
override val nextCursor: String? = null,
@SerialName("_meta")
override val meta: JsonObject? = null,
) : ClientResult,
ServerResult,
PaginatedResult
// ============================================================================
// tasks/cancel
// ============================================================================
/**
* A request to cancel a task.
*
* This request can be sent by either side (both [ClientRequest] and [ServerRequest]).
*
* @property params The parameters specifying which task to cancel.
*/
@Serializable
public data class CancelTaskRequest(override val params: CancelTaskRequestParams) :
ClientRequest,
ServerRequest {
@EncodeDefault
override val method: Method = Method.Defined.TasksCancel
/**
* The task identifier to cancel.
*/
public val taskId: String
get() = params.taskId
/**
* Metadata for this request. May include a progressToken for out-of-band progress notifications.
*/
public val meta: RequestMeta?
get() = params.meta
}
/**
* Parameters for a tasks/cancel request.
*
* @property taskId The task identifier to cancel.
* @property meta Optional metadata for this request.
*/
@Serializable
public data class CancelTaskRequestParams(
val taskId: String,
@SerialName("_meta")
override val meta: RequestMeta? = null,
) : RequestParams
/**
* The response to a tasks/cancel request.
*
* Contains all [Task] fields flattened into the result (intersection of Result and Task).
*
* @property taskId The task identifier.
* @property status Current task state.
* @property statusMessage Optional human-readable message describing the current task state.
* @property createdAt ISO 8601 timestamp when the task was created.
* @property lastUpdatedAt ISO 8601 timestamp when the task was last updated.
* @property ttl Actual retention duration from creation in milliseconds, null for unlimited.
* @property pollInterval Suggested polling interval in milliseconds.
* @property meta Optional metadata for this response.
*/
public typealias CancelTaskResult = GetTaskResult

View file

@ -310,4 +310,116 @@ class NotificationTest {
""".trimIndent(),
)
}
@Test
fun `should serialize TaskStatusNotification with all fields`() {
val notification = TaskStatusNotification(
TaskStatusNotificationParams(
taskId = "task-1",
status = TaskStatus.Working,
statusMessage = "Processing data",
createdAt = "2025-01-01T00:00:00Z",
lastUpdatedAt = "2025-01-01T00:01:00Z",
ttl = 60000,
pollInterval = 5000,
meta = buildJsonObject { put("source", "worker-1") },
),
)
verifySerialization(
notification,
McpJson,
"""
{
"method": "notifications/tasks/status",
"params": {
"taskId": "task-1",
"status": "working",
"statusMessage": "Processing data",
"createdAt": "2025-01-01T00:00:00Z",
"lastUpdatedAt": "2025-01-01T00:01:00Z",
"ttl": 60000,
"pollInterval": 5000,
"_meta": {
"source": "worker-1"
}
}
}
""".trimIndent(),
)
}
@Test
fun `should serialize TaskStatusNotification with minimal fields`() {
val notification = TaskStatusNotification(
TaskStatusNotificationParams(
taskId = "task-2",
status = TaskStatus.Completed,
createdAt = "2025-01-01T00:00:00Z",
lastUpdatedAt = "2025-01-01T00:02:00Z",
ttl = null,
),
)
verifySerialization(
notification,
McpJson,
"""
{
"method": "notifications/tasks/status",
"params": {
"taskId": "task-2",
"status": "completed",
"createdAt": "2025-01-01T00:00:00Z",
"lastUpdatedAt": "2025-01-01T00:02:00Z"
}
}
""".trimIndent(),
)
}
@Test
fun `should deserialize TaskStatusNotification`() {
val json = """
{
"method": "notifications/tasks/status",
"params": {
"taskId": "task-3",
"status": "failed",
"statusMessage": "Connection lost",
"createdAt": "2025-01-01T00:00:00Z",
"lastUpdatedAt": "2025-01-01T00:03:00Z",
"ttl": 30000,
"pollInterval": 1000
}
}
""".trimIndent()
val notification = verifyDeserialization<TaskStatusNotification>(McpJson, json)
val params = notification.params!!
assertEquals(Method.Defined.NotificationsTasksStatus, notification.method)
assertEquals("task-3", params.taskId)
assertEquals(TaskStatus.Failed, params.status)
assertEquals("Connection lost", params.statusMessage)
assertEquals("2025-01-01T00:00:00Z", params.createdAt)
assertEquals("2025-01-01T00:03:00Z", params.lastUpdatedAt)
assertEquals(30000L, params.ttl)
assertEquals(1000L, params.pollInterval)
assertNull(params.meta)
}
@Test
fun `should deserialize TaskStatusNotification without params`() {
val json = """
{
"method": "notifications/tasks/status"
}
""".trimIndent()
val notification = verifyDeserialization<TaskStatusNotification>(McpJson, json)
assertEquals(Method.Defined.NotificationsTasksStatus, notification.method)
assertNull(notification.params)
}
}

View file

@ -0,0 +1,802 @@
package io.modelcontextprotocol.kotlin.sdk.types
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import io.modelcontextprotocol.kotlin.test.utils.verifyDeserialization
import io.modelcontextprotocol.kotlin.test.utils.verifySerialization
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import kotlin.test.Test
class TasksTest {
// ========================================================================
// Task
// ========================================================================
@Test
fun `should serialize Task with minimal fields`() {
val task = Task(
taskId = "task-1",
status = TaskStatus.Working,
createdAt = "2025-01-01T00:00:00Z",
lastUpdatedAt = "2025-01-01T00:00:00Z",
ttl = null,
)
verifySerialization(
task,
McpJson,
"""
{
"taskId": "task-1",
"status": "working",
"createdAt": "2025-01-01T00:00:00Z",
"lastUpdatedAt": "2025-01-01T00:00:00Z"
}
""".trimIndent(),
)
}
@Test
fun `should serialize Task with all fields`() {
val task = Task(
taskId = "task-2",
status = TaskStatus.Completed,
statusMessage = "Processing complete",
createdAt = "2025-01-01T00:00:00Z",
lastUpdatedAt = "2025-01-01T00:01:00Z",
ttl = 60000,
pollInterval = 5000,
)
verifySerialization(
task,
McpJson,
"""
{
"taskId": "task-2",
"status": "completed",
"statusMessage": "Processing complete",
"createdAt": "2025-01-01T00:00:00Z",
"lastUpdatedAt": "2025-01-01T00:01:00Z",
"ttl": 60000,
"pollInterval": 5000
}
""".trimIndent(),
)
}
@Test
fun `should deserialize Task and verify TaskFields contract`() {
val json = """
{
"taskId": "task-3",
"status": "failed",
"statusMessage": "Connection lost",
"createdAt": "2025-01-01T00:00:00Z",
"lastUpdatedAt": "2025-01-01T00:02:00Z",
"ttl": 30000,
"pollInterval": 1000
}
""".trimIndent()
val task = verifyDeserialization<Task>(McpJson, json)
task.shouldBeInstanceOf<TaskFields>()
task.taskId shouldBe "task-3"
task.status shouldBe TaskStatus.Failed
task.statusMessage shouldBe "Connection lost"
task.createdAt shouldBe "2025-01-01T00:00:00Z"
task.lastUpdatedAt shouldBe "2025-01-01T00:02:00Z"
task.ttl shouldBe 30000L
task.pollInterval shouldBe 1000L
}
// ========================================================================
// TaskStatus
// ========================================================================
@Test
fun `should serialize all TaskStatus values`() {
verifySerialization(TaskStatus.Working, McpJson, "\"working\"")
verifySerialization(TaskStatus.InputRequired, McpJson, "\"input_required\"")
verifySerialization(TaskStatus.Completed, McpJson, "\"completed\"")
verifySerialization(TaskStatus.Failed, McpJson, "\"failed\"")
verifySerialization(TaskStatus.Cancelled, McpJson, "\"cancelled\"")
}
// ========================================================================
// TaskMetadata
// ========================================================================
@Test
fun `should serialize TaskMetadata with ttl`() {
val metadata = TaskMetadata(ttl = 120000)
verifySerialization(
metadata,
McpJson,
"""
{
"ttl": 120000
}
""".trimIndent(),
)
}
@Test
fun `should serialize TaskMetadata without ttl`() {
val metadata = TaskMetadata()
verifySerialization(
metadata,
McpJson,
"{}",
)
}
// ========================================================================
// RelatedTaskMetadata
// ========================================================================
@Test
fun `should serialize RelatedTaskMetadata`() {
val related = RelatedTaskMetadata(taskId = "task-42")
verifySerialization(
related,
McpJson,
"""
{
"taskId": "task-42"
}
""".trimIndent(),
)
}
@Test
fun `should deserialize RequestMeta with RelatedTaskMetadata`() {
val json = """
{
"io.modelcontextprotocol/related-task": {
"taskId": "786512e2-9e0d-44bd-8f29-789f320fe840"
}
}
""".trimIndent()
val meta = McpJson.decodeFromString<RequestMeta>(json)
val related = meta.relatedTask
related.shouldNotBeNull()
related.taskId shouldBe "786512e2-9e0d-44bd-8f29-789f320fe840"
}
// ========================================================================
// CreateTaskResult
// ========================================================================
@Test
fun `should serialize CreateTaskResult`() {
val result = CreateTaskResult(
task = Task(
taskId = "task-1",
status = TaskStatus.Working,
createdAt = "2025-01-01T00:00:00Z",
lastUpdatedAt = "2025-01-01T00:00:00Z",
ttl = null,
),
)
verifySerialization(
result,
McpJson,
"""
{
"task": {
"taskId": "task-1",
"status": "working",
"createdAt": "2025-01-01T00:00:00Z",
"lastUpdatedAt": "2025-01-01T00:00:00Z"
}
}
""".trimIndent(),
)
}
@Test
fun `should serialize CreateTaskResult with meta`() {
val result = CreateTaskResult(
task = Task(
taskId = "task-1",
status = TaskStatus.Working,
createdAt = "2025-01-01T00:00:00Z",
lastUpdatedAt = "2025-01-01T00:00:00Z",
ttl = 60000,
),
meta = buildJsonObject { put("trace", "abc") },
)
verifySerialization(
result,
McpJson,
"""
{
"task": {
"taskId": "task-1",
"status": "working",
"createdAt": "2025-01-01T00:00:00Z",
"lastUpdatedAt": "2025-01-01T00:00:00Z",
"ttl": 60000
},
"_meta": {
"trace": "abc"
}
}
""".trimIndent(),
)
}
@Test
fun `should deserialize CreateTaskResult`() {
val json = """
{
"task": {
"taskId": "task-5",
"status": "input_required",
"statusMessage": "Need more data",
"createdAt": "2025-01-01T00:00:00Z",
"lastUpdatedAt": "2025-01-01T00:01:00Z",
"pollInterval": 2000
}
}
""".trimIndent()
val result = verifyDeserialization<CreateTaskResult>(McpJson, json)
result.task.taskId shouldBe "task-5"
result.task.status shouldBe TaskStatus.InputRequired
result.task.statusMessage shouldBe "Need more data"
result.task.pollInterval shouldBe 2000L
result.task.ttl.shouldBeNull()
result.meta.shouldBeNull()
}
// ========================================================================
// GetTaskRequest
// ========================================================================
@Test
fun `should serialize GetTaskRequest`() {
val request = GetTaskRequest(
GetTaskRequestParams(taskId = "task-10"),
)
verifySerialization(
request,
McpJson,
"""
{
"method": "tasks/get",
"params": {
"taskId": "task-10"
}
}
""".trimIndent(),
)
}
@Test
fun `should serialize GetTaskRequest with meta`() {
val request = GetTaskRequest(
GetTaskRequestParams(
taskId = "task-10",
meta = RequestMeta(
buildJsonObject { put("progressToken", "pt-1") },
),
),
)
verifySerialization(
request,
McpJson,
"""
{
"method": "tasks/get",
"params": {
"taskId": "task-10",
"_meta": {
"progressToken": "pt-1"
}
}
}
""".trimIndent(),
)
}
@Test
fun `should deserialize GetTaskRequest`() {
val json = """
{
"method": "tasks/get",
"params": {
"taskId": "task-11"
}
}
""".trimIndent()
val request = verifyDeserialization<GetTaskRequest>(McpJson, json)
request.method shouldBe Method.Defined.TasksGet
request.taskId shouldBe "task-11"
request.meta.shouldBeNull()
}
// ========================================================================
// GetTaskResult
// ========================================================================
@Test
fun `should serialize GetTaskResult with all fields`() {
val result = GetTaskResult(
taskId = "task-20",
status = TaskStatus.Completed,
statusMessage = "Done",
createdAt = "2025-01-01T00:00:00Z",
lastUpdatedAt = "2025-01-01T00:05:00Z",
ttl = 300000,
pollInterval = 10000,
meta = buildJsonObject { put("server", "main") },
)
verifySerialization(
result,
McpJson,
"""
{
"taskId": "task-20",
"status": "completed",
"statusMessage": "Done",
"createdAt": "2025-01-01T00:00:00Z",
"lastUpdatedAt": "2025-01-01T00:05:00Z",
"ttl": 300000,
"pollInterval": 10000,
"_meta": {
"server": "main"
}
}
""".trimIndent(),
)
}
@Test
fun `should deserialize GetTaskResult and verify TaskFields contract`() {
val json = """
{
"taskId": "task-21",
"status": "working",
"createdAt": "2025-01-01T00:00:00Z",
"lastUpdatedAt": "2025-01-01T00:00:30Z"
}
""".trimIndent()
val result = verifyDeserialization<GetTaskResult>(McpJson, json)
result.shouldBeInstanceOf<TaskFields>()
result.taskId shouldBe "task-21"
result.status shouldBe TaskStatus.Working
result.statusMessage.shouldBeNull()
result.ttl.shouldBeNull()
result.pollInterval.shouldBeNull()
result.meta.shouldBeNull()
}
// ========================================================================
// GetTaskPayloadRequest
// ========================================================================
@Test
fun `should serialize GetTaskPayloadRequest`() {
val request = GetTaskPayloadRequest(
GetTaskPayloadRequestParams(taskId = "task-30"),
)
verifySerialization(
request,
McpJson,
"""
{
"method": "tasks/result",
"params": {
"taskId": "task-30"
}
}
""".trimIndent(),
)
}
@Test
fun `should deserialize GetTaskPayloadRequest`() {
val json = """
{
"method": "tasks/result",
"params": {
"taskId": "task-31"
}
}
""".trimIndent()
val request = verifyDeserialization<GetTaskPayloadRequest>(McpJson, json)
request.method shouldBe Method.Defined.TasksResult
request.taskId shouldBe "task-31"
request.meta.shouldBeNull()
}
// ========================================================================
// GetTaskPayloadResult
// ========================================================================
@Test
fun `should serialize GetTaskPayloadResult with meta`() {
val result = GetTaskPayloadResult(
buildJsonObject {
put("_meta", buildJsonObject { put("origin", "task-30") })
},
)
verifySerialization(
result,
McpJson,
"""
{
"_meta": {
"origin": "task-30"
}
}
""".trimIndent(),
)
}
@Test
fun `should serialize GetTaskPayloadResult without meta`() {
val result = GetTaskPayloadResult(buildJsonObject { })
verifySerialization(result, McpJson, "{}")
}
@Test
fun `should preserve arbitrary payload fields in GetTaskPayloadResult`() {
val json = """
{
"_meta": {
"origin": "task-42"
},
"content": [
{
"type": "text",
"text": "hello"
}
],
"isError": false
}
""".trimIndent()
val result = verifyDeserialization<GetTaskPayloadResult>(McpJson, json)
result.meta.shouldNotBeNull()
result.meta?.get("origin")?.jsonPrimitive?.content shouldBe "task-42"
result["content"].shouldNotBeNull()
result["isError"].shouldNotBeNull()
}
// ========================================================================
// ListTasksRequest
// ========================================================================
@Test
fun `should serialize ListTasksRequest without params`() {
val request = ListTasksRequest()
verifySerialization(
request,
McpJson,
"""
{
"method": "tasks/list"
}
""".trimIndent(),
)
}
@Test
fun `should serialize ListTasksRequest with cursor`() {
val request = ListTasksRequest(
params = PaginatedRequestParams(cursor = "page-2"),
)
verifySerialization(
request,
McpJson,
"""
{
"method": "tasks/list",
"params": {
"cursor": "page-2"
}
}
""".trimIndent(),
)
}
@Test
fun `should deserialize ListTasksRequest`() {
val json = """
{
"method": "tasks/list",
"params": {
"cursor": "next-page"
}
}
""".trimIndent()
val request = verifyDeserialization<ListTasksRequest>(McpJson, json)
request.method shouldBe Method.Defined.TasksList
request.cursor shouldBe "next-page"
}
// ========================================================================
// ListTasksResult
// ========================================================================
@Test
fun `should serialize ListTasksResult with empty list`() {
val result = ListTasksResult(tasks = emptyList())
verifySerialization(
result,
McpJson,
"""
{
"tasks": []
}
""".trimIndent(),
)
}
@Test
fun `should serialize ListTasksResult with tasks and pagination`() {
val result = ListTasksResult(
tasks = listOf(
Task(
taskId = "task-1",
status = TaskStatus.Working,
createdAt = "2025-01-01T00:00:00Z",
lastUpdatedAt = "2025-01-01T00:00:00Z",
ttl = null,
),
Task(
taskId = "task-2",
status = TaskStatus.Completed,
createdAt = "2025-01-01T00:00:00Z",
lastUpdatedAt = "2025-01-01T00:01:00Z",
ttl = 60000,
),
),
nextCursor = "cursor-abc",
)
verifySerialization(
result,
McpJson,
"""
{
"tasks": [
{
"taskId": "task-1",
"status": "working",
"createdAt": "2025-01-01T00:00:00Z",
"lastUpdatedAt": "2025-01-01T00:00:00Z"
},
{
"taskId": "task-2",
"status": "completed",
"createdAt": "2025-01-01T00:00:00Z",
"lastUpdatedAt": "2025-01-01T00:01:00Z",
"ttl": 60000
}
],
"nextCursor": "cursor-abc"
}
""".trimIndent(),
)
}
@Test
fun `should deserialize ListTasksResult`() {
val json = """
{
"tasks": [
{
"taskId": "task-99",
"status": "cancelled",
"statusMessage": "User cancelled",
"createdAt": "2025-01-01T00:00:00Z",
"lastUpdatedAt": "2025-01-01T00:03:00Z"
}
]
}
""".trimIndent()
val result = verifyDeserialization<ListTasksResult>(McpJson, json)
result.tasks.size shouldBe 1
result.tasks[0].taskId shouldBe "task-99"
result.tasks[0].status shouldBe TaskStatus.Cancelled
result.tasks[0].statusMessage shouldBe "User cancelled"
result.tasks[0].ttl.shouldBeNull()
result.nextCursor.shouldBeNull()
}
// ========================================================================
// CancelTaskRequest
// ========================================================================
@Test
fun `should serialize CancelTaskRequest`() {
val request = CancelTaskRequest(
CancelTaskRequestParams(taskId = "task-40"),
)
verifySerialization(
request,
McpJson,
"""
{
"method": "tasks/cancel",
"params": {
"taskId": "task-40"
}
}
""".trimIndent(),
)
}
@Test
fun `should deserialize CancelTaskRequest`() {
val json = """
{
"method": "tasks/cancel",
"params": {
"taskId": "task-41"
}
}
""".trimIndent()
val request = verifyDeserialization<CancelTaskRequest>(McpJson, json)
request.method shouldBe Method.Defined.TasksCancel
request.taskId shouldBe "task-41"
request.meta.shouldBeNull()
}
// ========================================================================
// CancelTaskResult
// ========================================================================
@Test
fun `should serialize CancelTaskResult`() {
val result = CancelTaskResult(
taskId = "task-40",
status = TaskStatus.Cancelled,
statusMessage = "Cancelled by user",
createdAt = "2025-01-01T00:00:00Z",
lastUpdatedAt = "2025-01-01T00:02:00Z",
ttl = null,
)
verifySerialization(
result,
McpJson,
"""
{
"taskId": "task-40",
"status": "cancelled",
"statusMessage": "Cancelled by user",
"createdAt": "2025-01-01T00:00:00Z",
"lastUpdatedAt": "2025-01-01T00:02:00Z"
}
""".trimIndent(),
)
}
@Test
fun `should deserialize CancelTaskResult and verify TaskFields contract`() {
val json = """
{
"taskId": "task-42",
"status": "cancelled",
"createdAt": "2025-01-01T00:00:00Z",
"lastUpdatedAt": "2025-01-01T00:04:00Z",
"ttl": 120000,
"pollInterval": 3000
}
""".trimIndent()
val result = verifyDeserialization<CancelTaskResult>(McpJson, json)
result.shouldBeInstanceOf<TaskFields>()
result.taskId shouldBe "task-42"
result.status shouldBe TaskStatus.Cancelled
result.statusMessage.shouldBeNull()
result.ttl shouldBe 120000L
result.pollInterval shouldBe 3000L
}
// ========================================================================
// Deserialization with unknown fields (ignoreUnknownKeys)
// ========================================================================
@Test
fun `should deserialize Task ignoring unknown fields`() {
val json = """
{
"taskId": "task-100",
"status": "working",
"createdAt": "2025-01-01T00:00:00Z",
"lastUpdatedAt": "2025-01-01T00:00:00Z",
"unknownField": "should be ignored"
}
""".trimIndent()
val task = McpJson.decodeFromString<Task>(json)
task.taskId shouldBe "task-100"
task.status shouldBe TaskStatus.Working
task.ttl.shouldBeNull()
}
// ========================================================================
// RequestMeta.relatedTask accessor
// ========================================================================
@Test
fun `should read relatedTask from RequestMeta`() {
val meta = RequestMeta(
buildJsonObject {
put(
RELATED_TASK_META_KEY,
buildJsonObject { put("taskId", "task-50") },
)
},
)
val related = meta.relatedTask
related.shouldNotBeNull()
related.taskId shouldBe "task-50"
}
@Test
fun `should return null relatedTask when key is absent`() {
val meta = RequestMeta(
buildJsonObject { put("progressToken", "pt-1") },
)
meta.relatedTask.shouldBeNull()
}
@Test
fun `GetTaskResult cast to CancelTaskResult via generic does not throw due to type erasure`() {
val result: RequestResult = GetTaskResult(
taskId = "task-1",
status = TaskStatus.Cancelled,
createdAt = "2025-01-01T00:00:00Z",
lastUpdatedAt = "2025-01-01T00:01:00Z",
ttl = null,
)
// Simulates what Protocol.request<T>() does:
// the cast goes through a generic type parameter, so JVM erases T to RequestResult
fun <T : RequestResult> uncheckedCast(value: RequestResult): T {
@Suppress("UNCHECKED_CAST")
return value as T
}
val cancelResult: CancelTaskResult = uncheckedCast(result)
cancelResult.taskId shouldBe "task-1"
cancelResult.status shouldBe TaskStatus.Cancelled
}
}