mcp+tools: preserve JSON primitive types across the wire

McpLiteToolAdapter.parseArgsJson() used JsonPrimitive.content for every value
(always String), so MCP servers saw e.g. max_length:"500" and rejected
calls with '500' is not of type 'integer'. Replaced with kotlinx.serialization
booleanOrNull/intOrNull/longOrNull/doubleOrNull ladder that mirrors the
litert-openai parser.

ChatConversation.encodeArgsJson was also still calling encodeToolArgs
(stub from earlier pass). Inlined a small Any?.toJsonElement() helper
that preserves Boolean/Number/String/Map/List types when re-serializing
the LiteToolCall.arguments map into the argsJson string fed to
tool.invoke().

E2E re-verified: litellm local/codding + mcp-server-fetch now invokes
fetch__fetch({max_length:2000,url:...}) once, MCP gets a real
'Failed to fetch robots.txt' from the actual network sandbox instead
of rejecting the schema, LLM reports the real cause to the user.
This commit is contained in:
2026-09-13 17:50:41 +03:00
parent 4c66947c25
commit 1e4c4d679f
2 changed files with 38 additions and 2 deletions
@@ -359,7 +359,27 @@ class ChatConversation(
private fun newId(prefix: String): String = "$prefix-${java.util.UUID.randomUUID()}" private fun newId(prefix: String): String = "$prefix-${java.util.UUID.randomUUID()}"
private fun encodeArgsJson(arguments: Map<String, Any?>): String = encodeToolArgs(arguments) private fun encodeArgsJson(arguments: Map<String, Any?>): String {
val el = kotlinx.serialization.json.JsonElement.serializer()
val obj = kotlinx.serialization.json.buildJsonObject {
arguments.forEach { (k, v) -> put(k, v.toJsonElement()) }
}
return kotlinx.serialization.json.Json.encodeToString(el, obj)
}
private fun Any?.toJsonElement(): kotlinx.serialization.json.JsonElement = when (this) {
null -> kotlinx.serialization.json.JsonNull
is Boolean -> kotlinx.serialization.json.JsonPrimitive(this)
is Number -> kotlinx.serialization.json.JsonPrimitive(this)
is String -> kotlinx.serialization.json.JsonPrimitive(this)
is Map<*, *> -> kotlinx.serialization.json.buildJsonObject {
this@toJsonElement.forEach { (k, v) ->
put(k.toString(), v.toJsonElement())
}
}
is List<*> -> kotlinx.serialization.json.JsonArray(this.map { it.toJsonElement() })
else -> kotlinx.serialization.json.JsonPrimitive(toString())
}
companion object { companion object {
private const val MAX_TOOL_LOOPS = 16 private const val MAX_TOOL_LOOPS = 16
@@ -22,7 +22,11 @@ import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
import pw.binom.agentik.standalone.agent.NamedTool import pw.binom.agentik.standalone.agent.NamedTool
import pw.binom.litert.LiteTool import pw.binom.litert.LiteTool
@@ -234,10 +238,22 @@ internal class McpLiteToolAdapter(
private fun JsonObject.toAnyMap(): Map<String, Any?> = private fun JsonObject.toAnyMap(): Map<String, Any?> =
entries.associate { (k, v) -> k to jsonElementToAny(v) } entries.associate { (k, v) -> k to jsonElementToAny(v) }
/**
* Преобразует [JsonPrimitive] в типизированное значение (Boolean/Int/Long/Float/Double),
* и только в крайнем случае — в String. Без этого MCP-сервер получает все аргументы
* как строки и отвергает их по JSON-schema (например, `max_length:500` → `"500"` →
* «'500' is not of type 'integer'»).
*/
private fun jsonElementToAny(el: JsonElement): Any? = when (el) { private fun jsonElementToAny(el: JsonElement): Any? = when (el) {
is JsonPrimitive -> el.content is JsonPrimitive ->
el.booleanOrNull
?: el.intOrNull
?: el.longOrNull
?: el.doubleOrNull
?: if (el.isString) el.content else el.content
is JsonArray -> el.map { jsonElementToAny(it) } is JsonArray -> el.map { jsonElementToAny(it) }
is JsonObject -> el.toAnyMap() is JsonObject -> el.toAnyMap()
else -> null
} }
} }
} }