Skip to content

Commit 407df7a

Browse files
maeryoMAERYOdevcrocod
authored
Add metadata support to callTool method (#289)
According to https://modelcontextprotocol.io/specification/2025-06-18/basic/index#meta, the _meta parameter should be passable when calling tools. While the CallToolRequest class (in types.kt) currently includes _meta in its definition, SDK users cannot actually input _meta when invoking the callTool method. This Pull Request adds a metadata parameter to the CallTool method and includes functionality to convert various data formats without loss. Additionally, to encourage the use of valid Key name formats as defined by the protocol, validation for prefixes has been implemented. --------- Co-authored-by: MAERYO <[email protected]> Co-authored-by: devcrocod <[email protected]>
1 parent 2da14b0 commit 407df7a

File tree

5 files changed

+500
-20
lines changed

5 files changed

+500
-20
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: 129 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,14 @@ 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
5354
import kotlinx.serialization.json.JsonElement
5455
import kotlinx.serialization.json.JsonNull
5556
import kotlinx.serialization.json.JsonObject
5657
import kotlinx.serialization.json.JsonPrimitive
58+
import kotlinx.serialization.json.add
59+
import kotlinx.serialization.json.buildJsonArray
60+
import kotlinx.serialization.json.buildJsonObject
5761
import kotlin.coroutines.cancellation.CancellationException
5862

5963
private val logger = KotlinLogging.logger {}
@@ -210,17 +214,13 @@ public open class Client(private val clientInfo: Implementation, options: Client
210214
}
211215
}
212216

213-
Method.Defined.ToolsCall,
214-
Method.Defined.ToolsList,
215-
-> {
217+
Method.Defined.ToolsCall, Method.Defined.ToolsList -> {
216218
if (serverCapabilities?.tools == null) {
217219
throw IllegalStateException("Server does not support tools (required for $method)")
218220
}
219221
}
220222

221-
Method.Defined.Initialize,
222-
Method.Defined.Ping,
223-
-> {
223+
Method.Defined.Initialize, Method.Defined.Ping -> {
224224
// No specific capability required
225225
}
226226

@@ -405,10 +405,14 @@ public open class Client(private val clientInfo: Implementation, options: Client
405405
): EmptyRequestResult = request(request, options)
406406

407407
/**
408-
* Calls a tool on the server by name, passing the specified arguments.
408+
* Calls a tool on the server by name, passing the specified arguments and metadata.
409409
*
410410
* @param name The name of the tool to call.
411411
* @param arguments A map of argument names to values for the tool.
412+
* @param meta A map of metadata key-value pairs. Keys must follow MCP specification format.
413+
* - Optional prefix: dot-separated labels followed by slash (e.g., "api.example.com/")
414+
* - Name: alphanumeric start/end, may contain hyphens, underscores, dots, alphanumerics
415+
* - Reserved prefixes starting with "mcp" or "modelcontextprotocol" are forbidden
412416
* @param compatibility Whether to use compatibility mode for older protocol versions.
413417
* @param options Optional request options.
414418
* @return The result of the tool call, or `null` if none.
@@ -417,23 +421,19 @@ public open class Client(private val clientInfo: Implementation, options: Client
417421
public suspend fun callTool(
418422
name: String,
419423
arguments: Map<String, Any?>,
424+
meta: Map<String, Any?> = emptyMap(),
420425
compatibility: Boolean = false,
421426
options: RequestOptions? = null,
422427
): CallToolResultBase? {
423-
val jsonArguments = arguments.mapValues { (_, value) ->
424-
when (value) {
425-
is String -> JsonPrimitive(value)
426-
is Number -> JsonPrimitive(value)
427-
is Boolean -> JsonPrimitive(value)
428-
is JsonElement -> value
429-
null -> JsonNull
430-
else -> JsonPrimitive(value.toString())
431-
}
432-
}
428+
validateMetaKeys(meta.keys)
429+
430+
val jsonArguments = convertToJsonMap(arguments)
431+
val jsonMeta = convertToJsonMap(meta)
433432

434433
val request = CallToolRequest(
435434
name = name,
436435
arguments = JsonObject(jsonArguments),
436+
_meta = JsonObject(jsonMeta),
437437
)
438438
return callTool(request, compatibility, options)
439439
}
@@ -588,4 +588,116 @@ public open class Client(private val clientInfo: Implementation, options: Client
588588
val rootList = roots.value.values.toList()
589589
return ListRootsResult(rootList)
590590
}
591+
592+
/**
593+
* Validates meta keys according to MCP specification.
594+
*
595+
* Key format: [prefix/]name
596+
* - Prefix (optional): dot-separated labels + slash
597+
* - Reserved prefixes contain "modelcontextprotocol" or "mcp" as complete labels
598+
* - Name: alphanumeric start/end, may contain hyphens, underscores, dots (empty allowed)
599+
*/
600+
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+
604+
keys.forEach { key ->
605+
require(key.isNotEmpty()) { "Meta key cannot be empty" }
606+
607+
val (prefix, name) = key.split('/', limit = 2).let { parts ->
608+
when (parts.size) {
609+
1 -> null to parts[0]
610+
2 -> parts[0] to parts[1]
611+
else -> throw IllegalArgumentException("Unexpected split result for key: $key")
612+
}
613+
}
614+
615+
// Validate prefix if present
616+
prefix?.let {
617+
require(it.isNotEmpty()) { "Invalid _meta key '$key': prefix cannot be empty" }
618+
619+
val labels = it.split('.')
620+
require(labels.all { label -> label.matches(labelPattern) }) {
621+
"Invalid _meta key '$key': prefix labels must start with a letter, end with letter/digit, and contain only letters, digits, or hyphens"
622+
}
623+
624+
require(
625+
labels.none { label ->
626+
label.equals("modelcontextprotocol", ignoreCase = true) ||
627+
label.equals("mcp", ignoreCase = true)
628+
},
629+
) {
630+
"Invalid _meta key '$key': prefix cannot contain reserved labels 'modelcontextprotocol' or 'mcp'"
631+
}
632+
}
633+
634+
// Validate name (empty allowed)
635+
require(name.isEmpty() || name.matches(namePattern)) {
636+
"Invalid _meta key '$key': name must start and end with alphanumeric characters, and contain only alphanumerics, hyphens, underscores, or dots"
637+
}
638+
}
639+
}
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+
}
591703
}

0 commit comments

Comments
 (0)