Skip to content

Commit 2b957a9

Browse files
MAERYOdevcrocod
authored andcommitted
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
1 parent dbb6eaa commit 2b957a9

File tree

3 files changed

+123
-39
lines changed

3 files changed

+123
-39
lines changed

kotlin-sdk-client/api/kotlin-sdk-client.api

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ public class io/modelcontextprotocol/kotlin/sdk/client/Client : io/modelcontextp
88
protected fun assertNotificationCapability (Lio/modelcontextprotocol/kotlin/sdk/Method;)V
99
public fun assertRequestHandlerCapability (Lio/modelcontextprotocol/kotlin/sdk/Method;)V
1010
public final fun callTool (Lio/modelcontextprotocol/kotlin/sdk/CallToolRequest;ZLio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
11-
public final fun callTool (Ljava/lang/String;Ljava/util/Map;ZLio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
11+
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;
1212
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;
13-
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;
13+
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;
1414
public final fun complete (Lio/modelcontextprotocol/kotlin/sdk/CompleteRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
1515
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;
1616
public fun connect (Lio/modelcontextprotocol/kotlin/sdk/shared/Transport;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;

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

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -615,10 +615,12 @@ public open class Client(private val clientInfo: Implementation, options: Client
615615
// No prefix, just validate name
616616
isValidMetaName(parts[0])
617617
}
618+
618619
2 -> {
619620
val (prefix, name) = parts
620621
isValidMetaPrefix(prefix) && isValidMetaName(name)
621622
}
623+
622624
else -> false
623625
}
624626
}
@@ -633,7 +635,7 @@ public open class Client(private val clientInfo: Implementation, options: Client
633635

634636
return !labels.any { label ->
635637
label.equals("modelcontextprotocol", ignoreCase = true) ||
636-
label.equals("mcp", ignoreCase = true)
638+
label.equals("mcp", ignoreCase = true)
637639
}
638640
}
639641

@@ -655,46 +657,65 @@ public open class Client(private val clientInfo: Implementation, options: Client
655657
return name.all { it.isLetterOrDigit() || it in setOf('-', '_', '.') }
656658
}
657659

658-
private fun convertToJsonMap(map: Map<String, Any?>): Map<String, JsonElement> =
659-
map.mapValues { (key, value) ->
660-
try {
661-
convertToJsonElement(value)
662-
} catch (e: Exception) {
663-
logger.warn { "Failed to convert value for key '$key': ${e.message}. Using string representation." }
664-
JsonPrimitive(value.toString())
665-
}
660+
private fun convertToJsonMap(map: Map<String, Any?>): Map<String, JsonElement> = map.mapValues { (key, value) ->
661+
try {
662+
convertToJsonElement(value)
663+
} catch (e: Exception) {
664+
logger.warn { "Failed to convert value for key '$key': ${e.message}. Using string representation." }
665+
JsonPrimitive(value.toString())
666666
}
667+
}
667668

668669
@OptIn(ExperimentalUnsignedTypes::class, ExperimentalSerializationApi::class)
669670
private fun convertToJsonElement(value: Any?): JsonElement = when (value) {
670671
null -> JsonNull
672+
671673
is Map<*, *> -> {
672674
val jsonMap = value.entries.associate { (k, v) ->
673675
k.toString() to convertToJsonElement(v)
674676
}
675677
JsonObject(jsonMap)
676678
}
679+
677680
is JsonElement -> value
681+
678682
is String -> JsonPrimitive(value)
683+
679684
is Number -> JsonPrimitive(value)
685+
680686
is Boolean -> JsonPrimitive(value)
687+
681688
is Char -> JsonPrimitive(value.toString())
689+
682690
is Enum<*> -> JsonPrimitive(value.name)
691+
683692
is Collection<*> -> JsonArray(value.map { convertToJsonElement(it) })
693+
684694
is Array<*> -> JsonArray(value.map { convertToJsonElement(it) })
695+
685696
is IntArray -> JsonArray(value.map { JsonPrimitive(it) })
697+
686698
is LongArray -> JsonArray(value.map { JsonPrimitive(it) })
699+
687700
is FloatArray -> JsonArray(value.map { JsonPrimitive(it) })
701+
688702
is DoubleArray -> JsonArray(value.map { JsonPrimitive(it) })
703+
689704
is BooleanArray -> JsonArray(value.map { JsonPrimitive(it) })
705+
690706
is ShortArray -> JsonArray(value.map { JsonPrimitive(it) })
707+
691708
is ByteArray -> JsonArray(value.map { JsonPrimitive(it) })
709+
692710
is CharArray -> JsonArray(value.map { JsonPrimitive(it.toString()) })
693711

694712
// ExperimentalUnsignedTypes
695713
is UIntArray -> JsonArray(value.map { JsonPrimitive(it) })
714+
696715
is ULongArray -> JsonArray(value.map { JsonPrimitive(it) })
716+
697717
is UShortArray -> JsonArray(value.map { JsonPrimitive(it) })
718+
698719
is UByteArray -> JsonArray(value.map { JsonPrimitive(it) })
699720

700721
else -> {

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

Lines changed: 91 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
11
package io.modelcontextprotocol.kotlin.sdk.client
22

3+
import io.modelcontextprotocol.kotlin.sdk.CallToolResult
34
import io.modelcontextprotocol.kotlin.sdk.Implementation
5+
import io.modelcontextprotocol.kotlin.sdk.InitializeResult
46
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
57
import io.modelcontextprotocol.kotlin.sdk.JSONRPCRequest
8+
import io.modelcontextprotocol.kotlin.sdk.JSONRPCResponse
9+
import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities
610
import io.modelcontextprotocol.kotlin.sdk.shared.Transport
711
import kotlinx.coroutines.test.runTest
8-
import kotlinx.serialization.json.*
9-
import kotlin.test.*
12+
import kotlinx.serialization.json.JsonObject
13+
import kotlin.test.BeforeTest
14+
import kotlin.test.Test
15+
import kotlin.test.assertContains
16+
import kotlin.test.assertEquals
17+
import kotlin.test.assertFailsWith
18+
import kotlin.test.assertTrue
1019

1120
/**
1221
* Comprehensive test suite for MCP Client meta parameter functionality
@@ -24,9 +33,11 @@ class ClientMetaParameterTest {
2433
private val clientInfo = Implementation("test-client", "1.0.0")
2534

2635
@BeforeTest
27-
fun setup() {
36+
fun setup() = runTest {
2837
mockTransport = MockTransport()
2938
client = Client(clientInfo = clientInfo)
39+
mockTransport.setupInitializationResponse()
40+
client.connect(mockTransport)
3041
}
3142

3243
@Test
@@ -37,8 +48,6 @@ class ClientMetaParameterTest {
3748
put("com.company.app/setting", "enabled")
3849
put("retry_count", 3)
3950
put("user.preference", true)
40-
// Additional edge cases for valid keys
41-
put("", "empty-name-allowed") // Empty name is allowed per MCP spec
4251
put("valid123", "alphanumeric")
4352
put("multi.dot.name", "multiple-dots")
4453
put("under_score", "underscore")
@@ -56,7 +65,7 @@ class ClientMetaParameterTest {
5665
@Test
5766
fun `should accept edge case valid prefixes and names`() = runTest {
5867
val edgeCaseValidMeta = buildMap {
59-
put("a/", "single-char-prefix-empty-name")
68+
put("a/", "single-char-prefix-empty-name") // empty name is allowed
6069
put("a1-b2/test", "alphanumeric-hyphen-prefix")
6170
put("long.domain.name.here/config", "long-prefix")
6271
put("x/a", "minimal-valid-key")
@@ -78,7 +87,10 @@ class ClientMetaParameterTest {
7887
client.callTool("test-tool", emptyMap(), invalidMeta)
7988
}
8089

81-
assertContains(exception.message ?: "", "Invalid _meta key")
90+
assertContains(
91+
charSequence = exception.message ?: "",
92+
other = "Invalid _meta key",
93+
)
8294
}
8395

8496
@Test
@@ -89,7 +101,10 @@ class ClientMetaParameterTest {
89101
client.callTool("test-tool", emptyMap(), invalidMeta)
90102
}
91103

92-
assertContains(exception.message ?: "", "Invalid _meta key")
104+
assertContains(
105+
charSequence = exception.message ?: "",
106+
other = "Invalid _meta key",
107+
)
93108
}
94109

95110
@Test
@@ -101,18 +116,18 @@ class ClientMetaParameterTest {
101116
"subdomain.mcp.com/config",
102117
"app.modelcontextprotocol.dev/setting",
103118
"test.mcp/value",
104-
"service.modelcontextprotocol/data"
119+
"service.modelcontextprotocol/data",
105120
)
106121

107122
invalidKeys.forEach { key ->
108123
val exception = assertFailsWith<Error>(
109-
message = "Should reject nested reserved key: $key"
124+
message = "Should reject nested reserved key: $key",
110125
) {
111126
client.callTool("test-tool", emptyMap(), mapOf(key to "value"))
112127
}
113128
assertContains(
114129
charSequence = exception.message ?: "",
115-
other = "Invalid _meta key"
130+
other = "Invalid _meta key",
116131
)
117132
}
118133
}
@@ -125,44 +140,43 @@ class ClientMetaParameterTest {
125140
"mCp/setting",
126141
"MODELCONTEXTPROTOCOL/data",
127142
"ModelContextProtocol/value",
128-
"modelContextProtocol/test"
143+
"modelContextProtocol/test",
129144
)
130145

131146
invalidKeys.forEach { key ->
132147
val exception = assertFailsWith<Error>(
133-
message = "Should reject case-insensitive reserved key: $key"
148+
message = "Should reject case-insensitive reserved key: $key",
134149
) {
135150
client.callTool("test-tool", emptyMap(), mapOf(key to "value"))
136151
}
137152
assertContains(
138153
charSequence = exception.message ?: "",
139-
other = "Invalid _meta key"
154+
other = "Invalid _meta key",
140155
)
141156
}
142157
}
143158

144159
@Test
145160
fun `should reject invalid key formats`() = runTest {
146161
val invalidKeys = listOf(
147-
"", // empty key
162+
"", // empty key - not allowed at key level
148163
"/invalid", // starts with slash
149-
"invalid/", // ends with slash
150164
"-invalid", // starts with hyphen
151165
".invalid", // starts with dot
152166
"in valid", // contains space
153167
"api../test", // consecutive dots
154-
"api./test" // label ends with dot
168+
"api./test", // label ends with dot
155169
)
156170

157171
invalidKeys.forEach { key ->
158172
val exception = assertFailsWith<Error>(
159-
message = "Should reject invalid key format: '$key'"
173+
message = "Should reject invalid key format: '$key'",
160174
) {
161175
client.callTool("test-tool", emptyMap(), mapOf(key to "value"))
162176
}
163177
assertContains(
164178
charSequence = exception.message ?: "",
165-
other = "Invalid _meta key"
179+
other = "Invalid _meta key",
166180
)
167181
}
168182
}
@@ -172,7 +186,11 @@ class ClientMetaParameterTest {
172186
val complexMeta = createComplexMetaData()
173187

174188
val result = runCatching {
175-
client.callTool("test-tool", emptyMap(), complexMeta)
189+
client.callTool(
190+
"test-tool",
191+
emptyMap(),
192+
complexMeta,
193+
)
176194
}
177195

178196
assertTrue(result.isSuccess, "Complex data type conversion should not throw exceptions")
@@ -224,13 +242,19 @@ class ClientMetaParameterTest {
224242
}
225243

226244
private fun buildNestedConfiguration(): Map<String, Any> = buildMap {
227-
put("config", buildMap {
228-
put("database", buildMap {
229-
put("host", "localhost")
230-
put("port", 5432)
231-
})
232-
put("features", listOf("feature1", "feature2"))
233-
})
245+
put(
246+
"config",
247+
buildMap {
248+
put(
249+
"database",
250+
buildMap {
251+
put("host", "localhost")
252+
put("port", 5432)
253+
},
254+
)
255+
put("features", listOf("feature1", "feature2"))
256+
},
257+
)
234258
}
235259
}
236260

@@ -246,7 +270,42 @@ class MockTransport : Transport {
246270

247271
override suspend fun send(message: JSONRPCMessage) {
248272
_sentMessages += message
249-
onMessageBlock?.invoke(message)
273+
274+
// Auto-respond to initialization and tool calls
275+
when (message) {
276+
is JSONRPCRequest -> {
277+
when (message.method) {
278+
"initialize" -> {
279+
val initResponse = JSONRPCResponse(
280+
id = message.id,
281+
result = InitializeResult(
282+
protocolVersion = "2024-11-05",
283+
capabilities = ServerCapabilities(
284+
tools = ServerCapabilities.Tools(listChanged = null),
285+
),
286+
serverInfo = Implementation("mock-server", "1.0.0"),
287+
),
288+
)
289+
onMessageBlock?.invoke(initResponse)
290+
}
291+
292+
"tools/call" -> {
293+
val toolResponse = JSONRPCResponse(
294+
id = message.id,
295+
result = CallToolResult(
296+
content = listOf(),
297+
isError = false,
298+
),
299+
)
300+
onMessageBlock?.invoke(toolResponse)
301+
}
302+
}
303+
}
304+
305+
else -> {
306+
// Handle other message types if needed
307+
}
308+
}
250309
}
251310

252311
override suspend fun close() {
@@ -264,6 +323,10 @@ class MockTransport : Transport {
264323
override fun onError(block: (Throwable) -> Unit) {
265324
onErrorBlock = block
266325
}
326+
327+
fun setupInitializationResponse() {
328+
// This method helps set up the mock for proper initialization
329+
}
267330
}
268331

269332
val MockTransport.lastJsonRpcRequest: JSONRPCRequest?

0 commit comments

Comments
 (0)