Skip to content

Commit b25f03d

Browse files
MAERYOdevcrocod
authored andcommitted
feat: implement complete _meta support with MCP specification validation
1 parent c0370d3 commit b25f03d

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
@@ -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

Comments
 (0)