Skip to content

Commit 1b3a8c1

Browse files
corylanouclaude
andauthored
fix: convert JSON Schema draft-07 exclusive bounds to draft-04 format (#147)
* fix: convert JSON Schema draft-07 exclusive bounds to draft-04 format Chrome DevTools MCP and other MCP servers use JSON Schema draft-07 where exclusiveMinimum/exclusiveMaximum are numeric values representing the actual bounds. However, kin-openapi (OpenAPI 3.0) expects these fields as booleans that modify the minimum/maximum values (draft-04 format). This fix recursively processes input schemas to convert: - exclusiveMinimum: N → minimum: N, exclusiveMinimum: true - exclusiveMaximum: N → maximum: N, exclusiveMaximum: true Handles nested schemas in properties, items, additionalProperties, and schema composition keywords (allOf, anyOf, oneOf, not). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> * test: add table-driven tests for JSON Schema draft conversion Adds comprehensive tests for convertExclusiveBoundsToBoolean(): - Simple exclusiveMinimum/exclusiveMaximum conversion - Both bounds together - Already boolean values (draft-04 style, unchanged) - No exclusive bounds (unchanged) - Nested properties - Array items - allOf composition - additionalProperties - Real-world Chrome DevTools MCP schema example - Invalid JSON handling (returns unchanged) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> --------- Co-authored-by: Claude Opus 4.5 <[email protected]>
1 parent 4891f87 commit 1b3a8c1

File tree

2 files changed

+298
-0
lines changed

2 files changed

+298
-0
lines changed

internal/tools/mcp.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,13 @@ func (m *MCPToolManager) loadServerTools(ctx context.Context, serverName string,
221221
if err != nil {
222222
return fmt.Errorf("conv mcp tool input schema fail(marshal): %w, tool name: %s", err, mcpTool.Name)
223223
}
224+
225+
// Fix for JSON Schema draft-07 vs draft-04 compatibility:
226+
// Chrome DevTools MCP uses draft-07 where exclusiveMinimum/exclusiveMaximum are numbers,
227+
// but kin-openapi (OpenAPI 3.0) expects them as booleans (draft-04 format).
228+
// Pre-process the schema to convert numeric exclusive bounds to boolean format.
229+
marshaledInputSchema = convertExclusiveBoundsToBoolean(marshaledInputSchema)
230+
224231
inputSchema := &openapi3.Schema{}
225232
err = sonic.Unmarshal(marshaledInputSchema, inputSchema)
226233
if err != nil {
@@ -555,3 +562,81 @@ func (m *MCPToolManager) debugLogConnectionInfo(serverName string, serverConfig
555562
}
556563
}
557564
}
565+
566+
// convertExclusiveBoundsToBoolean converts JSON Schema draft-07 style exclusive bounds
567+
// (where exclusiveMinimum/exclusiveMaximum are numbers) to draft-04 style
568+
// (where they are booleans that modify minimum/maximum).
569+
// This enables compatibility with kin-openapi which uses OpenAPI 3.0 (draft-04 based) schemas.
570+
func convertExclusiveBoundsToBoolean(schemaJSON []byte) []byte {
571+
var data map[string]interface{}
572+
if err := json.Unmarshal(schemaJSON, &data); err != nil {
573+
return schemaJSON // Return unchanged on error
574+
}
575+
576+
convertSchemaRecursive(data)
577+
578+
result, err := json.Marshal(data)
579+
if err != nil {
580+
return schemaJSON // Return unchanged on error
581+
}
582+
return result
583+
}
584+
585+
// convertSchemaRecursive recursively processes a schema map and converts
586+
// numeric exclusiveMinimum/exclusiveMaximum to boolean format.
587+
func convertSchemaRecursive(schema map[string]interface{}) {
588+
// Convert exclusiveMinimum if it's a number
589+
if exMin, ok := schema["exclusiveMinimum"]; ok {
590+
if num, isNum := exMin.(float64); isNum {
591+
// JSON Schema draft-07: exclusiveMinimum is the limit value
592+
// Convert to draft-04: set minimum = value, exclusiveMinimum = true
593+
schema["minimum"] = num
594+
schema["exclusiveMinimum"] = true
595+
}
596+
}
597+
598+
// Convert exclusiveMaximum if it's a number
599+
if exMax, ok := schema["exclusiveMaximum"]; ok {
600+
if num, isNum := exMax.(float64); isNum {
601+
// JSON Schema draft-07: exclusiveMaximum is the limit value
602+
// Convert to draft-04: set maximum = value, exclusiveMaximum = true
603+
schema["maximum"] = num
604+
schema["exclusiveMaximum"] = true
605+
}
606+
}
607+
608+
// Recursively process properties
609+
if props, ok := schema["properties"].(map[string]interface{}); ok {
610+
for _, prop := range props {
611+
if propSchema, ok := prop.(map[string]interface{}); ok {
612+
convertSchemaRecursive(propSchema)
613+
}
614+
}
615+
}
616+
617+
// Recursively process items (for arrays)
618+
if items, ok := schema["items"].(map[string]interface{}); ok {
619+
convertSchemaRecursive(items)
620+
}
621+
622+
// Recursively process additionalProperties
623+
if addProps, ok := schema["additionalProperties"].(map[string]interface{}); ok {
624+
convertSchemaRecursive(addProps)
625+
}
626+
627+
// Recursively process allOf, anyOf, oneOf
628+
for _, key := range []string{"allOf", "anyOf", "oneOf"} {
629+
if arr, ok := schema[key].([]interface{}); ok {
630+
for _, item := range arr {
631+
if itemSchema, ok := item.(map[string]interface{}); ok {
632+
convertSchemaRecursive(itemSchema)
633+
}
634+
}
635+
}
636+
}
637+
638+
// Recursively process not
639+
if not, ok := schema["not"].(map[string]interface{}); ok {
640+
convertSchemaRecursive(not)
641+
}
642+
}

internal/tools/mcp_test.go

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

33
import (
44
"context"
5+
"encoding/json"
56
"testing"
67
"time"
78

@@ -159,6 +160,218 @@ func TestIssue89_ObjectSchemaMissingProperties(t *testing.T) {
159160
}
160161
}
161162

163+
// TestConvertExclusiveBoundsToBoolean tests the JSON Schema draft-07 to draft-04 conversion
164+
// for exclusiveMinimum and exclusiveMaximum fields.
165+
// Draft-07: exclusiveMinimum/exclusiveMaximum are numeric values (the actual bounds)
166+
// Draft-04: exclusiveMinimum/exclusiveMaximum are booleans that modify minimum/maximum
167+
func TestConvertExclusiveBoundsToBoolean(t *testing.T) {
168+
tests := []struct {
169+
name string
170+
input string
171+
expected map[string]interface{}
172+
}{
173+
{
174+
name: "exclusiveMinimum as number",
175+
input: `{"type": "number", "exclusiveMinimum": 0}`,
176+
expected: map[string]interface{}{
177+
"type": "number",
178+
"minimum": float64(0),
179+
"exclusiveMinimum": true,
180+
},
181+
},
182+
{
183+
name: "exclusiveMaximum as number",
184+
input: `{"type": "number", "exclusiveMaximum": 100}`,
185+
expected: map[string]interface{}{
186+
"type": "number",
187+
"maximum": float64(100),
188+
"exclusiveMaximum": true,
189+
},
190+
},
191+
{
192+
name: "both exclusive bounds as numbers",
193+
input: `{"type": "integer", "exclusiveMinimum": 1, "exclusiveMaximum": 10}`,
194+
expected: map[string]interface{}{
195+
"type": "integer",
196+
"minimum": float64(1),
197+
"exclusiveMinimum": true,
198+
"maximum": float64(10),
199+
"exclusiveMaximum": true,
200+
},
201+
},
202+
{
203+
name: "already boolean exclusiveMinimum (draft-04 style)",
204+
input: `{"type": "number", "minimum": 0, "exclusiveMinimum": true}`,
205+
expected: map[string]interface{}{
206+
"type": "number",
207+
"minimum": float64(0),
208+
"exclusiveMinimum": true,
209+
},
210+
},
211+
{
212+
name: "no exclusive bounds",
213+
input: `{"type": "string", "minLength": 1}`,
214+
expected: map[string]interface{}{
215+
"type": "string",
216+
"minLength": float64(1),
217+
},
218+
},
219+
{
220+
name: "nested properties with exclusive bounds",
221+
input: `{"type": "object", "properties": {"age": {"type": "integer", "exclusiveMinimum": 0}}}`,
222+
expected: map[string]interface{}{
223+
"type": "object",
224+
"properties": map[string]interface{}{
225+
"age": map[string]interface{}{
226+
"type": "integer",
227+
"minimum": float64(0),
228+
"exclusiveMinimum": true,
229+
},
230+
},
231+
},
232+
},
233+
{
234+
name: "array items with exclusive bounds",
235+
input: `{"type": "array", "items": {"type": "number", "exclusiveMaximum": 100}}`,
236+
expected: map[string]interface{}{
237+
"type": "array",
238+
"items": map[string]interface{}{
239+
"type": "number",
240+
"maximum": float64(100),
241+
"exclusiveMaximum": true,
242+
},
243+
},
244+
},
245+
{
246+
name: "allOf with exclusive bounds",
247+
input: `{"allOf": [{"type": "number", "exclusiveMinimum": 0}]}`,
248+
expected: map[string]interface{}{
249+
"allOf": []interface{}{
250+
map[string]interface{}{
251+
"type": "number",
252+
"minimum": float64(0),
253+
"exclusiveMinimum": true,
254+
},
255+
},
256+
},
257+
},
258+
{
259+
name: "additionalProperties with exclusive bounds",
260+
input: `{"type": "object", "additionalProperties": {"type": "integer", "exclusiveMinimum": 0, "exclusiveMaximum": 255}}`,
261+
expected: map[string]interface{}{
262+
"type": "object",
263+
"additionalProperties": map[string]interface{}{
264+
"type": "integer",
265+
"minimum": float64(0),
266+
"exclusiveMinimum": true,
267+
"maximum": float64(255),
268+
"exclusiveMaximum": true,
269+
},
270+
},
271+
},
272+
{
273+
name: "Chrome DevTools MCP style schema (real-world example)",
274+
input: `{"type": "object", "properties": {"timeout": {"type": "integer", "exclusiveMinimum": 0}, "quality": {"type": "number", "minimum": 0, "maximum": 100}}}`,
275+
expected: map[string]interface{}{
276+
"type": "object",
277+
"properties": map[string]interface{}{
278+
"timeout": map[string]interface{}{
279+
"type": "integer",
280+
"minimum": float64(0),
281+
"exclusiveMinimum": true,
282+
},
283+
"quality": map[string]interface{}{
284+
"type": "number",
285+
"minimum": float64(0),
286+
"maximum": float64(100),
287+
},
288+
},
289+
},
290+
},
291+
}
292+
293+
for _, tt := range tests {
294+
t.Run(tt.name, func(t *testing.T) {
295+
result := convertExclusiveBoundsToBoolean([]byte(tt.input))
296+
297+
var got map[string]interface{}
298+
if err := json.Unmarshal(result, &got); err != nil {
299+
t.Fatalf("Failed to unmarshal result: %v", err)
300+
}
301+
302+
if !deepEqual(got, tt.expected) {
303+
t.Errorf("convertExclusiveBoundsToBoolean() =\n%v\nwant:\n%v", got, tt.expected)
304+
}
305+
})
306+
}
307+
}
308+
309+
// TestConvertExclusiveBoundsToBoolean_InvalidJSON tests that invalid JSON is returned unchanged
310+
func TestConvertExclusiveBoundsToBoolean_InvalidJSON(t *testing.T) {
311+
invalidJSON := []byte(`{invalid json}`)
312+
result := convertExclusiveBoundsToBoolean(invalidJSON)
313+
314+
if string(result) != string(invalidJSON) {
315+
t.Errorf("Expected invalid JSON to be returned unchanged, got: %s", string(result))
316+
}
317+
}
318+
319+
// deepEqual compares two maps recursively
320+
func deepEqual(a, b map[string]interface{}) bool {
321+
if len(a) != len(b) {
322+
return false
323+
}
324+
for k, v := range a {
325+
bv, ok := b[k]
326+
if !ok {
327+
return false
328+
}
329+
switch av := v.(type) {
330+
case map[string]interface{}:
331+
bvm, ok := bv.(map[string]interface{})
332+
if !ok || !deepEqual(av, bvm) {
333+
return false
334+
}
335+
case []interface{}:
336+
bva, ok := bv.([]interface{})
337+
if !ok || !sliceEqual(av, bva) {
338+
return false
339+
}
340+
default:
341+
if v != bv {
342+
return false
343+
}
344+
}
345+
}
346+
return true
347+
}
348+
349+
// sliceEqual compares two slices recursively
350+
func sliceEqual(a, b []interface{}) bool {
351+
if len(a) != len(b) {
352+
return false
353+
}
354+
for i := range a {
355+
switch av := a[i].(type) {
356+
case map[string]interface{}:
357+
bvm, ok := b[i].(map[string]interface{})
358+
if !ok || !deepEqual(av, bvm) {
359+
return false
360+
}
361+
case []interface{}:
362+
bva, ok := b[i].([]interface{})
363+
if !ok || !sliceEqual(av, bva) {
364+
return false
365+
}
366+
default:
367+
if a[i] != b[i] {
368+
return false
369+
}
370+
}
371+
}
372+
return true
373+
}
374+
162375
// Helper function to check if a string contains a substring
163376
func contains(s, substr string) bool {
164377
for i := 0; i <= len(s)-len(substr); i++ {

0 commit comments

Comments
 (0)