From e248e68e7333640a3222a0872aa0dbbeb83bf9a9 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Fri, 13 Feb 2026 17:13:09 -0300 Subject: [PATCH] fix: normalize mcp tool schemas for openai compatibility Convert JSON Schema type arrays to `anyOf` and ensure bare array types have items, preventing OpenAI "array schema missing items" errors. Assisted-by: Claude Opus 4.6 via Crush --- agent.go | 18 +++++--- schema/schema.go | 39 ++++++++++++++++ schema/schema_test.go | 105 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 7 deletions(-) diff --git a/agent.go b/agent.go index f7672b210..426beff16 100644 --- a/agent.go +++ b/agent.go @@ -9,6 +9,8 @@ import ( "maps" "slices" "sync" + + "charm.land/fantasy/schema" ) // StepResult represents the result of a single step in an agent execution. @@ -912,14 +914,16 @@ func (a *agent) prepareTools(tools []AgentTool, activeTools []string, disableAll continue } info := tool.Info() + inputSchema := map[string]any{ + "type": "object", + "properties": info.Parameters, + "required": info.Required, + } + schema.Normalize(inputSchema) preparedTools = append(preparedTools, FunctionTool{ - Name: info.Name, - Description: info.Description, - InputSchema: map[string]any{ - "type": "object", - "properties": info.Parameters, - "required": info.Required, - }, + Name: info.Name, + Description: info.Description, + InputSchema: inputSchema, ProviderOptions: tool.ProviderOptions(), }) } diff --git a/schema/schema.go b/schema/schema.go index eebc5053a..c1cc46d2b 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -401,3 +401,42 @@ func toSnakeCase(s string) string { } return strings.ToLower(result.String()) } + +// Normalize recursively normalizes a raw JSON Schema map so it is +// compatible with providers that reject type-arrays (e.g. OpenAI). Type +// arrays are converted to anyOf and bare "array" types get "items":{}. +func Normalize(node map[string]any) { + for _, child := range node { + switch v := child.(type) { + case map[string]any: + Normalize(v) + case []any: + for _, item := range v { + if m, ok := item.(map[string]any); ok { + Normalize(m) + } + } + } + } + + typeArr, ok := node["type"].([]any) + if !ok { + if node["type"] == "array" { + if _, has := node["items"]; !has { + node["items"] = map[string]any{} + } + } + return + } + + anyOf := make([]any, 0, len(typeArr)) + for _, t := range typeArr { + variant := map[string]any{"type": t} + if t == "array" { + variant["items"] = map[string]any{} + } + anyOf = append(anyOf, variant) + } + delete(node, "type") + node["anyOf"] = anyOf +} diff --git a/schema/schema_test.go b/schema/schema_test.go index b49323a38..8b48194ec 100644 --- a/schema/schema_test.go +++ b/schema/schema_test.go @@ -532,3 +532,108 @@ func TestSchemaToParametersEdgeCases(t *testing.T) { }) } } + +func TestNormalize_TypeArray(t *testing.T) { + t.Parallel() + + node := map[string]any{ + "type": "object", + "properties": map[string]any{ + "value": map[string]any{ + "description": "Config value", + "type": []any{"string", "number", "boolean", "object", "array", "null"}, + }, + }, + } + + Normalize(node) + + val := node["properties"].(map[string]any)["value"].(map[string]any) + require.Nil(t, val["type"]) + anyOf, ok := val["anyOf"].([]any) + require.True(t, ok) + require.Len(t, anyOf, 6) + + for _, v := range anyOf { + variant := v.(map[string]any) + if variant["type"] == "array" { + require.Contains(t, variant, "items") + } + } + require.Equal(t, "Config value", val["description"]) +} + +func TestNormalize_SingleStringType(t *testing.T) { + t.Parallel() + + node := map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + }, + } + + Normalize(node) + + val := node["properties"].(map[string]any)["name"].(map[string]any) + require.Equal(t, "string", val["type"]) +} + +func TestNormalize_BareArrayGetsItems(t *testing.T) { + t.Parallel() + + node := map[string]any{ + "type": "object", + "properties": map[string]any{ + "tags": map[string]any{"type": "array"}, + }, + } + + Normalize(node) + + val := node["properties"].(map[string]any)["tags"].(map[string]any) + require.Equal(t, "array", val["type"]) + require.Contains(t, val, "items") +} + +func TestNormalize_SingleElementTypeArray(t *testing.T) { + t.Parallel() + + node := map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": []any{"string"}}, + }, + } + + Normalize(node) + + val := node["properties"].(map[string]any)["name"].(map[string]any) + require.Nil(t, val["type"]) + anyOf, ok := val["anyOf"].([]any) + require.True(t, ok) + require.Len(t, anyOf, 1) + require.Equal(t, "string", anyOf[0].(map[string]any)["type"]) +} + +func TestNormalize_NestedProperties(t *testing.T) { + t.Parallel() + + node := map[string]any{ + "type": "object", + "properties": map[string]any{ + "config": map[string]any{ + "type": "object", + "properties": map[string]any{ + "val": map[string]any{"type": []any{"string", "number"}}, + }, + }, + }, + } + + Normalize(node) + + val := node["properties"].(map[string]any)["config"].(map[string]any)["properties"].(map[string]any)["val"].(map[string]any) + require.Nil(t, val["type"]) + require.NotNil(t, val["anyOf"]) +}