@@ -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
@@ -405,10 +407,14 @@ public open class Client(private val clientInfo: Implementation, options: Client
405407 ): EmptyRequestResult = request(request, options)
406408
407409 /* *
408- * Calls a tool on the server by name, passing the specified arguments.
410+ * Calls a tool on the server by name, passing the specified arguments and metadata .
409411 *
410412 * @param name The name of the tool to call.
411413 * @param arguments A map of argument names to values for the tool.
414+ * @param meta A map of metadata key-value pairs. Keys must follow MCP specification format.
415+ * - Optional prefix: dot-separated labels followed by slash (e.g., "api.example.com/")
416+ * - Name: alphanumeric start/end, may contain hyphens, underscores, dots, alphanumerics
417+ * - Reserved prefixes starting with "mcp" or "modelcontextprotocol" are forbidden
412418 * @param compatibility Whether to use compatibility mode for older protocol versions.
413419 * @param options Optional request options.
414420 * @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
421427 compatibility : Boolean = false,
422428 options : RequestOptions ? = null,
423429 ): CallToolResultBase ? {
430+ validateMetaKeys(meta.keys)
431+
424432 val jsonArguments = convertToJsonMap(arguments)
425433 val jsonMeta = convertToJsonMap(meta)
426434
@@ -583,15 +591,125 @@ public open class Client(private val clientInfo: Implementation, options: Client
583591 return ListRootsResult (rootList)
584592 }
585593
594+ /* *
595+ * Validates meta keys according to MCP specification.
596+ *
597+ * Key format: [prefix/]name
598+ * - Prefix (optional): dot-separated labels + slash
599+ * - Labels: start with letter, end with letter/digit, contain letters/digits/hyphens
600+ * - Reserved prefixes: those starting with "mcp" or "modelcontextprotocol"
601+ * - Name: alphanumeric start/end, may contain hyphens, underscores, dots, alphanumerics
602+ */
603+ private fun validateMetaKeys (keys : Set <String >) {
604+ for (key in keys) {
605+ if (! isValidMetaKey(key)) {
606+ throw Error (
607+ " Invalid _meta key '$key '. Keys must follow MCP specification format: " +
608+ " [prefix/]name where prefix is dot-separated labels and name is alphanumeric with allowed separators."
609+ )
610+ }
611+ }
612+ }
613+
614+ private fun isValidMetaKey (key : String ): Boolean {
615+ if (key.isEmpty()) return false
616+ val parts = key.split(' /' , limit = 2 )
617+ return when (parts.size) {
618+ 1 -> {
619+ // No prefix, just validate name
620+ isValidMetaName(parts[0 ])
621+ }
622+ 2 -> {
623+ val (prefix, name) = parts
624+ isValidMetaPrefix(prefix) && isValidMetaName(name)
625+ }
626+ else -> false
627+ }
628+ }
629+
630+ private fun isValidMetaPrefix (prefix : String ): Boolean {
631+ if (prefix.isEmpty()) return false
632+
633+ // Check for reserved prefixes
634+ val labels = prefix.split(' .' )
635+ if (labels.isNotEmpty()) {
636+ val firstLabel = labels[0 ].lowercase()
637+ if (firstLabel == " mcp" || firstLabel == " modelcontextprotocol" ) {
638+ return false
639+ }
640+
641+ // Check for reserved patterns like "*.mcp.*" or "*.modelcontextprotocol.*"
642+ for (label in labels) {
643+ val lowerLabel = label.lowercase()
644+ if (lowerLabel == " mcp" || lowerLabel == " modelcontextprotocol" ) {
645+ return false
646+ }
647+ }
648+ }
649+ return labels.all { isValidLabel(it) }
650+ }
651+
652+ private fun isValidLabel (label : String ): Boolean {
653+ if (label.isEmpty()) return false
654+ if (! label.first().isLetter() || ! label.last().let { it.isLetter() || it.isDigit() }) {
655+ return false
656+ }
657+ return label.all { it.isLetter() || it.isDigit() || it == ' -' }
658+ }
659+
660+ private fun isValidMetaName (name : String ): Boolean {
661+ if (name.isEmpty()) return false
662+ if (! name.first().isLetterOrDigit() || ! name.last().isLetterOrDigit()) {
663+ return false
664+ }
665+ return name.all { it.isLetterOrDigit() || it in setOf (' -' , ' _' , ' .' ) }
666+ }
667+
586668 private fun convertToJsonMap (map : Map <String , Any ?>): Map <String , JsonElement > =
587- map.mapValues { (_, value) ->
588- when (value) {
589- is String -> JsonPrimitive (value)
590- is Number -> JsonPrimitive (value)
591- is Boolean -> JsonPrimitive (value)
592- is JsonElement -> value
593- null -> JsonNull
594- else -> JsonPrimitive (value.toString())
669+ map.mapValues { (key, value) ->
670+ try {
671+ convertToJsonElement(value)
672+ } catch (e: Exception ) {
673+ logger.warn { " Failed to convert value for key '$key ': ${e.message} . Using string representation." }
674+ JsonPrimitive (value.toString())
675+ }
676+ }
677+
678+ @OptIn(ExperimentalUnsignedTypes ::class , ExperimentalSerializationApi ::class )
679+ private fun convertToJsonElement (value : Any? ): JsonElement = when (value) {
680+ null -> JsonNull
681+ is Map <* , * > -> {
682+ val jsonMap = value.entries.associate { (k, v) ->
683+ k.toString() to convertToJsonElement(v)
595684 }
685+ JsonObject (jsonMap)
596686 }
687+ is JsonElement -> value
688+ is String -> JsonPrimitive (value)
689+ is Number -> JsonPrimitive (value)
690+ is Boolean -> JsonPrimitive (value)
691+ is Char -> JsonPrimitive (value.toString())
692+ is Enum <* > -> JsonPrimitive (value.name)
693+ is Collection <* > -> JsonArray (value.map { convertToJsonElement(it) })
694+ is Array <* > -> JsonArray (value.map { convertToJsonElement(it) })
695+ is IntArray -> JsonArray (value.map { JsonPrimitive (it) })
696+ is LongArray -> JsonArray (value.map { JsonPrimitive (it) })
697+ is FloatArray -> JsonArray (value.map { JsonPrimitive (it) })
698+ is DoubleArray -> JsonArray (value.map { JsonPrimitive (it) })
699+ is BooleanArray -> JsonArray (value.map { JsonPrimitive (it) })
700+ is ShortArray -> JsonArray (value.map { JsonPrimitive (it) })
701+ is ByteArray -> JsonArray (value.map { JsonPrimitive (it) })
702+ is CharArray -> JsonArray (value.map { JsonPrimitive (it.toString()) })
703+
704+ // ExperimentalUnsignedTypes
705+ is UIntArray -> JsonArray (value.map { JsonPrimitive (it) })
706+ is ULongArray -> JsonArray (value.map { JsonPrimitive (it) })
707+ is UShortArray -> JsonArray (value.map { JsonPrimitive (it) })
708+ is UByteArray -> JsonArray (value.map { JsonPrimitive (it) })
709+
710+ else -> {
711+ logger.debug { " Converting unknown type ${value::class .simpleName} to string: $value " }
712+ JsonPrimitive (value.toString())
713+ }
714+ }
597715}
0 commit comments