MCP tool integration: oneOf not supported in schema parser, and LLM-generated arguments not coerced
- Dominant language
- Kotlin
- Stars
- 4.6k
- Forks
- 474
- Avg merge
- 58m
- Merged PRs (30d)
- 1
Description
## Summary
When using MCP servers whose tool schemas use JSON Schema `oneOf` (rather than `anyOf`), the `DefaultMcpToolDescriptorParser` fails to parse parameters correctly. Additionally, even when schemas are parsed, LLMs frequently stringify nested JSON objects in tool call arguments, and the `McpTool` forwards them as-is — causing MCP servers to reject the call.
Both issues are observable with the Obsidian MCP server (`obsidian-mcp`), but affect any MCP server using `oneOf` or nested object parameters.
## Issue 1: `oneOf` not handled by `DefaultMcpToolDescriptorParser`
**Context:** Many MCP servers (notably Obsidian MCP) use JSON Schema `oneOf` for union-type parameters. For example, the `target` parameter in `obsidian_write_note`:
```json
"target": {
"description": "Where the note lives.",
"oneOf": [
{ "type": "object", "properties": { "type": { "const": "path" }, "path": { ... } } },
{ "type": "object", "properties": { "type": { "const": "active" } } },
{ "type": "object", "properties": { "type": { "const": "periodic" }, ... } }
]
}
```
**What happens:** `DefaultMcpToolDescriptorParser.parseParameterType()` checks for `type`, `anyOf`, and `enum` — but **not `oneOf`**. When a parameter has `oneOf` without `type` or `anyOf`, the parser throws:
```
IllegalArgumentException: Parameter must have type property
```
**Expected:** `oneOf` should be treated equivalently to `anyOf` for the purpose of `ToolParameterType.AnyOf` construction.
**Suggested fix in `DefaultMcpToolDescriptorParser.parseParameterType()`:**
```kotlin
// After the existing anyOf check, add:
val oneOf = element["oneOf"]?.jsonArray
if (oneOf != null) {
return ToolParameterType.AnyOf(
types = oneOf.map { it.jsonObject }.map {
ToolParameterDescriptor(
name = "",
description = it["description"]?.jsonPrimitive?.content.orEmpty(),
type = parseParameterType(it.jsonObject)
)
}.toTypedArray()
)
}
```
Or normalize `oneOf` → `anyOf` before parsing.
## Issue 2: `McpTool` does not coerce stringified JSON arguments
**Context:** Even when the schema correctly describes a parameter as an object type (or `anyOf` of objects), LLMs frequently generate the argument as a JSON string containing the object, rather than a proper nested object:
```json
// What the LLM generates:
{ "target": "{\"type\":\"path\",\"path\":\"folder/note.md\"}" }
// What the MCP server expects:
{ "target": {"type": "path", "path": "folder/note.md"} }
```
**What happens:** `McpTool.execute()` calls `args.toKotlinxJsonObject()` and forwards to `mcpClient.callTool()` as-is. The MCP server receives a string where it expects an object, and the call fails silently or throws.
**Expected:** When a parameter's descriptor type is `Object`, `AnyOf`, or `List`, and the received argument is a string that parses as valid JSON, it should be coerced to the parsed structure before forwarding.
**Suggested fix:** Add argument coercion in `McpTool.execute()` (or provide a hook/wrapper point):
```kotlin
override suspend fun execute(args: JSONObject): CallToolResult {
val coerced = coerceStringifiedJsonArgs(args, descriptor)
return mcpClient.callTool(name = descriptor.name, arguments = coerced.toKotlinxJsonObject())
}
```
## Workaround
We're currently working around both issues in our project with:
1. A custom `McpToolDescriptorParser` that converts `oneOf` → `anyOf` before delegating to `DefaultMcpToolDescriptorParser`
2. A custom `Tool` subclass that coerces stringified JSON arguments before calling the MCP client
3. Building the `ToolRegistry` manually (bypassing `McpToolRegistryProvider.fromTransport()`) to use the custom tool class
This works but requires reimplementing `McpTool`'s logic and bypassing the standard registry builder.
## Environment
- Koog version: 0.7.3
- MCP server: `obsidian-mcp` (Obsidian Local REST API)
- Affected tools: `obsidian_write_note`, `obsidian_append_to_note`, `obsidian_get_note`, `obsidian_search_notes`, and others using `oneOf` or nested object params
Contributor guide
Assessment
This issue has not been assessed yet.