Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package io.modelcontextprotocol.kotlin.sdk.types

import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KeepGeneratedSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
Expand Down Expand Up @@ -413,7 +414,8 @@ public data class TaskStatusNotification(override val params: TaskStatusNotifica
* @property pollInterval Suggested polling interval in milliseconds.
* @property meta Optional metadata for this notification.
*/
@Serializable
@KeepGeneratedSerializer
@Serializable(with = TaskStatusNotificationParamsSerializer::class)
public data class TaskStatusNotificationParams(
override val taskId: String,
override val status: TaskStatus,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ import kotlinx.serialization.json.JsonContentPolymorphicSerializer
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonEncoder
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.JsonTransformingSerializer
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
Expand Down Expand Up @@ -235,6 +237,29 @@ internal object ResourceContentsPolymorphicSerializer :
}
}

// ============================================================================
// Task Serializers
// ============================================================================

/**
* Writes `"ttl": null` for a task with unlimited retention: the schema requires the key,
* but [McpJson] drops null properties before a property-level serializer could write it.
*/
internal open class TaskFieldsSerializer<T : TaskFields>(serializer: KSerializer<T>) :
JsonTransformingSerializer<T>(serializer) {
override fun transformSerialize(element: JsonElement): JsonElement {
val fields = element.jsonObject
return if ("ttl" in fields) fields else JsonObject(fields + ("ttl" to JsonNull))
}
}

internal object TaskSerializer : TaskFieldsSerializer<Task>(Task.generatedSerializer())

internal object GetTaskResultSerializer : TaskFieldsSerializer<GetTaskResult>(GetTaskResult.generatedSerializer())

internal object TaskStatusNotificationParamsSerializer :
TaskFieldsSerializer<TaskStatusNotificationParams>(TaskStatusNotificationParams.generatedSerializer())

// ============================================================================
// Request Serializers
// ============================================================================
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package io.modelcontextprotocol.kotlin.sdk.types

import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KeepGeneratedSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
Expand Down Expand Up @@ -54,7 +55,8 @@ public sealed interface TaskFields {
* @property ttl Actual retention duration from creation in milliseconds, null for unlimited.
* @property pollInterval Suggested polling interval in milliseconds.
*/
@Serializable
@KeepGeneratedSerializer
@Serializable(with = TaskSerializer::class)
public data class Task(
override val taskId: String,
override val status: TaskStatus,
Expand Down Expand Up @@ -176,7 +178,8 @@ public data class GetTaskRequestParams(
* @property pollInterval Suggested polling interval in milliseconds.
* @property meta Optional metadata for this response.
*/
@Serializable
@KeepGeneratedSerializer
@Serializable(with = GetTaskResultSerializer::class)
public data class GetTaskResult(
override val taskId: String,
override val status: TaskStatus,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.modelcontextprotocol.kotlin.sdk.types

import io.kotest.assertions.json.shouldEqualJson
import io.kotest.assertions.withClue
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
Expand Down Expand Up @@ -240,13 +241,41 @@ class NotificationTest {
"taskId": "task-2",
"status": "completed",
"createdAt": "2025-01-01T00:00:00Z",
"lastUpdatedAt": "2025-01-01T00:02:00Z"
"lastUpdatedAt": "2025-01-01T00:02:00Z",
"ttl": null
}
}
""".trimIndent(),
)
}

@Test
fun `should keep null ttl when converting TaskStatusNotification to JSONRPCNotification`() {
val notification = TaskStatusNotification(
TaskStatusNotificationParams(
taskId = "task-3",
status = TaskStatus.Working,
createdAt = "2025-01-01T00:00:00Z",
lastUpdatedAt = "2025-01-01T00:00:00Z",
ttl = null,
),
)

McpJson.encodeToString(notification.toJSON()) shouldEqualJson """
{
"jsonrpc": "2.0",
"method": "notifications/tasks/status",
"params": {
"taskId": "task-3",
"status": "working",
"createdAt": "2025-01-01T00:00:00Z",
"lastUpdatedAt": "2025-01-01T00:00:00Z",
"ttl": null
}
}
""".trimIndent()
}

@Test
fun `should deserialize TaskStatusNotification without params`() {
val json = """
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,45 @@ class TasksTest {
verifySerialization<ServerResult>(result, McpJson, json)
}

@Test
fun `should serialize GetTaskResult with minimal fields`() {
val result = GetTaskResult(
taskId = "task-21",
status = TaskStatus.Working,
createdAt = "2025-01-01T00:00:00Z",
lastUpdatedAt = "2025-01-01T00:00:00Z",
ttl = null,
)
val json = """
{
"taskId": "task-21",
"status": "working",
"createdAt": "2025-01-01T00:00:00Z",
"lastUpdatedAt": "2025-01-01T00:00:00Z",
"ttl": null
}
""".trimIndent()

verifySerialization<ClientResult>(result, McpJson, json)
verifySerialization<ServerResult>(result, McpJson, json)
}

@Test
fun `should decode Task without ttl as unlimited`() {
val task = McpJson.decodeFromString<Task>(
"""
{
"taskId": "task-3",
"status": "working",
"createdAt": "2025-01-01T00:00:00Z",
"lastUpdatedAt": "2025-01-01T00:00:00Z"
}
""".trimIndent(),
)

task.ttl.shouldBeNull()
}

@Test
fun `should preserve arbitrary payload fields in GetTaskPayloadResult`() {
val json = """
Expand Down Expand Up @@ -189,7 +228,8 @@ class TasksTest {
"taskId": "task-1",
"status": "working",
"createdAt": "2025-01-01T00:00:00Z",
"lastUpdatedAt": "2025-01-01T00:00:00Z"
"lastUpdatedAt": "2025-01-01T00:00:00Z",
"ttl": null
},
{
"taskId": "task-2",
Expand Down
Loading