From 44328616468fdf737d041c8f38beed4ae575a5e3 Mon Sep 17 00:00:00 2001 From: Cory LaNou Date: Sun, 28 Dec 2025 20:02:31 -0600 Subject: [PATCH 1/2] fix: convert JSON Schema draft-07 exclusive bounds to draft-04 format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/tools/mcp.go | 85 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/internal/tools/mcp.go b/internal/tools/mcp.go index 2da192d..1990939 100644 --- a/internal/tools/mcp.go +++ b/internal/tools/mcp.go @@ -221,6 +221,13 @@ func (m *MCPToolManager) loadServerTools(ctx context.Context, serverName string, if err != nil { return fmt.Errorf("conv mcp tool input schema fail(marshal): %w, tool name: %s", err, mcpTool.Name) } + + // Fix for JSON Schema draft-07 vs draft-04 compatibility: + // Chrome DevTools MCP uses draft-07 where exclusiveMinimum/exclusiveMaximum are numbers, + // but kin-openapi (OpenAPI 3.0) expects them as booleans (draft-04 format). + // Pre-process the schema to convert numeric exclusive bounds to boolean format. + marshaledInputSchema = convertExclusiveBoundsToBoolean(marshaledInputSchema) + inputSchema := &openapi3.Schema{} err = sonic.Unmarshal(marshaledInputSchema, inputSchema) if err != nil { @@ -555,3 +562,81 @@ func (m *MCPToolManager) debugLogConnectionInfo(serverName string, serverConfig } } } + +// convertExclusiveBoundsToBoolean converts JSON Schema draft-07 style exclusive bounds +// (where exclusiveMinimum/exclusiveMaximum are numbers) to draft-04 style +// (where they are booleans that modify minimum/maximum). +// This enables compatibility with kin-openapi which uses OpenAPI 3.0 (draft-04 based) schemas. +func convertExclusiveBoundsToBoolean(schemaJSON []byte) []byte { + var data map[string]interface{} + if err := json.Unmarshal(schemaJSON, &data); err != nil { + return schemaJSON // Return unchanged on error + } + + convertSchemaRecursive(data) + + result, err := json.Marshal(data) + if err != nil { + return schemaJSON // Return unchanged on error + } + return result +} + +// convertSchemaRecursive recursively processes a schema map and converts +// numeric exclusiveMinimum/exclusiveMaximum to boolean format. +func convertSchemaRecursive(schema map[string]interface{}) { + // Convert exclusiveMinimum if it's a number + if exMin, ok := schema["exclusiveMinimum"]; ok { + if num, isNum := exMin.(float64); isNum { + // JSON Schema draft-07: exclusiveMinimum is the limit value + // Convert to draft-04: set minimum = value, exclusiveMinimum = true + schema["minimum"] = num + schema["exclusiveMinimum"] = true + } + } + + // Convert exclusiveMaximum if it's a number + if exMax, ok := schema["exclusiveMaximum"]; ok { + if num, isNum := exMax.(float64); isNum { + // JSON Schema draft-07: exclusiveMaximum is the limit value + // Convert to draft-04: set maximum = value, exclusiveMaximum = true + schema["maximum"] = num + schema["exclusiveMaximum"] = true + } + } + + // Recursively process properties + if props, ok := schema["properties"].(map[string]interface{}); ok { + for _, prop := range props { + if propSchema, ok := prop.(map[string]interface{}); ok { + convertSchemaRecursive(propSchema) + } + } + } + + // Recursively process items (for arrays) + if items, ok := schema["items"].(map[string]interface{}); ok { + convertSchemaRecursive(items) + } + + // Recursively process additionalProperties + if addProps, ok := schema["additionalProperties"].(map[string]interface{}); ok { + convertSchemaRecursive(addProps) + } + + // Recursively process allOf, anyOf, oneOf + for _, key := range []string{"allOf", "anyOf", "oneOf"} { + if arr, ok := schema[key].([]interface{}); ok { + for _, item := range arr { + if itemSchema, ok := item.(map[string]interface{}); ok { + convertSchemaRecursive(itemSchema) + } + } + } + } + + // Recursively process not + if not, ok := schema["not"].(map[string]interface{}); ok { + convertSchemaRecursive(not) + } +} From 8fe4c7f164818d1cbec7dd945804f974b498189d Mon Sep 17 00:00:00 2001 From: Cory LaNou Date: Sun, 28 Dec 2025 20:14:14 -0600 Subject: [PATCH 2/2] test: add table-driven tests for JSON Schema draft conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/tools/mcp_test.go | 213 +++++++++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) diff --git a/internal/tools/mcp_test.go b/internal/tools/mcp_test.go index 9b4e72f..cd58bf7 100644 --- a/internal/tools/mcp_test.go +++ b/internal/tools/mcp_test.go @@ -2,6 +2,7 @@ package tools import ( "context" + "encoding/json" "testing" "time" @@ -159,6 +160,218 @@ func TestIssue89_ObjectSchemaMissingProperties(t *testing.T) { } } +// TestConvertExclusiveBoundsToBoolean tests the JSON Schema draft-07 to draft-04 conversion +// for exclusiveMinimum and exclusiveMaximum fields. +// Draft-07: exclusiveMinimum/exclusiveMaximum are numeric values (the actual bounds) +// Draft-04: exclusiveMinimum/exclusiveMaximum are booleans that modify minimum/maximum +func TestConvertExclusiveBoundsToBoolean(t *testing.T) { + tests := []struct { + name string + input string + expected map[string]interface{} + }{ + { + name: "exclusiveMinimum as number", + input: `{"type": "number", "exclusiveMinimum": 0}`, + expected: map[string]interface{}{ + "type": "number", + "minimum": float64(0), + "exclusiveMinimum": true, + }, + }, + { + name: "exclusiveMaximum as number", + input: `{"type": "number", "exclusiveMaximum": 100}`, + expected: map[string]interface{}{ + "type": "number", + "maximum": float64(100), + "exclusiveMaximum": true, + }, + }, + { + name: "both exclusive bounds as numbers", + input: `{"type": "integer", "exclusiveMinimum": 1, "exclusiveMaximum": 10}`, + expected: map[string]interface{}{ + "type": "integer", + "minimum": float64(1), + "exclusiveMinimum": true, + "maximum": float64(10), + "exclusiveMaximum": true, + }, + }, + { + name: "already boolean exclusiveMinimum (draft-04 style)", + input: `{"type": "number", "minimum": 0, "exclusiveMinimum": true}`, + expected: map[string]interface{}{ + "type": "number", + "minimum": float64(0), + "exclusiveMinimum": true, + }, + }, + { + name: "no exclusive bounds", + input: `{"type": "string", "minLength": 1}`, + expected: map[string]interface{}{ + "type": "string", + "minLength": float64(1), + }, + }, + { + name: "nested properties with exclusive bounds", + input: `{"type": "object", "properties": {"age": {"type": "integer", "exclusiveMinimum": 0}}}`, + expected: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "age": map[string]interface{}{ + "type": "integer", + "minimum": float64(0), + "exclusiveMinimum": true, + }, + }, + }, + }, + { + name: "array items with exclusive bounds", + input: `{"type": "array", "items": {"type": "number", "exclusiveMaximum": 100}}`, + expected: map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{ + "type": "number", + "maximum": float64(100), + "exclusiveMaximum": true, + }, + }, + }, + { + name: "allOf with exclusive bounds", + input: `{"allOf": [{"type": "number", "exclusiveMinimum": 0}]}`, + expected: map[string]interface{}{ + "allOf": []interface{}{ + map[string]interface{}{ + "type": "number", + "minimum": float64(0), + "exclusiveMinimum": true, + }, + }, + }, + }, + { + name: "additionalProperties with exclusive bounds", + input: `{"type": "object", "additionalProperties": {"type": "integer", "exclusiveMinimum": 0, "exclusiveMaximum": 255}}`, + expected: map[string]interface{}{ + "type": "object", + "additionalProperties": map[string]interface{}{ + "type": "integer", + "minimum": float64(0), + "exclusiveMinimum": true, + "maximum": float64(255), + "exclusiveMaximum": true, + }, + }, + }, + { + name: "Chrome DevTools MCP style schema (real-world example)", + input: `{"type": "object", "properties": {"timeout": {"type": "integer", "exclusiveMinimum": 0}, "quality": {"type": "number", "minimum": 0, "maximum": 100}}}`, + expected: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "timeout": map[string]interface{}{ + "type": "integer", + "minimum": float64(0), + "exclusiveMinimum": true, + }, + "quality": map[string]interface{}{ + "type": "number", + "minimum": float64(0), + "maximum": float64(100), + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := convertExclusiveBoundsToBoolean([]byte(tt.input)) + + var got map[string]interface{} + if err := json.Unmarshal(result, &got); err != nil { + t.Fatalf("Failed to unmarshal result: %v", err) + } + + if !deepEqual(got, tt.expected) { + t.Errorf("convertExclusiveBoundsToBoolean() =\n%v\nwant:\n%v", got, tt.expected) + } + }) + } +} + +// TestConvertExclusiveBoundsToBoolean_InvalidJSON tests that invalid JSON is returned unchanged +func TestConvertExclusiveBoundsToBoolean_InvalidJSON(t *testing.T) { + invalidJSON := []byte(`{invalid json}`) + result := convertExclusiveBoundsToBoolean(invalidJSON) + + if string(result) != string(invalidJSON) { + t.Errorf("Expected invalid JSON to be returned unchanged, got: %s", string(result)) + } +} + +// deepEqual compares two maps recursively +func deepEqual(a, b map[string]interface{}) bool { + if len(a) != len(b) { + return false + } + for k, v := range a { + bv, ok := b[k] + if !ok { + return false + } + switch av := v.(type) { + case map[string]interface{}: + bvm, ok := bv.(map[string]interface{}) + if !ok || !deepEqual(av, bvm) { + return false + } + case []interface{}: + bva, ok := bv.([]interface{}) + if !ok || !sliceEqual(av, bva) { + return false + } + default: + if v != bv { + return false + } + } + } + return true +} + +// sliceEqual compares two slices recursively +func sliceEqual(a, b []interface{}) bool { + if len(a) != len(b) { + return false + } + for i := range a { + switch av := a[i].(type) { + case map[string]interface{}: + bvm, ok := b[i].(map[string]interface{}) + if !ok || !deepEqual(av, bvm) { + return false + } + case []interface{}: + bva, ok := b[i].([]interface{}) + if !ok || !sliceEqual(av, bva) { + return false + } + default: + if a[i] != b[i] { + return false + } + } + } + return true +} + // Helper function to check if a string contains a substring func contains(s, substr string) bool { for i := 0; i <= len(s)-len(substr); i++ {