@@ -50,6 +50,8 @@ import kotlinx.atomicfu.update
5050import kotlinx.collections.immutable.minus
5151import kotlinx.collections.immutable.persistentMapOf
5252import kotlinx.collections.immutable.toPersistentSet
53+ import kotlinx.serialization.ExperimentalSerializationApi
54+ import kotlinx.serialization.json.JsonArray
5355import kotlinx.serialization.json.JsonElement
5456import kotlinx.serialization.json.JsonNull
5557import kotlinx.serialization.json.JsonObject
@@ -394,10 +396,14 @@ public open class Client(private val clientInfo: Implementation, options: Client
394396 ): EmptyRequestResult = request(request, options)
395397
396398 /* *
397- * Calls a tool on the server by name, passing the specified arguments.
399+ * Calls a tool on the server by name, passing the specified arguments and metadata .
398400 *
399401 * @param name The name of the tool to call.
400402 * @param arguments A map of argument names to values for the tool.
403+ * @param meta A map of metadata key-value pairs. Keys must follow MCP specification format.
404+ * - Optional prefix: dot-separated labels followed by slash (e.g., "api.example.com/")
405+ * - Name: alphanumeric start/end, may contain hyphens, underscores, dots, alphanumerics
406+ * - Reserved prefixes starting with "mcp" or "modelcontextprotocol" are forbidden
401407 * @param compatibility Whether to use compatibility mode for older protocol versions.
402408 * @param options Optional request options.
403409 * @return The result of the tool call, or `null` if none.
@@ -410,6 +416,8 @@ public open class Client(private val clientInfo: Implementation, options: Client
410416 compatibility : Boolean = false,
411417 options : RequestOptions ? = null,
412418 ): CallToolResultBase ? {
419+ validateMetaKeys(meta.keys)
420+
413421 val jsonArguments = convertToJsonMap(arguments)
414422 val jsonMeta = convertToJsonMap(meta)
415423
@@ -572,15 +580,125 @@ public open class Client(private val clientInfo: Implementation, options: Client
572580 return ListRootsResult (rootList)
573581 }
574582
583+ /* *
584+ * Validates meta keys according to MCP specification.
585+ *
586+ * Key format: [prefix/]name
587+ * - Prefix (optional): dot-separated labels + slash
588+ * - Labels: start with letter, end with letter/digit, contain letters/digits/hyphens
589+ * - Reserved prefixes: those starting with "mcp" or "modelcontextprotocol"
590+ * - Name: alphanumeric start/end, may contain hyphens, underscores, dots, alphanumerics
591+ */
592+ private fun validateMetaKeys (keys : Set <String >) {
593+ for (key in keys) {
594+ if (! isValidMetaKey(key)) {
595+ throw Error (
596+ " Invalid _meta key '$key '. Keys must follow MCP specification format: " +
597+ " [prefix/]name where prefix is dot-separated labels and name is alphanumeric with allowed separators."
598+ )
599+ }
600+ }
601+ }
602+
603+ private fun isValidMetaKey (key : String ): Boolean {
604+ if (key.isEmpty()) return false
605+ val parts = key.split(' /' , limit = 2 )
606+ return when (parts.size) {
607+ 1 -> {
608+ // No prefix, just validate name
609+ isValidMetaName(parts[0 ])
610+ }
611+ 2 -> {
612+ val (prefix, name) = parts
613+ isValidMetaPrefix(prefix) && isValidMetaName(name)
614+ }
615+ else -> false
616+ }
617+ }
618+
619+ private fun isValidMetaPrefix (prefix : String ): Boolean {
620+ if (prefix.isEmpty()) return false
621+
622+ // Check for reserved prefixes
623+ val labels = prefix.split(' .' )
624+ if (labels.isNotEmpty()) {
625+ val firstLabel = labels[0 ].lowercase()
626+ if (firstLabel == " mcp" || firstLabel == " modelcontextprotocol" ) {
627+ return false
628+ }
629+
630+ // Check for reserved patterns like "*.mcp.*" or "*.modelcontextprotocol.*"
631+ for (label in labels) {
632+ val lowerLabel = label.lowercase()
633+ if (lowerLabel == " mcp" || lowerLabel == " modelcontextprotocol" ) {
634+ return false
635+ }
636+ }
637+ }
638+ return labels.all { isValidLabel(it) }
639+ }
640+
641+ private fun isValidLabel (label : String ): Boolean {
642+ if (label.isEmpty()) return false
643+ if (! label.first().isLetter() || ! label.last().let { it.isLetter() || it.isDigit() }) {
644+ return false
645+ }
646+ return label.all { it.isLetter() || it.isDigit() || it == ' -' }
647+ }
648+
649+ private fun isValidMetaName (name : String ): Boolean {
650+ if (name.isEmpty()) return false
651+ if (! name.first().isLetterOrDigit() || ! name.last().isLetterOrDigit()) {
652+ return false
653+ }
654+ return name.all { it.isLetterOrDigit() || it in setOf (' -' , ' _' , ' .' ) }
655+ }
656+
575657 private fun convertToJsonMap (map : Map <String , Any ?>): Map <String , JsonElement > =
576- map.mapValues { (_, value) ->
577- when (value) {
578- is String -> JsonPrimitive (value)
579- is Number -> JsonPrimitive (value)
580- is Boolean -> JsonPrimitive (value)
581- is JsonElement -> value
582- null -> JsonNull
583- else -> JsonPrimitive (value.toString())
658+ map.mapValues { (key, value) ->
659+ try {
660+ convertToJsonElement(value)
661+ } catch (e: Exception ) {
662+ logger.warn { " Failed to convert value for key '$key ': ${e.message} . Using string representation." }
663+ JsonPrimitive (value.toString())
664+ }
665+ }
666+
667+ @OptIn(ExperimentalUnsignedTypes ::class , ExperimentalSerializationApi ::class )
668+ private fun convertToJsonElement (value : Any? ): JsonElement = when (value) {
669+ null -> JsonNull
670+ is Map <* , * > -> {
671+ val jsonMap = value.entries.associate { (k, v) ->
672+ k.toString() to convertToJsonElement(v)
584673 }
674+ JsonObject (jsonMap)
585675 }
676+ is JsonElement -> value
677+ is String -> JsonPrimitive (value)
678+ is Number -> JsonPrimitive (value)
679+ is Boolean -> JsonPrimitive (value)
680+ is Char -> JsonPrimitive (value.toString())
681+ is Enum <* > -> JsonPrimitive (value.name)
682+ is Collection <* > -> JsonArray (value.map { convertToJsonElement(it) })
683+ is Array <* > -> JsonArray (value.map { convertToJsonElement(it) })
684+ is IntArray -> JsonArray (value.map { JsonPrimitive (it) })
685+ is LongArray -> JsonArray (value.map { JsonPrimitive (it) })
686+ is FloatArray -> JsonArray (value.map { JsonPrimitive (it) })
687+ is DoubleArray -> JsonArray (value.map { JsonPrimitive (it) })
688+ is BooleanArray -> JsonArray (value.map { JsonPrimitive (it) })
689+ is ShortArray -> JsonArray (value.map { JsonPrimitive (it) })
690+ is ByteArray -> JsonArray (value.map { JsonPrimitive (it) })
691+ is CharArray -> JsonArray (value.map { JsonPrimitive (it.toString()) })
692+
693+ // ExperimentalUnsignedTypes
694+ is UIntArray -> JsonArray (value.map { JsonPrimitive (it) })
695+ is ULongArray -> JsonArray (value.map { JsonPrimitive (it) })
696+ is UShortArray -> JsonArray (value.map { JsonPrimitive (it) })
697+ is UByteArray -> JsonArray (value.map { JsonPrimitive (it) })
698+
699+ else -> {
700+ logger.debug { " Converting unknown type ${value::class .simpleName} to string: $value " }
701+ JsonPrimitive (value.toString())
702+ }
703+ }
586704}
0 commit comments