fix: normalize mcp tool schemas for openai compatibility (#138)

Andrey Nering created

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 <crush@charm.land>

Change summary

agent.go              |  18 ++++---
schema/schema.go      |  39 ++++++++++++++++
schema/schema_test.go | 105 +++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 155 insertions(+), 7 deletions(-)

Detailed changes

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(),
 		})
 	}

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
+}

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"])
+}