From ad37e5d34afdf9ef4f12615e1e37f8e6390c1271 Mon Sep 17 00:00:00 2001 From: MAERYO Date: Wed, 24 Sep 2025 21:09:15 +0900 Subject: [PATCH 1/8] feat: Add meta parameter support to callTool method --- .../kotlin/sdk/client/Client.kt | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt index 95a5bc5b..18a6968a 100644 --- a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt +++ b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt @@ -417,23 +417,17 @@ public open class Client(private val clientInfo: Implementation, options: Client public suspend fun callTool( name: String, arguments: Map, + meta: Map = emptyMap(), compatibility: Boolean = false, options: RequestOptions? = null, ): CallToolResultBase? { - val jsonArguments = arguments.mapValues { (_, value) -> - when (value) { - is String -> JsonPrimitive(value) - is Number -> JsonPrimitive(value) - is Boolean -> JsonPrimitive(value) - is JsonElement -> value - null -> JsonNull - else -> JsonPrimitive(value.toString()) - } - } + val jsonArguments = convertToJsonMap(arguments) + val jsonMeta = convertToJsonMap(meta) val request = CallToolRequest( name = name, arguments = JsonObject(jsonArguments), + _meta = JsonObject(jsonMeta), ) return callTool(request, compatibility, options) } @@ -588,4 +582,16 @@ public open class Client(private val clientInfo: Implementation, options: Client val rootList = roots.value.values.toList() return ListRootsResult(rootList) } + + private fun convertToJsonMap(map: Map): Map = + map.mapValues { (_, value) -> + when (value) { + is String -> JsonPrimitive(value) + is Number -> JsonPrimitive(value) + is Boolean -> JsonPrimitive(value) + is JsonElement -> value + null -> JsonNull + else -> JsonPrimitive(value.toString()) + } + } } From e6e5d54fc4dd55c4ce0880b0d4768ef1163eb0f9 Mon Sep 17 00:00:00 2001 From: MAERYO Date: Wed, 24 Sep 2025 21:39:34 +0900 Subject: [PATCH 2/8] feat: implement complete _meta support with MCP specification validation --- .../kotlin/sdk/client/Client.kt | 136 ++++++++++++++++-- 1 file changed, 127 insertions(+), 9 deletions(-) diff --git a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt index 18a6968a..a8f4e735 100644 --- a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt +++ b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt @@ -50,6 +50,8 @@ import kotlinx.atomicfu.update import kotlinx.collections.immutable.minus import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.toPersistentSet +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject @@ -405,10 +407,14 @@ public open class Client(private val clientInfo: Implementation, options: Client ): EmptyRequestResult = request(request, options) /** - * Calls a tool on the server by name, passing the specified arguments. + * Calls a tool on the server by name, passing the specified arguments and metadata. * * @param name The name of the tool to call. * @param arguments A map of argument names to values for the tool. + * @param meta A map of metadata key-value pairs. Keys must follow MCP specification format. + * - Optional prefix: dot-separated labels followed by slash (e.g., "api.example.com/") + * - Name: alphanumeric start/end, may contain hyphens, underscores, dots, alphanumerics + * - Reserved prefixes starting with "mcp" or "modelcontextprotocol" are forbidden * @param compatibility Whether to use compatibility mode for older protocol versions. * @param options Optional request options. * @return The result of the tool call, or `null` if none. @@ -421,6 +427,8 @@ public open class Client(private val clientInfo: Implementation, options: Client compatibility: Boolean = false, options: RequestOptions? = null, ): CallToolResultBase? { + validateMetaKeys(meta.keys) + val jsonArguments = convertToJsonMap(arguments) val jsonMeta = convertToJsonMap(meta) @@ -583,15 +591,125 @@ public open class Client(private val clientInfo: Implementation, options: Client return ListRootsResult(rootList) } + /** + * Validates meta keys according to MCP specification. + * + * Key format: [prefix/]name + * - Prefix (optional): dot-separated labels + slash + * - Labels: start with letter, end with letter/digit, contain letters/digits/hyphens + * - Reserved prefixes: those starting with "mcp" or "modelcontextprotocol" + * - Name: alphanumeric start/end, may contain hyphens, underscores, dots, alphanumerics + */ + private fun validateMetaKeys(keys: Set) { + for (key in keys) { + if (!isValidMetaKey(key)) { + throw Error( + "Invalid _meta key '$key'. Keys must follow MCP specification format: " + + "[prefix/]name where prefix is dot-separated labels and name is alphanumeric with allowed separators." + ) + } + } + } + + private fun isValidMetaKey(key: String): Boolean { + if (key.isEmpty()) return false + val parts = key.split('/', limit = 2) + return when (parts.size) { + 1 -> { + // No prefix, just validate name + isValidMetaName(parts[0]) + } + 2 -> { + val (prefix, name) = parts + isValidMetaPrefix(prefix) && isValidMetaName(name) + } + else -> false + } + } + + private fun isValidMetaPrefix(prefix: String): Boolean { + if (prefix.isEmpty()) return false + + // Check for reserved prefixes + val labels = prefix.split('.') + if (labels.isNotEmpty()) { + val firstLabel = labels[0].lowercase() + if (firstLabel == "mcp" || firstLabel == "modelcontextprotocol") { + return false + } + + // Check for reserved patterns like "*.mcp.*" or "*.modelcontextprotocol.*" + for (label in labels) { + val lowerLabel = label.lowercase() + if (lowerLabel == "mcp" || lowerLabel == "modelcontextprotocol") { + return false + } + } + } + return labels.all { isValidLabel(it) } + } + + private fun isValidLabel(label: String): Boolean { + if (label.isEmpty()) return false + if (!label.first().isLetter() || !label.last().let { it.isLetter() || it.isDigit() }) { + return false + } + return label.all { it.isLetter() || it.isDigit() || it == '-' } + } + + private fun isValidMetaName(name: String): Boolean { + if (name.isEmpty()) return false + if (!name.first().isLetterOrDigit() || !name.last().isLetterOrDigit()) { + return false + } + return name.all { it.isLetterOrDigit() || it in setOf('-', '_', '.') } + } + private fun convertToJsonMap(map: Map): Map = - map.mapValues { (_, value) -> - when (value) { - is String -> JsonPrimitive(value) - is Number -> JsonPrimitive(value) - is Boolean -> JsonPrimitive(value) - is JsonElement -> value - null -> JsonNull - else -> JsonPrimitive(value.toString()) + map.mapValues { (key, value) -> + try { + convertToJsonElement(value) + } catch (e: Exception) { + logger.warn { "Failed to convert value for key '$key': ${e.message}. Using string representation." } + JsonPrimitive(value.toString()) + } + } + + @OptIn(ExperimentalUnsignedTypes::class, ExperimentalSerializationApi::class) + private fun convertToJsonElement(value: Any?): JsonElement = when (value) { + null -> JsonNull + is Map<*, *> -> { + val jsonMap = value.entries.associate { (k, v) -> + k.toString() to convertToJsonElement(v) } + JsonObject(jsonMap) } + is JsonElement -> value + is String -> JsonPrimitive(value) + is Number -> JsonPrimitive(value) + is Boolean -> JsonPrimitive(value) + is Char -> JsonPrimitive(value.toString()) + is Enum<*> -> JsonPrimitive(value.name) + is Collection<*> -> JsonArray(value.map { convertToJsonElement(it) }) + is Array<*> -> JsonArray(value.map { convertToJsonElement(it) }) + is IntArray -> JsonArray(value.map { JsonPrimitive(it) }) + is LongArray -> JsonArray(value.map { JsonPrimitive(it) }) + is FloatArray -> JsonArray(value.map { JsonPrimitive(it) }) + is DoubleArray -> JsonArray(value.map { JsonPrimitive(it) }) + is BooleanArray -> JsonArray(value.map { JsonPrimitive(it) }) + is ShortArray -> JsonArray(value.map { JsonPrimitive(it) }) + is ByteArray -> JsonArray(value.map { JsonPrimitive(it) }) + is CharArray -> JsonArray(value.map { JsonPrimitive(it.toString()) }) + + // ExperimentalUnsignedTypes + is UIntArray -> JsonArray(value.map { JsonPrimitive(it) }) + is ULongArray -> JsonArray(value.map { JsonPrimitive(it) }) + is UShortArray -> JsonArray(value.map { JsonPrimitive(it) }) + is UByteArray -> JsonArray(value.map { JsonPrimitive(it) }) + + else -> { + logger.debug { "Converting unknown type ${value::class.simpleName} to string: $value" } + JsonPrimitive(value.toString()) + } + } } From 135ffd79421544363e9560ee79bc1a63946a8f76 Mon Sep 17 00:00:00 2001 From: M A E R Y O Date: Thu, 25 Sep 2025 10:58:13 +0900 Subject: [PATCH 3/8] fix: correct _meta key validation according to MCP spec --- .../kotlin/sdk/client/Client.kt | 36 +++++++------------ 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt index a8f4e735..b5a5ddfb 100644 --- a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt +++ b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt @@ -596,17 +596,13 @@ public open class Client(private val clientInfo: Implementation, options: Client * * Key format: [prefix/]name * - Prefix (optional): dot-separated labels + slash - * - Labels: start with letter, end with letter/digit, contain letters/digits/hyphens - * - Reserved prefixes: those starting with "mcp" or "modelcontextprotocol" - * - Name: alphanumeric start/end, may contain hyphens, underscores, dots, alphanumerics + * - Reserved prefixes contain "modelcontextprotocol" or "mcp" as complete labels + * - Name: alphanumeric start/end, may contain hyphens, underscores, dots (empty allowed) */ private fun validateMetaKeys(keys: Set) { for (key in keys) { if (!isValidMetaKey(key)) { - throw Error( - "Invalid _meta key '$key'. Keys must follow MCP specification format: " + - "[prefix/]name where prefix is dot-separated labels and name is alphanumeric with allowed separators." - ) + throw Error("Invalid _meta key '$key'. Must follow format [prefix/]name with valid labels.") } } } @@ -629,24 +625,16 @@ public open class Client(private val clientInfo: Implementation, options: Client private fun isValidMetaPrefix(prefix: String): Boolean { if (prefix.isEmpty()) return false - - // Check for reserved prefixes val labels = prefix.split('.') - if (labels.isNotEmpty()) { - val firstLabel = labels[0].lowercase() - if (firstLabel == "mcp" || firstLabel == "modelcontextprotocol") { - return false - } - // Check for reserved patterns like "*.mcp.*" or "*.modelcontextprotocol.*" - for (label in labels) { - val lowerLabel = label.lowercase() - if (lowerLabel == "mcp" || lowerLabel == "modelcontextprotocol") { - return false - } - } + if (!labels.all { isValidLabel(it) }) { + return false + } + + return !labels.any { label -> + label.equals("modelcontextprotocol", ignoreCase = true) || + label.equals("mcp", ignoreCase = true) } - return labels.all { isValidLabel(it) } } private fun isValidLabel(label: String): Boolean { @@ -658,7 +646,9 @@ public open class Client(private val clientInfo: Implementation, options: Client } private fun isValidMetaName(name: String): Boolean { - if (name.isEmpty()) return false + // Empty names are allowed per MCP specification + if (name.isEmpty()) return true + if (!name.first().isLetterOrDigit() || !name.last().isLetterOrDigit()) { return false } From b9b3ba28f618eea9474a723735f7299581b7dada Mon Sep 17 00:00:00 2001 From: MAERYO Date: Mon, 29 Sep 2025 19:44:50 +0900 Subject: [PATCH 4/8] test: add comprehensive meta parameter tests for callTool --- .../sdk/client/ClientMetaParameterTest.kt | 270 ++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientMetaParameterTest.kt diff --git a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientMetaParameterTest.kt b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientMetaParameterTest.kt new file mode 100644 index 00000000..9600fd8c --- /dev/null +++ b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientMetaParameterTest.kt @@ -0,0 +1,270 @@ +package io.modelcontextprotocol.kotlin.sdk.client + +import io.modelcontextprotocol.kotlin.sdk.Implementation +import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage +import io.modelcontextprotocol.kotlin.sdk.JSONRPCRequest +import io.modelcontextprotocol.kotlin.sdk.shared.Transport +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.* +import kotlin.test.* + +/** + * Comprehensive test suite for MCP Client meta parameter functionality + * + * Tests cover: + * - Meta key validation according to MCP specification + * - JSON type conversion for various data types + * - Error handling for invalid meta keys + * - Integration with callTool method + */ +class ClientMetaParameterTest { + + private lateinit var client: Client + private lateinit var mockTransport: MockTransport + private val clientInfo = Implementation("test-client", "1.0.0") + + @BeforeTest + fun setup() { + mockTransport = MockTransport() + client = Client(clientInfo = clientInfo) + } + + @Test + fun `should accept valid meta keys without throwing exception`() = runTest { + val validMeta = buildMap { + put("simple-key", "value1") + put("api.example.com/version", "1.0") + put("com.company.app/setting", "enabled") + put("retry_count", 3) + put("user.preference", true) + // Additional edge cases for valid keys + put("", "empty-name-allowed") // Empty name is allowed per MCP spec + put("valid123", "alphanumeric") + put("multi.dot.name", "multiple-dots") + put("under_score", "underscore") + put("hyphen-dash", "hyphen") + put("org.apache.kafka/consumer-config", "complex-valid-prefix") + } + + val result = runCatching { + client.callTool("test-tool", mapOf("arg" to "value"), validMeta) + } + + assertTrue(result.isSuccess, "Valid meta keys should not cause exceptions") + } + + @Test + fun `should accept edge case valid prefixes and names`() = runTest { + val edgeCaseValidMeta = buildMap { + put("a/", "single-char-prefix-empty-name") + put("a1-b2/test", "alphanumeric-hyphen-prefix") + put("long.domain.name.here/config", "long-prefix") + put("x/a", "minimal-valid-key") + put("test123", "alphanumeric-name-only") + } + + val result = runCatching { + client.callTool("test-tool", emptyMap(), edgeCaseValidMeta) + } + + assertTrue(result.isSuccess, "Edge case valid meta keys should be accepted") + } + + @Test + fun `should reject mcp reserved prefix`() = runTest { + val invalidMeta = mapOf("mcp/internal" to "value") + + val exception = assertFailsWith { + client.callTool("test-tool", emptyMap(), invalidMeta) + } + + assertContains(exception.message ?: "", "Invalid _meta key") + } + + @Test + fun `should reject modelcontextprotocol reserved prefix`() = runTest { + val invalidMeta = mapOf("modelcontextprotocol/config" to "value") + + val exception = assertFailsWith { + client.callTool("test-tool", emptyMap(), invalidMeta) + } + + assertContains(exception.message ?: "", "Invalid _meta key") + } + + @Test + fun `should reject nested reserved prefixes`() = runTest { + val invalidKeys = listOf( + "api.mcp.io/setting", + "com.modelcontextprotocol.test/value", + "example.mcp/data", + "subdomain.mcp.com/config", + "app.modelcontextprotocol.dev/setting", + "test.mcp/value", + "service.modelcontextprotocol/data" + ) + + invalidKeys.forEach { key -> + val exception = assertFailsWith( + message = "Should reject nested reserved key: $key" + ) { + client.callTool("test-tool", emptyMap(), mapOf(key to "value")) + } + assertContains( + charSequence = exception.message ?: "", + other = "Invalid _meta key" + ) + } + } + + @Test + fun `should reject case-insensitive reserved prefixes`() = runTest { + val invalidKeys = listOf( + "MCP/internal", + "Mcp/config", + "mCp/setting", + "MODELCONTEXTPROTOCOL/data", + "ModelContextProtocol/value", + "modelContextProtocol/test" + ) + + invalidKeys.forEach { key -> + val exception = assertFailsWith( + message = "Should reject case-insensitive reserved key: $key" + ) { + client.callTool("test-tool", emptyMap(), mapOf(key to "value")) + } + assertContains( + charSequence = exception.message ?: "", + other = "Invalid _meta key" + ) + } + } + + @Test + fun `should reject invalid key formats`() = runTest { + val invalidKeys = listOf( + "", // empty key + "/invalid", // starts with slash + "invalid/", // ends with slash + "-invalid", // starts with hyphen + ".invalid", // starts with dot + "in valid", // contains space + "api../test", // consecutive dots + "api./test" // label ends with dot + ) + + invalidKeys.forEach { key -> + val exception = assertFailsWith( + message = "Should reject invalid key format: '$key'" + ) { + client.callTool("test-tool", emptyMap(), mapOf(key to "value")) + } + assertContains( + charSequence = exception.message ?: "", + other = "Invalid _meta key" + ) + } + } + + @Test + fun `should convert various data types to JSON correctly`() = runTest { + val complexMeta = createComplexMetaData() + + val result = runCatching { + client.callTool("test-tool", emptyMap(), complexMeta) + } + + assertTrue(result.isSuccess, "Complex data type conversion should not throw exceptions") + + mockTransport.lastJsonRpcRequest?.let { request -> + assertEquals("tools/call", request.method) + val params = request.params as JsonObject + assertTrue(params.containsKey("_meta"), "Request should contain _meta field") + } + } + + @Test + fun `should handle nested map structures correctly`() = runTest { + val nestedMeta = buildNestedConfiguration() + + val result = runCatching { + client.callTool("test-tool", emptyMap(), nestedMeta) + } + + assertTrue(result.isSuccess) + + mockTransport.lastJsonRpcRequest?.let { request -> + val params = request.params as JsonObject + val metaField = params["_meta"] as JsonObject + assertTrue(metaField.containsKey("config")) + } + } + + @Test + fun `should include empty meta object when meta parameter not provided`() = runTest { + client.callTool("test-tool", mapOf("arg" to "value")) + + mockTransport.lastJsonRpcRequest?.let { request -> + val params = request.params as JsonObject + val metaField = params["_meta"] as JsonObject + assertTrue(metaField.isEmpty(), "Meta field should be empty when not provided") + } + } + + private fun createComplexMetaData(): Map = buildMap { + put("string", "text") + put("number", 42) + put("boolean", true) + put("null_value", null) + put("list", listOf(1, 2, 3)) + put("map", mapOf("nested" to "value")) + put("enum", "STRING") + put("int_array", intArrayOf(1, 2, 3)) + } + + private fun buildNestedConfiguration(): Map = buildMap { + put("config", buildMap { + put("database", buildMap { + put("host", "localhost") + put("port", 5432) + }) + put("features", listOf("feature1", "feature2")) + }) + } +} + +class MockTransport : Transport { + private val _sentMessages = mutableListOf() + val sentMessages: List = _sentMessages + + private var onMessageBlock: (suspend (JSONRPCMessage) -> Unit)? = null + private var onCloseBlock: (() -> Unit)? = null + private var onErrorBlock: ((Throwable) -> Unit)? = null + + override suspend fun start() = Unit + + override suspend fun send(message: JSONRPCMessage) { + _sentMessages += message + onMessageBlock?.invoke(message) + } + + override suspend fun close() { + onCloseBlock?.invoke() + } + + override fun onMessage(block: suspend (JSONRPCMessage) -> Unit) { + onMessageBlock = block + } + + override fun onClose(block: () -> Unit) { + onCloseBlock = block + } + + override fun onError(block: (Throwable) -> Unit) { + onErrorBlock = block + } +} + +val MockTransport.lastJsonRpcRequest: JSONRPCRequest? + get() = sentMessages.lastOrNull() as? JSONRPCRequest From 3f22a9a0ec5a955b8a74501020d811bc43561773 Mon Sep 17 00:00:00 2001 From: MAERYO Date: Mon, 29 Sep 2025 22:30:45 +0900 Subject: [PATCH 5/8] feat: Add comprehensive meta parameter support to MCP client - Add meta parameter support to callTool method with validation - Implement MCP-compliant meta key validation (reserved prefixes, format rules) - Enhance JSON conversion for complex data types with better error handling - Add comprehensive test suite for meta parameter functionality - Improve MockTransport to simulate proper initialization flow - Update API signatures to include meta parameter support --- kotlin-sdk-client/api/kotlin-sdk-client.api | 4 +- .../kotlin/sdk/client/Client.kt | 39 ++++-- .../sdk/client/ClientMetaParameterTest.kt | 119 +++++++++++++----- 3 files changed, 123 insertions(+), 39 deletions(-) diff --git a/kotlin-sdk-client/api/kotlin-sdk-client.api b/kotlin-sdk-client/api/kotlin-sdk-client.api index a785a916..46a7b60d 100644 --- a/kotlin-sdk-client/api/kotlin-sdk-client.api +++ b/kotlin-sdk-client/api/kotlin-sdk-client.api @@ -8,9 +8,9 @@ public class io/modelcontextprotocol/kotlin/sdk/client/Client : io/modelcontextp protected fun assertNotificationCapability (Lio/modelcontextprotocol/kotlin/sdk/Method;)V public fun assertRequestHandlerCapability (Lio/modelcontextprotocol/kotlin/sdk/Method;)V public final fun callTool (Lio/modelcontextprotocol/kotlin/sdk/CallToolRequest;ZLio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public final fun callTool (Ljava/lang/String;Ljava/util/Map;ZLio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun callTool (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ZLio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun callTool$default (Lio/modelcontextprotocol/kotlin/sdk/client/Client;Lio/modelcontextprotocol/kotlin/sdk/CallToolRequest;ZLio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; - public static synthetic fun callTool$default (Lio/modelcontextprotocol/kotlin/sdk/client/Client;Ljava/lang/String;Ljava/util/Map;ZLio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static synthetic fun callTool$default (Lio/modelcontextprotocol/kotlin/sdk/client/Client;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ZLio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun complete (Lio/modelcontextprotocol/kotlin/sdk/CompleteRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun complete$default (Lio/modelcontextprotocol/kotlin/sdk/client/Client;Lio/modelcontextprotocol/kotlin/sdk/CompleteRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public fun connect (Lio/modelcontextprotocol/kotlin/sdk/shared/Transport;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; diff --git a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt index b5a5ddfb..9929f92d 100644 --- a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt +++ b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt @@ -615,10 +615,12 @@ public open class Client(private val clientInfo: Implementation, options: Client // No prefix, just validate name isValidMetaName(parts[0]) } + 2 -> { val (prefix, name) = parts isValidMetaPrefix(prefix) && isValidMetaName(name) } + else -> false } } @@ -633,7 +635,7 @@ public open class Client(private val clientInfo: Implementation, options: Client return !labels.any { label -> label.equals("modelcontextprotocol", ignoreCase = true) || - label.equals("mcp", ignoreCase = true) + label.equals("mcp", ignoreCase = true) } } @@ -655,46 +657,65 @@ public open class Client(private val clientInfo: Implementation, options: Client return name.all { it.isLetterOrDigit() || it in setOf('-', '_', '.') } } - private fun convertToJsonMap(map: Map): Map = - map.mapValues { (key, value) -> - try { - convertToJsonElement(value) - } catch (e: Exception) { - logger.warn { "Failed to convert value for key '$key': ${e.message}. Using string representation." } - JsonPrimitive(value.toString()) - } + private fun convertToJsonMap(map: Map): Map = map.mapValues { (key, value) -> + try { + convertToJsonElement(value) + } catch (e: Exception) { + logger.warn { "Failed to convert value for key '$key': ${e.message}. Using string representation." } + JsonPrimitive(value.toString()) } + } @OptIn(ExperimentalUnsignedTypes::class, ExperimentalSerializationApi::class) private fun convertToJsonElement(value: Any?): JsonElement = when (value) { null -> JsonNull + is Map<*, *> -> { val jsonMap = value.entries.associate { (k, v) -> k.toString() to convertToJsonElement(v) } JsonObject(jsonMap) } + is JsonElement -> value + is String -> JsonPrimitive(value) + is Number -> JsonPrimitive(value) + is Boolean -> JsonPrimitive(value) + is Char -> JsonPrimitive(value.toString()) + is Enum<*> -> JsonPrimitive(value.name) + is Collection<*> -> JsonArray(value.map { convertToJsonElement(it) }) + is Array<*> -> JsonArray(value.map { convertToJsonElement(it) }) + is IntArray -> JsonArray(value.map { JsonPrimitive(it) }) + is LongArray -> JsonArray(value.map { JsonPrimitive(it) }) + is FloatArray -> JsonArray(value.map { JsonPrimitive(it) }) + is DoubleArray -> JsonArray(value.map { JsonPrimitive(it) }) + is BooleanArray -> JsonArray(value.map { JsonPrimitive(it) }) + is ShortArray -> JsonArray(value.map { JsonPrimitive(it) }) + is ByteArray -> JsonArray(value.map { JsonPrimitive(it) }) + is CharArray -> JsonArray(value.map { JsonPrimitive(it.toString()) }) // ExperimentalUnsignedTypes is UIntArray -> JsonArray(value.map { JsonPrimitive(it) }) + is ULongArray -> JsonArray(value.map { JsonPrimitive(it) }) + is UShortArray -> JsonArray(value.map { JsonPrimitive(it) }) + is UByteArray -> JsonArray(value.map { JsonPrimitive(it) }) else -> { diff --git a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientMetaParameterTest.kt b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientMetaParameterTest.kt index 9600fd8c..2c37b3eb 100644 --- a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientMetaParameterTest.kt +++ b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientMetaParameterTest.kt @@ -1,12 +1,21 @@ package io.modelcontextprotocol.kotlin.sdk.client +import io.modelcontextprotocol.kotlin.sdk.CallToolResult import io.modelcontextprotocol.kotlin.sdk.Implementation +import io.modelcontextprotocol.kotlin.sdk.InitializeResult import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage import io.modelcontextprotocol.kotlin.sdk.JSONRPCRequest +import io.modelcontextprotocol.kotlin.sdk.JSONRPCResponse +import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities import io.modelcontextprotocol.kotlin.sdk.shared.Transport import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.* -import kotlin.test.* +import kotlinx.serialization.json.JsonObject +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue /** * Comprehensive test suite for MCP Client meta parameter functionality @@ -24,9 +33,11 @@ class ClientMetaParameterTest { private val clientInfo = Implementation("test-client", "1.0.0") @BeforeTest - fun setup() { + fun setup() = runTest { mockTransport = MockTransport() client = Client(clientInfo = clientInfo) + mockTransport.setupInitializationResponse() + client.connect(mockTransport) } @Test @@ -37,8 +48,6 @@ class ClientMetaParameterTest { put("com.company.app/setting", "enabled") put("retry_count", 3) put("user.preference", true) - // Additional edge cases for valid keys - put("", "empty-name-allowed") // Empty name is allowed per MCP spec put("valid123", "alphanumeric") put("multi.dot.name", "multiple-dots") put("under_score", "underscore") @@ -56,7 +65,7 @@ class ClientMetaParameterTest { @Test fun `should accept edge case valid prefixes and names`() = runTest { val edgeCaseValidMeta = buildMap { - put("a/", "single-char-prefix-empty-name") + put("a/", "single-char-prefix-empty-name") // empty name is allowed put("a1-b2/test", "alphanumeric-hyphen-prefix") put("long.domain.name.here/config", "long-prefix") put("x/a", "minimal-valid-key") @@ -78,7 +87,10 @@ class ClientMetaParameterTest { client.callTool("test-tool", emptyMap(), invalidMeta) } - assertContains(exception.message ?: "", "Invalid _meta key") + assertContains( + charSequence = exception.message ?: "", + other = "Invalid _meta key", + ) } @Test @@ -89,7 +101,10 @@ class ClientMetaParameterTest { client.callTool("test-tool", emptyMap(), invalidMeta) } - assertContains(exception.message ?: "", "Invalid _meta key") + assertContains( + charSequence = exception.message ?: "", + other = "Invalid _meta key", + ) } @Test @@ -101,18 +116,18 @@ class ClientMetaParameterTest { "subdomain.mcp.com/config", "app.modelcontextprotocol.dev/setting", "test.mcp/value", - "service.modelcontextprotocol/data" + "service.modelcontextprotocol/data", ) invalidKeys.forEach { key -> val exception = assertFailsWith( - message = "Should reject nested reserved key: $key" + message = "Should reject nested reserved key: $key", ) { client.callTool("test-tool", emptyMap(), mapOf(key to "value")) } assertContains( charSequence = exception.message ?: "", - other = "Invalid _meta key" + other = "Invalid _meta key", ) } } @@ -125,18 +140,18 @@ class ClientMetaParameterTest { "mCp/setting", "MODELCONTEXTPROTOCOL/data", "ModelContextProtocol/value", - "modelContextProtocol/test" + "modelContextProtocol/test", ) invalidKeys.forEach { key -> val exception = assertFailsWith( - message = "Should reject case-insensitive reserved key: $key" + message = "Should reject case-insensitive reserved key: $key", ) { client.callTool("test-tool", emptyMap(), mapOf(key to "value")) } assertContains( charSequence = exception.message ?: "", - other = "Invalid _meta key" + other = "Invalid _meta key", ) } } @@ -144,25 +159,24 @@ class ClientMetaParameterTest { @Test fun `should reject invalid key formats`() = runTest { val invalidKeys = listOf( - "", // empty key + "", // empty key - not allowed at key level "/invalid", // starts with slash - "invalid/", // ends with slash "-invalid", // starts with hyphen ".invalid", // starts with dot "in valid", // contains space "api../test", // consecutive dots - "api./test" // label ends with dot + "api./test", // label ends with dot ) invalidKeys.forEach { key -> val exception = assertFailsWith( - message = "Should reject invalid key format: '$key'" + message = "Should reject invalid key format: '$key'", ) { client.callTool("test-tool", emptyMap(), mapOf(key to "value")) } assertContains( charSequence = exception.message ?: "", - other = "Invalid _meta key" + other = "Invalid _meta key", ) } } @@ -172,7 +186,11 @@ class ClientMetaParameterTest { val complexMeta = createComplexMetaData() val result = runCatching { - client.callTool("test-tool", emptyMap(), complexMeta) + client.callTool( + "test-tool", + emptyMap(), + complexMeta, + ) } assertTrue(result.isSuccess, "Complex data type conversion should not throw exceptions") @@ -224,13 +242,19 @@ class ClientMetaParameterTest { } private fun buildNestedConfiguration(): Map = buildMap { - put("config", buildMap { - put("database", buildMap { - put("host", "localhost") - put("port", 5432) - }) - put("features", listOf("feature1", "feature2")) - }) + put( + "config", + buildMap { + put( + "database", + buildMap { + put("host", "localhost") + put("port", 5432) + }, + ) + put("features", listOf("feature1", "feature2")) + }, + ) } } @@ -246,7 +270,42 @@ class MockTransport : Transport { override suspend fun send(message: JSONRPCMessage) { _sentMessages += message - onMessageBlock?.invoke(message) + + // Auto-respond to initialization and tool calls + when (message) { + is JSONRPCRequest -> { + when (message.method) { + "initialize" -> { + val initResponse = JSONRPCResponse( + id = message.id, + result = InitializeResult( + protocolVersion = "2024-11-05", + capabilities = ServerCapabilities( + tools = ServerCapabilities.Tools(listChanged = null), + ), + serverInfo = Implementation("mock-server", "1.0.0"), + ), + ) + onMessageBlock?.invoke(initResponse) + } + + "tools/call" -> { + val toolResponse = JSONRPCResponse( + id = message.id, + result = CallToolResult( + content = listOf(), + isError = false, + ), + ) + onMessageBlock?.invoke(toolResponse) + } + } + } + + else -> { + // Handle other message types if needed + } + } } override suspend fun close() { @@ -264,6 +323,10 @@ class MockTransport : Transport { override fun onError(block: (Throwable) -> Unit) { onErrorBlock = block } + + fun setupInitializationResponse() { + // This method helps set up the mock for proper initialization + } } val MockTransport.lastJsonRpcRequest: JSONRPCRequest? From 4a1a29e5339af53b0bb7383e5f9263b6083295ba Mon Sep 17 00:00:00 2001 From: MAERYO Date: Thu, 23 Oct 2025 10:24:56 +0900 Subject: [PATCH 6/8] refactor: simplify validation and JSON conversion --- .../kotlin/sdk/client/Client.kt | 141 +++++++----------- 1 file changed, 54 insertions(+), 87 deletions(-) diff --git a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt index 9929f92d..ca1bf436 100644 --- a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt +++ b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt @@ -56,6 +56,8 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject import kotlin.coroutines.cancellation.CancellationException private val logger = KotlinLogging.logger {} @@ -600,61 +602,38 @@ public open class Client(private val clientInfo: Implementation, options: Client * - Name: alphanumeric start/end, may contain hyphens, underscores, dots (empty allowed) */ private fun validateMetaKeys(keys: Set) { - for (key in keys) { - if (!isValidMetaKey(key)) { - throw Error("Invalid _meta key '$key'. Must follow format [prefix/]name with valid labels.") + val labelPattern = Regex("[a-zA-Z]([a-zA-Z0-9-]*[a-zA-Z0-9])?") + val namePattern = Regex("[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?") + + keys.forEach { key -> + require(key.isNotEmpty()) { "Meta key cannot be empty" } + + val (prefix, name) = key.split('/', limit = 2).let { parts -> + when (parts.size) { + 1 -> null to parts[0] + else -> parts[0] to parts[1] + } } - } - } - - private fun isValidMetaKey(key: String): Boolean { - if (key.isEmpty()) return false - val parts = key.split('/', limit = 2) - return when (parts.size) { - 1 -> { - // No prefix, just validate name - isValidMetaName(parts[0]) + + // Validate prefix if present + prefix?.let { + require(it.isNotEmpty()) { "Invalid _meta key '$key': prefix cannot be empty" } + + val labels = it.split('.') + require(labels.all { label -> label.matches(labelPattern) }) { + "Invalid _meta key '$key': prefix labels must start with a letter, end with letter/digit, and contain only letters, digits, or hyphens" + } + + require(labels.none { label -> label.equals("modelcontextprotocol", ignoreCase = true) || label.equals("mcp", ignoreCase = true) }) { + "Invalid _meta key '$key': prefix cannot contain reserved labels 'modelcontextprotocol' or 'mcp'" + } } - - 2 -> { - val (prefix, name) = parts - isValidMetaPrefix(prefix) && isValidMetaName(name) + + // Validate name (empty allowed) + require(name.isEmpty() || name.matches(namePattern)) { + "Invalid _meta key '$key': name must start and end with alphanumeric characters, and contain only alphanumerics, hyphens, underscores, or dots" } - - else -> false - } - } - - private fun isValidMetaPrefix(prefix: String): Boolean { - if (prefix.isEmpty()) return false - val labels = prefix.split('.') - - if (!labels.all { isValidLabel(it) }) { - return false - } - - return !labels.any { label -> - label.equals("modelcontextprotocol", ignoreCase = true) || - label.equals("mcp", ignoreCase = true) - } - } - - private fun isValidLabel(label: String): Boolean { - if (label.isEmpty()) return false - if (!label.first().isLetter() || !label.last().let { it.isLetter() || it.isDigit() }) { - return false } - return label.all { it.isLetter() || it.isDigit() || it == '-' } - } - - private fun isValidMetaName(name: String): Boolean { - // Empty names are allowed per MCP specification - if (name.isEmpty()) return true - - if (!name.first().isLetterOrDigit() || !name.last().isLetterOrDigit()) { - return false - } - return name.all { it.isLetterOrDigit() || it in setOf('-', '_', '.') } } private fun convertToJsonMap(map: Map): Map = map.mapValues { (key, value) -> @@ -669,54 +648,42 @@ public open class Client(private val clientInfo: Implementation, options: Client @OptIn(ExperimentalUnsignedTypes::class, ExperimentalSerializationApi::class) private fun convertToJsonElement(value: Any?): JsonElement = when (value) { null -> JsonNull - - is Map<*, *> -> { - val jsonMap = value.entries.associate { (k, v) -> - k.toString() to convertToJsonElement(v) - } - JsonObject(jsonMap) - } - is JsonElement -> value - is String -> JsonPrimitive(value) - is Number -> JsonPrimitive(value) - is Boolean -> JsonPrimitive(value) - is Char -> JsonPrimitive(value.toString()) - is Enum<*> -> JsonPrimitive(value.name) - is Collection<*> -> JsonArray(value.map { convertToJsonElement(it) }) - - is Array<*> -> JsonArray(value.map { convertToJsonElement(it) }) - - is IntArray -> JsonArray(value.map { JsonPrimitive(it) }) - - is LongArray -> JsonArray(value.map { JsonPrimitive(it) }) - - is FloatArray -> JsonArray(value.map { JsonPrimitive(it) }) - - is DoubleArray -> JsonArray(value.map { JsonPrimitive(it) }) - - is BooleanArray -> JsonArray(value.map { JsonPrimitive(it) }) + is Map<*, *> -> buildJsonObject { + value.forEach { (k, v) -> + put(k.toString(), convertToJsonElement(v)) + } + } - is ShortArray -> JsonArray(value.map { JsonPrimitive(it) }) + is Collection<*> -> buildJsonArray { + value.forEach { add(convertToJsonElement(it)) } + } - is ByteArray -> JsonArray(value.map { JsonPrimitive(it) }) + is Array<*> -> buildJsonArray { + value.forEach { add(convertToJsonElement(it)) } + } - is CharArray -> JsonArray(value.map { JsonPrimitive(it.toString()) }) + // Primitive arrays - use iterator for unified handling + is IntArray -> buildJsonArray { value.forEach { add(it) } } + is LongArray -> buildJsonArray { value.forEach { add(it) } } + is FloatArray -> buildJsonArray { value.forEach { add(it) } } + is DoubleArray -> buildJsonArray { value.forEach { add(it) } } + is BooleanArray -> buildJsonArray { value.forEach { add(it) } } + is ShortArray -> buildJsonArray { value.forEach { add(it) } } + is ByteArray -> buildJsonArray { value.forEach { add(it) } } + is CharArray -> buildJsonArray { value.forEach { add(it.toString()) } } // ExperimentalUnsignedTypes - is UIntArray -> JsonArray(value.map { JsonPrimitive(it) }) - - is ULongArray -> JsonArray(value.map { JsonPrimitive(it) }) - - is UShortArray -> JsonArray(value.map { JsonPrimitive(it) }) - - is UByteArray -> JsonArray(value.map { JsonPrimitive(it) }) + is UIntArray -> buildJsonArray { value.forEach { add(it) } } + is ULongArray -> buildJsonArray { value.forEach { add(it) } } + is UShortArray -> buildJsonArray { value.forEach { add(it) } } + is UByteArray -> buildJsonArray { value.forEach { add(it) } } else -> { logger.debug { "Converting unknown type ${value::class.simpleName} to string: $value" } From 7e087b4cdff6e488789b55f74d3793569793486b Mon Sep 17 00:00:00 2001 From: devcrocod Date: Thu, 23 Oct 2025 14:13:55 +0200 Subject: [PATCH 7/8] Fix compilation and tests for meta. small refactoring --- .../kotlin/sdk/client/Client.kt | 88 ++++++++++--------- .../sdk/client/ClientMetaParameterTest.kt | 14 ++- .../kotlin/AbstractToolIntegrationTest.kt | 2 +- 3 files changed, 52 insertions(+), 52 deletions(-) diff --git a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt index ca1bf436..384cd79e 100644 --- a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt +++ b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt @@ -51,11 +51,11 @@ import kotlinx.collections.immutable.minus import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.toPersistentSet import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.add import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject import kotlin.coroutines.cancellation.CancellationException @@ -189,20 +189,14 @@ public open class Client(private val clientInfo: Implementation, options: Client } } - Method.Defined.PromptsGet, - Method.Defined.PromptsList, - Method.Defined.CompletionComplete, - -> { + Method.Defined.PromptsGet, Method.Defined.PromptsList, Method.Defined.CompletionComplete -> { if (serverCapabilities?.prompts == null) { throw IllegalStateException("Server does not support prompts (required for $method)") } } - Method.Defined.ResourcesList, - Method.Defined.ResourcesTemplatesList, - Method.Defined.ResourcesRead, - Method.Defined.ResourcesSubscribe, - Method.Defined.ResourcesUnsubscribe, + Method.Defined.ResourcesList, Method.Defined.ResourcesTemplatesList, + Method.Defined.ResourcesRead, Method.Defined.ResourcesSubscribe, Method.Defined.ResourcesUnsubscribe, -> { val resCaps = serverCapabilities?.resources ?: error("Server does not support resources (required for $method)") @@ -214,17 +208,13 @@ public open class Client(private val clientInfo: Implementation, options: Client } } - Method.Defined.ToolsCall, - Method.Defined.ToolsList, - -> { + Method.Defined.ToolsCall, Method.Defined.ToolsList -> { if (serverCapabilities?.tools == null) { throw IllegalStateException("Server does not support tools (required for $method)") } } - Method.Defined.Initialize, - Method.Defined.Ping, - -> { + Method.Defined.Initialize, Method.Defined.Ping -> { // No specific capability required } @@ -604,31 +594,37 @@ public open class Client(private val clientInfo: Implementation, options: Client private fun validateMetaKeys(keys: Set) { val labelPattern = Regex("[a-zA-Z]([a-zA-Z0-9-]*[a-zA-Z0-9])?") val namePattern = Regex("[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?") - + keys.forEach { key -> require(key.isNotEmpty()) { "Meta key cannot be empty" } - + val (prefix, name) = key.split('/', limit = 2).let { parts -> when (parts.size) { 1 -> null to parts[0] - else -> parts[0] to parts[1] + 2 -> parts[0] to parts[1] + else -> throw IllegalArgumentException("Unexpected split result for key: $key") } } - + // Validate prefix if present prefix?.let { require(it.isNotEmpty()) { "Invalid _meta key '$key': prefix cannot be empty" } - + val labels = it.split('.') require(labels.all { label -> label.matches(labelPattern) }) { "Invalid _meta key '$key': prefix labels must start with a letter, end with letter/digit, and contain only letters, digits, or hyphens" } - - require(labels.none { label -> label.equals("modelcontextprotocol", ignoreCase = true) || label.equals("mcp", ignoreCase = true) }) { + + require( + labels.none { label -> + label.equals("modelcontextprotocol", ignoreCase = true) || + label.equals("mcp", ignoreCase = true) + }, + ) { "Invalid _meta key '$key': prefix cannot contain reserved labels 'modelcontextprotocol' or 'mcp'" } } - + // Validate name (empty allowed) require(name.isEmpty() || name.matches(namePattern)) { "Invalid _meta key '$key': name must start and end with alphanumeric characters, and contain only alphanumerics, hyphens, underscores, or dots" @@ -648,45 +644,53 @@ public open class Client(private val clientInfo: Implementation, options: Client @OptIn(ExperimentalUnsignedTypes::class, ExperimentalSerializationApi::class) private fun convertToJsonElement(value: Any?): JsonElement = when (value) { null -> JsonNull + is JsonElement -> value + is String -> JsonPrimitive(value) + is Number -> JsonPrimitive(value) + is Boolean -> JsonPrimitive(value) + is Char -> JsonPrimitive(value.toString()) + is Enum<*> -> JsonPrimitive(value.name) - is Map<*, *> -> buildJsonObject { - value.forEach { (k, v) -> - put(k.toString(), convertToJsonElement(v)) - } - } + is Map<*, *> -> buildJsonObject { value.forEach { (k, v) -> put(k.toString(), convertToJsonElement(v)) } } - is Collection<*> -> buildJsonArray { - value.forEach { add(convertToJsonElement(it)) } - } + is Collection<*> -> buildJsonArray { value.forEach { add(convertToJsonElement(it)) } } - is Array<*> -> buildJsonArray { - value.forEach { add(convertToJsonElement(it)) } - } + is Array<*> -> buildJsonArray { value.forEach { add(convertToJsonElement(it)) } } - // Primitive arrays - use iterator for unified handling + // Primitive arrays is IntArray -> buildJsonArray { value.forEach { add(it) } } + is LongArray -> buildJsonArray { value.forEach { add(it) } } + is FloatArray -> buildJsonArray { value.forEach { add(it) } } + is DoubleArray -> buildJsonArray { value.forEach { add(it) } } + is BooleanArray -> buildJsonArray { value.forEach { add(it) } } + is ShortArray -> buildJsonArray { value.forEach { add(it) } } + is ByteArray -> buildJsonArray { value.forEach { add(it) } } + is CharArray -> buildJsonArray { value.forEach { add(it.toString()) } } - // ExperimentalUnsignedTypes - is UIntArray -> buildJsonArray { value.forEach { add(it) } } - is ULongArray -> buildJsonArray { value.forEach { add(it) } } - is UShortArray -> buildJsonArray { value.forEach { add(it) } } - is UByteArray -> buildJsonArray { value.forEach { add(it) } } + // Unsigned arrays + is UIntArray -> buildJsonArray { value.forEach { add(JsonPrimitive(it)) } } + + is ULongArray -> buildJsonArray { value.forEach { add(JsonPrimitive(it)) } } + + is UShortArray -> buildJsonArray { value.forEach { add(JsonPrimitive(it)) } } + + is UByteArray -> buildJsonArray { value.forEach { add(JsonPrimitive(it)) } } else -> { - logger.debug { "Converting unknown type ${value::class.simpleName} to string: $value" } + logger.debug { "Converting unknown type ${value::class} to string: $value" } JsonPrimitive(value.toString()) } } diff --git a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientMetaParameterTest.kt b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientMetaParameterTest.kt index 2c37b3eb..634cd1a8 100644 --- a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientMetaParameterTest.kt +++ b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientMetaParameterTest.kt @@ -83,7 +83,7 @@ class ClientMetaParameterTest { fun `should reject mcp reserved prefix`() = runTest { val invalidMeta = mapOf("mcp/internal" to "value") - val exception = assertFailsWith { + val exception = assertFailsWith { client.callTool("test-tool", emptyMap(), invalidMeta) } @@ -97,7 +97,7 @@ class ClientMetaParameterTest { fun `should reject modelcontextprotocol reserved prefix`() = runTest { val invalidMeta = mapOf("modelcontextprotocol/config" to "value") - val exception = assertFailsWith { + val exception = assertFailsWith { client.callTool("test-tool", emptyMap(), invalidMeta) } @@ -120,7 +120,7 @@ class ClientMetaParameterTest { ) invalidKeys.forEach { key -> - val exception = assertFailsWith( + val exception = assertFailsWith( message = "Should reject nested reserved key: $key", ) { client.callTool("test-tool", emptyMap(), mapOf(key to "value")) @@ -144,7 +144,7 @@ class ClientMetaParameterTest { ) invalidKeys.forEach { key -> - val exception = assertFailsWith( + val exception = assertFailsWith( message = "Should reject case-insensitive reserved key: $key", ) { client.callTool("test-tool", emptyMap(), mapOf(key to "value")) @@ -169,15 +169,11 @@ class ClientMetaParameterTest { ) invalidKeys.forEach { key -> - val exception = assertFailsWith( + assertFailsWith( message = "Should reject invalid key format: '$key'", ) { client.callTool("test-tool", emptyMap(), mapOf(key to "value")) } - assertContains( - charSequence = exception.message ?: "", - other = "Invalid _meta key", - ) } } diff --git a/kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractToolIntegrationTest.kt b/kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractToolIntegrationTest.kt index 3b0de299..a9a8f278 100644 --- a/kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractToolIntegrationTest.kt +++ b/kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractToolIntegrationTest.kt @@ -484,7 +484,7 @@ abstract class AbstractToolIntegrationTest : KotlinTestBase() { "result" : 11.0, "formattedResult" : "11,000", "precision" : 3, - "tags" : [ ] + "tags" : ["test", "calculator", "integration"] } """.trimIndent() From de5f8b58c52c58f61a972b648f2ad24f4538f55e Mon Sep 17 00:00:00 2001 From: devcrocod Date: Thu, 23 Oct 2025 15:02:53 +0200 Subject: [PATCH 8/8] Add `MockTransport` for testability and improve meta field validation in tests --- .../kotlin/sdk/client/Client.kt | 12 +- .../sdk/client/ClientMetaParameterTest.kt | 109 +++++------------- .../kotlin/sdk/client/MockTransport.kt | 94 +++++++++++++++ 3 files changed, 130 insertions(+), 85 deletions(-) create mode 100644 kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/MockTransport.kt diff --git a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt index 384cd79e..56fd1caf 100644 --- a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt +++ b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt @@ -189,14 +189,20 @@ public open class Client(private val clientInfo: Implementation, options: Client } } - Method.Defined.PromptsGet, Method.Defined.PromptsList, Method.Defined.CompletionComplete -> { + Method.Defined.PromptsGet, + Method.Defined.PromptsList, + Method.Defined.CompletionComplete, + -> { if (serverCapabilities?.prompts == null) { throw IllegalStateException("Server does not support prompts (required for $method)") } } - Method.Defined.ResourcesList, Method.Defined.ResourcesTemplatesList, - Method.Defined.ResourcesRead, Method.Defined.ResourcesSubscribe, Method.Defined.ResourcesUnsubscribe, + Method.Defined.ResourcesList, + Method.Defined.ResourcesTemplatesList, + Method.Defined.ResourcesRead, + Method.Defined.ResourcesSubscribe, + Method.Defined.ResourcesUnsubscribe, -> { val resCaps = serverCapabilities?.resources ?: error("Server does not support resources (required for $method)") diff --git a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientMetaParameterTest.kt b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientMetaParameterTest.kt index 634cd1a8..e7061073 100644 --- a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientMetaParameterTest.kt +++ b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientMetaParameterTest.kt @@ -1,15 +1,12 @@ package io.modelcontextprotocol.kotlin.sdk.client -import io.modelcontextprotocol.kotlin.sdk.CallToolResult import io.modelcontextprotocol.kotlin.sdk.Implementation -import io.modelcontextprotocol.kotlin.sdk.InitializeResult -import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage import io.modelcontextprotocol.kotlin.sdk.JSONRPCRequest -import io.modelcontextprotocol.kotlin.sdk.JSONRPCResponse -import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities -import io.modelcontextprotocol.kotlin.sdk.shared.Transport import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonPrimitive import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertContains @@ -60,6 +57,26 @@ class ClientMetaParameterTest { } assertTrue(result.isSuccess, "Valid meta keys should not cause exceptions") + mockTransport.lastJsonRpcRequest()?.let { request -> + val params = request.params as JsonObject + assertTrue(params.containsKey("_meta"), "Request should contain _meta field") + val metaField = params["_meta"] as JsonObject + + // Verify all meta keys are present + assertEquals(validMeta.size, metaField.size, "All meta keys should be included") + + // Verify specific key-value pairs + assertEquals("value1", metaField["simple-key"]?.jsonPrimitive?.content) + assertEquals("1.0", metaField["api.example.com/version"]?.jsonPrimitive?.content) + assertEquals("enabled", metaField["com.company.app/setting"]?.jsonPrimitive?.content) + assertEquals(3, metaField["retry_count"]?.jsonPrimitive?.int) + assertEquals(true, metaField["user.preference"]?.jsonPrimitive?.boolean) + assertEquals("alphanumeric", metaField["valid123"]?.jsonPrimitive?.content) + assertEquals("multiple-dots", metaField["multi.dot.name"]?.jsonPrimitive?.content) + assertEquals("underscore", metaField["under_score"]?.jsonPrimitive?.content) + assertEquals("hyphen", metaField["hyphen-dash"]?.jsonPrimitive?.content) + assertEquals("complex-valid-prefix", metaField["org.apache.kafka/consumer-config"]?.jsonPrimitive?.content) + } } @Test @@ -191,7 +208,7 @@ class ClientMetaParameterTest { assertTrue(result.isSuccess, "Complex data type conversion should not throw exceptions") - mockTransport.lastJsonRpcRequest?.let { request -> + mockTransport.lastJsonRpcRequest()?.let { request -> assertEquals("tools/call", request.method) val params = request.params as JsonObject assertTrue(params.containsKey("_meta"), "Request should contain _meta field") @@ -208,7 +225,7 @@ class ClientMetaParameterTest { assertTrue(result.isSuccess) - mockTransport.lastJsonRpcRequest?.let { request -> + mockTransport.lastJsonRpcRequest()?.let { request -> val params = request.params as JsonObject val metaField = params["_meta"] as JsonObject assertTrue(metaField.containsKey("config")) @@ -219,7 +236,7 @@ class ClientMetaParameterTest { fun `should include empty meta object when meta parameter not provided`() = runTest { client.callTool("test-tool", mapOf("arg" to "value")) - mockTransport.lastJsonRpcRequest?.let { request -> + mockTransport.lastJsonRpcRequest()?.let { request -> val params = request.params as JsonObject val metaField = params["_meta"] as JsonObject assertTrue(metaField.isEmpty(), "Meta field should be empty when not provided") @@ -254,76 +271,4 @@ class ClientMetaParameterTest { } } -class MockTransport : Transport { - private val _sentMessages = mutableListOf() - val sentMessages: List = _sentMessages - - private var onMessageBlock: (suspend (JSONRPCMessage) -> Unit)? = null - private var onCloseBlock: (() -> Unit)? = null - private var onErrorBlock: ((Throwable) -> Unit)? = null - - override suspend fun start() = Unit - - override suspend fun send(message: JSONRPCMessage) { - _sentMessages += message - - // Auto-respond to initialization and tool calls - when (message) { - is JSONRPCRequest -> { - when (message.method) { - "initialize" -> { - val initResponse = JSONRPCResponse( - id = message.id, - result = InitializeResult( - protocolVersion = "2024-11-05", - capabilities = ServerCapabilities( - tools = ServerCapabilities.Tools(listChanged = null), - ), - serverInfo = Implementation("mock-server", "1.0.0"), - ), - ) - onMessageBlock?.invoke(initResponse) - } - - "tools/call" -> { - val toolResponse = JSONRPCResponse( - id = message.id, - result = CallToolResult( - content = listOf(), - isError = false, - ), - ) - onMessageBlock?.invoke(toolResponse) - } - } - } - - else -> { - // Handle other message types if needed - } - } - } - - override suspend fun close() { - onCloseBlock?.invoke() - } - - override fun onMessage(block: suspend (JSONRPCMessage) -> Unit) { - onMessageBlock = block - } - - override fun onClose(block: () -> Unit) { - onCloseBlock = block - } - - override fun onError(block: (Throwable) -> Unit) { - onErrorBlock = block - } - - fun setupInitializationResponse() { - // This method helps set up the mock for proper initialization - } -} - -val MockTransport.lastJsonRpcRequest: JSONRPCRequest? - get() = sentMessages.lastOrNull() as? JSONRPCRequest +suspend fun MockTransport.lastJsonRpcRequest(): JSONRPCRequest? = getSentMessages().lastOrNull() as? JSONRPCRequest diff --git a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/MockTransport.kt b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/MockTransport.kt new file mode 100644 index 00000000..c987619d --- /dev/null +++ b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/MockTransport.kt @@ -0,0 +1,94 @@ +package io.modelcontextprotocol.kotlin.sdk.client + +import io.modelcontextprotocol.kotlin.sdk.CallToolResult +import io.modelcontextprotocol.kotlin.sdk.Implementation +import io.modelcontextprotocol.kotlin.sdk.InitializeResult +import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage +import io.modelcontextprotocol.kotlin.sdk.JSONRPCRequest +import io.modelcontextprotocol.kotlin.sdk.JSONRPCResponse +import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities +import io.modelcontextprotocol.kotlin.sdk.shared.Transport +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class MockTransport : Transport { + private val _sentMessages = mutableListOf() + private val _receivedMessages = mutableListOf() + private val mutex = Mutex() + + suspend fun getSentMessages() = mutex.withLock { _sentMessages.toList() } + suspend fun getReceivedMessages() = mutex.withLock { _receivedMessages.toList() } + + private var onMessageBlock: (suspend (JSONRPCMessage) -> Unit)? = null + private var onCloseBlock: (() -> Unit)? = null + private var onErrorBlock: ((Throwable) -> Unit)? = null + + override suspend fun start() = Unit + + override suspend fun send(message: JSONRPCMessage) { + mutex.withLock { + _sentMessages += message + } + + // Auto-respond to initialization and tool calls + when (message) { + is JSONRPCRequest -> { + when (message.method) { + "initialize" -> { + val initResponse = JSONRPCResponse( + id = message.id, + result = InitializeResult( + protocolVersion = "2024-11-05", + capabilities = ServerCapabilities( + tools = ServerCapabilities.Tools(listChanged = null), + ), + serverInfo = Implementation("mock-server", "1.0.0"), + ), + ) + onMessageBlock?.invoke(initResponse) + } + + "tools/call" -> { + val toolResponse = JSONRPCResponse( + id = message.id, + result = CallToolResult( + content = listOf(), + isError = false, + ), + ) + onMessageBlock?.invoke(toolResponse) + } + } + } + + else -> { + // Handle other message types if needed + } + } + } + + override suspend fun close() { + onCloseBlock?.invoke() + } + + override fun onMessage(block: suspend (JSONRPCMessage) -> Unit) { + onMessageBlock = { message -> + mutex.withLock { + _receivedMessages += message + } + block(message) + } + } + + override fun onClose(block: () -> Unit) { + onCloseBlock = block + } + + override fun onError(block: (Throwable) -> Unit) { + onErrorBlock = block + } + + fun setupInitializationResponse() { + // This method helps set up the mock for proper initialization + } +}