-
Notifications
You must be signed in to change notification settings - Fork 16
Expand file tree
/
Copy pathmcp_boolschema_test.go
More file actions
157 lines (143 loc) · 4.81 KB
/
Copy pathmcp_boolschema_test.go
File metadata and controls
157 lines (143 loc) · 4.81 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
package cogito
import (
"encoding/json"
"testing"
"github.com/tmc/langchaingo/jsonschema"
)
// containsBoolSchema reports whether any schema-valued slot in v is still a
// JSON boolean (i.e. the normalization missed it). Mirrors containsTypeArray.
func containsBoolSchema(v any) bool {
// Keywords whose value IS a schema, so a bool there is a boolean schema.
// "properties"/"$defs" etc. are bags of schemas and handled by recursion.
single := []string{"items", "additionalProperties", "contains", "not",
"if", "then", "else", "propertyNames"}
switch n := v.(type) {
case map[string]any:
for _, k := range single {
if _, ok := n[k].(bool); ok {
return true
}
}
for _, child := range n {
if containsBoolSchema(child) {
return true
}
}
case []any:
for _, child := range n {
if containsBoolSchema(child) {
return true
}
}
}
return false
}
// JSON Schema 2020-12 lets any schema be the boolean `true` ("allow anything")
// or `false` ("allow nothing"), and google/jsonschema-go emits exactly that for
// a Go `any` — an empty schema marshals as `true`. langchaingo's
// jsonschema.Definition models every nested schema as a *Definition, so a bool
// fails the unmarshal and mcpToolsFromTransport drops the ENTIRE tool with a
// single log line. The server is healthy and its tool simply never reaches the
// model. Same failure class as the type-array coercion next door.
func TestCoerceBooleanSchemas(t *testing.T) {
cases := []struct {
name string
in string
}{
{
// The real-world shape that surfaced this: Go [][]any (rows of
// cells) infers `items: true` for the innermost element.
name: "items true nested in array of arrays",
in: `{"rows":{"type":"array","items":{"type":"array","items":true}}}`,
},
{
name: "additionalProperties false",
in: `{"cfg":{"type":"object","additionalProperties":false}}`,
},
{
name: "bool schema inside properties",
in: `{"outer":{"type":"object","properties":{"anything":true}}}`,
},
{
name: "bool schema in composition keyword",
in: `{"x":{"anyOf":[true,{"type":"string"}]}}`,
},
{
name: "bool schema in $defs and prefixItems",
in: `{"x":{"$defs":{"any":true},"prefixItems":[true,{"type":"string"}]}}`,
},
{
name: "false schema is preserved as allow-nothing",
in: `{"never":{"type":"array","items":false}}`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var props map[string]any
if err := json.Unmarshal([]byte(tc.in), &props); err != nil {
t.Fatalf("bad test fixture: %v", err)
}
coerceNullableTypes(props)
if containsBoolSchema(props) {
got, _ := json.Marshal(props)
t.Errorf("a boolean schema survived normalization: %s", got)
}
// The point of the exercise: cogito must be able to convert the
// result into langchaingo Definitions, or it drops the tool.
dat, err := json.Marshal(props)
if err != nil {
t.Fatalf("marshal: %v", err)
}
defs := map[string]jsonschema.Definition{}
if err := json.Unmarshal(dat, &defs); err != nil {
t.Errorf("tool would be DROPPED — properties do not convert to langchaingo Definitions: %v\ngot: %s", err, dat)
}
})
}
}
// `true` means "anything allowed" and `false` means "nothing allowed". The
// rewrite must preserve that distinction rather than flattening both to an
// empty schema, which would silently widen a deliberately-closed schema.
func TestBooleanSchemaSemanticsPreserved(t *testing.T) {
var props map[string]any
if err := json.Unmarshal([]byte(`{"a":{"items":true},"b":{"items":false}}`), &props); err != nil {
t.Fatal(err)
}
coerceNullableTypes(props)
a := props["a"].(map[string]any)["items"]
if m, ok := a.(map[string]any); !ok || len(m) != 0 {
t.Errorf(`items:true must become the empty schema {} (allow anything), got %#v`, a)
}
b := props["b"].(map[string]any)["items"]
m, ok := b.(map[string]any)
if !ok {
t.Fatalf(`items:false must become an object, got %#v`, b)
}
if _, hasNot := m["not"]; !hasNot {
t.Errorf(`items:false must become {"not":{}} (allow nothing), got %#v`, m)
}
}
// Normalization must not disturb a schema that was already convertible.
func TestNormalizationLeavesPlainSchemasAlone(t *testing.T) {
const in = `{"name":{"type":"string","description":"a name"},"tags":{"type":"array","items":{"type":"string"}}}`
var props map[string]any
if err := json.Unmarshal([]byte(in), &props); err != nil {
t.Fatal(err)
}
coerceNullableTypes(props)
got, err := json.Marshal(props)
if err != nil {
t.Fatal(err)
}
var want, have any
_ = json.Unmarshal([]byte(in), &want)
_ = json.Unmarshal(got, &have)
if string(got) == "" || !jsonEqual(want, have) {
t.Errorf("plain schema was modified:\n want %s\n got %s", in, got)
}
}
func jsonEqual(a, b any) bool {
x, _ := json.Marshal(a)
y, _ := json.Marshal(b)
return string(x) == string(y)
}