diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/notification.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/notification.kt index 3d33c2132..fe29fea4f 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/notification.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/notification.kt @@ -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 @@ -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, diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/serializers.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/serializers.kt index 36b7e372d..6f0de2af6 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/serializers.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/serializers.kt @@ -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 @@ -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(serializer: KSerializer) : + JsonTransformingSerializer(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.generatedSerializer()) + +internal object GetTaskResultSerializer : TaskFieldsSerializer(GetTaskResult.generatedSerializer()) + +internal object TaskStatusNotificationParamsSerializer : + TaskFieldsSerializer(TaskStatusNotificationParams.generatedSerializer()) + // ============================================================================ // Request Serializers // ============================================================================ diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/tasks.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/tasks.kt index 7363561ae..a5b19a3b8 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/tasks.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/tasks.kt @@ -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 @@ -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, @@ -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, diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/NotificationTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/NotificationTest.kt index d8a217a92..f72291efa 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/NotificationTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/NotificationTest.kt @@ -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 @@ -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 = """ diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/TasksTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/TasksTest.kt index 2a4723c7e..6e96de951 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/TasksTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/TasksTest.kt @@ -137,6 +137,45 @@ class TasksTest { verifySerialization(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(result, McpJson, json) + verifySerialization(result, McpJson, json) + } + + @Test + fun `should decode Task without ttl as unlimited`() { + val task = McpJson.decodeFromString( + """ + { + "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 = """ @@ -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",