| 1 | package provider |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/json" |
| 6 | "net/url" |
| 7 | "strings" |
| 8 | ) |
| 9 | |
| 10 | // IsMiMoEndpoint reports whether rawURL points at an official Xiaomi MiMo API |
| 11 | // host, including the regional token-plan subdomains. The bare apex is rejected |
| 12 | // because it is not an API endpoint. |
| 13 | func IsMiMoEndpoint(rawURL string) bool { |
| 14 | u, err := url.Parse(rawURL) |
| 15 | if err != nil { |
| 16 | return false |
| 17 | } |
| 18 | host := strings.ToLower(u.Hostname()) |
| 19 | return host != "xiaomimimo.com" && strings.HasSuffix(host, ".xiaomimimo.com") |
| 20 | } |
| 21 | |
| 22 | // NormalizeLegacyTupleItemsForDraft202012 rewrites only the pre-2020-12 tuple |
| 23 | // keywords in a JSON Schema. It is intentionally separate from |
| 24 | // CanonicalizeSchema: provider implementations must opt in only after the target |
| 25 | // endpoint's schema dialect is known, so other vendors keep their original tool |
| 26 | // schema bytes and cache prefixes. A schema that needs no rewrite is returned |
| 27 | // with its input bytes unchanged, byte for byte. |
| 28 | // |
| 29 | // There is deliberately no memoization: MCP schemas are externally supplied |
| 30 | // bytes, and a process-global cache keyed by them would accumulate without |
| 31 | // bound across projects, sessions, and server reconnects in a long-lived |
| 32 | // desktop process. The lexical gate below keeps the common no-op case at zero |
| 33 | // parses, and the rare legacy-tuple schema is cheap to re-convert per request. |
| 34 | func NormalizeLegacyTupleItemsForDraft202012(raw json.RawMessage) json.RawMessage { |
| 35 | if len(raw) == 0 { |
| 36 | return raw |
| 37 | } |
| 38 | // Legacy tuple syntax cannot exist without an "items" keyword; the common |
| 39 | // no-op case skips even the parse. |
| 40 | if !bytes.Contains(raw, []byte(`"items"`)) { |
| 41 | return raw |
| 42 | } |
| 43 | var schema any |
| 44 | if err := json.Unmarshal(raw, &schema); err != nil { |
| 45 | return raw |
| 46 | } |
| 47 | if _, documentChanged := normalizeDraft202012Schema(schema); !documentChanged { |
| 48 | return raw |
| 49 | } |
| 50 | out, err := json.Marshal(schema) |
| 51 | if err != nil { |
| 52 | return raw |
| 53 | } |
| 54 | return json.RawMessage(out) |
| 55 | } |
| 56 | |
| 57 | // normalizeDraft202012Schema rewrites legacy tuple keywords in place. It |
| 58 | // returns two signals: resourceChanged reports conversions that belong to the |
| 59 | // CALLER's schema resource (the node itself plus descendants up to the next |
| 60 | // $schema boundary), documentChanged reports conversions anywhere in the |
| 61 | // subtree and drives reserialization. |
| 62 | // |
| 63 | // The distinction is what keeps dialect updates inside their own resource: an |
| 64 | // object carrying its own $schema is an independent schema resource, so its |
| 65 | // conversions update its own old-draft declaration and then stop — they must |
| 66 | // not mark the parent as changed, or a 2019-09 parent embedding a converting |
| 67 | // 2020-12 $defs resource would have its untouched declaration "upgraded" and |
| 68 | // the semantics of unrelated parent keywords silently changed. |
| 69 | func normalizeDraft202012Schema(value any) (resourceChanged, documentChanged bool) { |
| 70 | schema, ok := value.(map[string]any) |
| 71 | if !ok { |
| 72 | return false, false |
| 73 | } |
| 74 | // Classify the resource's own dialect BEFORE touching anything: for an |
| 75 | // unknown/custom $schema this code cannot know whether the dialect defines |
| 76 | // 2020-12 tuple semantics, and a partial rewrite (keywords converted, |
| 77 | // declaration kept) would be self-contradictory in the other direction. |
| 78 | // JSON Schema requires processors to switch modes per dialect or leave the |
| 79 | // resource alone — so the whole custom resource, subtree included, stays |
| 80 | // untouched. Known legacy drafts and 2020-12 itself (where array-form |
| 81 | // items is simply malformed input worth repairing) proceed. |
| 82 | decl, hasDecl := schema["$schema"].(string) |
| 83 | if hasDecl && !isLegacyJSONSchemaDialect(decl) && !isDraft202012Dialect(decl) { |
| 84 | return false, false |
| 85 | } |
| 86 | changed := false // within this resource, up to nested $schema boundaries |
| 87 | doc := false |
| 88 | visit := func(child any) { |
| 89 | childResource, childDocument := normalizeDraft202012Schema(child) |
| 90 | changed = changed || childResource |
| 91 | doc = doc || childDocument |
| 92 | } |
| 93 | |
| 94 | for _, keyword := range []string{ |
| 95 | "additionalItems", "additionalProperties", "contains", "contentSchema", |
| 96 | "else", "if", "items", "not", "propertyNames", "then", |
| 97 | "unevaluatedItems", "unevaluatedProperties", |
| 98 | } { |
| 99 | visit(schema[keyword]) |
| 100 | } |
| 101 | for _, keyword := range []string{"allOf", "anyOf", "oneOf", "prefixItems"} { |
| 102 | if children, ok := schema[keyword].([]any); ok { |
| 103 | for _, child := range children { |
| 104 | visit(child) |
| 105 | } |
| 106 | } |
| 107 | } |
| 108 | for _, keyword := range []string{ |
| 109 | "$defs", "definitions", "dependentSchemas", "patternProperties", "properties", |
| 110 | } { |
| 111 | if children, ok := schema[keyword].(map[string]any); ok { |
| 112 | for _, child := range children { |
| 113 | visit(child) |
| 114 | } |
| 115 | } |
| 116 | } |
| 117 | if dependencies, ok := schema["dependencies"].(map[string]any); ok { |
| 118 | for _, child := range dependencies { |
| 119 | visit(child) |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | if legacyItems, ok := schema["items"].([]any); ok { |
| 124 | for _, child := range legacyItems { |
| 125 | visit(child) |
| 126 | } |
| 127 | changed = true |
| 128 | delete(schema, "items") |
| 129 | if len(legacyItems) > 0 { |
| 130 | // Keep an explicit 2020-12 prefix if a malformed mixed-dialect schema |
| 131 | // contains both forms. |
| 132 | if _, exists := schema["prefixItems"]; !exists { |
| 133 | schema["prefixItems"] = legacyItems |
| 134 | } |
| 135 | } |
| 136 | if additional, exists := schema["additionalItems"]; exists { |
| 137 | delete(schema, "additionalItems") |
| 138 | if isSchemaObjectOrBool(additional) { |
| 139 | schema["items"] = additional |
| 140 | } |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | if changed { |
| 145 | doc = true |
| 146 | if hasDecl && isLegacyJSONSchemaDialect(decl) { |
| 147 | schema["$schema"] = "https://json-schema.org/draft/2020-12/schema" |
| 148 | } |
| 149 | } |
| 150 | if hasDecl { |
| 151 | // Resource boundary: this resource's changes (and its declaration |
| 152 | // update) are settled here and must not leak into the parent. |
| 153 | return false, doc |
| 154 | } |
| 155 | return changed, doc |
| 156 | } |
| 157 | |
| 158 | // isLegacyJSONSchemaDialect reports whether decl names a pre-2020-12 JSON |
| 159 | // Schema dialect. Unknown or custom dialect URIs make the whole resource |
| 160 | // off-limits — this normalizer only understands the official drafts' tuple |
| 161 | // semantics. |
| 162 | func isLegacyJSONSchemaDialect(decl string) bool { |
| 163 | switch normalizeDialectURI(decl) { |
| 164 | case "json-schema.org/schema", |
| 165 | "json-schema.org/draft-03/schema", |
| 166 | "json-schema.org/draft-04/schema", |
| 167 | "json-schema.org/draft-06/schema", |
| 168 | "json-schema.org/draft-07/schema", |
| 169 | "json-schema.org/draft/2019-09/schema": |
| 170 | return true |
| 171 | } |
| 172 | return false |
| 173 | } |
| 174 | |
| 175 | // isDraft202012Dialect reports whether decl names the 2020-12 dialect itself. |
| 176 | func isDraft202012Dialect(decl string) bool { |
| 177 | return normalizeDialectURI(decl) == "json-schema.org/draft/2020-12/schema" |
| 178 | } |
| 179 | |
| 180 | func normalizeDialectURI(decl string) string { |
| 181 | d := strings.TrimSuffix(strings.TrimSpace(decl), "#") |
| 182 | d = strings.TrimPrefix(d, "http://") |
| 183 | return strings.TrimPrefix(d, "https://") |
| 184 | } |
| 185 | |
| 186 | func isSchemaObjectOrBool(value any) bool { |
| 187 | switch value.(type) { |
| 188 | case map[string]any, bool: |
| 189 | return true |
| 190 | default: |
| 191 | return false |
| 192 | } |
| 193 | } |
| 194 |