Skip to content

Commit 5664f55

Browse files
author
MAERYO
committed
refactor: extract JSON converter and add meta assertions
1 parent 8f9f484 commit 5664f55

File tree

3 files changed

+157
-76
lines changed

3 files changed

+157
-76
lines changed

kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt

Lines changed: 7 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,7 @@ import kotlinx.atomicfu.update
5050
import kotlinx.collections.immutable.minus
5151
import kotlinx.collections.immutable.persistentMapOf
5252
import kotlinx.collections.immutable.toPersistentSet
53-
import kotlinx.serialization.ExperimentalSerializationApi
54-
import kotlinx.serialization.json.JsonElement
55-
import kotlinx.serialization.json.JsonNull
5653
import kotlinx.serialization.json.JsonObject
57-
import kotlinx.serialization.json.JsonPrimitive
58-
import kotlinx.serialization.json.add
59-
import kotlinx.serialization.json.buildJsonArray
60-
import kotlinx.serialization.json.buildJsonObject
6154
import kotlin.coroutines.cancellation.CancellationException
6255

6356
private val logger = KotlinLogging.logger {}
@@ -87,6 +80,11 @@ public class ClientOptions(
8780
public open class Client(private val clientInfo: Implementation, options: ClientOptions = ClientOptions()) :
8881
Protocol(options) {
8982

83+
companion object {
84+
private val labelPattern = Regex("[a-zA-Z]([a-zA-Z0-9-]*[a-zA-Z0-9])?")
85+
private val namePattern = Regex("[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?")
86+
}
87+
9088
/**
9189
* Retrieves the server's reported capabilities after the initialization process completes.
9290
*
@@ -427,8 +425,8 @@ public open class Client(private val clientInfo: Implementation, options: Client
427425
): CallToolResultBase? {
428426
validateMetaKeys(meta.keys)
429427

430-
val jsonArguments = convertToJsonMap(arguments)
431-
val jsonMeta = convertToJsonMap(meta)
428+
val jsonArguments = JsonConverter.convertToJsonMap(arguments)
429+
val jsonMeta = JsonConverter.convertToJsonMap(meta)
432430

433431
val request = CallToolRequest(
434432
name = name,
@@ -598,9 +596,6 @@ public open class Client(private val clientInfo: Implementation, options: Client
598596
* - Name: alphanumeric start/end, may contain hyphens, underscores, dots (empty allowed)
599597
*/
600598
private fun validateMetaKeys(keys: Set<String>) {
601-
val labelPattern = Regex("[a-zA-Z]([a-zA-Z0-9-]*[a-zA-Z0-9])?")
602-
val namePattern = Regex("[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?")
603-
604599
keys.forEach { key ->
605600
require(key.isNotEmpty()) { "Meta key cannot be empty" }
606601

@@ -637,67 +632,4 @@ public open class Client(private val clientInfo: Implementation, options: Client
637632
}
638633
}
639634
}
640-
641-
private fun convertToJsonMap(map: Map<String, Any?>): Map<String, JsonElement> = map.mapValues { (key, value) ->
642-
try {
643-
convertToJsonElement(value)
644-
} catch (e: Exception) {
645-
logger.warn { "Failed to convert value for key '$key': ${e.message}. Using string representation." }
646-
JsonPrimitive(value.toString())
647-
}
648-
}
649-
650-
@OptIn(ExperimentalUnsignedTypes::class, ExperimentalSerializationApi::class)
651-
private fun convertToJsonElement(value: Any?): JsonElement = when (value) {
652-
null -> JsonNull
653-
654-
is JsonElement -> value
655-
656-
is String -> JsonPrimitive(value)
657-
658-
is Number -> JsonPrimitive(value)
659-
660-
is Boolean -> JsonPrimitive(value)
661-
662-
is Char -> JsonPrimitive(value.toString())
663-
664-
is Enum<*> -> JsonPrimitive(value.name)
665-
666-
is Map<*, *> -> buildJsonObject { value.forEach { (k, v) -> put(k.toString(), convertToJsonElement(v)) } }
667-
668-
is Collection<*> -> buildJsonArray { value.forEach { add(convertToJsonElement(it)) } }
669-
670-
is Array<*> -> buildJsonArray { value.forEach { add(convertToJsonElement(it)) } }
671-
672-
// Primitive arrays
673-
is IntArray -> buildJsonArray { value.forEach { add(it) } }
674-
675-
is LongArray -> buildJsonArray { value.forEach { add(it) } }
676-
677-
is FloatArray -> buildJsonArray { value.forEach { add(it) } }
678-
679-
is DoubleArray -> buildJsonArray { value.forEach { add(it) } }
680-
681-
is BooleanArray -> buildJsonArray { value.forEach { add(it) } }
682-
683-
is ShortArray -> buildJsonArray { value.forEach { add(it) } }
684-
685-
is ByteArray -> buildJsonArray { value.forEach { add(it) } }
686-
687-
is CharArray -> buildJsonArray { value.forEach { add(it.toString()) } }
688-
689-
// Unsigned arrays
690-
is UIntArray -> buildJsonArray { value.forEach { add(JsonPrimitive(it)) } }
691-
692-
is ULongArray -> buildJsonArray { value.forEach { add(JsonPrimitive(it)) } }
693-
694-
is UShortArray -> buildJsonArray { value.forEach { add(JsonPrimitive(it)) } }
695-
696-
is UByteArray -> buildJsonArray { value.forEach { add(JsonPrimitive(it)) } }
697-
698-
else -> {
699-
logger.debug { "Converting unknown type ${value::class} to string: $value" }
700-
JsonPrimitive(value.toString())
701-
}
702-
}
703635
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package io.modelcontextprotocol.kotlin.sdk.client
2+
3+
import io.github.oshai.kotlinlogging.KotlinLogging
4+
import kotlinx.serialization.ExperimentalSerializationApi
5+
import kotlinx.serialization.json.JsonElement
6+
import kotlinx.serialization.json.JsonNull
7+
import kotlinx.serialization.json.JsonPrimitive
8+
import kotlinx.serialization.json.add
9+
import kotlinx.serialization.json.buildJsonArray
10+
import kotlinx.serialization.json.buildJsonObject
11+
12+
private val logger = KotlinLogging.logger {}
13+
14+
/**
15+
* Utility object for converting Kotlin values to JSON elements.
16+
*/
17+
internal object JsonConverter {
18+
/**
19+
* Converts a map of values to a map of JSON elements.
20+
*
21+
* @param map The map to convert.
22+
* @return A map where each value has been converted to a JsonElement.
23+
*/
24+
fun convertToJsonMap(map: Map<String, Any?>): Map<String, JsonElement> = map.mapValues { (key, value) ->
25+
try {
26+
convertToJsonElement(value)
27+
} catch (e: Exception) {
28+
logger.warn { "Failed to convert value for key '$key': ${e.message}. Using string representation." }
29+
JsonPrimitive(value.toString())
30+
}
31+
}
32+
33+
/**
34+
* Converts a Kotlin value to a JSON element.
35+
*
36+
* Supports primitive types, collections, arrays, and nested structures.
37+
*
38+
* @param value The value to convert.
39+
* @return The corresponding JsonElement.
40+
*/
41+
@OptIn(ExperimentalUnsignedTypes::class, ExperimentalSerializationApi::class)
42+
fun convertToJsonElement(value: Any?): JsonElement = when (value) {
43+
null -> JsonNull
44+
45+
is JsonElement -> value
46+
47+
is String -> JsonPrimitive(value)
48+
49+
is Number -> JsonPrimitive(value)
50+
51+
is Boolean -> JsonPrimitive(value)
52+
53+
is Char -> JsonPrimitive(value.toString())
54+
55+
is Enum<*> -> JsonPrimitive(value.name)
56+
57+
is Map<*, *> -> buildJsonObject { value.forEach { (k, v) -> put(k.toString(), convertToJsonElement(v)) } }
58+
59+
is Collection<*> -> buildJsonArray { value.forEach { add(convertToJsonElement(it)) } }
60+
61+
is Array<*> -> buildJsonArray { value.forEach { add(convertToJsonElement(it)) } }
62+
63+
// Primitive arrays
64+
is IntArray -> buildJsonArray { value.forEach { add(it) } }
65+
66+
is LongArray -> buildJsonArray { value.forEach { add(it) } }
67+
68+
is FloatArray -> buildJsonArray { value.forEach { add(it) } }
69+
70+
is DoubleArray -> buildJsonArray { value.forEach { add(it) } }
71+
72+
is BooleanArray -> buildJsonArray { value.forEach { add(it) } }
73+
74+
is ShortArray -> buildJsonArray { value.forEach { add(it) } }
75+
76+
is ByteArray -> buildJsonArray { value.forEach { add(it) } }
77+
78+
is CharArray -> buildJsonArray { value.forEach { add(it.toString()) } }
79+
80+
// Unsigned arrays
81+
is UIntArray -> buildJsonArray { value.forEach { add(JsonPrimitive(it)) } }
82+
83+
is ULongArray -> buildJsonArray { value.forEach { add(JsonPrimitive(it)) } }
84+
85+
is UShortArray -> buildJsonArray { value.forEach { add(JsonPrimitive(it)) } }
86+
87+
is UByteArray -> buildJsonArray { value.forEach { add(JsonPrimitive(it)) } }
88+
89+
else -> {
90+
logger.debug { "Converting unknown type ${value::class} to string: $value" }
91+
JsonPrimitive(value.toString())
92+
}
93+
}
94+
}
95+

kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientMetaParameterTest.kt

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,19 @@ class ClientMetaParameterTest {
9494
}
9595

9696
assertTrue(result.isSuccess, "Edge case valid meta keys should be accepted")
97+
98+
mockTransport.lastJsonRpcRequest()?.let { request ->
99+
val params = request.params as JsonObject
100+
val metaField = params["_meta"] as JsonObject
101+
102+
// Verify all edge case meta keys are present with correct values
103+
assertEquals(edgeCaseValidMeta.size, metaField.size, "All edge case meta keys should be included")
104+
assertEquals("single-char-prefix-empty-name", metaField["a/"]?.jsonPrimitive?.content)
105+
assertEquals("alphanumeric-hyphen-prefix", metaField["a1-b2/test"]?.jsonPrimitive?.content)
106+
assertEquals("long-prefix", metaField["long.domain.name.here/config"]?.jsonPrimitive?.content)
107+
assertEquals("minimal-valid-key", metaField["x/a"]?.jsonPrimitive?.content)
108+
assertEquals("alphanumeric-name-only", metaField["test123"]?.jsonPrimitive?.content)
109+
}
97110
}
98111

99112
@Test
@@ -212,6 +225,34 @@ class ClientMetaParameterTest {
212225
assertEquals("tools/call", request.method)
213226
val params = request.params as JsonObject
214227
assertTrue(params.containsKey("_meta"), "Request should contain _meta field")
228+
229+
val metaField = params["_meta"] as JsonObject
230+
231+
// Verify string conversion
232+
assertEquals("text", metaField["string"]?.jsonPrimitive?.content)
233+
234+
// Verify number conversion
235+
assertEquals(42, metaField["number"]?.jsonPrimitive?.int)
236+
237+
// Verify boolean conversion
238+
assertEquals(true, metaField["boolean"]?.jsonPrimitive?.boolean)
239+
240+
// Verify null conversion
241+
assertTrue(metaField.containsKey("null_value"), "Should contain null_value key")
242+
243+
// Verify list conversion
244+
assertTrue(metaField.containsKey("list"), "Should contain list")
245+
246+
// Verify map conversion
247+
assertTrue(metaField.containsKey("map"), "Should contain nested map")
248+
val nestedMap = metaField["map"] as JsonObject
249+
assertEquals("value", nestedMap["nested"]?.jsonPrimitive?.content)
250+
251+
// Verify enum/string conversion
252+
assertEquals("STRING", metaField["enum"]?.jsonPrimitive?.content)
253+
254+
// Verify array conversion
255+
assertTrue(metaField.containsKey("int_array"), "Should contain int_array")
215256
}
216257
}
217258

@@ -228,7 +269,20 @@ class ClientMetaParameterTest {
228269
mockTransport.lastJsonRpcRequest()?.let { request ->
229270
val params = request.params as JsonObject
230271
val metaField = params["_meta"] as JsonObject
231-
assertTrue(metaField.containsKey("config"))
272+
assertTrue(metaField.containsKey("config"), "Should contain config key")
273+
274+
// Verify nested structure
275+
val config = metaField["config"] as JsonObject
276+
assertTrue(config.containsKey("database"), "Config should contain database")
277+
assertTrue(config.containsKey("features"), "Config should contain features")
278+
279+
// Verify database nested structure
280+
val database = config["database"] as JsonObject
281+
assertEquals("localhost", database["host"]?.jsonPrimitive?.content)
282+
assertEquals(5432, database["port"]?.jsonPrimitive?.int)
283+
284+
// Verify features list
285+
assertTrue(config.containsKey("features"), "Config should contain features list")
232286
}
233287
}
234288

0 commit comments

Comments
 (0)