Files
2026-07-13 13:00:08 +08:00

194 lines
6.5 KiB
Go

package provider
import (
"bytes"
"encoding/json"
"net/url"
"strings"
)
// IsMiMoEndpoint reports whether rawURL points at an official Xiaomi MiMo API
// host, including the regional token-plan subdomains. The bare apex is rejected
// because it is not an API endpoint.
func IsMiMoEndpoint(rawURL string) bool {
u, err := url.Parse(rawURL)
if err != nil {
return false
}
host := strings.ToLower(u.Hostname())
return host != "xiaomimimo.com" && strings.HasSuffix(host, ".xiaomimimo.com")
}
// NormalizeLegacyTupleItemsForDraft202012 rewrites only the pre-2020-12 tuple
// keywords in a JSON Schema. It is intentionally separate from
// CanonicalizeSchema: provider implementations must opt in only after the target
// endpoint's schema dialect is known, so other vendors keep their original tool
// schema bytes and cache prefixes. A schema that needs no rewrite is returned
// with its input bytes unchanged, byte for byte.
//
// There is deliberately no memoization: MCP schemas are externally supplied
// bytes, and a process-global cache keyed by them would accumulate without
// bound across projects, sessions, and server reconnects in a long-lived
// desktop process. The lexical gate below keeps the common no-op case at zero
// parses, and the rare legacy-tuple schema is cheap to re-convert per request.
func NormalizeLegacyTupleItemsForDraft202012(raw json.RawMessage) json.RawMessage {
if len(raw) == 0 {
return raw
}
// Legacy tuple syntax cannot exist without an "items" keyword; the common
// no-op case skips even the parse.
if !bytes.Contains(raw, []byte(`"items"`)) {
return raw
}
var schema any
if err := json.Unmarshal(raw, &schema); err != nil {
return raw
}
if _, documentChanged := normalizeDraft202012Schema(schema); !documentChanged {
return raw
}
out, err := json.Marshal(schema)
if err != nil {
return raw
}
return json.RawMessage(out)
}
// normalizeDraft202012Schema rewrites legacy tuple keywords in place. It
// returns two signals: resourceChanged reports conversions that belong to the
// CALLER's schema resource (the node itself plus descendants up to the next
// $schema boundary), documentChanged reports conversions anywhere in the
// subtree and drives reserialization.
//
// The distinction is what keeps dialect updates inside their own resource: an
// object carrying its own $schema is an independent schema resource, so its
// conversions update its own old-draft declaration and then stop — they must
// not mark the parent as changed, or a 2019-09 parent embedding a converting
// 2020-12 $defs resource would have its untouched declaration "upgraded" and
// the semantics of unrelated parent keywords silently changed.
func normalizeDraft202012Schema(value any) (resourceChanged, documentChanged bool) {
schema, ok := value.(map[string]any)
if !ok {
return false, false
}
// Classify the resource's own dialect BEFORE touching anything: for an
// unknown/custom $schema this code cannot know whether the dialect defines
// 2020-12 tuple semantics, and a partial rewrite (keywords converted,
// declaration kept) would be self-contradictory in the other direction.
// JSON Schema requires processors to switch modes per dialect or leave the
// resource alone — so the whole custom resource, subtree included, stays
// untouched. Known legacy drafts and 2020-12 itself (where array-form
// items is simply malformed input worth repairing) proceed.
decl, hasDecl := schema["$schema"].(string)
if hasDecl && !isLegacyJSONSchemaDialect(decl) && !isDraft202012Dialect(decl) {
return false, false
}
changed := false // within this resource, up to nested $schema boundaries
doc := false
visit := func(child any) {
childResource, childDocument := normalizeDraft202012Schema(child)
changed = changed || childResource
doc = doc || childDocument
}
for _, keyword := range []string{
"additionalItems", "additionalProperties", "contains", "contentSchema",
"else", "if", "items", "not", "propertyNames", "then",
"unevaluatedItems", "unevaluatedProperties",
} {
visit(schema[keyword])
}
for _, keyword := range []string{"allOf", "anyOf", "oneOf", "prefixItems"} {
if children, ok := schema[keyword].([]any); ok {
for _, child := range children {
visit(child)
}
}
}
for _, keyword := range []string{
"$defs", "definitions", "dependentSchemas", "patternProperties", "properties",
} {
if children, ok := schema[keyword].(map[string]any); ok {
for _, child := range children {
visit(child)
}
}
}
if dependencies, ok := schema["dependencies"].(map[string]any); ok {
for _, child := range dependencies {
visit(child)
}
}
if legacyItems, ok := schema["items"].([]any); ok {
for _, child := range legacyItems {
visit(child)
}
changed = true
delete(schema, "items")
if len(legacyItems) > 0 {
// Keep an explicit 2020-12 prefix if a malformed mixed-dialect schema
// contains both forms.
if _, exists := schema["prefixItems"]; !exists {
schema["prefixItems"] = legacyItems
}
}
if additional, exists := schema["additionalItems"]; exists {
delete(schema, "additionalItems")
if isSchemaObjectOrBool(additional) {
schema["items"] = additional
}
}
}
if changed {
doc = true
if hasDecl && isLegacyJSONSchemaDialect(decl) {
schema["$schema"] = "https://json-schema.org/draft/2020-12/schema"
}
}
if hasDecl {
// Resource boundary: this resource's changes (and its declaration
// update) are settled here and must not leak into the parent.
return false, doc
}
return changed, doc
}
// isLegacyJSONSchemaDialect reports whether decl names a pre-2020-12 JSON
// Schema dialect. Unknown or custom dialect URIs make the whole resource
// off-limits — this normalizer only understands the official drafts' tuple
// semantics.
func isLegacyJSONSchemaDialect(decl string) bool {
switch normalizeDialectURI(decl) {
case "json-schema.org/schema",
"json-schema.org/draft-03/schema",
"json-schema.org/draft-04/schema",
"json-schema.org/draft-06/schema",
"json-schema.org/draft-07/schema",
"json-schema.org/draft/2019-09/schema":
return true
}
return false
}
// isDraft202012Dialect reports whether decl names the 2020-12 dialect itself.
func isDraft202012Dialect(decl string) bool {
return normalizeDialectURI(decl) == "json-schema.org/draft/2020-12/schema"
}
func normalizeDialectURI(decl string) string {
d := strings.TrimSuffix(strings.TrimSpace(decl), "#")
d = strings.TrimPrefix(d, "http://")
return strings.TrimPrefix(d, "https://")
}
func isSchemaObjectOrBool(value any) bool {
switch value.(type) {
case map[string]any, bool:
return true
default:
return false
}
}