| 1 | package provider |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "sort" |
| 6 | ) |
| 7 | |
| 8 | // CanonicalizeSchema recursively stabilizes a JSON Schema so the same logical |
| 9 | // schema always produces the same byte representation. |
| 10 | func CanonicalizeSchema(raw json.RawMessage) json.RawMessage { |
| 11 | if len(raw) == 0 { |
| 12 | // A tool with no parameters (common for MCP tools) yields an empty |
| 13 | // schema. An empty json.RawMessage makes json.Marshal of the enclosing |
| 14 | // request fail ("unexpected end of JSON input") and bricks the whole |
| 15 | // provider; emit a strict OpenAI-compatible empty-object schema instead. |
| 16 | return json.RawMessage(`{"properties":{},"type":"object"}`) |
| 17 | } |
| 18 | var v any |
| 19 | if err := json.Unmarshal(raw, &v); err != nil { |
| 20 | return raw |
| 21 | } |
| 22 | if v == nil { |
| 23 | // A nil RawMessage persists as JSON null in the MCP schema cache. Treat |
| 24 | // both forms as the same no-argument schema so old cache entries remain |
| 25 | // usable and never reach a strict provider as parameters: null. |
| 26 | return json.RawMessage(`{"properties":{},"type":"object"}`) |
| 27 | } |
| 28 | canon := canonicalizeSchemaValue(v) |
| 29 | ensureRootObjectProperties(canon) |
| 30 | b, err := json.Marshal(canon) |
| 31 | if err != nil { |
| 32 | return raw |
| 33 | } |
| 34 | return json.RawMessage(b) |
| 35 | } |
| 36 | |
| 37 | func ensureRootObjectProperties(v any) { |
| 38 | m, ok := v.(map[string]any) |
| 39 | if !ok { |
| 40 | return |
| 41 | } |
| 42 | if _, ok := m["type"]; !ok { |
| 43 | // MCP servers routinely omit the root type (or advertise a bare {}), |
| 44 | // while MCP and the Anthropic/OpenAI tool contracts require tool |
| 45 | // parameters to declare type "object". Tool arguments are always JSON |
| 46 | // objects, so the omission can only mean an object schema; make it |
| 47 | // explicit instead of letting validation quarantine a usable tool. |
| 48 | m["type"] = "object" |
| 49 | } |
| 50 | if m["type"] != "object" { |
| 51 | return |
| 52 | } |
| 53 | if _, ok := m["properties"]; !ok { |
| 54 | m["properties"] = map[string]any{} |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | func canonicalizeSchemaValue(v any) any { |
| 59 | return canonicalizeSchemaObject(v) |
| 60 | } |
| 61 | |
| 62 | func canonicalizeSchemaObject(v any) any { |
| 63 | switch val := v.(type) { |
| 64 | case map[string]any: |
| 65 | for k, inner := range val { |
| 66 | switch k { |
| 67 | case "properties", "patternProperties", "$defs", "definitions", "dependentSchemas": |
| 68 | val[k] = canonicalizeNamedSchemas(inner) |
| 69 | case "dependentRequired": |
| 70 | val[k] = canonicalizeDependentRequired(inner) |
| 71 | default: |
| 72 | val[k] = canonicalizeSchemaObject(inner) |
| 73 | } |
| 74 | } |
| 75 | if req, ok := val["required"]; ok { |
| 76 | if arr, ok := req.([]any); ok { |
| 77 | sortSchemaArray(arr) |
| 78 | } else { |
| 79 | // Some MCP servers emit OpenAPI-style property metadata such as |
| 80 | // {"required": true}. OpenAI-compatible function schemas require |
| 81 | // JSON Schema's array form; dropping the invalid value keeps the |
| 82 | // whole tool list from being rejected with HTTP 400. |
| 83 | delete(val, "required") |
| 84 | } |
| 85 | } |
| 86 | if dr, ok := val["dependentRequired"]; ok && !isJSONObject(dr) { |
| 87 | delete(val, "dependentRequired") |
| 88 | } |
| 89 | return val |
| 90 | case []any: |
| 91 | for i, elem := range val { |
| 92 | val[i] = canonicalizeSchemaObject(elem) |
| 93 | } |
| 94 | return val |
| 95 | default: |
| 96 | return v |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | func canonicalizeNamedSchemas(v any) any { |
| 101 | m, ok := v.(map[string]any) |
| 102 | if !ok { |
| 103 | return canonicalizeSchemaObject(v) |
| 104 | } |
| 105 | for name, schema := range m { |
| 106 | m[name] = canonicalizeSchemaObject(schema) |
| 107 | } |
| 108 | return m |
| 109 | } |
| 110 | |
| 111 | func canonicalizeDependentRequired(v any) any { |
| 112 | m, ok := v.(map[string]any) |
| 113 | if !ok { |
| 114 | return v |
| 115 | } |
| 116 | for key, inner := range m { |
| 117 | if arr, ok := inner.([]any); ok { |
| 118 | sortSchemaArray(arr) |
| 119 | } else { |
| 120 | delete(m, key) |
| 121 | } |
| 122 | } |
| 123 | return m |
| 124 | } |
| 125 | |
| 126 | func isJSONObject(v any) bool { |
| 127 | _, ok := v.(map[string]any) |
| 128 | return ok |
| 129 | } |
| 130 | |
| 131 | func sortSchemaArray(arr []any) { |
| 132 | sort.SliceStable(arr, func(i, j int) bool { |
| 133 | return schemaJSONString(arr[i]) < schemaJSONString(arr[j]) |
| 134 | }) |
| 135 | } |
| 136 | |
| 137 | func schemaJSONString(v any) string { |
| 138 | b, _ := json.Marshal(v) |
| 139 | return string(b) |
| 140 | } |
| 141 |