Skip to content

Commit a7e3596

Browse files
committed
fix(gemini): normalize malformed schema nodes before cleanup
- Added a preprocessing pass to repair malformed MCP-style JSON schemas. - Wraps bare property maps as object schemas, promotes `required: true` flags to parent `required` arrays, and removes boolean `required` from properties. - Recurses through nested schema containers and skips known API request envelopes to avoid rewriting non-schema docs. - Switched schema decoding/serialization path to preserve large numeric values and avoid HTML-escaping side effects. Closes: router-for-me#5178
1 parent 1be8417 commit a7e3596

2 files changed

Lines changed: 973 additions & 0 deletions

File tree

internal/util/gemini_schema.go

Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
package util
33

44
import (
5+
"bytes"
56
"encoding/json"
67
"fmt"
78
"sort"
@@ -91,6 +92,9 @@ func CleanJSONSchemaForGemini(jsonStr string) string {
9192

9293
// cleanJSONSchema performs the core cleaning operations on the JSON schema.
9394
func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string {
95+
// Phase 0: Normalize malformed schemas (e.g. bare property maps and boolean required from MCP tools)
96+
jsonStr = normalizeMalformedSchemaObjects(jsonStr)
97+
9498
// Phase 1: Convert and add hints
9599
if options.antigravitySemantics {
96100
jsonStr = inlineLocalRefs(jsonStr)
@@ -222,6 +226,361 @@ func removePlaceholderFields(jsonStr string) string {
222226
return jsonStr
223227
}
224228

229+
// normalizeMalformedSchemaObjects normalizes malformed JSON schema nodes commonly produced by
230+
// certain MCP tool definitions (e.g. Asana MCP server):
231+
// 1. Bare property maps missing the "type": "object" and "properties": {...} wrappers are wrapped.
232+
// 2. Boolean "required": true on property definitions are stripped and promoted to the parent's "required" array.
233+
func normalizeMalformedSchemaObjects(jsonStr string) string {
234+
if jsonStr == "" {
235+
return jsonStr
236+
}
237+
238+
decoder := json.NewDecoder(strings.NewReader(jsonStr))
239+
decoder.UseNumber()
240+
var root any
241+
if err := decoder.Decode(&root); err != nil {
242+
return jsonStr
243+
}
244+
245+
rootMap, ok := root.(map[string]any)
246+
if !ok || isAPIRequestDocument(rootMap) {
247+
return jsonStr
248+
}
249+
250+
// If wrapped in single-key {"schema": ...} by cleanNestedSchema, unwrap, repair, and re-wrap.
251+
if len(rootMap) == 1 {
252+
if innerSchema, ok := rootMap["schema"].(map[string]any); ok {
253+
repairedInner, modified := repairSchemaNode(innerSchema)
254+
if !modified {
255+
return jsonStr
256+
}
257+
out, err := marshalJSONNoHTMLEscape(map[string]any{"schema": repairedInner})
258+
if err != nil {
259+
return jsonStr
260+
}
261+
return string(out)
262+
}
263+
}
264+
265+
repaired, modified := repairSchemaNode(rootMap)
266+
if !modified {
267+
return jsonStr
268+
}
269+
270+
out, err := marshalJSONNoHTMLEscape(repaired)
271+
if err != nil {
272+
return jsonStr
273+
}
274+
return string(out)
275+
}
276+
277+
func marshalJSONNoHTMLEscape(v any) ([]byte, error) {
278+
var buf bytes.Buffer
279+
enc := json.NewEncoder(&buf)
280+
enc.SetEscapeHTML(false)
281+
if err := enc.Encode(v); err != nil {
282+
return nil, err
283+
}
284+
b := buf.Bytes()
285+
if len(b) > 0 && b[len(b)-1] == '\n' {
286+
b = b[:len(b)-1]
287+
}
288+
return b, nil
289+
}
290+
291+
func isKnownSchemaKeywordOrExtension(key string) bool {
292+
if strings.HasPrefix(key, "x-") {
293+
return true
294+
}
295+
switch key {
296+
case "properties", "patternProperties", "additionalProperties", "items", "prefixItems",
297+
"$defs", "definitions", "dependentSchemas", "dependentRequired", "dependencies",
298+
"if", "then", "else", "not", "contains", "propertyNames",
299+
"unevaluatedProperties", "unevaluatedItems", "contentSchema", "additionalItems",
300+
"default", "const", "example", "examples", "discriminator", "xml", "externalDocs",
301+
"enumDescriptions", "enumTitles":
302+
return true
303+
}
304+
return false
305+
}
306+
307+
func isNonObjectDeclaredType(t any) bool {
308+
if s, ok := t.(string); ok {
309+
return s != "" && s != "object"
310+
}
311+
if arr, ok := t.([]any); ok {
312+
for _, item := range arr {
313+
if s, ok := item.(string); ok && s == "object" {
314+
return false
315+
}
316+
}
317+
return len(arr) > 0
318+
}
319+
return false
320+
}
321+
322+
func isAPIRequestDocument(m map[string]any) bool {
323+
if _, ok := m["tools"].([]any); ok {
324+
return true
325+
}
326+
if _, ok := m["contents"].([]any); ok {
327+
return true
328+
}
329+
if _, ok := m["messages"].([]any); ok {
330+
return true
331+
}
332+
if _, ok := m["functionDeclarations"].([]any); ok {
333+
return true
334+
}
335+
if _, ok := m["function_declarations"].([]any); ok {
336+
return true
337+
}
338+
if reqMap, ok := m["request"].(map[string]any); ok {
339+
if isAPIRequestDocument(reqMap) {
340+
return true
341+
}
342+
}
343+
return false
344+
}
345+
346+
func repairSchemaNode(node map[string]any) (map[string]any, bool) {
347+
if node == nil {
348+
return nil, false
349+
}
350+
351+
modified := false
352+
clone := make(map[string]any, len(node))
353+
for k, v := range node {
354+
clone[k] = v
355+
}
356+
357+
// 1. If not declared as a primitive/array type, collect bare property definition maps
358+
if !isNonObjectDeclaredType(clone["type"]) {
359+
var bareProps map[string]any
360+
for k, v := range clone {
361+
if childMap, isMap := v.(map[string]any); isMap {
362+
if !isKnownSchemaKeywordOrExtension(k) {
363+
if bareProps == nil {
364+
bareProps = make(map[string]any)
365+
}
366+
bareProps[k] = childMap
367+
}
368+
}
369+
}
370+
371+
if len(bareProps) > 0 {
372+
repairedProps, promotedReqs, _ := repairPropertyMap(bareProps)
373+
for k := range bareProps {
374+
delete(clone, k)
375+
}
376+
377+
if existingProps, ok := clone["properties"].(map[string]any); ok {
378+
newProps := make(map[string]any, len(existingProps)+len(repairedProps))
379+
for k, v := range existingProps {
380+
newProps[k] = v
381+
}
382+
for k, v := range repairedProps {
383+
newProps[k] = v
384+
}
385+
clone["properties"] = newProps
386+
} else {
387+
clone["properties"] = repairedProps
388+
if _, hasType := clone["type"]; !hasType {
389+
clone["type"] = "object"
390+
}
391+
}
392+
393+
if len(promotedReqs) > 0 {
394+
existingReqs := extractStringArray(clone["required"])
395+
merged := mergeStringSlices(existingReqs, promotedReqs)
396+
clone["required"] = merged
397+
}
398+
modified = true
399+
}
400+
}
401+
402+
// 2. If node has a "properties" map, recursively repair all properties inside it
403+
if propsVal, ok := clone["properties"].(map[string]any); ok {
404+
repairedProps, promotedReqs, propsMod := repairPropertyMap(propsVal)
405+
if propsMod {
406+
clone["properties"] = repairedProps
407+
modified = true
408+
}
409+
if len(promotedReqs) > 0 {
410+
existingReqs := extractStringArray(clone["required"])
411+
merged := mergeStringSlices(existingReqs, promotedReqs)
412+
clone["required"] = merged
413+
modified = true
414+
}
415+
}
416+
417+
// 3. Recurse into all other standard schema containers
418+
if itemsVal, ok := clone["items"].(map[string]any); ok {
419+
repairedItems, itemsMod := repairSchemaNode(itemsVal)
420+
if itemsMod {
421+
clone["items"] = repairedItems
422+
modified = true
423+
}
424+
} else if itemsList, ok := clone["items"].([]any); ok {
425+
repairedList, listMod := repairSchemaList(itemsList)
426+
if listMod {
427+
clone["items"] = repairedList
428+
modified = true
429+
}
430+
}
431+
432+
if addProps, ok := clone["additionalProperties"].(map[string]any); ok {
433+
repairedAddProps, addPropsMod := repairSchemaNode(addProps)
434+
if addPropsMod {
435+
clone["additionalProperties"] = repairedAddProps
436+
modified = true
437+
}
438+
}
439+
440+
if patProps, ok := clone["patternProperties"].(map[string]any); ok {
441+
repairedPatProps, _, patMod := repairPropertyMap(patProps)
442+
if patMod {
443+
clone["patternProperties"] = repairedPatProps
444+
modified = true
445+
}
446+
}
447+
448+
for _, key := range []string{"if", "then", "else", "not", "contains", "propertyNames", "unevaluatedProperties", "unevaluatedItems", "contentSchema", "additionalItems"} {
449+
if subVal, ok := clone[key].(map[string]any); ok {
450+
repairedSub, subMod := repairSchemaNode(subVal)
451+
if subMod {
452+
clone[key] = repairedSub
453+
modified = true
454+
}
455+
}
456+
}
457+
458+
for _, key := range []string{"anyOf", "oneOf", "allOf", "prefixItems"} {
459+
if listVal, ok := clone[key].([]any); ok {
460+
repairedList, listMod := repairSchemaList(listVal)
461+
if listMod {
462+
clone[key] = repairedList
463+
modified = true
464+
}
465+
}
466+
}
467+
468+
for _, key := range []string{"$defs", "definitions", "dependentSchemas"} {
469+
if defsVal, ok := clone[key].(map[string]any); ok {
470+
repairedDefs := make(map[string]any, len(defsVal))
471+
defsModified := false
472+
for dk, dv := range defsVal {
473+
if defMap, ok := dv.(map[string]any); ok {
474+
repairedDef, defMod := repairSchemaNode(defMap)
475+
repairedDefs[dk] = repairedDef
476+
if defMod {
477+
defsModified = true
478+
modified = true
479+
}
480+
} else {
481+
repairedDefs[dk] = dv
482+
}
483+
}
484+
if defsModified {
485+
clone[key] = repairedDefs
486+
}
487+
}
488+
}
489+
490+
return clone, modified
491+
}
492+
493+
func repairSchemaList(list []any) ([]any, bool) {
494+
var repairedList []any
495+
listModified := false
496+
for _, item := range list {
497+
if itemMap, ok := item.(map[string]any); ok {
498+
repairedItem, itemMod := repairSchemaNode(itemMap)
499+
repairedList = append(repairedList, repairedItem)
500+
if itemMod {
501+
listModified = true
502+
}
503+
} else {
504+
repairedList = append(repairedList, item)
505+
}
506+
}
507+
return repairedList, listModified
508+
}
509+
510+
func repairPropertyMap(props map[string]any) (map[string]any, []string, bool) {
511+
out := make(map[string]any, len(props))
512+
var promotedReqs []string
513+
modified := false
514+
515+
for k, v := range props {
516+
childMap, isMap := v.(map[string]any)
517+
if !isMap {
518+
out[k] = v
519+
continue
520+
}
521+
522+
childClone := make(map[string]any, len(childMap))
523+
for ck, cv := range childMap {
524+
childClone[ck] = cv
525+
}
526+
527+
if reqBool, isBool := childClone["required"].(bool); isBool {
528+
delete(childClone, "required")
529+
modified = true
530+
if reqBool {
531+
promotedReqs = append(promotedReqs, k)
532+
}
533+
}
534+
535+
repairedChild, childMod := repairSchemaNode(childClone)
536+
if childMod {
537+
modified = true
538+
}
539+
out[k] = repairedChild
540+
}
541+
542+
sort.Strings(promotedReqs)
543+
return out, promotedReqs, modified
544+
}
545+
546+
func extractStringArray(val any) []string {
547+
if val == nil {
548+
return nil
549+
}
550+
arr, ok := val.([]any)
551+
if !ok {
552+
if strArr, ok := val.([]string); ok {
553+
return strArr
554+
}
555+
return nil
556+
}
557+
var res []string
558+
for _, item := range arr {
559+
if s, ok := item.(string); ok {
560+
res = append(res, s)
561+
}
562+
}
563+
return res
564+
}
565+
566+
func mergeStringSlices(existing, promoted []string) []string {
567+
seen := make(map[string]bool)
568+
var res []string
569+
for _, s := range existing {
570+
if !seen[s] && s != "" {
571+
seen[s] = true
572+
res = append(res, s)
573+
}
574+
}
575+
for _, s := range promoted {
576+
if !seen[s] && s != "" {
577+
seen[s] = true
578+
res = append(res, s)
579+
}
580+
}
581+
return res
582+
}
583+
225584
// inlineLocalRefs resolves JSON Pointer references against the original schema before definition
226585
// containers are stripped. Each expansion receives its own copy, sibling keywords override the
227586
// referenced definition, and cycles terminate as a typed hint instead of recursing forever.

0 commit comments

Comments
 (0)