From a7edff947c0d8b5c42a0fb5a1702f171486bbf9f Mon Sep 17 00:00:00 2001 From: Andrei Efimov Date: Fri, 9 Jan 2026 11:13:11 +0100 Subject: [PATCH 1/3] fix: Add McpServer serializer for JSON encoding/decoding --- acp-model/api/acp-model.api | 2 - .../com/agentclientprotocol/model/Requests.kt | 72 +++++++++++++-- .../model/McpServerSerializerTest.kt | 91 +++++++++++++++++++ 3 files changed, 155 insertions(+), 10 deletions(-) create mode 100644 acp-model/src/commonTest/kotlin/com/agentclientprotocol/model/McpServerSerializerTest.kt diff --git a/acp-model/api/acp-model.api b/acp-model/api/acp-model.api index 6e97b33..000f22a 100644 --- a/acp-model/api/acp-model.api +++ b/acp-model/api/acp-model.api @@ -1232,9 +1232,7 @@ public final class com/agentclientprotocol/model/McpCapabilities$Companion { public abstract class com/agentclientprotocol/model/McpServer { public static final field Companion Lcom/agentclientprotocol/model/McpServer$Companion; - public synthetic fun (ILkotlinx/serialization/internal/SerializationConstructorMarker;)V public abstract fun getName ()Ljava/lang/String; - public static final synthetic fun write$Self (Lcom/agentclientprotocol/model/McpServer;Lkotlinx/serialization/encoding/CompositeEncoder;Lkotlinx/serialization/descriptors/SerialDescriptor;)V } public final class com/agentclientprotocol/model/McpServer$Companion { diff --git a/acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Requests.kt b/acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Requests.kt index 8d2650a..e397b98 100644 --- a/acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Requests.kt +++ b/acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Requests.kt @@ -6,10 +6,22 @@ package com.agentclientprotocol.model import com.agentclientprotocol.annotations.UnstableApi import com.agentclientprotocol.rpc.RequestId import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.PolymorphicSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.JsonClassDiscriminator +import kotlinx.serialization.json.JsonDecoder import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject /** * Describes an available authentication method. @@ -42,6 +54,53 @@ public data class HttpHeader( override val _meta: JsonElement? = null ) : AcpWithMeta +@OptIn(ExperimentalSerializationApi::class) +internal object McpServerSerializer : KSerializer { + override val descriptor: SerialDescriptor = PolymorphicSerializer(McpServer::class).descriptor + private const val DISCRIMINATOR_KEY: String = "type" + + override fun deserialize(decoder: Decoder): McpServer { + require(decoder is JsonDecoder) { "Can be deserialized only by JSON" } + val jsonObject = decoder.decodeJsonElement().jsonObject + val type = jsonObject.discriminator()?.lowercase() + val deserializer = when (type) { + null, "stdio" -> McpServer.Stdio.serializer() + "http" -> McpServer.Http.serializer() + "sse" -> McpServer.Sse.serializer() + else -> throw SerializationException("Unknown McpServer type '$type'") + } + return decoder.json.decodeFromJsonElement(deserializer, jsonObject) + } + + override fun serialize(encoder: Encoder, value: McpServer) { + require(encoder is JsonEncoder) { "Can be serialized only by JSON" } + val contentObject = when (value) { + is McpServer.Stdio -> encoder.json.encodeToJsonElement(McpServer.Stdio.serializer(), value) + is McpServer.Http -> encoder.json.encodeToJsonElement(McpServer.Http.serializer(), value) + is McpServer.Sse -> encoder.json.encodeToJsonElement(McpServer.Sse.serializer(), value) + }.jsonObject + val type = when (value) { + is McpServer.Stdio -> null + is McpServer.Http -> "http" + is McpServer.Sse -> "sse" + } + val payload = if (contentObject.hasDiscriminator() || type == null) { + contentObject + } else { + buildJsonObject { + put(DISCRIMINATOR_KEY, JsonPrimitive(type)) + contentObject.forEach { (key, value) -> put(key, value) } + } + } + encoder.encodeJsonElement(payload) + } + + private fun JsonObject.discriminator(): String? = + (this[DISCRIMINATOR_KEY] as? JsonPrimitive)?.takeIf { it.isString }?.content + + private fun JsonObject.hasDiscriminator(): Boolean = this.containsKey(DISCRIMINATOR_KEY) +} + /** * Configuration for connecting to an MCP (Model Context Protocol) server. * @@ -50,44 +109,41 @@ public data class HttpHeader( * * See protocol docs: [MCP Servers](https://agentclientprotocol.com/protocol/session-setup#mcp-servers) */ -@Serializable +@Serializable(with = McpServerSerializer::class) public sealed class McpServer { public abstract val name: String - + /** * Stdio transport configuration * * All Agents MUST support this transport. */ @Serializable - @SerialName("stdio") public data class Stdio( override val name: String, val command: String, val args: List, val env: List ) : McpServer() - + /** * HTTP transport configuration * * Only available when the Agent capabilities indicate `mcp_capabilities.http` is `true`. */ @Serializable - @SerialName("http") public data class Http( override val name: String, val url: String, val headers: List ) : McpServer() - + /** * SSE transport configuration * * Only available when the Agent capabilities indicate `mcp_capabilities.sse` is `true`. */ @Serializable - @SerialName("sse") public data class Sse( override val name: String, val url: String, @@ -534,4 +590,4 @@ public class CancelRequestNotification( public val requestId: RequestId, public val message: String?, override val _meta: JsonElement? = null, -) : AcpNotification \ No newline at end of file +) : AcpNotification diff --git a/acp-model/src/commonTest/kotlin/com/agentclientprotocol/model/McpServerSerializerTest.kt b/acp-model/src/commonTest/kotlin/com/agentclientprotocol/model/McpServerSerializerTest.kt new file mode 100644 index 0000000..3079259 --- /dev/null +++ b/acp-model/src/commonTest/kotlin/com/agentclientprotocol/model/McpServerSerializerTest.kt @@ -0,0 +1,91 @@ +package com.agentclientprotocol.model + +import com.agentclientprotocol.rpc.ACPJson +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class McpServerSerializerTest { + @Test + fun `decodes stdio transport without discriminator`() { + val payload = """ + { + "name": "filesystem", + "command": "/path/to/mcp-server", + "args": ["--stdio"], + "env": [{"name": "TOKEN", "value": "secret"}] + } + """.trimIndent() + + val server = ACPJson.decodeFromString(McpServer.serializer(), payload) + assertTrue(server is McpServer.Stdio) + assertEquals("filesystem", server.name) + assertEquals("/path/to/mcp-server", server.command) + assertEquals(listOf("--stdio"), server.args) + assertEquals("TOKEN", server.env.first().name) + assertEquals("secret", server.env.first().value) + } + + @Test + fun `decodes http transport with type discriminator`() { + val payload = """ + { + "type": "http", + "name": "api-server", + "url": "https://api.example.com/mcp", + "headers": [{"name": "Authorization", "value": "Bearer token123"}] + } + """.trimIndent() + + val server = ACPJson.decodeFromString(McpServer.serializer(), payload) + assertTrue(server is McpServer.Http) + assertEquals("api-server", server.name) + assertEquals("https://api.example.com/mcp", server.url) + assertEquals("Authorization", server.headers.first().name) + assertEquals("Bearer token123", server.headers.first().value) + } + + @Test + fun `decodes sse transport when type is sse`() { + val payload = """ + { + "type": "sse", + "name": "event-stream", + "url": "https://events.example.com/mcp", + "headers": [{"name": "X-API-Key", "value": "apikey456"}] + } + """.trimIndent() + + val server = ACPJson.decodeFromString(McpServer.serializer(), payload) + assertTrue(server is McpServer.Sse) + assertEquals("event-stream", server.name) + assertEquals("https://events.example.com/mcp", server.url) + assertEquals("X-API-Key", server.headers.first().name) + assertEquals("apikey456", server.headers.first().value) + } + + @Test + fun `encodes discriminator when serializing`() { + val server = McpServer.Sse( + name = "event-stream", + url = "https://events.example.com/mcp", + headers = listOf(HttpHeader("X-API-Key", "apikey456")) + ) + + val encoded = ACPJson.encodeToString(McpServer.serializer(), server) + assertTrue(encoded.contains("\"type\":\"sse\"")) + } + + @Test + fun `does not encode discriminator for stdio`() { + val server = McpServer.Stdio( + name = "filesystem", + command = "/path/to/mcp-server", + args = listOf("--stdio"), + env = listOf(EnvVariable("TOKEN", "secret")) + ) + + val encoded = ACPJson.encodeToString(McpServer.serializer(), server) + assertTrue(!encoded.contains("\"type\"")) + } +} From 8215f2f5a543306e54c1b806f42306049e1dffca Mon Sep 17 00:00:00 2001 From: Andrei Efimov Date: Fri, 9 Jan 2026 11:19:59 +0100 Subject: [PATCH 2/3] fix: Update McpServer serializer to use TYPE_DISCRIMINATOR --- .../kotlin/com/agentclientprotocol/model/Requests.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Requests.kt b/acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Requests.kt index e397b98..fb7fc93 100644 --- a/acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Requests.kt +++ b/acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Requests.kt @@ -57,7 +57,6 @@ public data class HttpHeader( @OptIn(ExperimentalSerializationApi::class) internal object McpServerSerializer : KSerializer { override val descriptor: SerialDescriptor = PolymorphicSerializer(McpServer::class).descriptor - private const val DISCRIMINATOR_KEY: String = "type" override fun deserialize(decoder: Decoder): McpServer { require(decoder is JsonDecoder) { "Can be deserialized only by JSON" } @@ -88,7 +87,7 @@ internal object McpServerSerializer : KSerializer { contentObject } else { buildJsonObject { - put(DISCRIMINATOR_KEY, JsonPrimitive(type)) + put(TYPE_DISCRIMINATOR, JsonPrimitive(type)) contentObject.forEach { (key, value) -> put(key, value) } } } @@ -96,9 +95,9 @@ internal object McpServerSerializer : KSerializer { } private fun JsonObject.discriminator(): String? = - (this[DISCRIMINATOR_KEY] as? JsonPrimitive)?.takeIf { it.isString }?.content + (this[TYPE_DISCRIMINATOR] as? JsonPrimitive)?.takeIf { it.isString }?.content - private fun JsonObject.hasDiscriminator(): Boolean = this.containsKey(DISCRIMINATOR_KEY) + private fun JsonObject.hasDiscriminator(): Boolean = this.containsKey(TYPE_DISCRIMINATOR) } /** From 5305de383ef553b7f8197566e41918cd2ffd6209 Mon Sep 17 00:00:00 2001 From: Andrei Efimov Date: Mon, 12 Jan 2026 10:40:10 +0100 Subject: [PATCH 3/3] fix: Enhance McpServer serialization with polymorphic support and update tests --- acp-model/api/acp-model.api | 2 + .../com/agentclientprotocol/model/Requests.kt | 68 ++----------------- .../com/agentclientprotocol/rpc/JsonRpc.kt | 9 ++- .../model/McpServerSerializerTest.kt | 4 +- 4 files changed, 19 insertions(+), 64 deletions(-) diff --git a/acp-model/api/acp-model.api b/acp-model/api/acp-model.api index 000f22a..6e97b33 100644 --- a/acp-model/api/acp-model.api +++ b/acp-model/api/acp-model.api @@ -1232,7 +1232,9 @@ public final class com/agentclientprotocol/model/McpCapabilities$Companion { public abstract class com/agentclientprotocol/model/McpServer { public static final field Companion Lcom/agentclientprotocol/model/McpServer$Companion; + public synthetic fun (ILkotlinx/serialization/internal/SerializationConstructorMarker;)V public abstract fun getName ()Ljava/lang/String; + public static final synthetic fun write$Self (Lcom/agentclientprotocol/model/McpServer;Lkotlinx/serialization/encoding/CompositeEncoder;Lkotlinx/serialization/descriptors/SerialDescriptor;)V } public final class com/agentclientprotocol/model/McpServer$Companion { diff --git a/acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Requests.kt b/acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Requests.kt index fb7fc93..5339ac8 100644 --- a/acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Requests.kt +++ b/acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Requests.kt @@ -6,22 +6,11 @@ package com.agentclientprotocol.model import com.agentclientprotocol.annotations.UnstableApi import com.agentclientprotocol.rpc.RequestId import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.KSerializer -import kotlinx.serialization.PolymorphicSerializer +import kotlinx.serialization.Polymorphic import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.SerializationException -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.JsonClassDiscriminator -import kotlinx.serialization.json.JsonDecoder import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonEncoder -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.jsonObject /** * Describes an available authentication method. @@ -54,52 +43,6 @@ public data class HttpHeader( override val _meta: JsonElement? = null ) : AcpWithMeta -@OptIn(ExperimentalSerializationApi::class) -internal object McpServerSerializer : KSerializer { - override val descriptor: SerialDescriptor = PolymorphicSerializer(McpServer::class).descriptor - - override fun deserialize(decoder: Decoder): McpServer { - require(decoder is JsonDecoder) { "Can be deserialized only by JSON" } - val jsonObject = decoder.decodeJsonElement().jsonObject - val type = jsonObject.discriminator()?.lowercase() - val deserializer = when (type) { - null, "stdio" -> McpServer.Stdio.serializer() - "http" -> McpServer.Http.serializer() - "sse" -> McpServer.Sse.serializer() - else -> throw SerializationException("Unknown McpServer type '$type'") - } - return decoder.json.decodeFromJsonElement(deserializer, jsonObject) - } - - override fun serialize(encoder: Encoder, value: McpServer) { - require(encoder is JsonEncoder) { "Can be serialized only by JSON" } - val contentObject = when (value) { - is McpServer.Stdio -> encoder.json.encodeToJsonElement(McpServer.Stdio.serializer(), value) - is McpServer.Http -> encoder.json.encodeToJsonElement(McpServer.Http.serializer(), value) - is McpServer.Sse -> encoder.json.encodeToJsonElement(McpServer.Sse.serializer(), value) - }.jsonObject - val type = when (value) { - is McpServer.Stdio -> null - is McpServer.Http -> "http" - is McpServer.Sse -> "sse" - } - val payload = if (contentObject.hasDiscriminator() || type == null) { - contentObject - } else { - buildJsonObject { - put(TYPE_DISCRIMINATOR, JsonPrimitive(type)) - contentObject.forEach { (key, value) -> put(key, value) } - } - } - encoder.encodeJsonElement(payload) - } - - private fun JsonObject.discriminator(): String? = - (this[TYPE_DISCRIMINATOR] as? JsonPrimitive)?.takeIf { it.isString }?.content - - private fun JsonObject.hasDiscriminator(): Boolean = this.containsKey(TYPE_DISCRIMINATOR) -} - /** * Configuration for connecting to an MCP (Model Context Protocol) server. * @@ -108,7 +51,7 @@ internal object McpServerSerializer : KSerializer { * * See protocol docs: [MCP Servers](https://agentclientprotocol.com/protocol/session-setup#mcp-servers) */ -@Serializable(with = McpServerSerializer::class) +@Serializable public sealed class McpServer { public abstract val name: String @@ -118,6 +61,7 @@ public sealed class McpServer { * All Agents MUST support this transport. */ @Serializable + @SerialName("stdio") public data class Stdio( override val name: String, val command: String, @@ -131,6 +75,7 @@ public sealed class McpServer { * Only available when the Agent capabilities indicate `mcp_capabilities.http` is `true`. */ @Serializable + @SerialName("http") public data class Http( override val name: String, val url: String, @@ -143,6 +88,7 @@ public sealed class McpServer { * Only available when the Agent capabilities indicate `mcp_capabilities.sse` is `true`. */ @Serializable + @SerialName("sse") public data class Sse( override val name: String, val url: String, @@ -247,7 +193,7 @@ public data class AuthenticateRequest( @Serializable public data class NewSessionRequest( val cwd: String, - val mcpServers: List, + val mcpServers: List<@Polymorphic McpServer>, override val _meta: JsonElement? = null ) : AcpRequest @@ -262,7 +208,7 @@ public data class NewSessionRequest( public data class LoadSessionRequest( override val sessionId: SessionId, val cwd: String, - val mcpServers: List, + val mcpServers: List<@Polymorphic McpServer>, override val _meta: JsonElement? = null ) : AcpRequest, AcpWithSessionId diff --git a/acp-model/src/commonMain/kotlin/com/agentclientprotocol/rpc/JsonRpc.kt b/acp-model/src/commonMain/kotlin/com/agentclientprotocol/rpc/JsonRpc.kt index 24d0755..527c105 100644 --- a/acp-model/src/commonMain/kotlin/com/agentclientprotocol/rpc/JsonRpc.kt +++ b/acp-model/src/commonMain/kotlin/com/agentclientprotocol/rpc/JsonRpc.kt @@ -3,6 +3,7 @@ package com.agentclientprotocol.rpc import com.agentclientprotocol.model.AvailableCommandInput +import com.agentclientprotocol.model.McpServer import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable @@ -178,6 +179,12 @@ private val acpSerializersModule = SerializersModule { subclass(AvailableCommandInput.Unstructured::class, AvailableCommandInput.Unstructured.serializer()) defaultDeserializer { AvailableCommandInput.Unstructured.serializer() } } + polymorphic(McpServer::class) { + subclass(McpServer.Stdio::class, McpServer.Stdio.serializer()) + subclass(McpServer.Http::class, McpServer.Http.serializer()) + subclass(McpServer.Sse::class, McpServer.Sse.serializer()) + defaultDeserializer { McpServer.Stdio.serializer() } + } } @OptIn(ExperimentalSerializationApi::class) @@ -223,4 +230,4 @@ public fun decodeJsonRpcMessage(jsonString: String): JsonRpcMessage { hasMethod -> ACPJson.decodeFromJsonElement(JsonRpcNotification.serializer(), element) else -> error("Unable to determine JsonRpcMessage type from JSON structure") } -} \ No newline at end of file +} diff --git a/acp-model/src/commonTest/kotlin/com/agentclientprotocol/model/McpServerSerializerTest.kt b/acp-model/src/commonTest/kotlin/com/agentclientprotocol/model/McpServerSerializerTest.kt index 3079259..71fca11 100644 --- a/acp-model/src/commonTest/kotlin/com/agentclientprotocol/model/McpServerSerializerTest.kt +++ b/acp-model/src/commonTest/kotlin/com/agentclientprotocol/model/McpServerSerializerTest.kt @@ -77,7 +77,7 @@ class McpServerSerializerTest { } @Test - fun `does not encode discriminator for stdio`() { + fun `encodes stdio discriminator when serializing`() { val server = McpServer.Stdio( name = "filesystem", command = "/path/to/mcp-server", @@ -86,6 +86,6 @@ class McpServerSerializerTest { ) val encoded = ACPJson.encodeToString(McpServer.serializer(), server) - assertTrue(!encoded.contains("\"type\"")) + assertTrue(encoded.contains("\"type\":\"stdio\"")) } }