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

124 lines
2.9 KiB
Go

package provider
import (
"encoding/json"
"sort"
)
// CanonicalizeSchema recursively stabilizes a JSON Schema so the same logical
// schema always produces the same byte representation.
func CanonicalizeSchema(raw json.RawMessage) json.RawMessage {
if len(raw) == 0 {
// A tool with no parameters (common for MCP tools) yields an empty
// schema. An empty json.RawMessage makes json.Marshal of the enclosing
// request fail ("unexpected end of JSON input") and bricks the whole
// provider; emit a strict OpenAI-compatible empty-object schema instead.
return json.RawMessage(`{"properties":{},"type":"object"}`)
}
var v any
if err := json.Unmarshal(raw, &v); err != nil {
return raw
}
canon := canonicalizeSchemaValue(v)
ensureRootObjectProperties(canon)
b, err := json.Marshal(canon)
if err != nil {
return raw
}
return json.RawMessage(b)
}
func ensureRootObjectProperties(v any) {
m, ok := v.(map[string]any)
if !ok || m["type"] != "object" {
return
}
if _, ok := m["properties"]; !ok {
m["properties"] = map[string]any{}
}
}
func canonicalizeSchemaValue(v any) any {
return canonicalizeSchemaObject(v)
}
func canonicalizeSchemaObject(v any) any {
switch val := v.(type) {
case map[string]any:
for k, inner := range val {
switch k {
case "properties", "patternProperties", "$defs", "definitions", "dependentSchemas":
val[k] = canonicalizeNamedSchemas(inner)
case "dependentRequired":
val[k] = canonicalizeDependentRequired(inner)
default:
val[k] = canonicalizeSchemaObject(inner)
}
}
if req, ok := val["required"]; ok {
if arr, ok := req.([]any); ok {
sortSchemaArray(arr)
} else {
// Some MCP servers emit OpenAPI-style property metadata such as
// {"required": true}. OpenAI-compatible function schemas require
// JSON Schema's array form; dropping the invalid value keeps the
// whole tool list from being rejected with HTTP 400.
delete(val, "required")
}
}
if dr, ok := val["dependentRequired"]; ok && !isJSONObject(dr) {
delete(val, "dependentRequired")
}
return val
case []any:
for i, elem := range val {
val[i] = canonicalizeSchemaObject(elem)
}
return val
default:
return v
}
}
func canonicalizeNamedSchemas(v any) any {
m, ok := v.(map[string]any)
if !ok {
return canonicalizeSchemaObject(v)
}
for name, schema := range m {
m[name] = canonicalizeSchemaObject(schema)
}
return m
}
func canonicalizeDependentRequired(v any) any {
m, ok := v.(map[string]any)
if !ok {
return v
}
for key, inner := range m {
if arr, ok := inner.([]any); ok {
sortSchemaArray(arr)
} else {
delete(m, key)
}
}
return m
}
func isJSONObject(v any) bool {
_, ok := v.(map[string]any)
return ok
}
func sortSchemaArray(arr []any) {
sort.SliceStable(arr, func(i, j int) bool {
return schemaJSONString(arr[i]) < schemaJSONString(arr[j])
})
}
func schemaJSONString(v any) string {
b, _ := json.Marshal(v)
return string(b)
}