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 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 {
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.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
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 pw.binom.agentik.standalone.agent.NamedTool
import pw.binom.litert.LiteTool
@@ -234,10 +238,22 @@ internal class McpLiteToolAdapter(
private fun JsonObject.toAnyMap(): Map<String, Any?> =
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) {
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 JsonObject -> el.toAnyMap()
else -> null
}
}
}