Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions internal/tools/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
213 changes: 213 additions & 0 deletions internal/tools/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package tools

import (
"context"
"encoding/json"
"testing"
"time"

Expand Down Expand Up @@ -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++ {
Expand Down