Skip to content

Commit 7ed7cd2

Browse files
author
MAERYO
committed
feat: implement complete _meta support with MCP specification validation
1 parent 845bdc4 commit 7ed7cd2

File tree

1 file changed

+127
-9
lines changed
  • kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client

1 file changed

+127
-9
lines changed

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

Lines changed: 127 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ 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
54+
import kotlinx.serialization.json.JsonArray
5355
import kotlinx.serialization.json.JsonElement
5456
import kotlinx.serialization.json.JsonNull
5557
import 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

Comments
 (0)