From 0526d5567b58dff9650198160ca476f3ef546c61 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Fri, 7 Aug 2026 23:00:57 +0100 Subject: [PATCH 01/24] feat: add mcp.scalar_mappings config for custom scalar JSON schema types --- router/pkg/config/config.go | 9 +++++++-- router/pkg/config/config.schema.json | 8 ++++++++ router/pkg/config/fixtures/full.yaml | 3 +++ router/pkg/config/testdata/config_defaults.json | 1 + router/pkg/config/testdata/config_full.json | 4 ++++ 5 files changed, 23 insertions(+), 2 deletions(-) diff --git a/router/pkg/config/config.go b/router/pkg/config/config.go index 0a7a20012..4a511cc15 100644 --- a/router/pkg/config/config.go +++ b/router/pkg/config/config.go @@ -1348,8 +1348,13 @@ type MCPConfiguration struct { RouterURL string `yaml:"router_url,omitempty" env:"MCP_ROUTER_URL"` // OmitToolNamePrefix removes the "execute_operation_" prefix from MCP tool names. // When enabled, GetUser becomes get_user. When disabled (default), GetUser becomes execute_operation_get_user. - OmitToolNamePrefix bool `yaml:"omit_tool_name_prefix" envDefault:"false" env:"MCP_OMIT_TOOL_NAME_PREFIX"` - OAuth MCPOAuthConfiguration `yaml:"oauth,omitempty" envPrefix:"MCP_OAUTH_"` + OmitToolNamePrefix bool `yaml:"omit_tool_name_prefix" envDefault:"false" env:"MCP_OMIT_TOOL_NAME_PREFIX"` + // ScalarMappings overrides the JSON Schema type advertised for custom scalar + // variables in MCP tool input schemas, keyed by scalar type name. Unmapped + // custom scalars default to "string". Allowed values: string, integer, + // number, boolean, object, array. + ScalarMappings map[string]string `yaml:"scalar_mappings,omitempty" env:"MCP_SCALAR_MAPPINGS"` + OAuth MCPOAuthConfiguration `yaml:"oauth,omitempty" envPrefix:"MCP_OAUTH_"` // ResourceDocumentation is a URL to a human-readable page describing this MCP resource, // its access policies, and how to get started. Included in RFC 9728 Protected Resource Metadata if set. ResourceDocumentation string `yaml:"resource_documentation,omitempty" env:"MCP_RESOURCE_DOCUMENTATION"` diff --git a/router/pkg/config/config.schema.json b/router/pkg/config/config.schema.json index 767dcd51e..64a094d6c 100644 --- a/router/pkg/config/config.schema.json +++ b/router/pkg/config/config.schema.json @@ -2733,6 +2733,14 @@ "default": false, "description": "When enabled, MCP tool names generated from GraphQL operations omit the 'execute_operation_' prefix. For example, the GraphQL operation 'GetUser' results in a tool named 'get_user' instead of 'execute_operation_get_user'." }, + "scalar_mappings": { + "type": "object", + "description": "Overrides the JSON Schema type advertised for custom scalar variables in MCP tool input schemas, keyed by scalar type name (e.g. JSON: object). Unmapped custom scalars default to string.", + "additionalProperties": { + "type": "string", + "enum": ["string", "integer", "number", "boolean", "object", "array"] + } + }, "resource_documentation": { "type": "string", "description": "A URL to a human-readable page describing this MCP resource, its access policies, and how to get started. Included in the RFC 9728 Protected Resource Metadata response if set.", diff --git a/router/pkg/config/fixtures/full.yaml b/router/pkg/config/fixtures/full.yaml index 552bc6194..019e8d586 100644 --- a/router/pkg/config/fixtures/full.yaml +++ b/router/pkg/config/fixtures/full.yaml @@ -106,6 +106,9 @@ mcp: enable_arbitrary_operations: false exclude_mutations: false omit_tool_name_prefix: false + scalar_mappings: + JSON: object + BigInt: integer graph_name: cosmo router_url: https://cosmo-router.wundergraph.com server: diff --git a/router/pkg/config/testdata/config_defaults.json b/router/pkg/config/testdata/config_defaults.json index ff89b5c09..73f5ec97a 100644 --- a/router/pkg/config/testdata/config_defaults.json +++ b/router/pkg/config/testdata/config_defaults.json @@ -205,6 +205,7 @@ "ExposeSchema": false, "RouterURL": "", "OmitToolNamePrefix": false, + "ScalarMappings": null, "OAuth": { "Enabled": false, "JWKS": null, diff --git a/router/pkg/config/testdata/config_full.json b/router/pkg/config/testdata/config_full.json index 010674777..e0b9203a5 100644 --- a/router/pkg/config/testdata/config_full.json +++ b/router/pkg/config/testdata/config_full.json @@ -274,6 +274,10 @@ "ExposeSchema": false, "RouterURL": "https://cosmo-router.wundergraph.com", "OmitToolNamePrefix": false, + "ScalarMappings": { + "BigInt": "integer", + "JSON": "object" + }, "OAuth": { "Enabled": false, "JWKS": null, From 156bdf19a0bd641dde4c53aaaf1f7d5b97f13fed Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Fri, 7 Aug 2026 23:07:55 +0100 Subject: [PATCH 02/24] feat: support custom scalar schema overrides and defaulted-scalar reporting in schemaloader --- router/pkg/schemaloader/schema_builder.go | 50 +++++++++- .../pkg/schemaloader/schema_builder_test.go | 92 +++++++++++++++++++ 2 files changed, 137 insertions(+), 5 deletions(-) create mode 100644 router/pkg/schemaloader/schema_builder_test.go diff --git a/router/pkg/schemaloader/schema_builder.go b/router/pkg/schemaloader/schema_builder.go index 2ce204a4a..89b9f2eb1 100644 --- a/router/pkg/schemaloader/schema_builder.go +++ b/router/pkg/schemaloader/schema_builder.go @@ -2,6 +2,7 @@ package schemaloader import ( "fmt" + "sort" "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/jsonschema" @@ -9,14 +10,37 @@ import ( // SchemaBuilder builds JSON schema from GraphQL operations type SchemaBuilder struct { - schemaDoc *ast.Document + schemaDoc *ast.Document + scalarSchemas map[string]*jsonschema.JsonSchema + // defaultedScalars accumulates, across every operation built so far, the + // custom scalar names that fell back to the default "string" schema. + defaultedScalars map[string]bool +} + +// SchemaBuilderOption configures a SchemaBuilder. +type SchemaBuilderOption func(*SchemaBuilder) + +// WithScalarSchemas overrides the JSON schema emitted per custom scalar type +// name in generated operation schemas. Unmapped custom scalars default to +// "string" and are reported via DefaultedScalars. +func WithScalarSchemas(schemas map[string]*jsonschema.JsonSchema) SchemaBuilderOption { + return func(b *SchemaBuilder) { + b.scalarSchemas = schemas + } } // NewSchemaBuilder creates a new SchemaBuilder with the given schema document -func NewSchemaBuilder(schemaDoc *ast.Document) *SchemaBuilder { - return &SchemaBuilder{ - schemaDoc: schemaDoc, +func NewSchemaBuilder(schemaDoc *ast.Document, opts ...SchemaBuilderOption) *SchemaBuilder { + b := &SchemaBuilder{ + schemaDoc: schemaDoc, + defaultedScalars: make(map[string]bool), + } + + for _, opt := range opts { + opt(b) } + + return b } // BuildSchemasForOperations builds JSON schemas for all input objects used in operations @@ -35,10 +59,15 @@ func (b *SchemaBuilder) BuildSchemasForOperations(operations []Operation) error // buildSchemaForOperation builds JSON schema for input objects in a single operation func (b *SchemaBuilder) buildSchemaForOperation(operation *Operation) error { - schema, err := jsonschema.BuildJsonSchema(&operation.Document, b.schemaDoc) + builder := jsonschema.NewVariablesSchemaBuilder(&operation.Document, b.schemaDoc, + jsonschema.WithScalarSchemas(b.scalarSchemas)) + schema, err := builder.Build() if err != nil { return fmt.Errorf("failed to build JSON schema: %w", err) } + for _, name := range builder.DefaultedScalars() { + b.defaultedScalars[name] = true + } if schema != nil { s, err := schema.MarshalJSON() @@ -57,3 +86,14 @@ func (b *SchemaBuilder) buildSchemaForOperation(operation *Operation) error { return nil } + +// DefaultedScalars returns the sorted names of custom scalars that fell back +// to the default "string" schema across all operations built so far. +func (b *SchemaBuilder) DefaultedScalars() []string { + names := make([]string, 0, len(b.defaultedScalars)) + for name := range b.defaultedScalars { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/router/pkg/schemaloader/schema_builder_test.go b/router/pkg/schemaloader/schema_builder_test.go new file mode 100644 index 000000000..5e913f1a5 --- /dev/null +++ b/router/pkg/schemaloader/schema_builder_test.go @@ -0,0 +1,92 @@ +package schemaloader + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" + "github.com/wundergraph/graphql-go-tools/v2/pkg/asttransform" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/jsonschema" +) + +func TestSchemaBuilderScalarOverrides(t *testing.T) { + t.Run("mapped scalar emits the override type and unmapped scalar keeps the string default", func(t *testing.T) { + schemaStr := ` +schema { query: Query } +scalar Cursor +scalar JSON +type Query { + items(filter: JSON!, after: Cursor): String +} +` + schemaDoc, report := astparser.ParseGraphqlDocumentString(schemaStr) + require.False(t, report.HasErrors(), "failed to parse schema") + require.NoError(t, asttransform.MergeDefinitionWithBaseSchema(&schemaDoc)) + + opStr := `query Items($filter: JSON!, $after: Cursor) { + items(filter: $filter, after: $after) +}` + opDoc, report := astparser.ParseGraphqlDocumentString(opStr) + require.False(t, report.HasErrors(), "failed to parse operation") + + ops := []Operation{{Name: "Items", Document: opDoc}} + + builder := NewSchemaBuilder(&schemaDoc, WithScalarSchemas(map[string]*jsonschema.JsonSchema{ + "JSON": {Type: jsonschema.TypeObject}, + })) + require.NoError(t, builder.BuildSchemasForOperations(ops)) + + var inputSchema struct { + Properties map[string]json.RawMessage `json:"properties"` + } + require.NoError(t, json.Unmarshal(ops[0].JSONSchema, &inputSchema)) + + require.JSONEq(t, `{"type":"object"}`, string(inputSchema.Properties["filter"]), + "mapped scalar JSON should emit the overridden object type") + require.JSONEq(t, `{"type":["string","null"]}`, string(inputSchema.Properties["after"]), + "unmapped scalar Cursor should fall back to the nullable string default") + }) + + t.Run("defaulted scalars aggregate across operations without duplicates", func(t *testing.T) { + schemaStr := ` +schema { query: Query } +scalar Cursor +scalar BigInt +scalar JSON +type Query { + items(after: Cursor, big: BigInt, filter: JSON): String + moreItems(after: Cursor): String +} +` + schemaDoc, report := astparser.ParseGraphqlDocumentString(schemaStr) + require.False(t, report.HasErrors(), "failed to parse schema") + require.NoError(t, asttransform.MergeDefinitionWithBaseSchema(&schemaDoc)) + + op1Str := `query Items($after: Cursor, $big: BigInt) { + items(after: $after, big: $big) +}` + op1Doc, report := astparser.ParseGraphqlDocumentString(op1Str) + require.False(t, report.HasErrors(), "failed to parse operation 1") + + op2Str := `query MoreItems($after: Cursor) { + moreItems(after: $after) +}` + op2Doc, report := astparser.ParseGraphqlDocumentString(op2Str) + require.False(t, report.HasErrors(), "failed to parse operation 2") + + ops := []Operation{ + {Name: "Items", Document: op1Doc}, + {Name: "MoreItems", Document: op2Doc}, + } + + builder := NewSchemaBuilder(&schemaDoc, WithScalarSchemas(map[string]*jsonschema.JsonSchema{ + "JSON": {Type: jsonschema.TypeObject}, + })) + require.NoError(t, builder.BuildSchemasForOperations(ops)) + + require.Equal(t, []string{"BigInt", "Cursor"}, builder.DefaultedScalars(), + "defaulted scalars should be sorted, unique, and exclude the overridden JSON scalar") + }) +} From 9ece97cee19fe193e27358ab8cb542dd6659cdc8 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Fri, 7 Aug 2026 23:18:20 +0100 Subject: [PATCH 03/24] feat: plumb mcp scalar mappings into tool schema generation with startup warning --- router/core/router.go | 1 + router/pkg/mcpserver/operation_manager.go | 14 ++++++- router/pkg/mcpserver/scalar_mappings.go | 36 ++++++++++++++++ router/pkg/mcpserver/scalar_mappings_test.go | 44 ++++++++++++++++++++ router/pkg/mcpserver/server.go | 23 +++++++++- 5 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 router/pkg/mcpserver/scalar_mappings.go create mode 100644 router/pkg/mcpserver/scalar_mappings_test.go diff --git a/router/core/router.go b/router/core/router.go index 297d24064..dae249d4b 100644 --- a/router/core/router.go +++ b/router/core/router.go @@ -1200,6 +1200,7 @@ func (r *Router) startMCPServer(ctx context.Context) error { mcpserver.WithEnableArbitraryOperations(r.mcp.EnableArbitraryOperations), mcpserver.WithExposeSchema(r.mcp.ExposeSchema), mcpserver.WithOmitToolNamePrefix(r.mcp.OmitToolNamePrefix), + mcpserver.WithScalarMappings(r.mcp.ScalarMappings), mcpserver.WithStateless(r.mcp.Session.Stateless), } diff --git a/router/pkg/mcpserver/operation_manager.go b/router/pkg/mcpserver/operation_manager.go index 85e4b3a1c..6172ef74c 100644 --- a/router/pkg/mcpserver/operation_manager.go +++ b/router/pkg/mcpserver/operation_manager.go @@ -9,6 +9,7 @@ import ( "github.com/wundergraph/cosmo/router/pkg/schemaloader" "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/jsonschema" ) // OperationsManager handles the loading and preparation of GraphQL operations @@ -17,10 +18,13 @@ type OperationsManager struct { operations []schemaloader.Operation logger *zap.Logger excludeMutations bool + // scalarSchemas overrides the JSON schema emitted for custom scalar types + // in generated tool input schemas, keyed by scalar type name. + scalarSchemas map[string]*jsonschema.JsonSchema } // NewOperationsManager creates a new operations manager -func NewOperationsManager(schemaDoc *ast.Document, logger *zap.Logger, excludeMutations bool) *OperationsManager { +func NewOperationsManager(schemaDoc *ast.Document, logger *zap.Logger, excludeMutations bool, scalarSchemas map[string]*jsonschema.JsonSchema) *OperationsManager { if logger == nil { logger = zap.NewNop() } @@ -29,6 +33,7 @@ func NewOperationsManager(schemaDoc *ast.Document, logger *zap.Logger, excludeMu schemaDoc: schemaDoc, logger: logger, excludeMutations: excludeMutations, + scalarSchemas: scalarSchemas, } } @@ -42,12 +47,17 @@ func (om *OperationsManager) LoadOperationsFromDirectory(operationsDir string) e } // Build schemas for operations - builder := schemaloader.NewSchemaBuilder(om.schemaDoc) + builder := schemaloader.NewSchemaBuilder(om.schemaDoc, schemaloader.WithScalarSchemas(om.scalarSchemas)) err = builder.BuildSchemasForOperations(operations) if err != nil { return fmt.Errorf("failed to build schemas: %w", err) } + if defaulted := builder.DefaultedScalars(); len(defaulted) > 0 { + om.logger.Warn("custom scalars defaulted to type \"string\" in MCP tool input schemas; non-string arguments for these scalars will be rejected by input validation — add mcp.scalar_mappings entries to override", + zap.Strings("scalars", defaulted)) + } + om.operations = operations return nil diff --git a/router/pkg/mcpserver/scalar_mappings.go b/router/pkg/mcpserver/scalar_mappings.go new file mode 100644 index 000000000..e4fda4eef --- /dev/null +++ b/router/pkg/mcpserver/scalar_mappings.go @@ -0,0 +1,36 @@ +package mcpserver + +import ( + "fmt" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/jsonschema" +) + +// allowedScalarMappingTypes are the JSON schema type names an mcp.scalar_mappings +// entry may map a custom scalar to. +var allowedScalarMappingTypes = map[string]jsonschema.SchemaType{ + "string": jsonschema.TypeString, + "integer": jsonschema.TypeInteger, + "number": jsonschema.TypeNumber, + "boolean": jsonschema.TypeBoolean, + "object": jsonschema.TypeObject, + "array": jsonschema.TypeArray, +} + +// scalarSchemasFromMappings translates config scalar mappings (scalar name -> +// JSON schema type name) into schema overrides. The config JSON schema already +// restricts values via enum; this guards programmatic callers. +func scalarSchemasFromMappings(mappings map[string]string) (map[string]*jsonschema.JsonSchema, error) { + if len(mappings) == 0 { + return nil, nil + } + schemas := make(map[string]*jsonschema.JsonSchema, len(mappings)) + for scalar, typeName := range mappings { + schemaType, ok := allowedScalarMappingTypes[typeName] + if !ok { + return nil, fmt.Errorf("invalid scalar mapping for scalar %q: %q is not a JSON schema type (allowed: string, integer, number, boolean, object, array)", scalar, typeName) + } + schemas[scalar] = &jsonschema.JsonSchema{Type: schemaType} + } + return schemas, nil +} diff --git a/router/pkg/mcpserver/scalar_mappings_test.go b/router/pkg/mcpserver/scalar_mappings_test.go new file mode 100644 index 000000000..169be8b84 --- /dev/null +++ b/router/pkg/mcpserver/scalar_mappings_test.go @@ -0,0 +1,44 @@ +package mcpserver + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/jsonschema" +) + +func TestScalarMappingsTranslateToSchemas(t *testing.T) { + t.Run("valid mappings translate to typed schemas", func(t *testing.T) { + schemas, err := scalarSchemasFromMappings(map[string]string{ + "JSON": "object", + "BigInt": "integer", + }) + require.NoError(t, err) + assert.Equal(t, map[string]*jsonschema.JsonSchema{ + "JSON": {Type: jsonschema.TypeObject}, + "BigInt": {Type: jsonschema.TypeInteger}, + }, schemas) + }) + + t.Run("unknown JSON schema type returns an error naming the scalar and the value", func(t *testing.T) { + schemas, err := scalarSchemasFromMappings(map[string]string{ + "JSON": "blob", + }) + require.Error(t, err) + assert.Nil(t, schemas) + assert.ErrorContains(t, err, `scalar "JSON"`) + assert.ErrorContains(t, err, `"blob"`) + }) + + t.Run("empty and nil mappings translate to nil", func(t *testing.T) { + schemas, err := scalarSchemasFromMappings(nil) + require.NoError(t, err) + assert.Nil(t, schemas) + + schemas, err = scalarSchemasFromMappings(map[string]string{}) + require.NoError(t, err) + assert.Nil(t, schemas) + }) +} diff --git a/router/pkg/mcpserver/server.go b/router/pkg/mcpserver/server.go index a3c5c3685..2ae618ee5 100644 --- a/router/pkg/mcpserver/server.go +++ b/router/pkg/mcpserver/server.go @@ -27,6 +27,7 @@ import ( "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" "github.com/wundergraph/graphql-go-tools/v2/pkg/astprinter" + enginejsonschema "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/jsonschema" ) // reservedToolNames contains tool names that are internally registered by the MCP server @@ -83,6 +84,9 @@ type Options struct { ExposeSchema bool // OmitToolNamePrefix removes the "execute_operation_" prefix from MCP tool names OmitToolNamePrefix bool + // ScalarMappings overrides the JSON schema type advertised for custom scalar + // variables in MCP tool input schemas, keyed by scalar type name. + ScalarMappings map[string]string // Stateless determines whether the MCP server should be stateless Stateless bool // CorsConfig is the CORS configuration for the MCP server @@ -110,6 +114,7 @@ type GraphQLSchemaServer struct { enableArbitraryOperations bool exposeSchema bool omitToolNamePrefix bool + scalarSchemas map[string]*enginejsonschema.JsonSchema stateless bool operationsManager *OperationsManager schemaCompiler *SchemaCompiler @@ -218,6 +223,13 @@ func NewGraphQLSchemaServer(ctx context.Context, routerGraphQLEndpoint string, o opt(options) } + // Translate scalar mappings once at startup; an invalid mapping is a + // config error and must abort server start, not merely warn. + scalarSchemas, err := scalarSchemasFromMappings(options.ScalarMappings) + if err != nil { + return nil, fmt.Errorf("invalid mcp scalar mappings: %w", err) + } + ctx, cancel := context.WithCancel(ctx) var authMiddleware *MCPAuthMiddleware @@ -312,6 +324,7 @@ func NewGraphQLSchemaServer(ctx context.Context, routerGraphQLEndpoint string, o enableArbitraryOperations: options.EnableArbitraryOperations, exposeSchema: options.ExposeSchema, omitToolNamePrefix: options.OmitToolNamePrefix, + scalarSchemas: scalarSchemas, stateless: options.Stateless, corsConfig: options.CorsConfig, cancel: cancel, @@ -391,6 +404,14 @@ func WithOmitToolNamePrefix(omitToolNamePrefix bool) func(*Options) { } } +// WithScalarMappings sets the custom scalar to JSON schema type mappings +// used when generating MCP tool input schemas. +func WithScalarMappings(scalarMappings map[string]string) func(*Options) { + return func(o *Options) { + o.ScalarMappings = scalarMappings + } +} + func WithCORS(corsCfg cors.Config) func(*Options) { return func(o *Options) { // Force specific CORS settings for MCP server @@ -520,7 +541,7 @@ func (s *GraphQLSchemaServer) Reload(schema *ast.Document, fieldConfigs []*nodev } s.schemaCompiler = NewSchemaCompiler(s.logger) - s.operationsManager = NewOperationsManager(schema, s.logger, s.excludeMutations) + s.operationsManager = NewOperationsManager(schema, s.logger, s.excludeMutations, s.scalarSchemas) if s.operationsDir != "" { if err := s.operationsManager.LoadOperationsFromDirectory(s.operationsDir); err != nil { From f74105d072260effaa8c117d96eb57e7632290a6 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Fri, 7 Aug 2026 23:23:35 +0100 Subject: [PATCH 04/24] test: cover custom scalar typing and scalar mapping overrides in mcp tool schemas Bumps graphql-go-tools to the eng-9903 branch pseudo-version; must be replaced with the tagged v2.15.0 release before this PR leaves draft. --- router-tests/go.mod | 2 +- router-tests/go.sum | 4 +- router-tests/protocol/mcp_test.go | 66 +++++++++++++++++++ .../UploadFile.graphql | 4 ++ router/go.mod | 2 +- router/go.sum | 4 +- 6 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 router-tests/protocol/testdata/mcp_operations_custom_scalar/UploadFile.graphql diff --git a/router-tests/go.mod b/router-tests/go.mod index 43215f8a5..ac807e8c7 100644 --- a/router-tests/go.mod +++ b/router-tests/go.mod @@ -31,7 +31,7 @@ require ( github.com/wundergraph/cosmo/router v0.0.0-20260710155145-803a4bc06d92 github.com/wundergraph/cosmo/router-plugin v0.0.0-20250808194725-de123ba1c65e github.com/wundergraph/cosmo/speedtrap v0.0.0-00010101000000-000000000000 - github.com/wundergraph/graphql-go-tools/v2 v2.14.1 + github.com/wundergraph/graphql-go-tools/v2 v2.14.3-0.20260807102141-88073b801ad2 go.opentelemetry.io/otel v1.44.0 go.opentelemetry.io/otel/sdk v1.44.0 go.opentelemetry.io/otel/sdk/metric v1.44.0 diff --git a/router-tests/go.sum b/router-tests/go.sum index 2973f7fb9..8c88c06d8 100644 --- a/router-tests/go.sum +++ b/router-tests/go.sum @@ -386,8 +386,8 @@ github.com/wundergraph/astjson v1.1.0 h1:xORDosrZ87zQFJwNGe/HIHXqzpdHOFmqWgykCLV github.com/wundergraph/astjson v1.1.0/go.mod h1:h12D/dxxnedtLzsKyBLK7/Oe4TAoGpRVC9nDpDrZSWw= github.com/wundergraph/go-arena v1.3.0 h1:n0ng5a1vbd8YGq1u3rMr0vPU5f6AZ1BXIiUhL1UIok8= github.com/wundergraph/go-arena v1.3.0/go.mod h1:ROOysEHWJjLQ8FSfNxZCziagb7Qw2nXY3/vgKRh7eWw= -github.com/wundergraph/graphql-go-tools/v2 v2.14.1 h1:TecnvTyhskoeiqo6W+1xh7HqAPL/RG5vfbFhezu7RLs= -github.com/wundergraph/graphql-go-tools/v2 v2.14.1/go.mod h1:zREIKLmpjfNcGSubndaW/913r0Y8XbbYOXQeZFkwHdo= +github.com/wundergraph/graphql-go-tools/v2 v2.14.3-0.20260807102141-88073b801ad2 h1:L7FceLC3ZApDOrGan5x6fh7tTophfE+rYbUSAj/B690= +github.com/wundergraph/graphql-go-tools/v2 v2.14.3-0.20260807102141-88073b801ad2/go.mod h1:zREIKLmpjfNcGSubndaW/913r0Y8XbbYOXQeZFkwHdo= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= diff --git a/router-tests/protocol/mcp_test.go b/router-tests/protocol/mcp_test.go index d82a732c8..8fe086a24 100644 --- a/router-tests/protocol/mcp_test.go +++ b/router-tests/protocol/mcp_test.go @@ -242,6 +242,72 @@ func TestMCP(t *testing.T) { }) }) + t.Run("Custom scalar variables carry a JSON Schema type in tool input schemas", func(t *testing.T) { + testenv.Run(t, &testenv.Config{ + MCPOperationsPath: "testdata/mcp_operations_custom_scalar", + MCP: config.MCPConfiguration{ + Enabled: true, + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + + toolsRequest := mcp.ListToolsRequest{} + resp, err := xEnv.MCPClient.ListTools(xEnv.Context, toolsRequest) + require.NoError(t, err) + require.NotNil(t, resp) + + var tool *mcp.Tool + for i := range resp.Tools { + if resp.Tools[i].Name == "execute_operation_upload_file" { + tool = &resp.Tools[i] + break + } + } + require.NotNil(t, tool, "expected the UploadFile operation to be registered as a tool") + + fileSchema, ok := tool.InputSchema.Properties["file"].(map[string]any) + require.True(t, ok, "expected a schema object for the 'file' variable, got: %#v", tool.InputSchema.Properties) + + actual, err := json.Marshal(fileSchema) + require.NoError(t, err) + + // Custom scalars are opaque to JSON Schema, but MCP tool consumers + // (Anthropic directory validation, OpenAI strict mode) reject input + // schema properties that declare no "type". Opaque scalars must carry + // a best-effort primitive type: string. + require.Contains(t, fileSchema, "type", "custom scalar variable 'file' must declare a JSON Schema type, got: %s", actual) + assert.Equal(t, "string", fileSchema["type"], "custom scalar variable 'file' should be typed as a string, got: %s", actual) + }) + }) + + t.Run("scalar mapping overrides the string default for a mapped custom scalar", func(t *testing.T) { + testenv.Run(t, &testenv.Config{ + MCPOperationsPath: "testdata/mcp_operations_custom_scalar", + MCP: config.MCPConfiguration{ + Enabled: true, + ScalarMappings: map[string]string{"Upload": "object"}, + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + toolsRequest := mcp.ListToolsRequest{} + resp, err := xEnv.MCPClient.ListTools(xEnv.Context, toolsRequest) + require.NoError(t, err) + + var tool *mcp.Tool + for i := range resp.Tools { + if resp.Tools[i].Name == "execute_operation_upload_file" { + tool = &resp.Tools[i] + break + } + } + require.NotNil(t, tool, "expected the UploadFile operation to be registered as a tool") + + fileSchema, ok := tool.InputSchema.Properties["file"].(map[string]any) + require.True(t, ok, "expected a schema object for the 'file' variable") + + // The mapping replaces the string default; Upload! is non-null so no null union. + require.Equal(t, "object", fileSchema["type"], "mapped custom scalar should carry the configured type") + }) + }) + t.Run("List user Operations / Static operations of type mutation aren't exposed when excludeMutations is set", func(t *testing.T) { testenv.Run(t, &testenv.Config{ MCP: config.MCPConfiguration{ diff --git a/router-tests/protocol/testdata/mcp_operations_custom_scalar/UploadFile.graphql b/router-tests/protocol/testdata/mcp_operations_custom_scalar/UploadFile.graphql new file mode 100644 index 000000000..198d31603 --- /dev/null +++ b/router-tests/protocol/testdata/mcp_operations_custom_scalar/UploadFile.graphql @@ -0,0 +1,4 @@ +# This mutation uploads a single file. +mutation UploadFile($file: Upload!) { + singleUpload(file: $file) +} diff --git a/router/go.mod b/router/go.mod index c1b136a24..c41fad93b 100644 --- a/router/go.mod +++ b/router/go.mod @@ -31,7 +31,7 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/twmb/franz-go v1.16.1 - github.com/wundergraph/graphql-go-tools/v2 v2.14.1 + github.com/wundergraph/graphql-go-tools/v2 v2.14.3-0.20260807102141-88073b801ad2 // Do not upgrade, it renames attributes we rely on go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 go.opentelemetry.io/contrib/propagators/b3 v1.44.0 diff --git a/router/go.sum b/router/go.sum index b8cc383c2..99c322633 100644 --- a/router/go.sum +++ b/router/go.sum @@ -334,8 +334,8 @@ github.com/wundergraph/astjson v1.1.0 h1:xORDosrZ87zQFJwNGe/HIHXqzpdHOFmqWgykCLV github.com/wundergraph/astjson v1.1.0/go.mod h1:h12D/dxxnedtLzsKyBLK7/Oe4TAoGpRVC9nDpDrZSWw= github.com/wundergraph/go-arena v1.3.0 h1:n0ng5a1vbd8YGq1u3rMr0vPU5f6AZ1BXIiUhL1UIok8= github.com/wundergraph/go-arena v1.3.0/go.mod h1:ROOysEHWJjLQ8FSfNxZCziagb7Qw2nXY3/vgKRh7eWw= -github.com/wundergraph/graphql-go-tools/v2 v2.14.1 h1:TecnvTyhskoeiqo6W+1xh7HqAPL/RG5vfbFhezu7RLs= -github.com/wundergraph/graphql-go-tools/v2 v2.14.1/go.mod h1:zREIKLmpjfNcGSubndaW/913r0Y8XbbYOXQeZFkwHdo= +github.com/wundergraph/graphql-go-tools/v2 v2.14.3-0.20260807102141-88073b801ad2 h1:L7FceLC3ZApDOrGan5x6fh7tTophfE+rYbUSAj/B690= +github.com/wundergraph/graphql-go-tools/v2 v2.14.3-0.20260807102141-88073b801ad2/go.mod h1:zREIKLmpjfNcGSubndaW/913r0Y8XbbYOXQeZFkwHdo= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= From 8d9eb3029217a19672440dc36b6929e7e8f12780 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Fri, 7 Aug 2026 23:36:30 +0100 Subject: [PATCH 05/24] test: guard invalid scalar mappings at server construction and fix env-path comment --- router-tests/protocol/mcp_test.go | 2 +- router/pkg/mcpserver/scalar_mappings.go | 6 ++++-- router/pkg/mcpserver/scalar_mappings_test.go | 14 ++++++++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/router-tests/protocol/mcp_test.go b/router-tests/protocol/mcp_test.go index 8fe086a24..df80d33a6 100644 --- a/router-tests/protocol/mcp_test.go +++ b/router-tests/protocol/mcp_test.go @@ -279,7 +279,7 @@ func TestMCP(t *testing.T) { }) }) - t.Run("scalar mapping overrides the string default for a mapped custom scalar", func(t *testing.T) { + t.Run("Scalar mapping overrides the string default for a mapped custom scalar", func(t *testing.T) { testenv.Run(t, &testenv.Config{ MCPOperationsPath: "testdata/mcp_operations_custom_scalar", MCP: config.MCPConfiguration{ diff --git a/router/pkg/mcpserver/scalar_mappings.go b/router/pkg/mcpserver/scalar_mappings.go index e4fda4eef..1c93e918b 100644 --- a/router/pkg/mcpserver/scalar_mappings.go +++ b/router/pkg/mcpserver/scalar_mappings.go @@ -18,8 +18,10 @@ var allowedScalarMappingTypes = map[string]jsonschema.SchemaType{ } // scalarSchemasFromMappings translates config scalar mappings (scalar name -> -// JSON schema type name) into schema overrides. The config JSON schema already -// restricts values via enum; this guards programmatic callers. +// JSON schema type name) into schema overrides. +// YAML config is enum-checked by the config JSON schema, but env-sourced +// config (MCP_SCALAR_MAPPINGS) bypasses schema validation entirely — this +// runtime check is the only guard on that path. func scalarSchemasFromMappings(mappings map[string]string) (map[string]*jsonschema.JsonSchema, error) { if len(mappings) == 0 { return nil, nil diff --git a/router/pkg/mcpserver/scalar_mappings_test.go b/router/pkg/mcpserver/scalar_mappings_test.go index 169be8b84..3ee237557 100644 --- a/router/pkg/mcpserver/scalar_mappings_test.go +++ b/router/pkg/mcpserver/scalar_mappings_test.go @@ -42,3 +42,17 @@ func TestScalarMappingsTranslateToSchemas(t *testing.T) { assert.Nil(t, schemas) }) } + +func TestScalarMappingsAbortServerConstruction(t *testing.T) { + t.Run("invalid scalar mapping aborts server construction", func(t *testing.T) { + srv, err := NewGraphQLSchemaServer( + t.Context(), + "http://localhost:4000/graphql", + WithScalarMappings(map[string]string{"JSON": "blob"}), + ) + require.Error(t, err) + require.Nil(t, srv) + require.Contains(t, err.Error(), `scalar "JSON"`) + require.Contains(t, err.Error(), `"blob"`) + }) +} From 15770d702dcef64265e89d229835f638a8615c07 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Fri, 7 Aug 2026 23:44:42 +0100 Subject: [PATCH 06/24] docs: document mcp scalar_mappings and custom scalar type defaults --- docs-website/router/mcp/configuration.mdx | 30 +++++++++++++++++++++++ docs-website/router/mcp/operations.mdx | 2 ++ 2 files changed, 32 insertions(+) diff --git a/docs-website/router/mcp/configuration.mdx b/docs-website/router/mcp/configuration.mdx index 4c0a5eac1..ae2912e54 100644 --- a/docs-website/router/mcp/configuration.mdx +++ b/docs-website/router/mcp/configuration.mdx @@ -39,6 +39,7 @@ storage_providers: | `enable_arbitrary_operations` | Enables the `execute_graphql` built-in tool, allowing clients to run arbitrary GraphQL operations beyond the pre-defined operation set. | `false` | | `expose_schema` | Enables the `get_schema` built-in tool, exposing the full GraphQL schema to MCP clients. | `false` | | `omit_tool_name_prefix` | When enabled, MCP tool names omit the `execute_operation_` prefix. For example, `GetUser` becomes `get_user` instead of `execute_operation_get_user`. See [Operations - Omitting the Tool Name Prefix](/router/mcp/operations#omitting-the-tool-name-prefix). | `false` | +| `scalar_mappings` | Maps custom scalar type names to the JSON Schema type advertised in MCP tool input schemas. Allowed values: `string`, `integer`, `number`, `boolean`, `object`, `array`. Custom scalars without a mapping default to `string`. See [Custom Scalar Mappings](#custom-scalar-mappings). | - | For OAuth-specific configuration, see [OAuth 2.1 Authorization](/router/mcp/oauth/overview). @@ -59,9 +60,35 @@ All MCP options can also be set via environment variables: | `MCP_ENABLE_ARBITRARY_OPERATIONS` | `mcp.enable_arbitrary_operations` | | `MCP_EXPOSE_SCHEMA` | `mcp.expose_schema` | | `MCP_OMIT_TOOL_NAME_PREFIX` | `mcp.omit_tool_name_prefix` | +| `MCP_SCALAR_MAPPINGS` | `mcp.scalar_mappings` | + +Map-valued options use comma-separated `key:value` pairs. For example: `MCP_SCALAR_MAPPINGS=JSON:object,BigInt:integer`. For OAuth-related environment variables, see [OAuth Configuration Reference](/router/mcp/oauth/configuration#environment-variables). +## Custom Scalar Mappings + +The router generates each tool's input schema from the variables of the GraphQL operation. Custom scalars are opaque to JSON Schema, but MCP clients require every property to declare a type. The router therefore advertises custom scalar variables as `string` by default. This matches the wire format of most opaque scalars, such as cursors, IDs, and timestamps. + +Use `scalar_mappings` for custom scalars whose wire format is not a string: + +```yaml +mcp: + enabled: true + scalar_mappings: + JSON: object + BigInt: integer +``` + +Two behaviors help you keep mappings correct: + +- On startup, the router logs a warning that lists every custom scalar that fell back to the `string` default. Non-string arguments for these scalars are rejected by input validation, so add a mapping for any scalar with a different wire format. +- A mapping with a value outside the allowed set fails router startup. A wrong schema contract is a configuration error, not a warning. + + + Top-level variables with an `object` type are always advertised as non-nullable in the generated schema. A nullable variable mapped to `object` therefore loses its `null` union in the tool's input schema. + + ## Storage Providers MCP loads operations from a configured storage provider. Currently, only the `file_system` provider is supported: @@ -112,6 +139,9 @@ mcp: enable_arbitrary_operations: false expose_schema: false omit_tool_name_prefix: false + scalar_mappings: + JSON: object + BigInt: integer storage: provider_id: 'mcp' diff --git a/docs-website/router/mcp/operations.mdx b/docs-website/router/mcp/operations.mdx index f80e321c0..3fd4fab6b 100644 --- a/docs-website/router/mcp/operations.mdx +++ b/docs-website/router/mcp/operations.mdx @@ -102,6 +102,8 @@ Operations are converted to `snake_case` for tool naming consistency. The tool's input schema is automatically generated from your GraphQL operation's variables, ensuring type safety. AI models use this schema to understand what parameters are required and their types. +Custom scalar variables are advertised as `string` by default. See [Custom Scalar Mappings](/router/mcp/configuration#custom-scalar-mappings) to map scalars with a different wire format. + ### Omitting the Tool Name Prefix By default, all operation tools include the `execute_operation_` prefix. You can enable `omit_tool_name_prefix` to generate shorter tool names: From dd6857d82b4fda24043543b2c2ad1d8426569251 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Fri, 7 Aug 2026 23:44:42 +0100 Subject: [PATCH 07/24] chore: use ascii punctuation in scalar mapping warning and comment --- router/pkg/mcpserver/operation_manager.go | 2 +- router/pkg/mcpserver/scalar_mappings.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/router/pkg/mcpserver/operation_manager.go b/router/pkg/mcpserver/operation_manager.go index 6172ef74c..6a88fd267 100644 --- a/router/pkg/mcpserver/operation_manager.go +++ b/router/pkg/mcpserver/operation_manager.go @@ -54,7 +54,7 @@ func (om *OperationsManager) LoadOperationsFromDirectory(operationsDir string) e } if defaulted := builder.DefaultedScalars(); len(defaulted) > 0 { - om.logger.Warn("custom scalars defaulted to type \"string\" in MCP tool input schemas; non-string arguments for these scalars will be rejected by input validation — add mcp.scalar_mappings entries to override", + om.logger.Warn("custom scalars defaulted to type \"string\" in MCP tool input schemas; non-string arguments for these scalars will be rejected by input validation - add mcp.scalar_mappings entries to override", zap.Strings("scalars", defaulted)) } diff --git a/router/pkg/mcpserver/scalar_mappings.go b/router/pkg/mcpserver/scalar_mappings.go index 1c93e918b..72b132f32 100644 --- a/router/pkg/mcpserver/scalar_mappings.go +++ b/router/pkg/mcpserver/scalar_mappings.go @@ -20,7 +20,7 @@ var allowedScalarMappingTypes = map[string]jsonschema.SchemaType{ // scalarSchemasFromMappings translates config scalar mappings (scalar name -> // JSON schema type name) into schema overrides. // YAML config is enum-checked by the config JSON schema, but env-sourced -// config (MCP_SCALAR_MAPPINGS) bypasses schema validation entirely — this +// config (MCP_SCALAR_MAPPINGS) bypasses schema validation entirely - this // runtime check is the only guard on that path. func scalarSchemasFromMappings(mappings map[string]string) (map[string]*jsonschema.JsonSchema, error) { if len(mappings) == 0 { From d23fe4d60088180c56b81ac00225437dd03bf49a Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Sat, 8 Aug 2026 01:03:55 +0100 Subject: [PATCH 08/24] feat: vendor graphql operation json schema generation as router-internal package The MCP tool schema generator moves from graphql-go-tools engine/jsonschema into router/internal/jsonschema. The router no longer depends on graphql-go-tools for JSON schema generation. The library package is unchanged. --- .../jsonschema/nullable_2020_12_test.go | 103 + .../jsonschema/recursive_input_test.go | 89 + router/internal/jsonschema/schema.go | 308 +++ router/internal/jsonschema/schema_test.go | 661 ++++++ .../internal/jsonschema/variables_schema.go | 593 +++++ .../jsonschema/variables_schema_test.go | 1965 +++++++++++++++++ router/pkg/mcpserver/operation_manager.go | 2 +- router/pkg/mcpserver/scalar_mappings.go | 2 +- router/pkg/mcpserver/scalar_mappings_test.go | 2 +- router/pkg/mcpserver/server.go | 2 +- router/pkg/schemaloader/schema_builder.go | 2 +- .../pkg/schemaloader/schema_builder_test.go | 2 +- 12 files changed, 3725 insertions(+), 6 deletions(-) create mode 100644 router/internal/jsonschema/nullable_2020_12_test.go create mode 100644 router/internal/jsonschema/recursive_input_test.go create mode 100644 router/internal/jsonschema/schema.go create mode 100644 router/internal/jsonschema/schema_test.go create mode 100644 router/internal/jsonschema/variables_schema.go create mode 100644 router/internal/jsonschema/variables_schema_test.go diff --git a/router/internal/jsonschema/nullable_2020_12_test.go b/router/internal/jsonschema/nullable_2020_12_test.go new file mode 100644 index 000000000..5973023a7 --- /dev/null +++ b/router/internal/jsonschema/nullable_2020_12_test.go @@ -0,0 +1,103 @@ +package jsonschema + +import ( + "encoding/json" + "testing" + + "github.com/santhosh-tekuri/jsonschema/v5" + "github.com/stretchr/testify/require" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" +) + +// TestNullableFieldsAreJSONSchema2020_12 verifies that the generator expresses +// nullability in the JSON Schema 2020-12 form rather than the OpenAPI 3.0 +// keyword `"nullable": true` (which standard validators silently ignore). +// +// Concretely: a payload that contains explicit `null` values for nullable +// scalar, enum, and recursive-ref fields must validate cleanly against the +// generated schema using a strict standard JSON Schema validator. +func TestNullableFieldsAreJSONSchema2020_12(t *testing.T) { + schemaSDL := scalarDefinitions + ` + schema { query: Query } + + type Query { + processFormula(tree: FormulaNodeInput): Boolean + doThing(input: ThingInput): Boolean + } + + input ThingInput { + name: String + count: Int + rating: Float + active: Boolean + status: Status + } + + enum Status { ACTIVE INACTIVE } + + input FormulaNodeInput { + nodeType: NodeType! + left: FormulaNodeInput + right: FormulaNodeInput + value: Float + } + + enum NodeType { CONSTANT BINARY_OPERATION } + ` + + operationSDL := ` + query Run($tree: FormulaNodeInput, $input: ThingInput) { + processFormula(tree: $tree) + doThing(input: $input) + } + ` + + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed: %s", report.Error()) + + operationDoc, report := astparser.ParseGraphqlDocumentString(operationSDL) + require.False(t, report.HasErrors(), "operation parsing failed: %s", report.Error()) + + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + schemaJSON, err := json.Marshal(schema) + require.NoError(t, err) + + compiled, err := jsonschema.CompileString("schema.json", string(schemaJSON)) + require.NoError(t, err, "generated JSON schema should compile") + + // Nullable scalars and enum: explicit null values must be accepted. + t.Run("explicit nulls accepted for nullable scalar and enum fields", func(t *testing.T) { + const payloadJSON = `{ + "input": { + "name": null, + "count": null, + "rating": null, + "active": null, + "status": null + } + }` + var payload any + require.NoError(t, json.Unmarshal([]byte(payloadJSON), &payload)) + require.NoError(t, compiled.Validate(payload), + "nullable scalar/enum fields must accept explicit null per JSON Schema 2020-12") + }) + + // Nullable recursive $ref: a leaf may explicitly set left/right to null + // (rather than omitting them) and the schema must accept it. + t.Run("explicit nulls accepted for nullable recursive ref fields", func(t *testing.T) { + const payloadJSON = `{ + "tree": { + "nodeType": "BINARY_OPERATION", + "left": { "nodeType": "CONSTANT", "value": 1, "left": null, "right": null }, + "right": null + } + }` + var payload any + require.NoError(t, json.Unmarshal([]byte(payloadJSON), &payload)) + require.NoError(t, compiled.Validate(payload), + "nullable recursive ref fields must accept explicit null per JSON Schema 2020-12") + }) +} diff --git a/router/internal/jsonschema/recursive_input_test.go b/router/internal/jsonschema/recursive_input_test.go new file mode 100644 index 000000000..1913ca4c7 --- /dev/null +++ b/router/internal/jsonschema/recursive_input_test.go @@ -0,0 +1,89 @@ +package jsonschema + +import ( + "encoding/json" + "testing" + + "github.com/santhosh-tekuri/jsonschema/v5" + "github.com/stretchr/testify/require" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" +) + +// TestRecursiveInputAcceptsNestedPayload verifies that a self-recursive GraphQL +// input type produces a JSON Schema that accepts arbitrarily nested payloads. +// +// A self-recursive input type cannot be represented by inlining and truncating +// at a fixed recursion depth: the recursive fields get dropped from the schema, +// and because every object is emitted with `additionalProperties: false`, a +// valid nested payload is then rejected at the validation boundary with +// "additional properties '...' not allowed". The schema must instead reference +// the recursive type so that nesting is permitted to any depth. +func TestRecursiveInputAcceptsNestedPayload(t *testing.T) { + schemaSDL := scalarDefinitions + ` + schema { query: Query } + + type Query { + createColumn(input: ColumnInput!): Boolean + } + + input ColumnInput { + node: FormulaNodeInput! + } + + input FormulaNodeInput { + nodeType: NodeType! + left: FormulaNodeInput + right: FormulaNodeInput + value: Float + } + + enum NodeType { + CONSTANT + BINARY_OPERATION + } + ` + + operationSDL := ` + query CreateColumn($input: ColumnInput!) { + createColumn(input: $input) + } + ` + + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed: %s", report.Error()) + + operationDoc, report := astparser.ParseGraphqlDocumentString(operationSDL) + require.False(t, report.HasErrors(), "operation parsing failed: %s", report.Error()) + + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + schemaJSON, err := json.Marshal(schema) + require.NoError(t, err) + + compiled, err := jsonschema.CompileString("schema.json", string(schemaJSON)) + require.NoError(t, err, "generated JSON schema should compile") + + // A depth-2 expression tree: the inner BINARY_OPERATION node has its own + // left/right children, exercising recursion beyond a single level. + const payloadJSON = `{ + "input": { + "node": { + "nodeType": "BINARY_OPERATION", + "left": { + "nodeType": "BINARY_OPERATION", + "left": { "nodeType": "CONSTANT", "value": 1 }, + "right": { "nodeType": "CONSTANT", "value": 2 } + }, + "right": { "nodeType": "CONSTANT", "value": 3 } + } + } + }` + + var payload any + require.NoError(t, json.Unmarshal([]byte(payloadJSON), &payload)) + + err = compiled.Validate(payload) + require.NoError(t, err, "valid nested recursive payload must be accepted by the generated schema") +} diff --git a/router/internal/jsonschema/schema.go b/router/internal/jsonschema/schema.go new file mode 100644 index 000000000..9303dc45f --- /dev/null +++ b/router/internal/jsonschema/schema.go @@ -0,0 +1,308 @@ +package jsonschema + +import ( + "encoding/json" +) + +// SchemaType represents the type of a JSON Schema property +type SchemaType string + +const ( + TypeObject SchemaType = "object" + TypeArray SchemaType = "array" + TypeString SchemaType = "string" + TypeNumber SchemaType = "number" + TypeInteger SchemaType = "integer" + TypeBoolean SchemaType = "boolean" + TypeNull SchemaType = "null" +) + +// JsonSchema represents a JSON Schema definition. +// When adding a reference-typed field (map, slice, pointer), update Clone or it will alias. +type JsonSchema struct { + // Core schema fields + Type SchemaType `json:"type,omitempty"` + Properties map[string]*JsonSchema `json:"properties,omitempty"` + Required []string `json:"required,omitempty"` + AdditionalProperties *bool `json:"additionalProperties,omitempty"` + Description string `json:"description,omitempty"` + // Nullable is tracked internally; serialization expresses nullability in the + // JSON Schema 2020-12 form (type-union, anyOf, or null in enum), not the + // OpenAPI 3.0 "nullable" keyword. + Nullable bool `json:"-"` + + // Ref references a schema defined under the root "$defs" (e.g. "#/$defs/MyInput"). + // Used to represent recursive input types, which cannot be inlined. + Ref string `json:"$ref,omitempty"` + // Defs holds reusable schema definitions, referenced via Ref. Only populated + // on the root schema. + Defs map[string]*JsonSchema `json:"$defs,omitempty"` + + // Array-specific fields + Items *JsonSchema `json:"items,omitempty"` + + // Enum values + Enum []string `json:"enum,omitempty"` + + // Default value + Default any `json:"default,omitempty"` + + // String-specific fields + Format string `json:"format,omitempty"` + + // Number-specific fields + Minimum *float64 `json:"minimum,omitempty"` + Maximum *float64 `json:"maximum,omitempty"` + + // Additional validation + Pattern string `json:"pattern,omitempty"` +} + +// MarshalJSON customizes JSON serialization to omit empty fields +func (s *JsonSchema) MarshalJSON() ([]byte, error) { + // Use a map to only include non-empty fields + m := make(map[string]any) + + // Nullability is expressed per JSON Schema 2020-12: + // - typed schemas: "type": [, "null"] + // - enum schemas: null appended to the "enum" array + // - $ref schemas: {"anyOf": [{"$ref": ...}, {"type": "null"}]} + // rather than the OpenAPI 3.0 keyword "nullable: true", which standard + // validators ignore. + + if s.Type != "" { + if s.Nullable { + m["type"] = []string{string(s.Type), "null"} + } else { + m["type"] = string(s.Type) + } + } + + if len(s.Properties) > 0 { + m["properties"] = s.Properties + } + + if len(s.Required) > 0 { + m["required"] = s.Required + } + + if s.AdditionalProperties != nil { + m["additionalProperties"] = *s.AdditionalProperties + } + + if s.Description != "" { + m["description"] = s.Description + } + + if s.Items != nil { + m["items"] = s.Items + } + + if len(s.Enum) > 0 { + if s.Nullable { + enum := make([]any, 0, len(s.Enum)+1) + for _, v := range s.Enum { + enum = append(enum, v) + } + enum = append(enum, nil) + m["enum"] = enum + } else { + m["enum"] = s.Enum + } + } + + if s.Default != nil { + m["default"] = s.Default + } + + if s.Format != "" { + m["format"] = s.Format + } + + if s.Minimum != nil { + m["minimum"] = *s.Minimum + } + + if s.Maximum != nil { + m["maximum"] = *s.Maximum + } + + if s.Pattern != "" { + m["pattern"] = s.Pattern + } + + if s.Ref != "" { + if s.Nullable { + m["anyOf"] = []map[string]string{ + {"$ref": s.Ref}, + {"type": "null"}, + } + } else { + m["$ref"] = s.Ref + } + } + + if len(s.Defs) > 0 { + m["$defs"] = s.Defs + } + + return json.Marshal(m) +} + +// NewObjectSchema creates a new schema for an object type +func NewObjectSchema() *JsonSchema { + additionalProps := false + + return &JsonSchema{ + Type: TypeObject, + Properties: make(map[string]*JsonSchema), + AdditionalProperties: &additionalProps, + Required: []string{}, + Nullable: true, // Default to nullable + } +} + +// NewRefSchema creates a schema that references a definition under the root "$defs". +func NewRefSchema(typeName string) *JsonSchema { + return &JsonSchema{ + Ref: defsRef(typeName), + Nullable: true, // Default to nullable; callers adjust based on context + } +} + +// defsRef returns the JSON Pointer to a definition under the root "$defs". +func defsRef(typeName string) string { + return "#/$defs/" + typeName +} + +// NewAnySchema creates a schema representing any value (serialized as {} in JSON) +func NewAnySchema() *JsonSchema { + // This will represent as an empty object in JSON schema + return &JsonSchema{ + Nullable: true, // Default to nullable + } +} + +// NewArraySchema creates a new schema for an array type +func NewArraySchema(items *JsonSchema) *JsonSchema { + return &JsonSchema{ + Type: TypeArray, + Items: items, + Nullable: true, // Default to nullable + } +} + +// NewStringSchema creates a new schema for a string type +func NewStringSchema() *JsonSchema { + return &JsonSchema{ + Type: TypeString, + Nullable: true, // Default to nullable + } +} + +// NewIntegerSchema creates a new schema for an integer type +func NewIntegerSchema() *JsonSchema { + return &JsonSchema{ + Type: TypeInteger, + Nullable: true, // Default to nullable + } +} + +// NewNumberSchema creates a new schema for a number type +func NewNumberSchema() *JsonSchema { + return &JsonSchema{ + Type: TypeNumber, + Nullable: true, // Default to nullable + } +} + +// NewBooleanSchema creates a new schema for a boolean type +func NewBooleanSchema() *JsonSchema { + return &JsonSchema{ + Type: TypeBoolean, + Nullable: true, // Default to nullable + } +} + +// NewEnumSchema creates a new schema for an enum type +func NewEnumSchema(values []string) *JsonSchema { + return &JsonSchema{ + Type: TypeString, + Enum: values, + Nullable: true, // Default to nullable + } +} + +// WithDescription adds a description to the schema +func (s *JsonSchema) WithDescription(description string) *JsonSchema { + s.Description = description + return s +} + +// WithDefault adds a default value to the schema +func (s *JsonSchema) WithDefault(defaultValue any) *JsonSchema { + s.Default = defaultValue + return s +} + +// WithFormat adds a format to a string schema +func (s *JsonSchema) WithFormat(format string) *JsonSchema { + s.Format = format + return s +} + +// WithNullable marks a schema as nullable +func (s *JsonSchema) WithNullable(nullable bool) *JsonSchema { + s.Nullable = nullable + return s +} + +// Clone returns a deep copy of the schema. Callers that hand out schemas from +// a shared map (e.g. scalar overrides) must clone per use: nullability belongs +// to each usage site, and the builder records it by mutating Nullable on the +// schema it returns. Without a per-use copy, two variables of the same mapped +// scalar alias one object and the last-processed variable's nullability +// overwrites the first (guarded by the "same overridden scalar at two +// nullabilities" regression test). +// This is a hand-written copy on purpose: a marshal/unmarshal round-trip would +// silently drop Nullable (tagged json:"-"), and a shallow struct copy would +// still share Properties/Items/Defs. +// Clone returns nil if s is nil. +func (s *JsonSchema) Clone() *JsonSchema { + if s == nil { + return nil + } + clone := *s + if s.Properties != nil { + clone.Properties = make(map[string]*JsonSchema, len(s.Properties)) + for k, v := range s.Properties { + clone.Properties[k] = v.Clone() + } + } + if s.Required != nil { + clone.Required = append([]string(nil), s.Required...) + } + if s.AdditionalProperties != nil { + val := *s.AdditionalProperties + clone.AdditionalProperties = &val + } + if s.Defs != nil { + clone.Defs = make(map[string]*JsonSchema, len(s.Defs)) + for k, v := range s.Defs { + clone.Defs[k] = v.Clone() + } + } + clone.Items = s.Items.Clone() + if s.Enum != nil { + clone.Enum = append([]string(nil), s.Enum...) + } + if s.Minimum != nil { + val := *s.Minimum + clone.Minimum = &val + } + if s.Maximum != nil { + val := *s.Maximum + clone.Maximum = &val + } + return &clone +} diff --git a/router/internal/jsonschema/schema_test.go b/router/internal/jsonschema/schema_test.go new file mode 100644 index 000000000..0cbc265ea --- /dev/null +++ b/router/internal/jsonschema/schema_test.go @@ -0,0 +1,661 @@ +package jsonschema + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestJsonSchema_MarshalJSON(t *testing.T) { + t.Run("object schema", func(t *testing.T) { + // Create a complex nested schema + schema := NewObjectSchema() + schema.Description = "Test object schema" + + // Add string property with description and default + stringProp := NewStringSchema() + stringProp.Description = "A string property" + stringProp.Default = "default value" + schema.Properties["name"] = stringProp + schema.Required = append(schema.Required, "name") + + // Add integer property with minimum + intProp := NewIntegerSchema() + min := float64(0) + intProp.Minimum = &min + schema.Properties["age"] = intProp + schema.Required = append(schema.Required, "age") + + // Add enum property + enumValues := []string{"ONE", "TWO", "THREE"} + enumProp := NewEnumSchema(enumValues) + schema.Properties["category"] = enumProp + + // Add nested object property + nestedObj := NewObjectSchema() + nestedObj.Properties["street"] = NewStringSchema() + nestedObj.Properties["city"] = NewStringSchema() + nestedObj.Required = append(nestedObj.Required, "street") + schema.Properties["address"] = nestedObj + + // Add array property + arrayProp := NewArraySchema(NewStringSchema()) + schema.Properties["tags"] = arrayProp + + // Serialize to JSON + data, err := json.MarshalIndent(schema, "", " ") + require.NoError(t, err) + + // Define expected JSON schema + expectedJSON := `{ + "additionalProperties": false, + "description": "Test object schema", + "properties": { + "address": { + "additionalProperties": false, + "properties": { + "city": { + "type": [ + "string", + "null" + ] + }, + "street": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "street" + ], + "type": [ + "object", + "null" + ] + }, + "age": { + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "category": { + "enum": [ + "ONE", + "TWO", + "THREE", + null + ], + "type": [ + "string", + "null" + ] + }, + "name": { + "default": "default value", + "description": "A string property", + "type": [ + "string", + "null" + ] + }, + "tags": { + "items": { + "type": [ + "string", + "null" + ] + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "name", + "age" + ], + "type": [ + "object", + "null" + ] +}` + + // Compare actual JSON with expected JSON + assert.JSONEq(t, expectedJSON, string(data), "JSON schema does not match expected structure") + }) + + t.Run("nested schema", func(t *testing.T) { + // Create a schema with nested objects (previously would have used references) + rootSchema := NewObjectSchema() + rootSchema.Description = "Root schema" + + // Create a nested schema + nestedSchema := NewObjectSchema() + nestedSchema.Description = "Nested schema" + nestedSchema.Properties["value"] = NewStringSchema() + + // Add the nested schema as a property + rootSchema.Properties["nested"] = nestedSchema + + // Create an array of the nested schema + arraySchema := NewArraySchema(nestedSchema) + rootSchema.Properties["items"] = arraySchema + + // Serialize to JSON + data, err := json.Marshal(rootSchema) + require.NoError(t, err) + + // Parse it back to verify + var parsed map[string]any + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + // Verify structure - there should be no $ref + properties := parsed["properties"].(map[string]any) + nestedProp := properties["nested"].(map[string]any) + + // Check that it's properly inlined; nullable schemas serialize "type" as + // the JSON Schema 2020-12 two-element array [, "null"]. + assert.Equal(t, []any{"object", "null"}, nestedProp["type"]) + assert.Equal(t, "Nested schema", nestedProp["description"]) + assert.Contains(t, nestedProp, "properties") + + // Check the array contains the same schema inline + itemsProp := properties["items"].(map[string]any) + assert.Equal(t, []any{"array", "null"}, itemsProp["type"]) + assert.Contains(t, itemsProp, "items") + + itemsSchema := itemsProp["items"].(map[string]any) + assert.Equal(t, []any{"object", "null"}, itemsSchema["type"]) + assert.Equal(t, "Nested schema", itemsSchema["description"]) + }) +} + +func TestSchemaFeatures(t *testing.T) { + t.Run("enum schema", func(t *testing.T) { + // Test creating and validating enum schema + values := []string{"RED", "GREEN", "BLUE"} + schema := NewEnumSchema(values) + + // Test serialization + data, err := json.MarshalIndent(schema, "", " ") + require.NoError(t, err) + + // Define expected JSON schema + expectedJSON := `{ + "enum": [ + "RED", + "GREEN", + "BLUE", + null + ], + "type": [ + "string", + "null" + ] +}` + + // Compare actual JSON with expected JSON + assert.JSONEq(t, expectedJSON, string(data), "JSON schema does not match expected structure") + }) + + t.Run("required fields", func(t *testing.T) { + // Create schema with required fields + schema := NewObjectSchema() + schema.Properties["id"] = NewStringSchema() + schema.Properties["name"] = NewStringSchema() + schema.Properties["age"] = NewIntegerSchema() + + // Mark id and age as required + schema.Required = []string{"id", "age"} + + // Serialize and check + data, err := json.MarshalIndent(schema, "", " ") + require.NoError(t, err) + + // Define expected JSON schema + expectedJSON := `{ + "additionalProperties": false, + "properties": { + "age": { + "type": [ + "integer", + "null" + ] + }, + "id": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "age" + ], + "type": [ + "object", + "null" + ] +}` + + // Compare actual JSON with expected JSON + assert.JSONEq(t, expectedJSON, string(data), "JSON schema does not match expected structure") + }) + + t.Run("numeric constraints", func(t *testing.T) { + // Test numeric constraints (min/max) + min := float64(0) + max := float64(100) + + // Integer schema + intSchema := NewIntegerSchema() + intSchema.Minimum = &min + intSchema.Maximum = &max + + data, err := json.MarshalIndent(intSchema, "", " ") + require.NoError(t, err) + + // Define expected JSON schema for integer + expectedIntJSON := `{ + "type": ["integer", "null"], + "minimum": 0, + "maximum": 100 +}` + + // Compare actual JSON with expected JSON + assert.JSONEq(t, expectedIntJSON, string(data), "Integer schema does not match expected structure") + + // Number schema + numSchema := NewNumberSchema() + numSchema.Minimum = &min + numSchema.Maximum = &max + + data, err = json.MarshalIndent(numSchema, "", " ") + require.NoError(t, err) + + // Define expected JSON schema for number + expectedNumJSON := `{ + "type": ["number", "null"], + "minimum": 0, + "maximum": 100 +}` + + // Compare actual JSON with expected JSON + assert.JSONEq(t, expectedNumJSON, string(data), "Number schema does not match expected structure") + }) + + t.Run("string format", func(t *testing.T) { + // Test string format + schema := NewStringSchema() + schema.Format = "email" + + data, err := json.Marshal(schema) + require.NoError(t, err) + + var parsed map[string]any + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + assert.Equal(t, "email", parsed["format"]) + }) + + t.Run("default values", func(t *testing.T) { + // Test default values for different types + stringSchema := NewStringSchema() + stringSchema.Default = "default string" + + intSchema := NewIntegerSchema() + intSchema.Default = 42 + + boolSchema := NewBooleanSchema() + boolSchema.Default = true + + // Test object with default values + objSchema := NewObjectSchema() + objSchema.Properties["str"] = stringSchema + objSchema.Properties["num"] = intSchema + objSchema.Properties["bool"] = boolSchema + + data, err := json.Marshal(objSchema) + require.NoError(t, err) + + var parsed map[string]any + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + properties := parsed["properties"].(map[string]any) + + strProp := properties["str"].(map[string]any) + assert.Equal(t, "default string", strProp["default"]) + + numProp := properties["num"].(map[string]any) + assert.Equal(t, float64(42), numProp["default"]) + + boolProp := properties["bool"].(map[string]any) + assert.Equal(t, true, boolProp["default"]) + }) + + t.Run("pattern validation", func(t *testing.T) { + // Test pattern validation for strings + schema := NewStringSchema() + schema.Pattern = "^[a-zA-Z0-9]+$" + + data, err := json.Marshal(schema) + require.NoError(t, err) + + var parsed map[string]any + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + assert.Equal(t, "^[a-zA-Z0-9]+$", parsed["pattern"]) + }) + + t.Run("nullable types", func(t *testing.T) { + // Test all nullable types + schemas := []*JsonSchema{ + NewObjectSchema(), + NewArraySchema(NewStringSchema()), + NewStringSchema(), + NewIntegerSchema(), + NewNumberSchema(), + NewBooleanSchema(), + NewEnumSchema([]string{"A", "B"}), + } + + for _, schema := range schemas { + data, err := json.Marshal(schema) + require.NoError(t, err) + + var parsed map[string]any + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + // A nullable schema serializes "type" as the JSON Schema 2020-12 two- + // element array [, "null"], not the OpenAPI "nullable: true". + typeArr, ok := parsed["type"].([]any) + require.True(t, ok, "nullable schema should serialize type as an array") + require.Len(t, typeArr, 2) + require.Contains(t, typeArr, "null") + require.NotEqual(t, "null", typeArr[0], + "primary (non-null) type should appear first in the type array") + } + }) + + t.Run("fluent interface", func(t *testing.T) { + // Test fluent interface for building schemas + schema := NewStringSchema(). + WithDescription("A string with format and default"). + WithFormat("email"). + WithDefault("user@example.com") + + data, err := json.Marshal(schema) + require.NoError(t, err) + + var parsed map[string]any + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + assert.Equal(t, "A string with format and default", parsed["description"]) + assert.Equal(t, "email", parsed["format"]) + assert.Equal(t, "user@example.com", parsed["default"]) + }) + + t.Run("complex nested schema", func(t *testing.T) { + // Test a complex schema with all features + userSchema := NewObjectSchema() + userSchema.Description = "User schema with all features" + + // Required string with pattern + idSchema := NewStringSchema() + idSchema.Pattern = "^[a-zA-Z0-9]{8,}$" + userSchema.Properties["id"] = idSchema + userSchema.Required = append(userSchema.Required, "id") + + // String with format and default + emailSchema := NewStringSchema() + emailSchema.Format = "email" + emailSchema.Default = "user@example.com" + userSchema.Properties["email"] = emailSchema + + // Integer with constraints + min := float64(13) + ageSchema := NewIntegerSchema() + ageSchema.Minimum = &min + userSchema.Properties["age"] = ageSchema + + // Enum property + roleSchema := NewEnumSchema([]string{"ADMIN", "USER", "GUEST"}) + roleSchema.Default = "USER" + userSchema.Properties["role"] = roleSchema + + // Array of strings + tagsSchema := NewArraySchema(NewStringSchema()) + userSchema.Properties["tags"] = tagsSchema + + // Nested object + addressSchema := NewObjectSchema() + addressSchema.Properties["street"] = NewStringSchema() + addressSchema.Properties["city"] = NewStringSchema() + addressSchema.Required = append(addressSchema.Required, "street") + userSchema.Properties["address"] = addressSchema + + // Serialize the whole thing + data, err := json.MarshalIndent(userSchema, "", " ") + require.NoError(t, err) + + // Define expected JSON schema + expectedJSON := `{ + "additionalProperties": false, + "description": "User schema with all features", + "properties": { + "address": { + "additionalProperties": false, + "properties": { + "city": { + "type": [ + "string", + "null" + ] + }, + "street": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "street" + ], + "type": [ + "object", + "null" + ] + }, + "age": { + "minimum": 13, + "type": [ + "integer", + "null" + ] + }, + "email": { + "default": "user@example.com", + "format": "email", + "type": [ + "string", + "null" + ] + }, + "id": { + "pattern": "^[a-zA-Z0-9]{8,}$", + "type": [ + "string", + "null" + ] + }, + "role": { + "default": "USER", + "enum": [ + "ADMIN", + "USER", + "GUEST", + null + ], + "type": [ + "string", + "null" + ] + }, + "tags": { + "items": { + "type": [ + "string", + "null" + ] + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "id" + ], + "type": [ + "object", + "null" + ] +}` + + // Compare actual JSON with expected JSON + assert.JSONEq(t, expectedJSON, string(data), "JSON schema does not match expected structure") + }) + + t.Run("nullable schema property", func(t *testing.T) { + // Test creating schemas with different nullable settings + + // Create a schema with nullable field + schema := NewObjectSchema() + schema.Properties["nullableString"] = NewStringSchema().WithNullable(true) + schema.Properties["nonNullableString"] = NewStringSchema().WithNullable(false) + + // By default, all types should be nullable + schema.Properties["defaultString"] = NewStringSchema() + + // Check that factory methods set nullable to true by default + intSchema := NewIntegerSchema() + assert.True(t, intSchema.Nullable) + + numSchema := NewNumberSchema() + assert.True(t, numSchema.Nullable) + + boolSchema := NewBooleanSchema() + assert.True(t, boolSchema.Nullable) + + enumSchema := NewEnumSchema([]string{"A", "B"}) + assert.True(t, enumSchema.Nullable) + + arraySchema := NewArraySchema(NewStringSchema()) + assert.True(t, arraySchema.Nullable) + + objSchema := NewObjectSchema() + assert.True(t, objSchema.Nullable) + + // Test serialization + data, err := json.Marshal(schema) + require.NoError(t, err) + + var parsed map[string]any + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + properties := parsed["properties"].(map[string]any) + + // Nullability is expressed via the JSON Schema 2020-12 type-union form, + // not the OpenAPI "nullable" keyword (which is no longer emitted). + + // Explicitly nullable property: "type" is the two-element [, "null"] array. + nullableProp := properties["nullableString"].(map[string]any) + assert.Equal(t, []any{"string", "null"}, nullableProp["type"]) + _, hasNullableKey := nullableProp["nullable"] + assert.False(t, hasNullableKey, "nullable keyword should not be emitted") + + // Non-nullable property: "type" is a single string and no "nullable" key. + nonNullableProp := properties["nonNullableString"].(map[string]any) + assert.Equal(t, "string", nonNullableProp["type"]) + _, hasNullableOnNonNullable := nonNullableProp["nullable"] + assert.False(t, hasNullableOnNonNullable) + + // Default (factory-nullable) property: same shape as explicitly nullable. + defaultProp := properties["defaultString"].(map[string]any) + assert.Equal(t, []any{"string", "null"}, defaultProp["type"]) + + // Test WithNullable method + schema = NewStringSchema() + schema.WithNullable(true) + assert.True(t, schema.Nullable) + + schema.WithNullable(false) + assert.False(t, schema.Nullable) + }) +} + +func TestJsonSchemaClone(t *testing.T) { + t.Run("mutating the clone does not affect the original", func(t *testing.T) { + additionalProps := false + minimum := 1.0 + original := &JsonSchema{ + Type: TypeObject, + Properties: map[string]*JsonSchema{"name": NewStringSchema()}, + Required: []string{"name"}, + AdditionalProperties: &additionalProps, + Description: "original", + Nullable: true, + Items: NewStringSchema(), + Enum: []string{"a", "b"}, + Minimum: &minimum, + } + + clone := original.Clone() + + clone.Nullable = false + clone.Description = "mutated" + clone.Properties["name"].Type = TypeInteger + clone.Required[0] = "changed" + *clone.AdditionalProperties = true + clone.Items.Type = TypeBoolean + clone.Enum[0] = "z" + *clone.Minimum = 99 + + assert.True(t, original.Nullable) + assert.Equal(t, "original", original.Description) + assert.Equal(t, TypeString, original.Properties["name"].Type) + assert.Equal(t, "name", original.Required[0]) + assert.False(t, *original.AdditionalProperties) + assert.Equal(t, TypeString, original.Items.Type) + assert.Equal(t, "a", original.Enum[0]) + assert.Equal(t, 1.0, *original.Minimum) + }) + + t.Run("nil receiver returns nil", func(t *testing.T) { + var s *JsonSchema + assert.Nil(t, s.Clone()) + }) +} diff --git a/router/internal/jsonschema/variables_schema.go b/router/internal/jsonschema/variables_schema.go new file mode 100644 index 000000000..30d63bad0 --- /dev/null +++ b/router/internal/jsonschema/variables_schema.go @@ -0,0 +1,593 @@ +package jsonschema + +import ( + "fmt" + "sort" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + "github.com/wundergraph/graphql-go-tools/v2/pkg/astvisitor" + "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" +) + +// VariablesSchemaBuilder creates a unified JSON schema for the variables of a GraphQL operation +type VariablesSchemaBuilder struct { + operationDocument *ast.Document + definitionDocument *ast.Document + schema *JsonSchema + report *operationreport.Report + // recursiveTypes holds the names of input types that are self- or mutually + // recursive. They are emitted once under the root "$defs" and referenced via + // "$ref" instead of being inlined, which supports arbitrary nesting depth. + recursiveTypes map[string]bool + // defs accumulates schemas for recursive input types; attached to the root + // schema as "$defs". + defs map[string]*JsonSchema + // scalarSchemas overrides the schema emitted per custom scalar type name. + scalarSchemas map[string]*JsonSchema + // defaultedScalars records custom scalars that fell back to the string + // default, so callers can surface missing mappings. + defaultedScalars map[string]bool +} + +// VariablesSchemaOption configures a VariablesSchemaBuilder. +type VariablesSchemaOption func(*VariablesSchemaBuilder) + +// WithScalarSchemas overrides the JSON schema emitted for custom scalar types, +// keyed by scalar type name. Unmapped custom scalars default to "string". +// Built-in scalars (String, ID, Int, Float, Boolean) cannot be overridden. +func WithScalarSchemas(schemas map[string]*JsonSchema) VariablesSchemaOption { + return func(v *VariablesSchemaBuilder) { + v.scalarSchemas = schemas + } +} + +// Ensure VariablesSchemaBuilder implements the necessary astvisitor interfaces +var ( + _ astvisitor.EnterDocumentVisitor = (*VariablesSchemaBuilder)(nil) + _ astvisitor.EnterVariableDefinitionVisitor = (*VariablesSchemaBuilder)(nil) +) + +// NewVariablesSchemaBuilder creates a new VariablesSchemaBuilder. +func NewVariablesSchemaBuilder(operationDocument, definitionDocument *ast.Document, opts ...VariablesSchemaOption) *VariablesSchemaBuilder { + v := &VariablesSchemaBuilder{ + operationDocument: operationDocument, + definitionDocument: definitionDocument, + schema: NewObjectSchema(), + report: &operationreport.Report{}, + recursiveTypes: make(map[string]bool), + defs: make(map[string]*JsonSchema), + defaultedScalars: make(map[string]bool), + } + + for _, opt := range opts { + opt(v) + } + + return v +} + +// EnterDocument implements the astvisitor.EnterDocumentVisitor interface +func (v *VariablesSchemaBuilder) EnterDocument(operation, definition *ast.Document) { + if len(operation.OperationDefinitions) == 0 { + return + } + + v.schema = NewObjectSchema() + v.defs = make(map[string]*JsonSchema) // Reset defs for each build + v.defaultedScalars = make(map[string]bool) // Reset defaulted scalars for each build + v.recursiveTypes = v.computeRecursiveInputTypes() // Identify recursive input types + + // Extract descriptions from root fields + var descriptions []string + + operationDefinition := operation.OperationDefinitions[0] + + // Process SelectionSet to extract field descriptions + if operationDefinition.HasSelections { + selectionSetRef := operationDefinition.SelectionSet + for _, selectionRef := range operation.SelectionSets[selectionSetRef].SelectionRefs { + selection := operation.Selections[selectionRef] + if selection.Kind == ast.SelectionKindField { + fieldName := operation.FieldNameString(selection.Ref) + + // Look up field in schema definition to get description + operationType := operationDefinition.OperationType + var rootTypeName string + + // Determine root type based on operation type + switch operationType { + case ast.OperationTypeQuery: + rootTypeName = "Query" + case ast.OperationTypeMutation: + rootTypeName = "Mutation" + case ast.OperationTypeSubscription: + rootTypeName = "Subscription" + default: + v.report.AddInternalError(fmt.Errorf("unsupported operation type %q", operationType)) + return + } + + rootType, exists := definition.Index.FirstNodeByNameStr(rootTypeName) + if exists && rootType.Kind == ast.NodeKindObjectTypeDefinition { + // Find the field in the root type + for _, fieldDefRef := range definition.ObjectTypeDefinitions[rootType.Ref].FieldsDefinition.Refs { + fieldDefName := definition.FieldDefinitionNameString(fieldDefRef) + + // Match field name + if fieldDefName == fieldName && definition.FieldDefinitions[fieldDefRef].Description.IsDefined { + description := definition.FieldDefinitionDescriptionString(fieldDefRef) + if description != "" { + descriptions = append(descriptions, description) + } + break + } + } + } + } + } + } + + // Set concatenated descriptions on root schema if any were found + if len(descriptions) > 0 { + v.schema.Description = "" + for i, desc := range descriptions { + if i > 0 { + v.schema.Description += " " + } + v.schema.Description += desc + } + } +} + +// EnterVariableDefinition implements the astvisitor.EnterVariableDefinitionVisitor interface +func (v *VariablesSchemaBuilder) EnterVariableDefinition(ref int) { + varName := v.operationDocument.VariableDefinitionNameString(ref) + typeRef := v.operationDocument.VariableDefinitions[ref].Type + + // Convert type to schema starting from the operation document + varSchema := v.processOperationTypeRef(typeRef) + + // Skip this variable if its type could not be resolved to a schema + if varSchema == nil { + return + } + + // Add variable to required list if it's non-nullable + if v.operationDocument.TypeIsNonNull(typeRef) { + v.schema.Required = append(v.schema.Required, varName) + } + + if v.operationDocument.VariableDefinitions[ref].Description.IsDefined { + varSchema.Description = v.operationDocument.VariableDefinitionDescriptionString(ref) + } + + // Set default value if exists + if v.operationDocument.VariableDefinitionHasDefaultValue(ref) { + defaultValue := v.operationDocument.VariableDefinitionDefaultValue(ref) + varSchema.Default = v.convertOperationValueToNative(defaultValue) + } + + // Force top-level object fields to be not nullable (Nullable=false) so they can't be null + // This ensures they appear as empty objects at minimum + if varSchema.Type == TypeObject { + // Setting Nullable to false means the field can't be null + // Since the nullable field is only included when true, this effectively removes it + // from the output JSON, which is what we want + varSchema.Nullable = false + } + + // Add variable to schema + v.schema.Properties[varName] = varSchema +} + +// GetSchema returns the built schema +func (v *VariablesSchemaBuilder) GetSchema() *JsonSchema { + // The root variables object is always a concrete object and must never be + // nullable: the variables container is either present or omitted, never the + // JSON literal null. Emitting a nullable root (type ["object","null"] under + // JSON Schema 2020-12) breaks strict consumers such as the MCP SDK, which + // require the input schema's type to be exactly "object". + v.schema.Nullable = false + // Attach definitions for any recursive input types referenced via "$ref" + if len(v.defs) > 0 { + v.schema.Defs = v.defs + } + return v.schema +} + +// GetReport returns the report containing any errors +func (v *VariablesSchemaBuilder) GetReport() *operationreport.Report { + return v.report +} + +// DefaultedScalars returns the sorted names of custom scalars that fell back +// to the default "string" schema during the last build. +func (v *VariablesSchemaBuilder) DefaultedScalars() []string { + names := make([]string, 0, len(v.defaultedScalars)) + for name := range v.defaultedScalars { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// Build traverses the operation and builds a unified JSON schema for its variables +func (v *VariablesSchemaBuilder) Build() (*JsonSchema, error) { + // Create a new walker for AST traversal + walker := astvisitor.NewDefaultWalker() + + // Register this builder as a visitor + walker.RegisterEnterDocumentVisitor(v) + walker.RegisterEnterVariableDefinitionVisitor(v) + + // Walk the AST + walker.Walk(v.operationDocument, v.definitionDocument, v.report) + + if v.report.HasErrors() { + return nil, fmt.Errorf("%s", v.report.Error()) + } + + return v.GetSchema(), nil +} + +// processOperationTypeRef processes a type reference from the operation document +func (v *VariablesSchemaBuilder) processOperationTypeRef(typeRef int) *JsonSchema { + switch v.operationDocument.Types[typeRef].TypeKind { + case ast.TypeKindNonNull: + ofType := v.operationDocument.Types[typeRef].OfType + schema := v.processOperationTypeRef(ofType) + if schema == nil { + return nil + } + // Non-null types are not nullable + schema.Nullable = false + return schema + + case ast.TypeKindList: + ofType := v.operationDocument.Types[typeRef].OfType + itemSchema := v.processOperationTypeRef(ofType) + if itemSchema == nil { + return nil + } + // If we're not in a non-null context, list is nullable + schema := NewArraySchema(itemSchema) + schema.Nullable = true + return schema + + case ast.TypeKindNamed: + typeName := v.operationDocument.TypeNameString(typeRef) + schema := v.processTypeByName(typeName) + if schema != nil { + // If we're not in a non-null context, named type is nullable + schema.Nullable = true + } + return schema + } + + return nil +} + +// processTypeByName processes a type by its name, looking it up in the definition document +func (v *VariablesSchemaBuilder) processTypeByName(typeName string) *JsonSchema { + // Handle built-in scalars + switch typeName { + case "String", "ID": + return NewStringSchema() + case "Int": + return NewIntegerSchema() + case "Float": + return NewNumberSchema() + case "Boolean": + return NewBooleanSchema() + } + + // For custom types, look up in the definition document + node, exists := v.definitionDocument.Index.FirstNodeByNameStr(typeName) + if !exists { + v.report.AddInternalError(fmt.Errorf("type %s is not defined", typeName)) + return NewObjectSchema() + } + + // Recursive input types are emitted once under "$defs" and referenced via + // "$ref" so that nesting is permitted to any depth. + if node.Kind == ast.NodeKindInputObjectTypeDefinition && v.recursiveTypes[typeName] { + v.ensureDef(typeName, node) + return NewRefSchema(typeName) + } + + // Process the type based on its kind + switch node.Kind { + case ast.NodeKindEnumTypeDefinition: + return v.processEnumType(node) + + case ast.NodeKindInputObjectTypeDefinition: + return v.processInputObjectType(node) + + case ast.NodeKindScalarTypeDefinition: + if override, ok := v.scalarSchemas[typeName]; ok { + // Clone per use: the builder mutates Nullable on returned schemas + // depending on each variable's non-null context. + schema := override.Clone() + if schema.Description == "" && v.definitionDocument.ScalarTypeDefinitions[node.Ref].Description.IsDefined { + schema.Description = v.definitionDocument.ScalarTypeDefinitionDescriptionString(node.Ref) + } + return schema + } + // Custom scalars are opaque to JSON Schema. Emit a best-effort "string" + // type: MCP/LLM tool consumers reject or degrade on untyped properties, + // and opaque scalars are overwhelmingly strings on the wire. Callers can + // override per scalar via WithScalarSchemas. + v.defaultedScalars[typeName] = true + schema := NewStringSchema() + if v.definitionDocument.ScalarTypeDefinitions[node.Ref].Description.IsDefined { + schema.Description = v.definitionDocument.ScalarTypeDefinitionDescriptionString(node.Ref) + } + return schema + + default: + // If we can't determine the type, default to any + return NewAnySchema() + } +} + +// computeRecursiveInputTypes returns the set of input object type names that are +// self- or mutually-recursive, i.e. reachable from themselves by following input +// field type references. These are the types that must be referenced via "$ref" +// rather than inlined. +func (v *VariablesSchemaBuilder) computeRecursiveInputTypes() map[string]bool { + def := v.definitionDocument + + // Build the dependency graph between input object types. + dependencies := make(map[string][]string, len(def.InputObjectTypeDefinitions)) + for ref := range def.InputObjectTypeDefinitions { + name := def.InputObjectTypeDefinitionNameString(ref) + inputDef := def.InputObjectTypeDefinitions[ref] + if !inputDef.HasInputFieldsDefinition { + dependencies[name] = nil + continue + } + for _, fieldRef := range inputDef.InputFieldsDefinition.Refs { + fieldType := def.InputValueDefinitionType(fieldRef) + dependencies[name] = append(dependencies[name], def.ResolveTypeNameString(fieldType)) + } + } + + recursive := make(map[string]bool) + for start := range dependencies { + if reachableFromSelf(start, dependencies) { + recursive[start] = true + } + } + return recursive +} + +// reachableFromSelf reports whether start can reach itself by following the given +// type dependencies (detecting both self- and mutual recursion). +func reachableFromSelf(start string, dependencies map[string][]string) bool { + visited := make(map[string]bool) + stack := append([]string(nil), dependencies[start]...) + for len(stack) > 0 { + current := stack[len(stack)-1] + stack = stack[:len(stack)-1] + if current == start { + return true + } + if visited[current] { + continue + } + visited[current] = true + stack = append(stack, dependencies[current]...) + } + return false +} + +// ensureDef generates the schema for a recursive input type once and stores it +// under "$defs". A placeholder is registered before the body is generated so that +// self-references encountered during generation resolve to a "$ref" rather than +// recursing infinitely. +func (v *VariablesSchemaBuilder) ensureDef(typeName string, node ast.Node) { + if _, ok := v.defs[typeName]; ok { + return + } + v.defs[typeName] = NewObjectSchema() // placeholder to break the recursion + body := v.processInputObjectType(node) + // The definition body is the type itself, not nullable; nullability is + // applied per use-site via the "$ref" (rewritten as anyOf-with-null when + // the referencing context is nullable). + body.Nullable = false + v.defs[typeName] = body +} + +// processEnumType processes an enum type definition +func (v *VariablesSchemaBuilder) processEnumType(node ast.Node) *JsonSchema { + values := make([]string, 0) + enumDef := v.definitionDocument.EnumTypeDefinitions[node.Ref] + + for _, valueRef := range enumDef.EnumValuesDefinition.Refs { + valueName := v.definitionDocument.EnumValueDefinitionNameString(valueRef) + values = append(values, valueName) + } + + schema := NewEnumSchema(values) + + // Add description if available + if enumDef.Description.IsDefined { + schema.Description = v.definitionDocument.EnumTypeDefinitionDescriptionString(node.Ref) + } + + return schema +} + +// processInputObjectType processes an input object type definition +func (v *VariablesSchemaBuilder) processInputObjectType(node ast.Node) *JsonSchema { + schema := NewObjectSchema() + inputDef := v.definitionDocument.InputObjectTypeDefinitions[node.Ref] + + // Set description if available + if inputDef.Description.IsDefined { + schema.Description = v.definitionDocument.InputObjectTypeDefinitionDescriptionString(node.Ref) + } + + if !inputDef.HasInputFieldsDefinition { + return schema + } + + // Process each input field + for _, fieldRef := range inputDef.InputFieldsDefinition.Refs { + v.processInputField(fieldRef, schema) + } + + return schema +} + +// processInputField processes a single input field +func (v *VariablesSchemaBuilder) processInputField(fieldRef int, schema *JsonSchema) { + fieldName := v.definitionDocument.InputValueDefinitionNameString(fieldRef) + fieldTypeRef := v.definitionDocument.InputValueDefinitionType(fieldRef) + + // Process the field type starting from the definition document + fieldSchema := v.processDefinitionTypeRef(fieldTypeRef) + + // Skip this field if its type could not be resolved to a schema + if fieldSchema == nil { + return + } + + // Add to required list if non-nullable + if v.definitionDocument.TypeIsNonNull(fieldTypeRef) { + schema.Required = append(schema.Required, fieldName) + } + + // Set field description if exists + if v.definitionDocument.InputValueDefinitions[fieldRef].Description.IsDefined { + description := v.definitionDocument.InputValueDefinitionDescriptionString(fieldRef) + fieldSchema.Description = description + } + + // Set default value if exists + if v.definitionDocument.InputValueDefinitionHasDefaultValue(fieldRef) { + defaultValue := v.definitionDocument.InputValueDefinitionDefaultValue(fieldRef) + fieldSchema.Default = v.convertDefinitionValueToNative(defaultValue) + } + + // Add field to schema + schema.Properties[fieldName] = fieldSchema +} + +// processDefinitionTypeRef processes a type reference from the definition document +func (v *VariablesSchemaBuilder) processDefinitionTypeRef(typeRef int) *JsonSchema { + switch v.definitionDocument.Types[typeRef].TypeKind { + case ast.TypeKindNonNull: + ofType := v.definitionDocument.Types[typeRef].OfType + schema := v.processDefinitionTypeRef(ofType) + if schema == nil { + return nil + } + // Non-null types are not nullable + schema.Nullable = false + return schema + + case ast.TypeKindList: + ofType := v.definitionDocument.Types[typeRef].OfType + itemSchema := v.processDefinitionTypeRef(ofType) + if itemSchema == nil { + return nil + } + // If we're not in a non-null context, list is nullable + schema := NewArraySchema(itemSchema) + schema.Nullable = true + return schema + + case ast.TypeKindNamed: + typeName := v.definitionDocument.TypeNameString(typeRef) + schema := v.processTypeByName(typeName) + if schema != nil { + // If we're not in a non-null context, named type is nullable + schema.Nullable = true + } + return schema + } + + return nil +} + +// convertOperationValueToNative converts a GraphQL AST value from the operation document to a native Go value +func (v *VariablesSchemaBuilder) convertOperationValueToNative(value ast.Value) any { + switch value.Kind { + case ast.ValueKindString: + return v.operationDocument.StringValueContentString(value.Ref) + case ast.ValueKindInteger: + return v.operationDocument.IntValueAsInt(value.Ref) + case ast.ValueKindFloat: + return v.operationDocument.FloatValueAsFloat32(value.Ref) + case ast.ValueKindBoolean: + return v.operationDocument.BooleanValue(value.Ref) + case ast.ValueKindNull: + return nil + case ast.ValueKindEnum: + return v.operationDocument.EnumValueNameString(value.Ref) + case ast.ValueKindList: + list := make([]any, 0) + for _, itemRef := range v.operationDocument.ListValues[value.Ref].Refs { + item := v.operationDocument.Value(itemRef) + list = append(list, v.convertOperationValueToNative(item)) + } + return list + case ast.ValueKindObject: + obj := make(map[string]any) + for _, fieldRef := range v.operationDocument.ObjectValues[value.Ref].Refs { + fieldName := v.operationDocument.ObjectFieldNameString(fieldRef) + fieldValue := v.operationDocument.ObjectFieldValue(fieldRef) + obj[fieldName] = v.convertOperationValueToNative(fieldValue) + } + return obj + } + + return nil +} + +// convertDefinitionValueToNative converts a GraphQL AST value from the definition document to a native Go value +func (v *VariablesSchemaBuilder) convertDefinitionValueToNative(value ast.Value) any { + switch value.Kind { + case ast.ValueKindString: + return v.definitionDocument.StringValueContentString(value.Ref) + case ast.ValueKindInteger: + return v.definitionDocument.IntValueAsInt(value.Ref) + case ast.ValueKindFloat: + return v.definitionDocument.FloatValueAsFloat32(value.Ref) + case ast.ValueKindBoolean: + return v.definitionDocument.BooleanValue(value.Ref) + case ast.ValueKindNull: + return nil + case ast.ValueKindEnum: + return v.definitionDocument.EnumValueNameString(value.Ref) + case ast.ValueKindList: + list := make([]any, 0) + for _, itemRef := range v.definitionDocument.ListValues[value.Ref].Refs { + item := v.definitionDocument.Value(itemRef) + list = append(list, v.convertDefinitionValueToNative(item)) + } + return list + case ast.ValueKindObject: + obj := make(map[string]any) + for _, fieldRef := range v.definitionDocument.ObjectValues[value.Ref].Refs { + fieldName := v.definitionDocument.ObjectFieldNameString(fieldRef) + fieldValue := v.definitionDocument.ObjectFieldValue(fieldRef) + obj[fieldName] = v.convertDefinitionValueToNative(fieldValue) + } + return obj + } + + return nil +} + +// BuildJsonSchema builds a JSON schema for the variables of the given operation. +// Recursive input types are represented via "$ref"/"$defs" and support arbitrary +// nesting depth. +func BuildJsonSchema(operationDocument, definitionDocument *ast.Document, opts ...VariablesSchemaOption) (*JsonSchema, error) { + if len(operationDocument.OperationDefinitions) == 0 { + return nil, fmt.Errorf("no operations found in document") + } + + return NewVariablesSchemaBuilder(operationDocument, definitionDocument, opts...).Build() +} diff --git a/router/internal/jsonschema/variables_schema_test.go b/router/internal/jsonschema/variables_schema_test.go new file mode 100644 index 000000000..c465a1976 --- /dev/null +++ b/router/internal/jsonschema/variables_schema_test.go @@ -0,0 +1,1965 @@ +package jsonschema + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" +) + +// scalarDefinitions contains the basic scalar types that need to be defined for tests +const scalarDefinitions = ` +scalar String +scalar Int +scalar Float +scalar Boolean +scalar ID +` + +func TestBuildJsonSchema(t *testing.T) { + t.Run("simple query with input object", func(t *testing.T) { + // Define schema + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + type Query { + findEmployees(criteria: SearchInput): EmployeeResult + } + + type EmployeeResult { + details: EmployeeDetails + } + + type EmployeeDetails { + forename: String + } + + """Input criteria used to search for employees""" + input SearchInput { + name: String! + department: String + employmentStatus: EmploymentStatus + } + + enum EmploymentStatus { + FULL_TIME + PART_TIME + CONTRACTOR + INTERN + } + ` + + // Define operation + operationSDL := ` + query MyEmployees($criteria: SearchInput) { + findEmployees(criteria: $criteria) { + details { + forename + } + } + } + ` + + // Parse schema and operation + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operationSDL) + require.False(t, report.HasErrors(), "operation parsing failed") + + // Build JSON schema + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + // Serialize schema to JSON + data, err := json.MarshalIndent(schema, "", " ") + require.NoError(t, err) + + // Define expected JSON schema + expectedJSON := `{ + "additionalProperties": false, + "properties": { + "criteria": { + "additionalProperties": false, + "description": "Input criteria used to search for employees", + "properties": { + "department": { + "type": [ + "string", + "null" + ] + }, + "employmentStatus": { + "enum": [ + "FULL_TIME", + "PART_TIME", + "CONTRACTOR", + "INTERN", + null + ], + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + }, + "type": "object" +}` + + // Compare actual JSON with expected JSON + assert.JSONEq(t, expectedJSON, string(data), "JSON schema does not match expected structure") + }) + + t.Run("query with nested input objects", func(t *testing.T) { + // Define schema with nested inputs + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + type Query { + findEmployees(criteria: SearchInput): [Employee] + } + + type Employee { + id: ID! + name: String + } + + input SearchInput { + name: String + nested: NestedInput! + } + + input NestedInput { + hasChildren: Boolean + maritalStatus: MaritalStatus + nationality: Nationality! + } + + enum MaritalStatus { + MARRIED + ENGAGED + } + + enum Nationality { + AMERICAN + DUTCH + ENGLISH + GERMAN + INDIAN + SPANISH + UKRAINIAN + } + ` + + // Define operation + operationSDL := ` + query MyEmployees($criteria: SearchInput!) { + findEmployees(criteria: $criteria) { + id + } + } + ` + + // Parse schema and operation + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operationSDL) + require.False(t, report.HasErrors(), "operation parsing failed") + + // Build JSON schema + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + // Serialize schema to JSON + data, err := json.MarshalIndent(schema, "", " ") + require.NoError(t, err) + + // Define expected JSON schema + expectedJSON := `{ + "additionalProperties": false, + "properties": { + "criteria": { + "additionalProperties": false, + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "nested": { + "additionalProperties": false, + "properties": { + "hasChildren": { + "type": [ + "boolean", + "null" + ] + }, + "maritalStatus": { + "enum": [ + "MARRIED", + "ENGAGED", + null + ], + "type": [ + "string", + "null" + ] + }, + "nationality": { + "enum": [ + "AMERICAN", + "DUTCH", + "ENGLISH", + "GERMAN", + "INDIAN", + "SPANISH", + "UKRAINIAN" + ], + "type": "string" + } + }, + "required": [ + "nationality" + ], + "type": "object" + } + }, + "required": [ + "nested" + ], + "type": "object" + } + }, + "required": [ + "criteria" + ], + "type": "object" +}` + + // Compare actual JSON with expected JSON + assert.JSONEq(t, expectedJSON, string(data), "JSON schema does not match expected structure") + }) + + t.Run("query with default values", func(t *testing.T) { + // Define schema with default values + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + type Query { + getItems(filter: FilterInput): [Item] + } + + type Item { + id: ID + name: String + } + + input FilterInput { + limit: Int = 10 + includeDeleted: Boolean = false + status: Status = ACTIVE + } + + enum Status { + ACTIVE + PENDING + DELETED + } + ` + + // Define operation + operationSDL := ` + query GetItems($filter: FilterInput = {limit: 5}) { + getItems(filter: $filter) { + id + } + } + ` + + // Parse schema and operation + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operationSDL) + require.False(t, report.HasErrors(), "operation parsing failed") + + // Build JSON schema + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + // Serialize schema to JSON + data, err := json.MarshalIndent(schema, "", " ") + require.NoError(t, err) + + // Verify schema structure + var parsed map[string]any + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + // Verify filter property with default values + properties := parsed["properties"].(map[string]any) + filter := properties["filter"].(map[string]any) + + // Verify top-level default value + assert.Equal(t, map[string]any{"limit": float64(5)}, filter["default"]) + + // Verify filter properties + filterProps := filter["properties"].(map[string]any) + + // Verify input object default values + limit := filterProps["limit"].(map[string]any) + assert.Equal(t, float64(10), limit["default"]) + + includeDeleted := filterProps["includeDeleted"].(map[string]any) + assert.Equal(t, false, includeDeleted["default"]) + + status := filterProps["status"].(map[string]any) + assert.Equal(t, "ACTIVE", status["default"]) + }) + + t.Run("query with scalar arguments", func(t *testing.T) { + // Define schema with scalar arguments + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + type Query { + getUser(id: ID!, includeProfile: Boolean): User + } + + type User { + id: ID! + name: String + age: Int + rating: Float + active: Boolean + } + ` + + // Define operation + operationSDL := ` + query GetUser($id: ID!, $includeProfile: Boolean = true, $age: Int, $rating: Float, $name: String) { + getUser(id: $id, includeProfile: $includeProfile) { + id + name + age + } + } + ` + + // Parse schema and operation + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operationSDL) + require.False(t, report.HasErrors(), "operation parsing failed") + + // Build JSON schema + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + // Serialize schema to JSON + data, err := json.MarshalIndent(schema, "", " ") + require.NoError(t, err) + + // Verify schema structure + var parsed map[string]any + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + // Verify top-level structure + assert.Equal(t, "object", parsed["type"]) + properties := parsed["properties"].(map[string]any) + + // Verify required fields + required := parsed["required"].([]any) + assert.Contains(t, required, "id") + + // Verify ID property + id, ok := properties["id"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "string", id["type"]) + + // Nullable scalars serialize "type" as the JSON Schema 2020-12 two-element + // array [, "null"]. + + // Verify includeProfile property + includeProfile, ok := properties["includeProfile"].(map[string]any) + require.True(t, ok) + assert.Equal(t, []any{"boolean", "null"}, includeProfile["type"]) + assert.Equal(t, true, includeProfile["default"]) + + // Verify age property + age, ok := properties["age"].(map[string]any) + require.True(t, ok) + assert.Equal(t, []any{"integer", "null"}, age["type"]) + + // Verify rating property + rating, ok := properties["rating"].(map[string]any) + require.True(t, ok) + assert.Equal(t, []any{"number", "null"}, rating["type"]) + + // Verify name property + name, ok := properties["name"].(map[string]any) + require.True(t, ok) + assert.Equal(t, []any{"string", "null"}, name["type"]) + }) + + t.Run("operation with field descriptions", func(t *testing.T) { + // Define schema with field descriptions + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + type Query { + """Description for getUser field""" + getUser(id: ID!): User + + """Description for findUsers field""" + findUsers(filter: UserFilter): [User] + } + + type User { + id: ID! + name: String + } + + input UserFilter { + name: String + age: Int + } + ` + + // Define operation + operationSDL := ` + query GetUserInfo($id: ID!, $filter: UserFilter) { + getUser(id: $id) { + id + name + } + findUsers(filter: $filter) { + id + } + } + ` + + // Parse schema and operation + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operationSDL) + require.False(t, report.HasErrors(), "operation parsing failed") + + // Build JSON schema + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + // Verify schema root description contains field descriptions + assert.Contains(t, schema.Description, "Description for getUser field") + assert.Contains(t, schema.Description, "Description for findUsers field") + }) + + t.Run("error handling for undefined types", func(t *testing.T) { + // Schema missing SearchInput definition + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + type Query { + search(input: SearchInput): String + } + ` + + // Operation using SearchInput + operationSDL := ` + query Search($input: SearchInput) { + search(input: $input) + } + ` + + // Parse schema and operation + definitionDoc, report1 := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report1.HasErrors(), "operation parsing failed") + + operationDoc, report2 := astparser.ParseGraphqlDocumentString(operationSDL) + require.False(t, report2.HasErrors(), "operation parsing failed") + + // Build should return error because type is not defined + builder := NewVariablesSchemaBuilder(&operationDoc, &definitionDoc) + + // Try to build schema for operation with undefined type + _, err := builder.Build() + assert.Error(t, err) + }) + + t.Run("comprehensive test for required arguments", func(t *testing.T) { + // Define schema with various required and optional fields + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + type Query { + search(requiredArg: String!, optionalArg: Int): SearchResult + } + + type SearchResult { + id: ID! + } + + input RequiredArgsInput { + requiredField: String! + optionalField: Float + requiredNestedInput: RequiredNestedInput! + optionalNestedInput: OptionalNestedInput + } + + input RequiredNestedInput { + requiredInnerField: Boolean! + optionalInnerField: String + } + + input OptionalNestedInput { + innerField: Int + } + ` + + // Define operation + operationSDL := ` + query Search( + $requiredArg: String!, + $optionalArg: Int, + $requiredInput: RequiredArgsInput!, + $optionalInput: RequiredArgsInput + ) { + search(requiredArg: $requiredArg, optionalArg: $optionalArg) + } + ` + + // Parse schema and operation + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operationSDL) + require.False(t, report.HasErrors(), "operation parsing failed") + + // Build JSON schema + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + // Serialize schema to JSON + data, err := json.MarshalIndent(schema, "", " ") + require.NoError(t, err) + + // Verify schema structure + var parsed map[string]any + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + // Verify top-level required fields + required, ok := parsed["required"].([]any) + require.True(t, ok) + assert.Contains(t, required, "requiredArg") + assert.Contains(t, required, "requiredInput") + assert.NotContains(t, required, "optionalArg") + assert.NotContains(t, required, "optionalInput") + + // Verify properties + properties := parsed["properties"].(map[string]any) + + // Check required input structure + requiredInput := properties["requiredInput"].(map[string]any) + assert.Equal(t, "object", requiredInput["type"]) + + // Check required fields within input + inputRequired := requiredInput["required"].([]any) + assert.Contains(t, inputRequired, "requiredField") + assert.Contains(t, inputRequired, "requiredNestedInput") + assert.NotContains(t, inputRequired, "optionalField") + assert.NotContains(t, inputRequired, "optionalNestedInput") + + // Check nested input structure + inputProperties := requiredInput["properties"].(map[string]any) + requiredNestedInput := inputProperties["requiredNestedInput"].(map[string]any) + assert.Equal(t, "object", requiredNestedInput["type"]) + + // Check required fields within nested input + nestedRequired := requiredNestedInput["required"].([]any) + assert.Contains(t, nestedRequired, "requiredInnerField") + assert.NotContains(t, nestedRequired, "optionalInnerField") + }) + + t.Run("deeply nested types with mixed requirements", func(t *testing.T) { + // Define schema with deeply nested types + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + type Query { + complexSearch(input: Level1Input): SearchResult + } + + type SearchResult { + id: ID! + } + + """Level 1 input description""" + input Level1Input { + field1: String + nested: Level2Input! + optionalArray: [String] + requiredArray: [Int]! + } + + """Level 2 input description""" + input Level2Input { + field2: Boolean + deeper: Level3Input! + arrayOfObjects: [Level3Input] + } + + """Level 3 input description""" + input Level3Input { + field3: Float + enumField: DeepEnum! + arrayOfArrays: [[String!]!] + } + + enum DeepEnum { + OPTION_1 + OPTION_2 + OPTION_3 + } + ` + + // Define operation + operationSDL := ` + query DeepSearch($input: Level1Input!) { + complexSearch(input: $input) + } + ` + + // Parse schema and operation + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operationSDL) + require.False(t, report.HasErrors(), "operation parsing failed") + + // Build JSON schema + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + // Serialize schema to JSON + data, err := json.MarshalIndent(schema, "", " ") + require.NoError(t, err) + + // Define expected JSON schema + expectedJSON := `{ + "additionalProperties": false, + "properties": { + "input": { + "additionalProperties": false, + "description": "Level 1 input description", + "properties": { + "field1": { + "type": [ + "string", + "null" + ] + }, + "nested": { + "additionalProperties": false, + "description": "Level 2 input description", + "properties": { + "arrayOfObjects": { + "items": { + "additionalProperties": false, + "description": "Level 3 input description", + "properties": { + "arrayOfArrays": { + "items": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": [ + "array", + "null" + ] + }, + "enumField": { + "enum": [ + "OPTION_1", + "OPTION_2", + "OPTION_3" + ], + "type": "string" + }, + "field3": { + "type": [ + "number", + "null" + ] + } + }, + "required": [ + "enumField" + ], + "type": [ + "object", + "null" + ] + }, + "type": [ + "array", + "null" + ] + }, + "deeper": { + "additionalProperties": false, + "description": "Level 3 input description", + "properties": { + "arrayOfArrays": { + "items": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": [ + "array", + "null" + ] + }, + "enumField": { + "enum": [ + "OPTION_1", + "OPTION_2", + "OPTION_3" + ], + "type": "string" + }, + "field3": { + "type": [ + "number", + "null" + ] + } + }, + "required": [ + "enumField" + ], + "type": "object" + }, + "field2": { + "type": [ + "boolean", + "null" + ] + } + }, + "required": [ + "deeper" + ], + "type": "object" + }, + "optionalArray": { + "items": { + "type": [ + "string", + "null" + ] + }, + "type": [ + "array", + "null" + ] + }, + "requiredArray": { + "items": { + "type": [ + "integer", + "null" + ] + }, + "type": "array" + } + }, + "required": [ + "nested", + "requiredArray" + ], + "type": "object" + } + }, + "required": [ + "input" + ], + "type": "object" +}` + + // Compare actual JSON with expected JSON + assert.JSONEq(t, expectedJSON, string(data), "JSON schema does not match expected structure") + }) + + t.Run("recursive types with default recursion depth", func(t *testing.T) { + // Define schema with recursive input type + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + type Query { + processNode(node: RecursiveNode): Boolean + } + + """A node that can contain child nodes of the same type""" + input RecursiveNode { + id: ID! + name: String + value: Int + children: [RecursiveNode] + parent: RecursiveNode + } + ` + + // Define operation + operationSDL := ` + query ProcessTree($node: RecursiveNode!) { + processNode(node: $node) + } + ` + + // Parse schema and operation + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operationSDL) + require.False(t, report.HasErrors(), "operation parsing failed") + + // Build JSON schema with default recursion depth (1) + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + // Verify we got a valid schema back + require.NotNil(t, schema, "Should have a valid schema") + + // Serialize to JSON to check it's valid + data, err := json.Marshal(schema) + require.NoError(t, err) + require.NotEmpty(t, data, "JSON serialization should not be empty") + + // Parse the JSON to verify it's valid + var result any + err = json.Unmarshal(data, &result) + require.NoError(t, err, "Schema should be valid JSON") + + // Basic structure checks + jsonMap, ok := result.(map[string]any) + require.True(t, ok, "Schema should be a JSON object") + + // Check top-level fields + assert.Equal(t, "object", jsonMap["type"], "Schema should be an object type") + assert.Contains(t, jsonMap, "properties", "Schema should have properties") + + // Log the schema for debugging + t.Logf("Default recursion depth schema: %v", string(data)) + }) + + t.Run("recursive types are emitted via $ref and $defs", func(t *testing.T) { + // Define schema with recursive input type + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + type Query { + processNode(node: RecursiveNode): Boolean + } + + """A node that can contain child nodes of the same type""" + input RecursiveNode { + id: ID! + name: String + value: Int + children: [RecursiveNode] + } + ` + + // Define operation + operationSDL := ` + query ProcessTree($node: RecursiveNode!) { + processNode(node: $node) + } + ` + + // Parse schema and operation + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operationSDL) + require.False(t, report.HasErrors(), "operation parsing failed") + + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + data, err := json.Marshal(schema) + require.NoError(t, err) + + // The recursive type is defined once under "$defs" and referenced via "$ref". + require.Contains(t, schema.Defs, "RecursiveNode", + "recursive input type should be defined under $defs") + assert.Contains(t, string(data), `"$ref":"#/$defs/RecursiveNode"`, + "recursive input type should be referenced via $ref") + }) + + t.Run("query with two nested arguments", func(t *testing.T) { + // Define schema with two complex input types + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + type Query { + searchUsers(userFilter: UserFilter, orderBy: OrderByInput): [User] + } + + type User { + id: ID! + name: String + email: String + } + + """Input for filtering users""" + input UserFilter { + nameContains: String + emailDomain: String + status: UserStatus + metadata: MetadataInput + } + + """Input for ordering results""" + input OrderByInput { + field: OrderableField! + direction: SortDirection! + nullsPosition: NullsPosition + } + + input MetadataInput { + tags: [String!] + createdAfter: String + createdBefore: String + } + + enum UserStatus { + ACTIVE + INACTIVE + PENDING + } + + enum OrderableField { + NAME + EMAIL + CREATED_AT + UPDATED_AT + } + + enum SortDirection { + ASC + DESC + } + + enum NullsPosition { + FIRST + LAST + } + ` + + // Define operation using both input types + operationSDL := ` + query FindUsers($filter: UserFilter!, $order: OrderByInput!) { + searchUsers(userFilter: $filter, orderBy: $order) { + id + name + email + } + } + ` + + // Parse schema and operation + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operationSDL) + require.False(t, report.HasErrors(), "operation parsing failed") + + // Build JSON schema + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + // Serialize schema to JSON + data, err := json.MarshalIndent(schema, "", " ") + require.NoError(t, err) + + // Verify schema structure + var parsed map[string]any + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + // Verify top-level structure + assert.Equal(t, "object", parsed["type"]) + + // Verify both inputs are required + required, ok := parsed["required"].([]any) + require.True(t, ok) + assert.Contains(t, required, "filter") + assert.Contains(t, required, "order") + + // Verify properties exist + properties := parsed["properties"].(map[string]any) + assert.Contains(t, properties, "filter") + assert.Contains(t, properties, "order") + + // Verify filter structure + filter := properties["filter"].(map[string]any) + assert.Equal(t, "object", filter["type"]) + assert.Equal(t, "Input for filtering users", filter["description"]) + assert.Contains(t, filter["properties"], "metadata") + + // Verify order structure + order := properties["order"].(map[string]any) + assert.Equal(t, "object", order["type"]) + assert.Equal(t, "Input for ordering results", order["description"]) + + // Verify order required fields + orderRequired := order["required"].([]any) + assert.Contains(t, orderRequired, "field") + assert.Contains(t, orderRequired, "direction") + + // Verify enum values + orderProps := order["properties"].(map[string]any) + direction := orderProps["direction"].(map[string]any) + directionEnum := direction["enum"].([]any) + assert.ElementsMatch(t, []any{"ASC", "DESC"}, directionEnum) + }) + + t.Run("mutually recursive types", func(t *testing.T) { + // Define schema with mutually recursive input types + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + type Query { + processA(a: TypeA): Boolean + } + + input TypeA { + id: ID! + name: String + b: TypeB + } + + input TypeB { + id: ID! + description: String + a: TypeA + } + ` + + // Define operation + operationSDL := ` + query ProcessA($a: TypeA!) { + processA(a: $a) + } + ` + + // Parse schema and operation + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operationSDL) + require.False(t, report.HasErrors(), "operation parsing failed") + + // Build JSON schema + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + // Serialize schema to JSON + data, err := json.MarshalIndent(schema, "", " ") + require.NoError(t, err) + + // Mutually recursive input types (TypeA <-> TypeB) are emitted once each + // under "$defs" and referenced via "$ref", so nesting is permitted to any depth. + expectedJSON := `{ + "$defs": { + "TypeA": { + "additionalProperties": false, + "properties": { + "b": { + "anyOf": [ + { + "$ref": "#/$defs/TypeB" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "TypeB": { + "additionalProperties": false, + "properties": { + "a": { + "anyOf": [ + { + "$ref": "#/$defs/TypeA" + }, + { + "type": "null" + } + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + } + }, + "additionalProperties": false, + "properties": { + "a": { + "$ref": "#/$defs/TypeA" + } + }, + "required": [ + "a" + ], + "type": "object" +}` + + // Compare actual JSON with expected JSON + assert.JSONEq(t, expectedJSON, string(data), "JSON schema does not match expected structure") + }) + + t.Run("correctly handles nullable and non-nullable fields", func(t *testing.T) { + // Define schema with a mix of nullable and non-nullable fields + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + type Query { + findUser(input: UserInput): User + } + + type User { + id: ID! + name: String + } + + input UserInput { + id: ID + name: String! + age: Int + tags: [String] + requiredTags: [String]! + nonNullTags: [String!] + requiredNonNullTags: [String!]! + nested: NestedInput + requiredNested: NestedInput! + } + + input NestedInput { + field: String + requiredField: String! + } + ` + + // Define operation + operationSDL := ` + query FindUser($input: UserInput) { + findUser(input: $input) { + id + name + } + } + ` + + // Parse schema and operation + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operationSDL) + require.False(t, report.HasErrors(), "operation parsing failed") + + // Build JSON schema + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + // Serialize schema to JSON + data, err := json.MarshalIndent(schema, "", " ") + require.NoError(t, err) + + // Define expected JSON schema + expectedJSON := `{ + "additionalProperties": false, + "properties": { + "input": { + "additionalProperties": false, + "properties": { + "age": { + "type": [ + "integer", + "null" + ] + }, + "id": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "nested": { + "additionalProperties": false, + "properties": { + "field": { + "type": [ + "string", + "null" + ] + }, + "requiredField": { + "type": "string" + } + }, + "required": [ + "requiredField" + ], + "type": [ + "object", + "null" + ] + }, + "nonNullTags": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "requiredNested": { + "additionalProperties": false, + "properties": { + "field": { + "type": [ + "string", + "null" + ] + }, + "requiredField": { + "type": "string" + } + }, + "required": [ + "requiredField" + ], + "type": "object" + }, + "requiredNonNullTags": { + "items": { + "type": "string" + }, + "type": "array" + }, + "requiredTags": { + "items": { + "type": [ + "string", + "null" + ] + }, + "type": "array" + }, + "tags": { + "items": { + "type": [ + "string", + "null" + ] + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "name", + "requiredTags", + "requiredNonNullTags", + "requiredNested" + ], + "type": "object" + } + }, + "type": "object" +}` + + // Compare actual JSON with expected JSON + assert.JSONEq(t, expectedJSON, string(data), "JSON schema does not match expected structure") + }) + + t.Run("root schema is always a non-nullable object", func(t *testing.T) { + // Define schema with required and optional arguments + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + type Query { + findUser(id: ID!, name: String): User + } + + type User { + id: ID! + name: String + } + ` + + // Test case 1: Operation with required argument + operationWithRequired := ` + query GetUser($id: ID!) { + findUser(id: $id) { + id + name + } + } + ` + + // Test case 2: Operation with only optional arguments + operationOptionalOnly := ` + query GetUserByName($name: String) { + findUser(id: "fixed-id", name: $name) { + id + name + } + } + ` + + // Parse schema + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + // Parse and test operation with required argument + operationDoc1, report := astparser.ParseGraphqlDocumentString(operationWithRequired) + require.False(t, report.HasErrors(), "operation parsing failed") + + schema1, err := BuildJsonSchema(&operationDoc1, &definitionDoc) + require.NoError(t, err) + + // Convert to JSON to check nullable field + data1, err := json.MarshalIndent(schema1, "", " ") + require.NoError(t, err) + + // Define expected JSON schema for required argument case + expectedJSON1 := `{ + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" +}` + + // Compare actual JSON with expected JSON + assert.JSONEq(t, expectedJSON1, string(data1), "Required argument schema does not match expected structure") + + // Parse and test operation with only optional arguments + operationDoc2, report := astparser.ParseGraphqlDocumentString(operationOptionalOnly) + require.False(t, report.HasErrors(), "operation parsing failed") + + schema2, err := BuildJsonSchema(&operationDoc2, &definitionDoc) + require.NoError(t, err) + + // Convert to JSON to check nullable field + data2, err := json.MarshalIndent(schema2, "", " ") + require.NoError(t, err) + + // Define expected JSON schema for optional argument case. + // Even when every variable is optional, the root variables object stays a + // non-nullable "object": the container is omitted or present, never the + // JSON literal null. Only the individual optional fields are nullable. + expectedJSON2 := `{ + "additionalProperties": false, + "properties": { + "name": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" +}` + + // Compare actual JSON with expected JSON + assert.JSONEq(t, expectedJSON2, string(data2), "Optional argument schema does not match expected structure") + }) + + t.Run("top-level object fields are not nullable", func(t *testing.T) { + // Define schema + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + type Query { + findEmployees(criteria: SearchInput): [Employee] + } + + type Employee { + id: ID! + isAvailable: Boolean + details: EmployeeDetails + } + + type EmployeeDetails { + forename: String + nationality: String + } + + input SearchInput { + name: String + department: String + } + ` + + // Define operation + operationSDL := ` + query MyEmployees($criteria: SearchInput) { + findEmployees(criteria: $criteria) { + id + isAvailable + details { + forename + nationality + } + } + } + ` + + // Parse schema and operation + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operationSDL) + require.False(t, report.HasErrors(), "operation parsing failed") + + // Build JSON schema + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + // Serialize schema to JSON to check what's exported + data, err := json.MarshalIndent(schema, "", " ") + require.NoError(t, err) + + // Define expected JSON schema + expectedJSON := `{ + "additionalProperties": false, + "properties": { + "criteria": { + "additionalProperties": false, + "properties": { + "department": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + } + }, + "type": "object" +}` + + // Compare actual JSON with expected JSON + assert.JSONEq(t, expectedJSON, string(data), "JSON schema does not match expected structure") + }) + + t.Run("custom scalar variables with descriptions default to string type", func(t *testing.T) { + // Define schema with custom scalar types + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + """ISO-8601 date time format""" + scalar DateTime + + """JSON object represented as string""" + scalar JSON + + type Query { + searchEvents(from: DateTime, filter: JSON): [Event] + } + + type Event { + id: ID! + timestamp: DateTime + data: JSON + } + ` + + // Define operation using custom scalar types + operationSDL := ` + query SearchEvents($from: DateTime, $filter: JSON) { + searchEvents(from: $from, filter: $filter) { + id + timestamp + data + } + } + ` + + // Parse schema and operation + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operationSDL) + require.False(t, report.HasErrors(), "operation parsing failed") + + // Build JSON schema + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + // Serialize schema to JSON + data, err := json.MarshalIndent(schema, "", " ") + require.NoError(t, err) + + // Define expected JSON schema + expectedJSON := `{ + "additionalProperties": false, + "properties": { + "filter": { + "description": "JSON object represented as string", + "type": ["string", "null"] + }, + "from": { + "description": "ISO-8601 date time format", + "type": ["string", "null"] + } + }, + "type": "object" +}` + + // Compare actual JSON with expected JSON + assert.JSONEq(t, expectedJSON, string(data), "JSON schema does not match expected structure") + }) + + t.Run("variable with description propagated to JSON schema", func(t *testing.T) { + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + type Query { + employee(id: ID!): Employee + } + + type Employee { + id: ID! + name: String + } + ` + + operationSDL := ` + """ + Get an employee by their ID + """ + query FindEmployee( + "The unique employee identifier" + $id: ID! + ) { + employee(id: $id) { + id + name + } + } + ` + + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operationSDL) + require.False(t, report.HasErrors(), "operation parsing failed") + + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + data, err := json.MarshalIndent(schema, "", " ") + require.NoError(t, err) + + expectedJSON := `{ + "additionalProperties": false, + "properties": { + "id": { + "description": "The unique employee identifier", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" +}` + + assert.JSONEq(t, expectedJSON, string(data)) + }) + + t.Run("query with custom scalar variables defaults to string type", func(t *testing.T) { + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + """An opaque pagination cursor""" + scalar Cursor + + type Query { + items(after: Cursor, first: Int!): String + } + ` + operation := ` + query Items($after: Cursor, $first: Int!) { + items(after: $after, first: $first) + } + ` + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operation) + require.False(t, report.HasErrors(), "operation parsing failed") + + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + actualJSON, err := json.Marshal(schema) + require.NoError(t, err) + + expectedJSON := `{ + "additionalProperties": false, + "properties": { + "after": { + "description": "An opaque pagination cursor", + "type": [ + "string", + "null" + ] + }, + "first": { + "type": "integer" + } + }, + "required": [ + "first" + ], + "type": "object" +}` + + assert.JSONEq(t, expectedJSON, string(actualJSON)) + }) + + t.Run("input object field with custom scalar defaults to string type", func(t *testing.T) { + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + scalar Cursor + + input Pagination { + after: Cursor + first: Int! + } + + type Query { + items(page: Pagination!): String + } + ` + operation := ` + query Items($page: Pagination!) { + items(page: $page) + } + ` + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operation) + require.False(t, report.HasErrors(), "operation parsing failed") + + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + actualJSON, err := json.Marshal(schema) + require.NoError(t, err) + + expectedJSON := `{ + "additionalProperties": false, + "properties": { + "page": { + "additionalProperties": false, + "properties": { + "after": { + "type": [ + "string", + "null" + ] + }, + "first": { + "type": "integer" + } + }, + "required": [ + "first" + ], + "type": "object" + } + }, + "required": [ + "page" + ], + "type": "object" +}` + + assert.JSONEq(t, expectedJSON, string(actualJSON)) + }) + + t.Run("overridden scalar emits the mapped schema and unmapped scalars keep the string default", func(t *testing.T) { + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + scalar JSON + scalar Cursor + + type Query { + search(filter: JSON!, after: Cursor, cursor: Cursor!) : String + } + ` + operation := ` + query Search($filter: JSON!, $after: Cursor, $cursor: Cursor!) { + search(filter: $filter, after: $after, cursor: $cursor) + } + ` + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operation) + require.False(t, report.HasErrors(), "operation parsing failed") + + overrides := map[string]*JsonSchema{ + "JSON": {Type: TypeObject, Description: "Arbitrary JSON object"}, + } + + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc, WithScalarSchemas(overrides)) + require.NoError(t, err) + + actualJSON, err := json.Marshal(schema) + require.NoError(t, err) + + // - JSON! is mapped to object and non-null context strips the null union + // - after/cursor prove per-use cloning: same scalar, different nullability + expectedJSON := `{ + "additionalProperties": false, + "properties": { + "after": { + "type": [ + "string", + "null" + ] + }, + "cursor": { + "type": "string" + }, + "filter": { + "description": "Arbitrary JSON object", + "type": "object" + } + }, + "required": [ + "filter", + "cursor" + ], + "type": "object" +}` + assert.JSONEq(t, expectedJSON, string(actualJSON)) + }) + + // Guards the Clone-per-use invariant: if the override branch stops cloning, + // both variables below alias one *JsonSchema and the last-processed + // variable's nullability overwrites the first, failing this test. + t.Run("same overridden scalar at two nullabilities yields independent schemas", func(t *testing.T) { + // The override type is deliberately non-object: object-typed top-level + // variables are unconditionally forced non-nullable elsewhere (see + // EnterVariableDefinition), which would mask the nullability leak this + // test exists to catch. + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + scalar BigInt + + type Query { + search(filter: BigInt!, meta: BigInt) : String + } + ` + operation := ` + query Search($filter: BigInt!, $meta: BigInt) { + search(filter: $filter, meta: $meta) + } + ` + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operation) + require.False(t, report.HasErrors(), "operation parsing failed") + + overrides := map[string]*JsonSchema{ + "BigInt": {Type: TypeInteger}, + } + + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc, WithScalarSchemas(overrides)) + require.NoError(t, err) + + actualJSON, err := json.Marshal(schema) + require.NoError(t, err) + + // Both variables resolve the same override map entry. Without cloning + // per use, the second-processed variable's Nullable mutation would leak + // into the first via the shared *JsonSchema pointer. + expectedJSON := `{ + "additionalProperties": false, + "properties": { + "filter": { + "type": "integer" + }, + "meta": { + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "filter" + ], + "type": "object" +}` + assert.JSONEq(t, expectedJSON, string(actualJSON)) + }) + + t.Run("DefaultedScalars reports unmapped custom scalars once, sorted", func(t *testing.T) { + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + scalar JSON + scalar Cursor + scalar BigInt + + type Query { + search(filter: JSON!, after: Cursor, before: Cursor, size: BigInt) : String + } + ` + operation := ` + query Search($filter: JSON!, $after: Cursor, $before: Cursor, $size: BigInt) { + search(filter: $filter, after: $after, before: $before, size: $size) + } + ` + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operation) + require.False(t, report.HasErrors(), "operation parsing failed") + + builder := NewVariablesSchemaBuilder(&operationDoc, &definitionDoc, WithScalarSchemas(map[string]*JsonSchema{ + "JSON": {Type: TypeObject}, + })) + _, err := builder.Build() + require.NoError(t, err) + + // JSON is mapped -> not reported; Cursor used twice -> reported once + assert.Equal(t, []string{"BigInt", "Cursor"}, builder.DefaultedScalars()) + }) +} diff --git a/router/pkg/mcpserver/operation_manager.go b/router/pkg/mcpserver/operation_manager.go index 6a88fd267..7f1b290f7 100644 --- a/router/pkg/mcpserver/operation_manager.go +++ b/router/pkg/mcpserver/operation_manager.go @@ -6,10 +6,10 @@ import ( "go.uber.org/zap" nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" + "github.com/wundergraph/cosmo/router/internal/jsonschema" "github.com/wundergraph/cosmo/router/pkg/schemaloader" "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/jsonschema" ) // OperationsManager handles the loading and preparation of GraphQL operations diff --git a/router/pkg/mcpserver/scalar_mappings.go b/router/pkg/mcpserver/scalar_mappings.go index 72b132f32..06ea708cc 100644 --- a/router/pkg/mcpserver/scalar_mappings.go +++ b/router/pkg/mcpserver/scalar_mappings.go @@ -3,7 +3,7 @@ package mcpserver import ( "fmt" - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/jsonschema" + "github.com/wundergraph/cosmo/router/internal/jsonschema" ) // allowedScalarMappingTypes are the JSON schema type names an mcp.scalar_mappings diff --git a/router/pkg/mcpserver/scalar_mappings_test.go b/router/pkg/mcpserver/scalar_mappings_test.go index 3ee237557..3ec5733c1 100644 --- a/router/pkg/mcpserver/scalar_mappings_test.go +++ b/router/pkg/mcpserver/scalar_mappings_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/jsonschema" + "github.com/wundergraph/cosmo/router/internal/jsonschema" ) func TestScalarMappingsTranslateToSchemas(t *testing.T) { diff --git a/router/pkg/mcpserver/server.go b/router/pkg/mcpserver/server.go index 2ae618ee5..2a1848919 100644 --- a/router/pkg/mcpserver/server.go +++ b/router/pkg/mcpserver/server.go @@ -20,6 +20,7 @@ import ( nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" "github.com/wundergraph/cosmo/router/internal/headers" + enginejsonschema "github.com/wundergraph/cosmo/router/internal/jsonschema" "github.com/wundergraph/cosmo/router/pkg/authentication" "github.com/wundergraph/cosmo/router/pkg/config" "github.com/wundergraph/cosmo/router/pkg/cors" @@ -27,7 +28,6 @@ import ( "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" "github.com/wundergraph/graphql-go-tools/v2/pkg/astprinter" - enginejsonschema "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/jsonschema" ) // reservedToolNames contains tool names that are internally registered by the MCP server diff --git a/router/pkg/schemaloader/schema_builder.go b/router/pkg/schemaloader/schema_builder.go index 89b9f2eb1..d1a8bc751 100644 --- a/router/pkg/schemaloader/schema_builder.go +++ b/router/pkg/schemaloader/schema_builder.go @@ -4,8 +4,8 @@ import ( "fmt" "sort" + "github.com/wundergraph/cosmo/router/internal/jsonschema" "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/jsonschema" ) // SchemaBuilder builds JSON schema from GraphQL operations diff --git a/router/pkg/schemaloader/schema_builder_test.go b/router/pkg/schemaloader/schema_builder_test.go index 5e913f1a5..6397a3a57 100644 --- a/router/pkg/schemaloader/schema_builder_test.go +++ b/router/pkg/schemaloader/schema_builder_test.go @@ -6,9 +6,9 @@ import ( "github.com/stretchr/testify/require" + "github.com/wundergraph/cosmo/router/internal/jsonschema" "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" "github.com/wundergraph/graphql-go-tools/v2/pkg/asttransform" - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/jsonschema" ) func TestSchemaBuilderScalarOverrides(t *testing.T) { From 90a6ad329b3ce23ba3286e2c3934b16ee743c46c Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Sat, 8 Aug 2026 01:04:00 +0100 Subject: [PATCH 09/24] chore: restore released graphql-go-tools version in router and router-tests --- router-tests/go.mod | 2 +- router-tests/go.sum | 4 ++-- router/go.mod | 3 ++- router/go.sum | 4 ++-- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/router-tests/go.mod b/router-tests/go.mod index ac807e8c7..43215f8a5 100644 --- a/router-tests/go.mod +++ b/router-tests/go.mod @@ -31,7 +31,7 @@ require ( github.com/wundergraph/cosmo/router v0.0.0-20260710155145-803a4bc06d92 github.com/wundergraph/cosmo/router-plugin v0.0.0-20250808194725-de123ba1c65e github.com/wundergraph/cosmo/speedtrap v0.0.0-00010101000000-000000000000 - github.com/wundergraph/graphql-go-tools/v2 v2.14.3-0.20260807102141-88073b801ad2 + github.com/wundergraph/graphql-go-tools/v2 v2.14.1 go.opentelemetry.io/otel v1.44.0 go.opentelemetry.io/otel/sdk v1.44.0 go.opentelemetry.io/otel/sdk/metric v1.44.0 diff --git a/router-tests/go.sum b/router-tests/go.sum index 8c88c06d8..2973f7fb9 100644 --- a/router-tests/go.sum +++ b/router-tests/go.sum @@ -386,8 +386,8 @@ github.com/wundergraph/astjson v1.1.0 h1:xORDosrZ87zQFJwNGe/HIHXqzpdHOFmqWgykCLV github.com/wundergraph/astjson v1.1.0/go.mod h1:h12D/dxxnedtLzsKyBLK7/Oe4TAoGpRVC9nDpDrZSWw= github.com/wundergraph/go-arena v1.3.0 h1:n0ng5a1vbd8YGq1u3rMr0vPU5f6AZ1BXIiUhL1UIok8= github.com/wundergraph/go-arena v1.3.0/go.mod h1:ROOysEHWJjLQ8FSfNxZCziagb7Qw2nXY3/vgKRh7eWw= -github.com/wundergraph/graphql-go-tools/v2 v2.14.3-0.20260807102141-88073b801ad2 h1:L7FceLC3ZApDOrGan5x6fh7tTophfE+rYbUSAj/B690= -github.com/wundergraph/graphql-go-tools/v2 v2.14.3-0.20260807102141-88073b801ad2/go.mod h1:zREIKLmpjfNcGSubndaW/913r0Y8XbbYOXQeZFkwHdo= +github.com/wundergraph/graphql-go-tools/v2 v2.14.1 h1:TecnvTyhskoeiqo6W+1xh7HqAPL/RG5vfbFhezu7RLs= +github.com/wundergraph/graphql-go-tools/v2 v2.14.1/go.mod h1:zREIKLmpjfNcGSubndaW/913r0Y8XbbYOXQeZFkwHdo= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= diff --git a/router/go.mod b/router/go.mod index c41fad93b..f3fab3aa3 100644 --- a/router/go.mod +++ b/router/go.mod @@ -31,7 +31,7 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/twmb/franz-go v1.16.1 - github.com/wundergraph/graphql-go-tools/v2 v2.14.3-0.20260807102141-88073b801ad2 + github.com/wundergraph/graphql-go-tools/v2 v2.14.1 // Do not upgrade, it renames attributes we rely on go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 go.opentelemetry.io/contrib/propagators/b3 v1.44.0 @@ -80,6 +80,7 @@ require ( github.com/posthog/posthog-go v1.5.5 github.com/pquerna/cachecontrol v0.2.0 github.com/prometheus/otlptranslator v1.0.0 + github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 github.com/tonglil/opentelemetry-go-datadog-propagator v0.1.3 github.com/wundergraph/astjson v1.1.0 diff --git a/router/go.sum b/router/go.sum index 99c322633..b8cc383c2 100644 --- a/router/go.sum +++ b/router/go.sum @@ -334,8 +334,8 @@ github.com/wundergraph/astjson v1.1.0 h1:xORDosrZ87zQFJwNGe/HIHXqzpdHOFmqWgykCLV github.com/wundergraph/astjson v1.1.0/go.mod h1:h12D/dxxnedtLzsKyBLK7/Oe4TAoGpRVC9nDpDrZSWw= github.com/wundergraph/go-arena v1.3.0 h1:n0ng5a1vbd8YGq1u3rMr0vPU5f6AZ1BXIiUhL1UIok8= github.com/wundergraph/go-arena v1.3.0/go.mod h1:ROOysEHWJjLQ8FSfNxZCziagb7Qw2nXY3/vgKRh7eWw= -github.com/wundergraph/graphql-go-tools/v2 v2.14.3-0.20260807102141-88073b801ad2 h1:L7FceLC3ZApDOrGan5x6fh7tTophfE+rYbUSAj/B690= -github.com/wundergraph/graphql-go-tools/v2 v2.14.3-0.20260807102141-88073b801ad2/go.mod h1:zREIKLmpjfNcGSubndaW/913r0Y8XbbYOXQeZFkwHdo= +github.com/wundergraph/graphql-go-tools/v2 v2.14.1 h1:TecnvTyhskoeiqo6W+1xh7HqAPL/RG5vfbFhezu7RLs= +github.com/wundergraph/graphql-go-tools/v2 v2.14.1/go.mod h1:zREIKLmpjfNcGSubndaW/913r0Y8XbbYOXQeZFkwHdo= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= From f83c46308e7d4b653fd8812ac7ecab367f8c7d69 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Sat, 8 Aug 2026 01:05:43 +0100 Subject: [PATCH 10/24] chore: rename stale enginejsonschema alias to internaljsonschema --- router/pkg/mcpserver/server.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/router/pkg/mcpserver/server.go b/router/pkg/mcpserver/server.go index 2a1848919..cf7076a86 100644 --- a/router/pkg/mcpserver/server.go +++ b/router/pkg/mcpserver/server.go @@ -20,7 +20,7 @@ import ( nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" "github.com/wundergraph/cosmo/router/internal/headers" - enginejsonschema "github.com/wundergraph/cosmo/router/internal/jsonschema" + internaljsonschema "github.com/wundergraph/cosmo/router/internal/jsonschema" "github.com/wundergraph/cosmo/router/pkg/authentication" "github.com/wundergraph/cosmo/router/pkg/config" "github.com/wundergraph/cosmo/router/pkg/cors" @@ -114,7 +114,7 @@ type GraphQLSchemaServer struct { enableArbitraryOperations bool exposeSchema bool omitToolNamePrefix bool - scalarSchemas map[string]*enginejsonschema.JsonSchema + scalarSchemas map[string]*internaljsonschema.JsonSchema stateless bool operationsManager *OperationsManager schemaCompiler *SchemaCompiler From cfc999c5e23386ec47566c5b738442f20eed1a76 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Sat, 8 Aug 2026 09:30:34 +0100 Subject: [PATCH 11/24] chore: use jsonschema v6 in vendored schema tests to drop the v5 dependency --- router/go.mod | 1 - router/go.sum | 2 -- router/internal/jsonschema/nullable_2020_12_test.go | 9 +++++++-- router/internal/jsonschema/recursive_input_test.go | 9 +++++++-- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/router/go.mod b/router/go.mod index f3fab3aa3..c1b136a24 100644 --- a/router/go.mod +++ b/router/go.mod @@ -80,7 +80,6 @@ require ( github.com/posthog/posthog-go v1.5.5 github.com/pquerna/cachecontrol v0.2.0 github.com/prometheus/otlptranslator v1.0.0 - github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 github.com/tonglil/opentelemetry-go-datadog-propagator v0.1.3 github.com/wundergraph/astjson v1.1.0 diff --git a/router/go.sum b/router/go.sum index b8cc383c2..e76da9491 100644 --- a/router/go.sum +++ b/router/go.sum @@ -261,8 +261,6 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= -github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= -github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 h1:PKK9DyHxif4LZo+uQSgXNqs0jj5+xZwwfKHgph2lxBw= github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sebdah/goldie/v2 v2.7.1 h1:PkBHymaYdtvEkZV7TmyqKxdmn5/Vcj+8TpATWZjnG5E= diff --git a/router/internal/jsonschema/nullable_2020_12_test.go b/router/internal/jsonschema/nullable_2020_12_test.go index 5973023a7..ea0687322 100644 --- a/router/internal/jsonschema/nullable_2020_12_test.go +++ b/router/internal/jsonschema/nullable_2020_12_test.go @@ -1,10 +1,11 @@ package jsonschema import ( + "bytes" "encoding/json" "testing" - "github.com/santhosh-tekuri/jsonschema/v5" + "github.com/santhosh-tekuri/jsonschema/v6" "github.com/stretchr/testify/require" "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" @@ -65,7 +66,11 @@ func TestNullableFieldsAreJSONSchema2020_12(t *testing.T) { schemaJSON, err := json.Marshal(schema) require.NoError(t, err) - compiled, err := jsonschema.CompileString("schema.json", string(schemaJSON)) + schemaDoc, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaJSON)) + require.NoError(t, err, "generated JSON schema should parse") + compiler := jsonschema.NewCompiler() + require.NoError(t, compiler.AddResource("schema.json", schemaDoc)) + compiled, err := compiler.Compile("schema.json") require.NoError(t, err, "generated JSON schema should compile") // Nullable scalars and enum: explicit null values must be accepted. diff --git a/router/internal/jsonschema/recursive_input_test.go b/router/internal/jsonschema/recursive_input_test.go index 1913ca4c7..8334e56e0 100644 --- a/router/internal/jsonschema/recursive_input_test.go +++ b/router/internal/jsonschema/recursive_input_test.go @@ -1,10 +1,11 @@ package jsonschema import ( + "bytes" "encoding/json" "testing" - "github.com/santhosh-tekuri/jsonschema/v5" + "github.com/santhosh-tekuri/jsonschema/v6" "github.com/stretchr/testify/require" "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" @@ -62,7 +63,11 @@ func TestRecursiveInputAcceptsNestedPayload(t *testing.T) { schemaJSON, err := json.Marshal(schema) require.NoError(t, err) - compiled, err := jsonschema.CompileString("schema.json", string(schemaJSON)) + schemaDoc, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaJSON)) + require.NoError(t, err, "generated JSON schema should parse") + compiler := jsonschema.NewCompiler() + require.NoError(t, compiler.AddResource("schema.json", schemaDoc)) + compiled, err := compiler.Compile("schema.json") require.NoError(t, err, "generated JSON schema should compile") // A depth-2 expression tree: the inner BINARY_OPERATION node has its own From 0c752fe1bfd82a964075a63f50181b9f9de64602 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Sat, 8 Aug 2026 11:05:58 +0100 Subject: [PATCH 12/24] test: add opt-in live vendor schema acceptance tests for anthropic and openai Skipped unless SCHEMA_VENDOR_LIVE_TEST=1 and the vendor API key are set. Each probe uses max_tokens=1. The vendors validate tool schemas before inference, so a probe costs a fraction of a cent. --- .../internal/jsonschema/vendor_compat_test.go | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 router/internal/jsonschema/vendor_compat_test.go diff --git a/router/internal/jsonschema/vendor_compat_test.go b/router/internal/jsonschema/vendor_compat_test.go new file mode 100644 index 000000000..56446cf5a --- /dev/null +++ b/router/internal/jsonschema/vendor_compat_test.go @@ -0,0 +1,194 @@ +package jsonschema + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" +) + +// Live vendor acceptance tests. These tests send generated schemas to the real +// Anthropic and OpenAI APIs and assert that the vendors accept or reject them. +// The vendors validate tool schemas before inference, so each probe uses +// max_tokens=1 and costs a fraction of a cent. +// +// The tests are skipped unless BOTH conditions hold: +// - SCHEMA_VENDOR_LIVE_TEST=1 is set (explicit opt-in, never set in CI) +// - the vendor API key is present (ANTHROPIC_API_KEY / OPENAI_API_KEY) +// +// Rule sources: +// - Anthropic rejects oneOf/allOf/anyOf at the schema root (400, enforced by +// the API; observed across github.com/anthropics/claude-code issues 4753, 10606). +// - Anthropic strict tool use documents anyOf for null unions; acceptance of +// JSON Schema type arrays under strict mode is not documented. The strict +// subtest records the real behavior. +// - OpenAI strict mode requires every property in "required" and documents +// type unions with null as the optionality mechanism +// (developers.openai.com/api/docs/guides/structured-outputs). + +const liveTestEnv = "SCHEMA_VENDOR_LIVE_TEST" + +func requireLiveTest(t *testing.T, keyEnv string) string { + t.Helper() + if testing.Short() { + t.Skip("skipped in -short mode") + } + if os.Getenv(liveTestEnv) != "1" { + t.Skipf("set %s=1 to run live vendor API tests", liveTestEnv) + } + key := os.Getenv(keyEnv) + if key == "" { + t.Skipf("%s is not set", keyEnv) + } + return key +} + +// generatedCustomScalarSchema builds the schema for the canonical case this +// package exists to fix: a nullable custom scalar variable and a non-null Int. +func generatedCustomScalarSchema(t *testing.T) json.RawMessage { + t.Helper() + schemaSDL := ` + schema { + query: Query + } + + scalar String + scalar Int + scalar Cursor + + type Query { + items(after: Cursor, first: Int!): String + } + ` + operation := ` + query Items($after: Cursor, $first: Int!) { + items(after: $after, first: $first) + } + ` + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + operationDoc, report := astparser.ParseGraphqlDocumentString(operation) + require.False(t, report.HasErrors(), "operation parsing failed") + + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + raw, err := json.Marshal(schema) + require.NoError(t, err) + return raw +} + +func postJSON(t *testing.T, url string, headers map[string]string, body map[string]any) (int, string) { + t.Helper() + payload, err := json.Marshal(body) + require.NoError(t, err) + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, url, bytes.NewReader(payload)) + require.NoError(t, err) + req.Header.Set("content-type", "application/json") + for k, v := range headers { + req.Header.Set(k, v) + } + client := &http.Client{Timeout: 60 * time.Second} + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + return resp.StatusCode, string(respBody) +} + +func anthropicProbe(t *testing.T, key string, strict bool, inputSchema json.RawMessage) (int, string) { + t.Helper() + tool := map[string]any{ + "name": "activity_events", + "description": "list events", + "input_schema": inputSchema, + } + headers := map[string]string{ + "x-api-key": key, + "anthropic-version": "2023-06-01", + } + if strict { + tool["strict"] = true + headers["anthropic-beta"] = "structured-outputs-2025-11-13" + } + return postJSON(t, "https://api.anthropic.com/v1/messages", headers, map[string]any{ + "model": "claude-haiku-4-5", + "max_tokens": 1, + "messages": []map[string]any{{"role": "user", "content": "hi"}}, + "tools": []map[string]any{tool}, + }) +} + +func openAIProbe(t *testing.T, key string, strict bool, parameters json.RawMessage) (int, string) { + t.Helper() + return postJSON(t, "https://api.openai.com/v1/chat/completions", map[string]string{ + "authorization": "Bearer " + key, + }, map[string]any{ + "model": "gpt-4o-mini", + "max_tokens": 1, + "messages": []map[string]any{{"role": "user", "content": "hi"}}, + "tools": []map[string]any{{ + "type": "function", + "function": map[string]any{ + "name": "activity_events", + "description": "list events", + "strict": strict, + "parameters": parameters, + }, + }}, + }) +} + +func TestAnthropicSchemaAcceptance(t *testing.T) { + key := requireLiveTest(t, "ANTHROPIC_API_KEY") + generated := generatedCustomScalarSchema(t) + + t.Run("generated schema with typed custom scalar is accepted", func(t *testing.T) { + status, body := anthropicProbe(t, key, false, generated) + require.Equal(t, http.StatusOK, status, "body: %s", body) + }) + + t.Run("generated schema with type-array nullability is accepted under strict mode", func(t *testing.T) { + // Anthropic documents anyOf null unions for strict mode. Type arrays + // are undocumented. This subtest records the real behavior. If it + // fails, the emit profile needs an anyOf mode for Anthropic strict. + status, body := anthropicProbe(t, key, true, generated) + require.Equal(t, http.StatusOK, status, "body: %s", body) + }) + + t.Run("schema with root anyOf is rejected", func(t *testing.T) { + // Negative control: proves these probes detect invalid schemas. + bad := json.RawMessage(`{"anyOf":[{"type":"object"},{"type":"string"}]}`) + status, body := anthropicProbe(t, key, false, bad) + require.Equal(t, http.StatusBadRequest, status, "body: %s", body) + require.Contains(t, body, "anyOf", "body: %s", body) + }) +} + +func TestOpenAISchemaAcceptance(t *testing.T) { + key := requireLiveTest(t, "OPENAI_API_KEY") + generated := generatedCustomScalarSchema(t) + + t.Run("generated schema is accepted without strict mode", func(t *testing.T) { + status, body := openAIProbe(t, key, false, generated) + require.Equal(t, http.StatusOK, status, "body: %s", body) + }) + + t.Run("generated schema is rejected under strict mode because optional properties are not required", func(t *testing.T) { + // OpenAI strict mode requires every property in "required" and models + // optionality as a null type union. The generator keeps GraphQL + // optionality (nullable properties stay out of "required"), so strict + // mode rejects the schema today. This subtest documents that gap. An + // all-required emit mode is future emit-profile work (ENG-9929). + status, body := openAIProbe(t, key, true, generated) + require.Equal(t, http.StatusBadRequest, status, "body: %s", body) + require.Contains(t, body, "required", "body: %s", body) + }) +} From 9f13b01a5f75e4d91fd5eb5e3e06301002514f99 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Sat, 8 Aug 2026 11:08:00 +0100 Subject: [PATCH 13/24] test: always skip live vendor schema tests instead of env-flag gating --- .../internal/jsonschema/vendor_compat_test.go | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/router/internal/jsonschema/vendor_compat_test.go b/router/internal/jsonschema/vendor_compat_test.go index 56446cf5a..b543d7883 100644 --- a/router/internal/jsonschema/vendor_compat_test.go +++ b/router/internal/jsonschema/vendor_compat_test.go @@ -19,9 +19,9 @@ import ( // The vendors validate tool schemas before inference, so each probe uses // max_tokens=1 and costs a fraction of a cent. // -// The tests are skipped unless BOTH conditions hold: -// - SCHEMA_VENDOR_LIVE_TEST=1 is set (explicit opt-in, never set in CI) -// - the vendor API key is present (ANTHROPIC_API_KEY / OPENAI_API_KEY) +// The tests are always skipped. No automated run (CI or local) makes network +// calls or spends API credits. To run one locally, remove the Skip line in +// requireLiveTest and set the vendor API key (ANTHROPIC_API_KEY / OPENAI_API_KEY). // // Rule sources: // - Anthropic rejects oneOf/allOf/anyOf at the schema root (400, enforced by @@ -33,16 +33,13 @@ import ( // type unions with null as the optionality mechanism // (developers.openai.com/api/docs/guides/structured-outputs). -const liveTestEnv = "SCHEMA_VENDOR_LIVE_TEST" - func requireLiveTest(t *testing.T, keyEnv string) string { t.Helper() - if testing.Short() { - t.Skip("skipped in -short mode") - } - if os.Getenv(liveTestEnv) != "1" { - t.Skipf("set %s=1 to run live vendor API tests", liveTestEnv) - } + // Always skipped: these tests call the live vendor APIs, and no automated + // run may make network calls or spend API credits by accident. To run + // locally, remove the next line and set the vendor API key. + t.Skip("live vendor API test; remove this Skip line to run locally") + key := os.Getenv(keyEnv) if key == "" { t.Skipf("%s is not set", keyEnv) From dbe667110a96ef8a8c0119713f95bb1d90518bfe Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Sat, 8 Aug 2026 11:11:32 +0100 Subject: [PATCH 14/24] test: assert the root-type rejection message verified against the live api All three Anthropic subtests verified live 2026-08-08: the generated schema is accepted, strict mode accepts type-array nullability, and a schema without a root type is rejected with 400. --- router/internal/jsonschema/vendor_compat_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/router/internal/jsonschema/vendor_compat_test.go b/router/internal/jsonschema/vendor_compat_test.go index b543d7883..56aab88c4 100644 --- a/router/internal/jsonschema/vendor_compat_test.go +++ b/router/internal/jsonschema/vendor_compat_test.go @@ -160,12 +160,14 @@ func TestAnthropicSchemaAcceptance(t *testing.T) { require.Equal(t, http.StatusOK, status, "body: %s", body) }) - t.Run("schema with root anyOf is rejected", func(t *testing.T) { + t.Run("schema without a root type is rejected", func(t *testing.T) { // Negative control: proves these probes detect invalid schemas. + // Verified live 2026-08-08: the API requires a root "type" field and + // rejects a root-anyOf schema with "input_schema.type: Field required". bad := json.RawMessage(`{"anyOf":[{"type":"object"},{"type":"string"}]}`) status, body := anthropicProbe(t, key, false, bad) require.Equal(t, http.StatusBadRequest, status, "body: %s", body) - require.Contains(t, body, "anyOf", "body: %s", body) + require.Contains(t, body, "input_schema.type", "body: %s", body) }) } From ec46353973ad09869e81d698a963e6983568b9ad Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Sat, 8 Aug 2026 11:39:16 +0100 Subject: [PATCH 15/24] test: record live verification of openai schema acceptance probes --- router/internal/jsonschema/vendor_compat_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/router/internal/jsonschema/vendor_compat_test.go b/router/internal/jsonschema/vendor_compat_test.go index 56aab88c4..b0745fc55 100644 --- a/router/internal/jsonschema/vendor_compat_test.go +++ b/router/internal/jsonschema/vendor_compat_test.go @@ -32,6 +32,8 @@ import ( // - OpenAI strict mode requires every property in "required" and documents // type unions with null as the optionality mechanism // (developers.openai.com/api/docs/guides/structured-outputs). +// +// All subtests were verified against the live vendor APIs on 2026-08-08. func requireLiveTest(t *testing.T, keyEnv string) string { t.Helper() @@ -186,6 +188,7 @@ func TestOpenAISchemaAcceptance(t *testing.T) { // optionality (nullable properties stay out of "required"), so strict // mode rejects the schema today. This subtest documents that gap. An // all-required emit mode is future emit-profile work (ENG-9929). + // Verified live 2026-08-08: 400 with "required" in the error message. status, body := openAIProbe(t, key, true, generated) require.Equal(t, http.StatusBadRequest, status, "body: %s", body) require.Contains(t, body, "required", "body: %s", body) From eb3a464eb3345ed0ee9f4392dd1a2c5c7da93140 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Sat, 8 Aug 2026 12:18:48 +0100 Subject: [PATCH 16/24] refactor: emit google/jsonschema-go schemas from the graphql walk Deletes the hand-written JsonSchema model (struct, custom MarshalJSON, deep Clone). Nullability is decided at node construction, so schema nodes are immutable after creation and sharing needs no copies. Output is unchanged: the existing golden tests pass without assertion edits. --- router/go.mod | 2 +- router/internal/jsonschema/schema.go | 308 -------- router/internal/jsonschema/schema_test.go | 661 ------------------ .../internal/jsonschema/variables_schema.go | 491 +++++++------ .../jsonschema/variables_schema_test.go | 28 +- router/pkg/mcpserver/operation_manager.go | 6 +- router/pkg/mcpserver/scalar_mappings.go | 25 +- router/pkg/mcpserver/scalar_mappings_test.go | 9 +- router/pkg/mcpserver/server.go | 4 +- router/pkg/schemaloader/schema_builder.go | 36 +- .../pkg/schemaloader/schema_builder_test.go | 10 +- 11 files changed, 361 insertions(+), 1219 deletions(-) delete mode 100644 router/internal/jsonschema/schema.go delete mode 100644 router/internal/jsonschema/schema_test.go diff --git a/router/go.mod b/router/go.mod index 09fc818ae..76cbeaa0f 100644 --- a/router/go.mod +++ b/router/go.mod @@ -69,6 +69,7 @@ require ( github.com/expr-lang/expr v1.17.7 github.com/goccy/go-json v0.10.3 github.com/google/go-containerregistry v0.20.3 + github.com/google/jsonschema-go v0.4.3 github.com/google/uuid v1.6.0 github.com/grafana/pyroscope-go v1.4.0 github.com/hashicorp/go-hclog v1.6.3 @@ -118,7 +119,6 @@ require ( github.com/gobwas/pool v0.2.1 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/golang/protobuf v1.5.4 // indirect - github.com/google/jsonschema-go v0.4.3 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.11 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect diff --git a/router/internal/jsonschema/schema.go b/router/internal/jsonschema/schema.go deleted file mode 100644 index 9303dc45f..000000000 --- a/router/internal/jsonschema/schema.go +++ /dev/null @@ -1,308 +0,0 @@ -package jsonschema - -import ( - "encoding/json" -) - -// SchemaType represents the type of a JSON Schema property -type SchemaType string - -const ( - TypeObject SchemaType = "object" - TypeArray SchemaType = "array" - TypeString SchemaType = "string" - TypeNumber SchemaType = "number" - TypeInteger SchemaType = "integer" - TypeBoolean SchemaType = "boolean" - TypeNull SchemaType = "null" -) - -// JsonSchema represents a JSON Schema definition. -// When adding a reference-typed field (map, slice, pointer), update Clone or it will alias. -type JsonSchema struct { - // Core schema fields - Type SchemaType `json:"type,omitempty"` - Properties map[string]*JsonSchema `json:"properties,omitempty"` - Required []string `json:"required,omitempty"` - AdditionalProperties *bool `json:"additionalProperties,omitempty"` - Description string `json:"description,omitempty"` - // Nullable is tracked internally; serialization expresses nullability in the - // JSON Schema 2020-12 form (type-union, anyOf, or null in enum), not the - // OpenAPI 3.0 "nullable" keyword. - Nullable bool `json:"-"` - - // Ref references a schema defined under the root "$defs" (e.g. "#/$defs/MyInput"). - // Used to represent recursive input types, which cannot be inlined. - Ref string `json:"$ref,omitempty"` - // Defs holds reusable schema definitions, referenced via Ref. Only populated - // on the root schema. - Defs map[string]*JsonSchema `json:"$defs,omitempty"` - - // Array-specific fields - Items *JsonSchema `json:"items,omitempty"` - - // Enum values - Enum []string `json:"enum,omitempty"` - - // Default value - Default any `json:"default,omitempty"` - - // String-specific fields - Format string `json:"format,omitempty"` - - // Number-specific fields - Minimum *float64 `json:"minimum,omitempty"` - Maximum *float64 `json:"maximum,omitempty"` - - // Additional validation - Pattern string `json:"pattern,omitempty"` -} - -// MarshalJSON customizes JSON serialization to omit empty fields -func (s *JsonSchema) MarshalJSON() ([]byte, error) { - // Use a map to only include non-empty fields - m := make(map[string]any) - - // Nullability is expressed per JSON Schema 2020-12: - // - typed schemas: "type": [, "null"] - // - enum schemas: null appended to the "enum" array - // - $ref schemas: {"anyOf": [{"$ref": ...}, {"type": "null"}]} - // rather than the OpenAPI 3.0 keyword "nullable: true", which standard - // validators ignore. - - if s.Type != "" { - if s.Nullable { - m["type"] = []string{string(s.Type), "null"} - } else { - m["type"] = string(s.Type) - } - } - - if len(s.Properties) > 0 { - m["properties"] = s.Properties - } - - if len(s.Required) > 0 { - m["required"] = s.Required - } - - if s.AdditionalProperties != nil { - m["additionalProperties"] = *s.AdditionalProperties - } - - if s.Description != "" { - m["description"] = s.Description - } - - if s.Items != nil { - m["items"] = s.Items - } - - if len(s.Enum) > 0 { - if s.Nullable { - enum := make([]any, 0, len(s.Enum)+1) - for _, v := range s.Enum { - enum = append(enum, v) - } - enum = append(enum, nil) - m["enum"] = enum - } else { - m["enum"] = s.Enum - } - } - - if s.Default != nil { - m["default"] = s.Default - } - - if s.Format != "" { - m["format"] = s.Format - } - - if s.Minimum != nil { - m["minimum"] = *s.Minimum - } - - if s.Maximum != nil { - m["maximum"] = *s.Maximum - } - - if s.Pattern != "" { - m["pattern"] = s.Pattern - } - - if s.Ref != "" { - if s.Nullable { - m["anyOf"] = []map[string]string{ - {"$ref": s.Ref}, - {"type": "null"}, - } - } else { - m["$ref"] = s.Ref - } - } - - if len(s.Defs) > 0 { - m["$defs"] = s.Defs - } - - return json.Marshal(m) -} - -// NewObjectSchema creates a new schema for an object type -func NewObjectSchema() *JsonSchema { - additionalProps := false - - return &JsonSchema{ - Type: TypeObject, - Properties: make(map[string]*JsonSchema), - AdditionalProperties: &additionalProps, - Required: []string{}, - Nullable: true, // Default to nullable - } -} - -// NewRefSchema creates a schema that references a definition under the root "$defs". -func NewRefSchema(typeName string) *JsonSchema { - return &JsonSchema{ - Ref: defsRef(typeName), - Nullable: true, // Default to nullable; callers adjust based on context - } -} - -// defsRef returns the JSON Pointer to a definition under the root "$defs". -func defsRef(typeName string) string { - return "#/$defs/" + typeName -} - -// NewAnySchema creates a schema representing any value (serialized as {} in JSON) -func NewAnySchema() *JsonSchema { - // This will represent as an empty object in JSON schema - return &JsonSchema{ - Nullable: true, // Default to nullable - } -} - -// NewArraySchema creates a new schema for an array type -func NewArraySchema(items *JsonSchema) *JsonSchema { - return &JsonSchema{ - Type: TypeArray, - Items: items, - Nullable: true, // Default to nullable - } -} - -// NewStringSchema creates a new schema for a string type -func NewStringSchema() *JsonSchema { - return &JsonSchema{ - Type: TypeString, - Nullable: true, // Default to nullable - } -} - -// NewIntegerSchema creates a new schema for an integer type -func NewIntegerSchema() *JsonSchema { - return &JsonSchema{ - Type: TypeInteger, - Nullable: true, // Default to nullable - } -} - -// NewNumberSchema creates a new schema for a number type -func NewNumberSchema() *JsonSchema { - return &JsonSchema{ - Type: TypeNumber, - Nullable: true, // Default to nullable - } -} - -// NewBooleanSchema creates a new schema for a boolean type -func NewBooleanSchema() *JsonSchema { - return &JsonSchema{ - Type: TypeBoolean, - Nullable: true, // Default to nullable - } -} - -// NewEnumSchema creates a new schema for an enum type -func NewEnumSchema(values []string) *JsonSchema { - return &JsonSchema{ - Type: TypeString, - Enum: values, - Nullable: true, // Default to nullable - } -} - -// WithDescription adds a description to the schema -func (s *JsonSchema) WithDescription(description string) *JsonSchema { - s.Description = description - return s -} - -// WithDefault adds a default value to the schema -func (s *JsonSchema) WithDefault(defaultValue any) *JsonSchema { - s.Default = defaultValue - return s -} - -// WithFormat adds a format to a string schema -func (s *JsonSchema) WithFormat(format string) *JsonSchema { - s.Format = format - return s -} - -// WithNullable marks a schema as nullable -func (s *JsonSchema) WithNullable(nullable bool) *JsonSchema { - s.Nullable = nullable - return s -} - -// Clone returns a deep copy of the schema. Callers that hand out schemas from -// a shared map (e.g. scalar overrides) must clone per use: nullability belongs -// to each usage site, and the builder records it by mutating Nullable on the -// schema it returns. Without a per-use copy, two variables of the same mapped -// scalar alias one object and the last-processed variable's nullability -// overwrites the first (guarded by the "same overridden scalar at two -// nullabilities" regression test). -// This is a hand-written copy on purpose: a marshal/unmarshal round-trip would -// silently drop Nullable (tagged json:"-"), and a shallow struct copy would -// still share Properties/Items/Defs. -// Clone returns nil if s is nil. -func (s *JsonSchema) Clone() *JsonSchema { - if s == nil { - return nil - } - clone := *s - if s.Properties != nil { - clone.Properties = make(map[string]*JsonSchema, len(s.Properties)) - for k, v := range s.Properties { - clone.Properties[k] = v.Clone() - } - } - if s.Required != nil { - clone.Required = append([]string(nil), s.Required...) - } - if s.AdditionalProperties != nil { - val := *s.AdditionalProperties - clone.AdditionalProperties = &val - } - if s.Defs != nil { - clone.Defs = make(map[string]*JsonSchema, len(s.Defs)) - for k, v := range s.Defs { - clone.Defs[k] = v.Clone() - } - } - clone.Items = s.Items.Clone() - if s.Enum != nil { - clone.Enum = append([]string(nil), s.Enum...) - } - if s.Minimum != nil { - val := *s.Minimum - clone.Minimum = &val - } - if s.Maximum != nil { - val := *s.Maximum - clone.Maximum = &val - } - return &clone -} diff --git a/router/internal/jsonschema/schema_test.go b/router/internal/jsonschema/schema_test.go deleted file mode 100644 index 0cbc265ea..000000000 --- a/router/internal/jsonschema/schema_test.go +++ /dev/null @@ -1,661 +0,0 @@ -package jsonschema - -import ( - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestJsonSchema_MarshalJSON(t *testing.T) { - t.Run("object schema", func(t *testing.T) { - // Create a complex nested schema - schema := NewObjectSchema() - schema.Description = "Test object schema" - - // Add string property with description and default - stringProp := NewStringSchema() - stringProp.Description = "A string property" - stringProp.Default = "default value" - schema.Properties["name"] = stringProp - schema.Required = append(schema.Required, "name") - - // Add integer property with minimum - intProp := NewIntegerSchema() - min := float64(0) - intProp.Minimum = &min - schema.Properties["age"] = intProp - schema.Required = append(schema.Required, "age") - - // Add enum property - enumValues := []string{"ONE", "TWO", "THREE"} - enumProp := NewEnumSchema(enumValues) - schema.Properties["category"] = enumProp - - // Add nested object property - nestedObj := NewObjectSchema() - nestedObj.Properties["street"] = NewStringSchema() - nestedObj.Properties["city"] = NewStringSchema() - nestedObj.Required = append(nestedObj.Required, "street") - schema.Properties["address"] = nestedObj - - // Add array property - arrayProp := NewArraySchema(NewStringSchema()) - schema.Properties["tags"] = arrayProp - - // Serialize to JSON - data, err := json.MarshalIndent(schema, "", " ") - require.NoError(t, err) - - // Define expected JSON schema - expectedJSON := `{ - "additionalProperties": false, - "description": "Test object schema", - "properties": { - "address": { - "additionalProperties": false, - "properties": { - "city": { - "type": [ - "string", - "null" - ] - }, - "street": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "street" - ], - "type": [ - "object", - "null" - ] - }, - "age": { - "minimum": 0, - "type": [ - "integer", - "null" - ] - }, - "category": { - "enum": [ - "ONE", - "TWO", - "THREE", - null - ], - "type": [ - "string", - "null" - ] - }, - "name": { - "default": "default value", - "description": "A string property", - "type": [ - "string", - "null" - ] - }, - "tags": { - "items": { - "type": [ - "string", - "null" - ] - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "name", - "age" - ], - "type": [ - "object", - "null" - ] -}` - - // Compare actual JSON with expected JSON - assert.JSONEq(t, expectedJSON, string(data), "JSON schema does not match expected structure") - }) - - t.Run("nested schema", func(t *testing.T) { - // Create a schema with nested objects (previously would have used references) - rootSchema := NewObjectSchema() - rootSchema.Description = "Root schema" - - // Create a nested schema - nestedSchema := NewObjectSchema() - nestedSchema.Description = "Nested schema" - nestedSchema.Properties["value"] = NewStringSchema() - - // Add the nested schema as a property - rootSchema.Properties["nested"] = nestedSchema - - // Create an array of the nested schema - arraySchema := NewArraySchema(nestedSchema) - rootSchema.Properties["items"] = arraySchema - - // Serialize to JSON - data, err := json.Marshal(rootSchema) - require.NoError(t, err) - - // Parse it back to verify - var parsed map[string]any - err = json.Unmarshal(data, &parsed) - require.NoError(t, err) - - // Verify structure - there should be no $ref - properties := parsed["properties"].(map[string]any) - nestedProp := properties["nested"].(map[string]any) - - // Check that it's properly inlined; nullable schemas serialize "type" as - // the JSON Schema 2020-12 two-element array [, "null"]. - assert.Equal(t, []any{"object", "null"}, nestedProp["type"]) - assert.Equal(t, "Nested schema", nestedProp["description"]) - assert.Contains(t, nestedProp, "properties") - - // Check the array contains the same schema inline - itemsProp := properties["items"].(map[string]any) - assert.Equal(t, []any{"array", "null"}, itemsProp["type"]) - assert.Contains(t, itemsProp, "items") - - itemsSchema := itemsProp["items"].(map[string]any) - assert.Equal(t, []any{"object", "null"}, itemsSchema["type"]) - assert.Equal(t, "Nested schema", itemsSchema["description"]) - }) -} - -func TestSchemaFeatures(t *testing.T) { - t.Run("enum schema", func(t *testing.T) { - // Test creating and validating enum schema - values := []string{"RED", "GREEN", "BLUE"} - schema := NewEnumSchema(values) - - // Test serialization - data, err := json.MarshalIndent(schema, "", " ") - require.NoError(t, err) - - // Define expected JSON schema - expectedJSON := `{ - "enum": [ - "RED", - "GREEN", - "BLUE", - null - ], - "type": [ - "string", - "null" - ] -}` - - // Compare actual JSON with expected JSON - assert.JSONEq(t, expectedJSON, string(data), "JSON schema does not match expected structure") - }) - - t.Run("required fields", func(t *testing.T) { - // Create schema with required fields - schema := NewObjectSchema() - schema.Properties["id"] = NewStringSchema() - schema.Properties["name"] = NewStringSchema() - schema.Properties["age"] = NewIntegerSchema() - - // Mark id and age as required - schema.Required = []string{"id", "age"} - - // Serialize and check - data, err := json.MarshalIndent(schema, "", " ") - require.NoError(t, err) - - // Define expected JSON schema - expectedJSON := `{ - "additionalProperties": false, - "properties": { - "age": { - "type": [ - "integer", - "null" - ] - }, - "id": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "id", - "age" - ], - "type": [ - "object", - "null" - ] -}` - - // Compare actual JSON with expected JSON - assert.JSONEq(t, expectedJSON, string(data), "JSON schema does not match expected structure") - }) - - t.Run("numeric constraints", func(t *testing.T) { - // Test numeric constraints (min/max) - min := float64(0) - max := float64(100) - - // Integer schema - intSchema := NewIntegerSchema() - intSchema.Minimum = &min - intSchema.Maximum = &max - - data, err := json.MarshalIndent(intSchema, "", " ") - require.NoError(t, err) - - // Define expected JSON schema for integer - expectedIntJSON := `{ - "type": ["integer", "null"], - "minimum": 0, - "maximum": 100 -}` - - // Compare actual JSON with expected JSON - assert.JSONEq(t, expectedIntJSON, string(data), "Integer schema does not match expected structure") - - // Number schema - numSchema := NewNumberSchema() - numSchema.Minimum = &min - numSchema.Maximum = &max - - data, err = json.MarshalIndent(numSchema, "", " ") - require.NoError(t, err) - - // Define expected JSON schema for number - expectedNumJSON := `{ - "type": ["number", "null"], - "minimum": 0, - "maximum": 100 -}` - - // Compare actual JSON with expected JSON - assert.JSONEq(t, expectedNumJSON, string(data), "Number schema does not match expected structure") - }) - - t.Run("string format", func(t *testing.T) { - // Test string format - schema := NewStringSchema() - schema.Format = "email" - - data, err := json.Marshal(schema) - require.NoError(t, err) - - var parsed map[string]any - err = json.Unmarshal(data, &parsed) - require.NoError(t, err) - - assert.Equal(t, "email", parsed["format"]) - }) - - t.Run("default values", func(t *testing.T) { - // Test default values for different types - stringSchema := NewStringSchema() - stringSchema.Default = "default string" - - intSchema := NewIntegerSchema() - intSchema.Default = 42 - - boolSchema := NewBooleanSchema() - boolSchema.Default = true - - // Test object with default values - objSchema := NewObjectSchema() - objSchema.Properties["str"] = stringSchema - objSchema.Properties["num"] = intSchema - objSchema.Properties["bool"] = boolSchema - - data, err := json.Marshal(objSchema) - require.NoError(t, err) - - var parsed map[string]any - err = json.Unmarshal(data, &parsed) - require.NoError(t, err) - - properties := parsed["properties"].(map[string]any) - - strProp := properties["str"].(map[string]any) - assert.Equal(t, "default string", strProp["default"]) - - numProp := properties["num"].(map[string]any) - assert.Equal(t, float64(42), numProp["default"]) - - boolProp := properties["bool"].(map[string]any) - assert.Equal(t, true, boolProp["default"]) - }) - - t.Run("pattern validation", func(t *testing.T) { - // Test pattern validation for strings - schema := NewStringSchema() - schema.Pattern = "^[a-zA-Z0-9]+$" - - data, err := json.Marshal(schema) - require.NoError(t, err) - - var parsed map[string]any - err = json.Unmarshal(data, &parsed) - require.NoError(t, err) - - assert.Equal(t, "^[a-zA-Z0-9]+$", parsed["pattern"]) - }) - - t.Run("nullable types", func(t *testing.T) { - // Test all nullable types - schemas := []*JsonSchema{ - NewObjectSchema(), - NewArraySchema(NewStringSchema()), - NewStringSchema(), - NewIntegerSchema(), - NewNumberSchema(), - NewBooleanSchema(), - NewEnumSchema([]string{"A", "B"}), - } - - for _, schema := range schemas { - data, err := json.Marshal(schema) - require.NoError(t, err) - - var parsed map[string]any - err = json.Unmarshal(data, &parsed) - require.NoError(t, err) - - // A nullable schema serializes "type" as the JSON Schema 2020-12 two- - // element array [, "null"], not the OpenAPI "nullable: true". - typeArr, ok := parsed["type"].([]any) - require.True(t, ok, "nullable schema should serialize type as an array") - require.Len(t, typeArr, 2) - require.Contains(t, typeArr, "null") - require.NotEqual(t, "null", typeArr[0], - "primary (non-null) type should appear first in the type array") - } - }) - - t.Run("fluent interface", func(t *testing.T) { - // Test fluent interface for building schemas - schema := NewStringSchema(). - WithDescription("A string with format and default"). - WithFormat("email"). - WithDefault("user@example.com") - - data, err := json.Marshal(schema) - require.NoError(t, err) - - var parsed map[string]any - err = json.Unmarshal(data, &parsed) - require.NoError(t, err) - - assert.Equal(t, "A string with format and default", parsed["description"]) - assert.Equal(t, "email", parsed["format"]) - assert.Equal(t, "user@example.com", parsed["default"]) - }) - - t.Run("complex nested schema", func(t *testing.T) { - // Test a complex schema with all features - userSchema := NewObjectSchema() - userSchema.Description = "User schema with all features" - - // Required string with pattern - idSchema := NewStringSchema() - idSchema.Pattern = "^[a-zA-Z0-9]{8,}$" - userSchema.Properties["id"] = idSchema - userSchema.Required = append(userSchema.Required, "id") - - // String with format and default - emailSchema := NewStringSchema() - emailSchema.Format = "email" - emailSchema.Default = "user@example.com" - userSchema.Properties["email"] = emailSchema - - // Integer with constraints - min := float64(13) - ageSchema := NewIntegerSchema() - ageSchema.Minimum = &min - userSchema.Properties["age"] = ageSchema - - // Enum property - roleSchema := NewEnumSchema([]string{"ADMIN", "USER", "GUEST"}) - roleSchema.Default = "USER" - userSchema.Properties["role"] = roleSchema - - // Array of strings - tagsSchema := NewArraySchema(NewStringSchema()) - userSchema.Properties["tags"] = tagsSchema - - // Nested object - addressSchema := NewObjectSchema() - addressSchema.Properties["street"] = NewStringSchema() - addressSchema.Properties["city"] = NewStringSchema() - addressSchema.Required = append(addressSchema.Required, "street") - userSchema.Properties["address"] = addressSchema - - // Serialize the whole thing - data, err := json.MarshalIndent(userSchema, "", " ") - require.NoError(t, err) - - // Define expected JSON schema - expectedJSON := `{ - "additionalProperties": false, - "description": "User schema with all features", - "properties": { - "address": { - "additionalProperties": false, - "properties": { - "city": { - "type": [ - "string", - "null" - ] - }, - "street": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "street" - ], - "type": [ - "object", - "null" - ] - }, - "age": { - "minimum": 13, - "type": [ - "integer", - "null" - ] - }, - "email": { - "default": "user@example.com", - "format": "email", - "type": [ - "string", - "null" - ] - }, - "id": { - "pattern": "^[a-zA-Z0-9]{8,}$", - "type": [ - "string", - "null" - ] - }, - "role": { - "default": "USER", - "enum": [ - "ADMIN", - "USER", - "GUEST", - null - ], - "type": [ - "string", - "null" - ] - }, - "tags": { - "items": { - "type": [ - "string", - "null" - ] - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "id" - ], - "type": [ - "object", - "null" - ] -}` - - // Compare actual JSON with expected JSON - assert.JSONEq(t, expectedJSON, string(data), "JSON schema does not match expected structure") - }) - - t.Run("nullable schema property", func(t *testing.T) { - // Test creating schemas with different nullable settings - - // Create a schema with nullable field - schema := NewObjectSchema() - schema.Properties["nullableString"] = NewStringSchema().WithNullable(true) - schema.Properties["nonNullableString"] = NewStringSchema().WithNullable(false) - - // By default, all types should be nullable - schema.Properties["defaultString"] = NewStringSchema() - - // Check that factory methods set nullable to true by default - intSchema := NewIntegerSchema() - assert.True(t, intSchema.Nullable) - - numSchema := NewNumberSchema() - assert.True(t, numSchema.Nullable) - - boolSchema := NewBooleanSchema() - assert.True(t, boolSchema.Nullable) - - enumSchema := NewEnumSchema([]string{"A", "B"}) - assert.True(t, enumSchema.Nullable) - - arraySchema := NewArraySchema(NewStringSchema()) - assert.True(t, arraySchema.Nullable) - - objSchema := NewObjectSchema() - assert.True(t, objSchema.Nullable) - - // Test serialization - data, err := json.Marshal(schema) - require.NoError(t, err) - - var parsed map[string]any - err = json.Unmarshal(data, &parsed) - require.NoError(t, err) - - properties := parsed["properties"].(map[string]any) - - // Nullability is expressed via the JSON Schema 2020-12 type-union form, - // not the OpenAPI "nullable" keyword (which is no longer emitted). - - // Explicitly nullable property: "type" is the two-element [, "null"] array. - nullableProp := properties["nullableString"].(map[string]any) - assert.Equal(t, []any{"string", "null"}, nullableProp["type"]) - _, hasNullableKey := nullableProp["nullable"] - assert.False(t, hasNullableKey, "nullable keyword should not be emitted") - - // Non-nullable property: "type" is a single string and no "nullable" key. - nonNullableProp := properties["nonNullableString"].(map[string]any) - assert.Equal(t, "string", nonNullableProp["type"]) - _, hasNullableOnNonNullable := nonNullableProp["nullable"] - assert.False(t, hasNullableOnNonNullable) - - // Default (factory-nullable) property: same shape as explicitly nullable. - defaultProp := properties["defaultString"].(map[string]any) - assert.Equal(t, []any{"string", "null"}, defaultProp["type"]) - - // Test WithNullable method - schema = NewStringSchema() - schema.WithNullable(true) - assert.True(t, schema.Nullable) - - schema.WithNullable(false) - assert.False(t, schema.Nullable) - }) -} - -func TestJsonSchemaClone(t *testing.T) { - t.Run("mutating the clone does not affect the original", func(t *testing.T) { - additionalProps := false - minimum := 1.0 - original := &JsonSchema{ - Type: TypeObject, - Properties: map[string]*JsonSchema{"name": NewStringSchema()}, - Required: []string{"name"}, - AdditionalProperties: &additionalProps, - Description: "original", - Nullable: true, - Items: NewStringSchema(), - Enum: []string{"a", "b"}, - Minimum: &minimum, - } - - clone := original.Clone() - - clone.Nullable = false - clone.Description = "mutated" - clone.Properties["name"].Type = TypeInteger - clone.Required[0] = "changed" - *clone.AdditionalProperties = true - clone.Items.Type = TypeBoolean - clone.Enum[0] = "z" - *clone.Minimum = 99 - - assert.True(t, original.Nullable) - assert.Equal(t, "original", original.Description) - assert.Equal(t, TypeString, original.Properties["name"].Type) - assert.Equal(t, "name", original.Required[0]) - assert.False(t, *original.AdditionalProperties) - assert.Equal(t, TypeString, original.Items.Type) - assert.Equal(t, "a", original.Enum[0]) - assert.Equal(t, 1.0, *original.Minimum) - }) - - t.Run("nil receiver returns nil", func(t *testing.T) { - var s *JsonSchema - assert.Nil(t, s.Clone()) - }) -} diff --git a/router/internal/jsonschema/variables_schema.go b/router/internal/jsonschema/variables_schema.go index 30d63bad0..d11bb6e98 100644 --- a/router/internal/jsonschema/variables_schema.go +++ b/router/internal/jsonschema/variables_schema.go @@ -1,8 +1,20 @@ +// Package jsonschema generates a JSON Schema (2020-12) for the variables of a +// GraphQL operation, emitting nodes of github.com/google/jsonschema-go. +// +// Invariant: no schema node is modified after its construction completes. +// Nullability is decided before a node is built and baked in at construction; +// use-site adornments (description, default) are applied by building a fresh +// copy. Because nothing mutates finished nodes, sharing them (scalar override +// map values, "$defs" bodies) is safe without copying. package jsonschema import ( + "encoding/json" "fmt" "sort" + "strings" + + "github.com/google/jsonschema-go/jsonschema" "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" "github.com/wundergraph/graphql-go-tools/v2/pkg/astvisitor" @@ -13,7 +25,7 @@ import ( type VariablesSchemaBuilder struct { operationDocument *ast.Document definitionDocument *ast.Document - schema *JsonSchema + schema *jsonschema.Schema report *operationreport.Report // recursiveTypes holds the names of input types that are self- or mutually // recursive. They are emitted once under the root "$defs" and referenced via @@ -21,9 +33,9 @@ type VariablesSchemaBuilder struct { recursiveTypes map[string]bool // defs accumulates schemas for recursive input types; attached to the root // schema as "$defs". - defs map[string]*JsonSchema + defs map[string]*jsonschema.Schema // scalarSchemas overrides the schema emitted per custom scalar type name. - scalarSchemas map[string]*JsonSchema + scalarSchemas map[string]*jsonschema.Schema // defaultedScalars records custom scalars that fell back to the string // default, so callers can surface missing mappings. defaultedScalars map[string]bool @@ -35,7 +47,9 @@ type VariablesSchemaOption func(*VariablesSchemaBuilder) // WithScalarSchemas overrides the JSON schema emitted for custom scalar types, // keyed by scalar type name. Unmapped custom scalars default to "string". // Built-in scalars (String, ID, Int, Float, Boolean) cannot be overridden. -func WithScalarSchemas(schemas map[string]*JsonSchema) VariablesSchemaOption { +// Each override must set Type (a single JSON type name), never Types: the +// builder derives the nullable form from Type per use site. +func WithScalarSchemas(schemas map[string]*jsonschema.Schema) VariablesSchemaOption { return func(v *VariablesSchemaBuilder) { v.scalarSchemas = schemas } @@ -52,10 +66,10 @@ func NewVariablesSchemaBuilder(operationDocument, definitionDocument *ast.Docume v := &VariablesSchemaBuilder{ operationDocument: operationDocument, definitionDocument: definitionDocument, - schema: NewObjectSchema(), + schema: newRootSchema(), report: &operationreport.Report{}, recursiveTypes: make(map[string]bool), - defs: make(map[string]*JsonSchema), + defs: make(map[string]*jsonschema.Schema), defaultedScalars: make(map[string]bool), } @@ -72,8 +86,8 @@ func (v *VariablesSchemaBuilder) EnterDocument(operation, definition *ast.Docume return } - v.schema = NewObjectSchema() - v.defs = make(map[string]*JsonSchema) // Reset defs for each build + v.schema = newRootSchema() + v.defs = make(map[string]*jsonschema.Schema) // Reset defs for each build v.defaultedScalars = make(map[string]bool) // Reset defaulted scalars for each build v.recursiveTypes = v.computeRecursiveInputTypes() // Identify recursive input types @@ -129,13 +143,7 @@ func (v *VariablesSchemaBuilder) EnterDocument(operation, definition *ast.Docume // Set concatenated descriptions on root schema if any were found if len(descriptions) > 0 { - v.schema.Description = "" - for i, desc := range descriptions { - if i > 0 { - v.schema.Description += " " - } - v.schema.Description += desc - } + v.schema.Description = strings.Join(descriptions, " ") } } @@ -145,7 +153,7 @@ func (v *VariablesSchemaBuilder) EnterVariableDefinition(ref int) { typeRef := v.operationDocument.VariableDefinitions[ref].Type // Convert type to schema starting from the operation document - varSchema := v.processOperationTypeRef(typeRef) + varSchema := v.typeRefSchema(v.operationDocument, typeRef) // Skip this variable if its type could not be resolved to a schema if varSchema == nil { @@ -157,38 +165,37 @@ func (v *VariablesSchemaBuilder) EnterVariableDefinition(ref int) { v.schema.Required = append(v.schema.Required, varName) } + var description string if v.operationDocument.VariableDefinitions[ref].Description.IsDefined { - varSchema.Description = v.operationDocument.VariableDefinitionDescriptionString(ref) + description = v.operationDocument.VariableDefinitionDescriptionString(ref) } - // Set default value if exists + var defaultValue any if v.operationDocument.VariableDefinitionHasDefaultValue(ref) { - defaultValue := v.operationDocument.VariableDefinitionDefaultValue(ref) - varSchema.Default = v.convertOperationValueToNative(defaultValue) + defaultValue = v.convertOperationValueToNative(v.operationDocument.VariableDefinitionDefaultValue(ref)) } - // Force top-level object fields to be not nullable (Nullable=false) so they can't be null - // This ensures they appear as empty objects at minimum - if varSchema.Type == TypeObject { - // Setting Nullable to false means the field can't be null - // Since the nullable field is only included when true, this effectively removes it - // from the output JSON, which is what we want - varSchema.Nullable = false + varSchema = v.adorned(varSchema, description, defaultValue) + + // Top-level object-typed variable schemas are always non-nullable: strict + // consumers reject tool inputs whose top-level objects admit null, and an + // omitted variable is expressed by leaving it out, not by passing null. + if len(varSchema.Types) > 0 && varSchema.Types[0] == "object" { + nonNull := *varSchema + nonNull.Type = "object" + nonNull.Types = nil + varSchema = &nonNull } // Add variable to schema + if v.schema.Properties == nil { + v.schema.Properties = make(map[string]*jsonschema.Schema) + } v.schema.Properties[varName] = varSchema } -// GetSchema returns the built schema -func (v *VariablesSchemaBuilder) GetSchema() *JsonSchema { - // The root variables object is always a concrete object and must never be - // nullable: the variables container is either present or omitted, never the - // JSON literal null. Emitting a nullable root (type ["object","null"] under - // JSON Schema 2020-12) breaks strict consumers such as the MCP SDK, which - // require the input schema's type to be exactly "object". - v.schema.Nullable = false - // Attach definitions for any recursive input types referenced via "$ref" +// GetSchema attaches the accumulated "$defs" to the root schema and returns it. +func (v *VariablesSchemaBuilder) GetSchema() *jsonschema.Schema { if len(v.defs) > 0 { v.schema.Defs = v.defs } @@ -212,7 +219,7 @@ func (v *VariablesSchemaBuilder) DefaultedScalars() []string { } // Build traverses the operation and builds a unified JSON schema for its variables -func (v *VariablesSchemaBuilder) Build() (*JsonSchema, error) { +func (v *VariablesSchemaBuilder) Build() (*jsonschema.Schema, error) { // Create a new walker for AST traversal walker := astvisitor.NewDefaultWalker() @@ -230,185 +237,130 @@ func (v *VariablesSchemaBuilder) Build() (*JsonSchema, error) { return v.GetSchema(), nil } -// processOperationTypeRef processes a type reference from the operation document -func (v *VariablesSchemaBuilder) processOperationTypeRef(typeRef int) *JsonSchema { - switch v.operationDocument.Types[typeRef].TypeKind { - case ast.TypeKindNonNull: - ofType := v.operationDocument.Types[typeRef].OfType - schema := v.processOperationTypeRef(ofType) - if schema == nil { - return nil - } - // Non-null types are not nullable - schema.Nullable = false - return schema +// typeRefSchema builds the schema node for a type reference in doc. The +// nullability of the node is computed from the NonNull wrapper before the node +// is built, so it is baked in at construction. +func (v *VariablesSchemaBuilder) typeRefSchema(doc *ast.Document, typeRef int) *jsonschema.Schema { + nullable := true + for doc.Types[typeRef].TypeKind == ast.TypeKindNonNull { + nullable = false + typeRef = doc.Types[typeRef].OfType + } + switch doc.Types[typeRef].TypeKind { case ast.TypeKindList: - ofType := v.operationDocument.Types[typeRef].OfType - itemSchema := v.processOperationTypeRef(ofType) + itemSchema := v.typeRefSchema(doc, doc.Types[typeRef].OfType) if itemSchema == nil { return nil } - // If we're not in a non-null context, list is nullable - schema := NewArraySchema(itemSchema) - schema.Nullable = true - return schema + return newArraySchema(itemSchema, nullable) case ast.TypeKindNamed: - typeName := v.operationDocument.TypeNameString(typeRef) - schema := v.processTypeByName(typeName) - if schema != nil { - // If we're not in a non-null context, named type is nullable - schema.Nullable = true - } - return schema - } + return v.namedTypeSchema(doc.TypeNameString(typeRef), nullable) - return nil + default: + return nil + } } -// processTypeByName processes a type by its name, looking it up in the definition document -func (v *VariablesSchemaBuilder) processTypeByName(typeName string) *JsonSchema { +// namedTypeSchema builds the schema node for a named type, looking it up in +// the definition document. +func (v *VariablesSchemaBuilder) namedTypeSchema(typeName string, nullable bool) *jsonschema.Schema { // Handle built-in scalars switch typeName { case "String", "ID": - return NewStringSchema() + return newTypedSchema("string", nullable) case "Int": - return NewIntegerSchema() + return newTypedSchema("integer", nullable) case "Float": - return NewNumberSchema() + return newTypedSchema("number", nullable) case "Boolean": - return NewBooleanSchema() + return newTypedSchema("boolean", nullable) } // For custom types, look up in the definition document node, exists := v.definitionDocument.Index.FirstNodeByNameStr(typeName) if !exists { v.report.AddInternalError(fmt.Errorf("type %s is not defined", typeName)) - return NewObjectSchema() + return newObjectSchema(nullable) } // Recursive input types are emitted once under "$defs" and referenced via // "$ref" so that nesting is permitted to any depth. if node.Kind == ast.NodeKindInputObjectTypeDefinition && v.recursiveTypes[typeName] { v.ensureDef(typeName, node) - return NewRefSchema(typeName) + return newRefSchema(typeName, nullable) } // Process the type based on its kind switch node.Kind { case ast.NodeKindEnumTypeDefinition: - return v.processEnumType(node) + return v.enumTypeSchema(node, nullable) case ast.NodeKindInputObjectTypeDefinition: - return v.processInputObjectType(node) + return v.inputObjectTypeSchema(node, nullable) case ast.NodeKindScalarTypeDefinition: - if override, ok := v.scalarSchemas[typeName]; ok { - // Clone per use: the builder mutates Nullable on returned schemas - // depending on each variable's non-null context. - schema := override.Clone() - if schema.Description == "" && v.definitionDocument.ScalarTypeDefinitions[node.Ref].Description.IsDefined { - schema.Description = v.definitionDocument.ScalarTypeDefinitionDescriptionString(node.Ref) - } - return schema - } + return v.scalarTypeSchema(typeName, node, nullable) + + default: + // If we can't determine the type, emit the empty schema, which accepts + // any value. + return &jsonschema.Schema{} + } +} + +// scalarTypeSchema builds the schema node for a custom scalar, honoring the +// configured override if one exists. +func (v *VariablesSchemaBuilder) scalarTypeSchema(typeName string, node ast.Node, nullable bool) *jsonschema.Schema { + var sdlDescription string + if v.definitionDocument.ScalarTypeDefinitions[node.Ref].Description.IsDefined { + sdlDescription = v.definitionDocument.ScalarTypeDefinitionDescriptionString(node.Ref) + } + + override, ok := v.scalarSchemas[typeName] + if !ok { // Custom scalars are opaque to JSON Schema. Emit a best-effort "string" // type: MCP/LLM tool consumers reject or degrade on untyped properties, // and opaque scalars are overwhelmingly strings on the wire. Callers can // override per scalar via WithScalarSchemas. v.defaultedScalars[typeName] = true - schema := NewStringSchema() - if v.definitionDocument.ScalarTypeDefinitions[node.Ref].Description.IsDefined { - schema.Description = v.definitionDocument.ScalarTypeDefinitionDescriptionString(node.Ref) - } + schema := newTypedSchema("string", nullable) + schema.Description = sdlDescription return schema - - default: - // If we can't determine the type, default to any - return NewAnySchema() } -} - -// computeRecursiveInputTypes returns the set of input object type names that are -// self- or mutually-recursive, i.e. reachable from themselves by following input -// field type references. These are the types that must be referenced via "$ref" -// rather than inlined. -func (v *VariablesSchemaBuilder) computeRecursiveInputTypes() map[string]bool { - def := v.definitionDocument - // Build the dependency graph between input object types. - dependencies := make(map[string][]string, len(def.InputObjectTypeDefinitions)) - for ref := range def.InputObjectTypeDefinitions { - name := def.InputObjectTypeDefinitionNameString(ref) - inputDef := def.InputObjectTypeDefinitions[ref] - if !inputDef.HasInputFieldsDefinition { - dependencies[name] = nil - continue - } - for _, fieldRef := range inputDef.InputFieldsDefinition.Refs { - fieldType := def.InputValueDefinitionType(fieldRef) - dependencies[name] = append(dependencies[name], def.ResolveTypeNameString(fieldType)) - } + if override.Description != "" { + sdlDescription = "" // the override's own description wins } - recursive := make(map[string]bool) - for start := range dependencies { - if reachableFromSelf(start, dependencies) { - recursive[start] = true - } + if !nullable && sdlDescription == "" { + // Nothing to change for this use site: share the override node itself. + // Safe because no node is ever modified after construction. + return override } - return recursive -} -// reachableFromSelf reports whether start can reach itself by following the given -// type dependencies (detecting both self- and mutual recursion). -func reachableFromSelf(start string, dependencies map[string][]string) bool { - visited := make(map[string]bool) - stack := append([]string(nil), dependencies[start]...) - for len(stack) > 0 { - current := stack[len(stack)-1] - stack = stack[:len(stack)-1] - if current == start { - return true - } - if visited[current] { - continue - } - visited[current] = true - stack = append(stack, dependencies[current]...) + schema := *override + if nullable { + schema.Type = "" + schema.Types = []string{override.Type, "null"} } - return false -} - -// ensureDef generates the schema for a recursive input type once and stores it -// under "$defs". A placeholder is registered before the body is generated so that -// self-references encountered during generation resolve to a "$ref" rather than -// recursing infinitely. -func (v *VariablesSchemaBuilder) ensureDef(typeName string, node ast.Node) { - if _, ok := v.defs[typeName]; ok { - return + if sdlDescription != "" { + schema.Description = sdlDescription } - v.defs[typeName] = NewObjectSchema() // placeholder to break the recursion - body := v.processInputObjectType(node) - // The definition body is the type itself, not nullable; nullability is - // applied per use-site via the "$ref" (rewritten as anyOf-with-null when - // the referencing context is nullable). - body.Nullable = false - v.defs[typeName] = body + return &schema } -// processEnumType processes an enum type definition -func (v *VariablesSchemaBuilder) processEnumType(node ast.Node) *JsonSchema { - values := make([]string, 0) +// enumTypeSchema builds the schema node for an enum type definition. +func (v *VariablesSchemaBuilder) enumTypeSchema(node ast.Node, nullable bool) *jsonschema.Schema { enumDef := v.definitionDocument.EnumTypeDefinitions[node.Ref] + values := make([]string, 0, len(enumDef.EnumValuesDefinition.Refs)) for _, valueRef := range enumDef.EnumValuesDefinition.Refs { - valueName := v.definitionDocument.EnumValueDefinitionNameString(valueRef) - values = append(values, valueName) + values = append(values, v.definitionDocument.EnumValueDefinitionNameString(valueRef)) } - schema := NewEnumSchema(values) + schema := newEnumSchema(values, nullable) // Add description if available if enumDef.Description.IsDefined { @@ -418,9 +370,10 @@ func (v *VariablesSchemaBuilder) processEnumType(node ast.Node) *JsonSchema { return schema } -// processInputObjectType processes an input object type definition -func (v *VariablesSchemaBuilder) processInputObjectType(node ast.Node) *JsonSchema { - schema := NewObjectSchema() +// inputObjectTypeSchema builds the schema node for an input object type +// definition, including its fields. +func (v *VariablesSchemaBuilder) inputObjectTypeSchema(node ast.Node, nullable bool) *jsonschema.Schema { + schema := newObjectSchema(nullable) inputDef := v.definitionDocument.InputObjectTypeDefinitions[node.Ref] // Set description if available @@ -440,13 +393,14 @@ func (v *VariablesSchemaBuilder) processInputObjectType(node ast.Node) *JsonSche return schema } -// processInputField processes a single input field -func (v *VariablesSchemaBuilder) processInputField(fieldRef int, schema *JsonSchema) { +// processInputField adds a single input field to the parent object schema, +// which is still under construction. +func (v *VariablesSchemaBuilder) processInputField(fieldRef int, parent *jsonschema.Schema) { fieldName := v.definitionDocument.InputValueDefinitionNameString(fieldRef) fieldTypeRef := v.definitionDocument.InputValueDefinitionType(fieldRef) // Process the field type starting from the definition document - fieldSchema := v.processDefinitionTypeRef(fieldTypeRef) + fieldSchema := v.typeRefSchema(v.definitionDocument, fieldTypeRef) // Skip this field if its type could not be resolved to a schema if fieldSchema == nil { @@ -455,60 +409,117 @@ func (v *VariablesSchemaBuilder) processInputField(fieldRef int, schema *JsonSch // Add to required list if non-nullable if v.definitionDocument.TypeIsNonNull(fieldTypeRef) { - schema.Required = append(schema.Required, fieldName) + parent.Required = append(parent.Required, fieldName) } - // Set field description if exists + var description string if v.definitionDocument.InputValueDefinitions[fieldRef].Description.IsDefined { - description := v.definitionDocument.InputValueDefinitionDescriptionString(fieldRef) - fieldSchema.Description = description + description = v.definitionDocument.InputValueDefinitionDescriptionString(fieldRef) } - // Set default value if exists + var defaultValue any if v.definitionDocument.InputValueDefinitionHasDefaultValue(fieldRef) { - defaultValue := v.definitionDocument.InputValueDefinitionDefaultValue(fieldRef) - fieldSchema.Default = v.convertDefinitionValueToNative(defaultValue) + defaultValue = v.convertDefinitionValueToNative(v.definitionDocument.InputValueDefinitionDefaultValue(fieldRef)) } // Add field to schema - schema.Properties[fieldName] = fieldSchema + if parent.Properties == nil { + parent.Properties = make(map[string]*jsonschema.Schema) + } + parent.Properties[fieldName] = v.adorned(fieldSchema, description, defaultValue) } -// processDefinitionTypeRef processes a type reference from the definition document -func (v *VariablesSchemaBuilder) processDefinitionTypeRef(typeRef int) *JsonSchema { - switch v.definitionDocument.Types[typeRef].TypeKind { - case ast.TypeKindNonNull: - ofType := v.definitionDocument.Types[typeRef].OfType - schema := v.processDefinitionTypeRef(ofType) - if schema == nil { - return nil - } - // Non-null types are not nullable - schema.Nullable = false +// adorned returns schema with the given use-site description and default +// applied. Adornments go onto a fresh shallow copy: the input node may be +// shared (a scalar override) and is never modified. +func (v *VariablesSchemaBuilder) adorned(schema *jsonschema.Schema, description string, defaultValue any) *jsonschema.Schema { + if description == "" && defaultValue == nil { return schema + } + adorned := *schema + if description != "" { + adorned.Description = description + } + if defaultValue != nil { + adorned.Default = v.rawDefault(defaultValue) + } + return &adorned +} - case ast.TypeKindList: - ofType := v.definitionDocument.Types[typeRef].OfType - itemSchema := v.processDefinitionTypeRef(ofType) - if itemSchema == nil { - return nil +// rawDefault marshals a native Go default value for embedding in a node at +// construction time. +func (v *VariablesSchemaBuilder) rawDefault(value any) json.RawMessage { + data, err := json.Marshal(value) + if err != nil { + v.report.AddInternalError(fmt.Errorf("failed to marshal default value: %w", err)) + return nil + } + return data +} + +// computeRecursiveInputTypes returns the set of input object type names that are +// self- or mutually-recursive, i.e. reachable from themselves by following input +// field type references. These are the types that must be referenced via "$ref" +// rather than inlined. +func (v *VariablesSchemaBuilder) computeRecursiveInputTypes() map[string]bool { + def := v.definitionDocument + + // Build the dependency graph between input object types. + dependencies := make(map[string][]string, len(def.InputObjectTypeDefinitions)) + for ref := range def.InputObjectTypeDefinitions { + name := def.InputObjectTypeDefinitionNameString(ref) + inputDef := def.InputObjectTypeDefinitions[ref] + if !inputDef.HasInputFieldsDefinition { + dependencies[name] = nil + continue } - // If we're not in a non-null context, list is nullable - schema := NewArraySchema(itemSchema) - schema.Nullable = true - return schema + for _, fieldRef := range inputDef.InputFieldsDefinition.Refs { + fieldType := def.InputValueDefinitionType(fieldRef) + dependencies[name] = append(dependencies[name], def.ResolveTypeNameString(fieldType)) + } + } - case ast.TypeKindNamed: - typeName := v.definitionDocument.TypeNameString(typeRef) - schema := v.processTypeByName(typeName) - if schema != nil { - // If we're not in a non-null context, named type is nullable - schema.Nullable = true + recursive := make(map[string]bool) + for start := range dependencies { + if reachableFromSelf(start, dependencies) { + recursive[start] = true } - return schema } + return recursive +} - return nil +// reachableFromSelf reports whether start can reach itself by following the given +// type dependencies (detecting both self- and mutual recursion). +func reachableFromSelf(start string, dependencies map[string][]string) bool { + visited := make(map[string]bool) + stack := append([]string(nil), dependencies[start]...) + for len(stack) > 0 { + current := stack[len(stack)-1] + stack = stack[:len(stack)-1] + if current == start { + return true + } + if visited[current] { + continue + } + visited[current] = true + stack = append(stack, dependencies[current]...) + } + return false +} + +// ensureDef generates the schema for a recursive input type once and stores it +// under "$defs". A placeholder is registered before the body is generated so that +// self-references encountered during generation resolve to a "$ref" rather than +// recursing infinitely; the map entry is then replaced with the real body. +func (v *VariablesSchemaBuilder) ensureDef(typeName string, node ast.Node) { + if _, ok := v.defs[typeName]; ok { + return + } + v.defs[typeName] = newObjectSchema(false) // placeholder to break the recursion + // The definition body is canonical (non-null); nullability is applied per + // use site on the "$ref" node (an anyOf-with-null wrapper when nullable). + v.defs[typeName] = v.inputObjectTypeSchema(node, false) } // convertOperationValueToNative converts a GraphQL AST value from the operation document to a native Go value @@ -581,10 +592,88 @@ func (v *VariablesSchemaBuilder) convertDefinitionValueToNative(value ast.Value) return nil } +// newRootSchema returns the root variables object. The root is always a +// non-nullable "object": the variables container is either present or omitted, +// never the JSON literal null, and strict consumers such as the MCP SDK +// require the input schema's root type to be exactly "object". +func newRootSchema() *jsonschema.Schema { + return newObjectSchema(false) +} + +// newTypedSchema returns a node for a single JSON type. Nullable use sites get +// the JSON Schema 2020-12 type union [, "null"] instead of the OpenAPI +// 3.0 "nullable" keyword, which standard validators ignore. +func newTypedSchema(typeName string, nullable bool) *jsonschema.Schema { + if nullable { + return &jsonschema.Schema{Types: []string{typeName, "null"}} + } + return &jsonschema.Schema{Type: typeName} +} + +// newObjectSchema returns an object node that disallows unknown properties. +// Properties stays nil until the first property is added, so that objects +// without properties marshal without a "properties" key. +func newObjectSchema(nullable bool) *jsonschema.Schema { + schema := newTypedSchema("object", nullable) + schema.AdditionalProperties = falseSchema() + return schema +} + +// falseSchema returns the schema that rejects every value; +// github.com/google/jsonschema-go marshals it as the literal false. +func falseSchema() *jsonschema.Schema { + return &jsonschema.Schema{Not: &jsonschema.Schema{}} +} + +// newArraySchema returns an array node with the given item schema. +func newArraySchema(items *jsonschema.Schema, nullable bool) *jsonschema.Schema { + schema := newTypedSchema("array", nullable) + schema.Items = items + return schema +} + +// newEnumSchema returns a string-typed enum node. A nullable enum additionally +// appends null to the value list, since the type union alone does not extend +// the allowed enum values. +func newEnumSchema(values []string, nullable bool) *jsonschema.Schema { + schema := newTypedSchema("string", nullable) + if len(values) == 0 { + return schema + } + enum := make([]any, 0, len(values)+1) + for _, value := range values { + enum = append(enum, value) + } + if nullable { + enum = append(enum, nil) + } + schema.Enum = enum + return schema +} + +// newRefSchema returns a node referencing a definition under the root "$defs". +// A nullable use site wraps the reference in anyOf with the null type, the +// 2020-12 form for a nullable "$ref"; the definition body itself stays +// canonical (non-null). +func newRefSchema(typeName string, nullable bool) *jsonschema.Schema { + ref := &jsonschema.Schema{Ref: defsRef(typeName)} + if nullable { + return &jsonschema.Schema{ + AnyOf: []*jsonschema.Schema{ref, {Type: "null"}}, + } + } + return ref +} + +// defsRef returns the JSON Pointer to a definition under the root "$defs". +func defsRef(typeName string) string { + return "#/$defs/" + typeName +} + // BuildJsonSchema builds a JSON schema for the variables of the given operation. // Recursive input types are represented via "$ref"/"$defs" and support arbitrary // nesting depth. -func BuildJsonSchema(operationDocument, definitionDocument *ast.Document, opts ...VariablesSchemaOption) (*JsonSchema, error) { +func BuildJsonSchema(operationDocument, definitionDocument *ast.Document, opts ...VariablesSchemaOption) (*jsonschema.Schema, error) { if len(operationDocument.OperationDefinitions) == 0 { return nil, fmt.Errorf("no operations found in document") } diff --git a/router/internal/jsonschema/variables_schema_test.go b/router/internal/jsonschema/variables_schema_test.go index c465a1976..e2a692b13 100644 --- a/router/internal/jsonschema/variables_schema_test.go +++ b/router/internal/jsonschema/variables_schema_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "testing" + "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -1826,8 +1827,8 @@ func TestBuildJsonSchema(t *testing.T) { operationDoc, report := astparser.ParseGraphqlDocumentString(operation) require.False(t, report.HasErrors(), "operation parsing failed") - overrides := map[string]*JsonSchema{ - "JSON": {Type: TypeObject, Description: "Arbitrary JSON object"}, + overrides := map[string]*jsonschema.Schema{ + "JSON": {Type: "object", Description: "Arbitrary JSON object"}, } schema, err := BuildJsonSchema(&operationDoc, &definitionDoc, WithScalarSchemas(overrides)) @@ -1837,7 +1838,7 @@ func TestBuildJsonSchema(t *testing.T) { require.NoError(t, err) // - JSON! is mapped to object and non-null context strips the null union - // - after/cursor prove per-use cloning: same scalar, different nullability + // - after/cursor prove per-use nullability: same scalar, different nullability expectedJSON := `{ "additionalProperties": false, "properties": { @@ -1864,9 +1865,11 @@ func TestBuildJsonSchema(t *testing.T) { assert.JSONEq(t, expectedJSON, string(actualJSON)) }) - // Guards the Clone-per-use invariant: if the override branch stops cloning, - // both variables below alias one *JsonSchema and the last-processed - // variable's nullability overwrites the first, failing this test. + // Guards the immutability invariant at the override-sharing site: if the + // nullable use of an override ever wrote nullability into the shared node + // instead of building a fresh one, both variables below would alias one + // *jsonschema.Schema and the last-processed variable's nullability would + // overwrite the first, failing this test. t.Run("same overridden scalar at two nullabilities yields independent schemas", func(t *testing.T) { // The override type is deliberately non-object: object-typed top-level // variables are unconditionally forced non-nullable elsewhere (see @@ -1894,8 +1897,8 @@ func TestBuildJsonSchema(t *testing.T) { operationDoc, report := astparser.ParseGraphqlDocumentString(operation) require.False(t, report.HasErrors(), "operation parsing failed") - overrides := map[string]*JsonSchema{ - "BigInt": {Type: TypeInteger}, + overrides := map[string]*jsonschema.Schema{ + "BigInt": {Type: "integer"}, } schema, err := BuildJsonSchema(&operationDoc, &definitionDoc, WithScalarSchemas(overrides)) @@ -1904,9 +1907,8 @@ func TestBuildJsonSchema(t *testing.T) { actualJSON, err := json.Marshal(schema) require.NoError(t, err) - // Both variables resolve the same override map entry. Without cloning - // per use, the second-processed variable's Nullable mutation would leak - // into the first via the shared *JsonSchema pointer. + // Both variables resolve the same override map entry. Nullability must + // land on a per-use node, never on the shared override. expectedJSON := `{ "additionalProperties": false, "properties": { @@ -1953,8 +1955,8 @@ func TestBuildJsonSchema(t *testing.T) { operationDoc, report := astparser.ParseGraphqlDocumentString(operation) require.False(t, report.HasErrors(), "operation parsing failed") - builder := NewVariablesSchemaBuilder(&operationDoc, &definitionDoc, WithScalarSchemas(map[string]*JsonSchema{ - "JSON": {Type: TypeObject}, + builder := NewVariablesSchemaBuilder(&operationDoc, &definitionDoc, WithScalarSchemas(map[string]*jsonschema.Schema{ + "JSON": {Type: "object"}, })) _, err := builder.Build() require.NoError(t, err) diff --git a/router/pkg/mcpserver/operation_manager.go b/router/pkg/mcpserver/operation_manager.go index 7f1b290f7..f5f67bc44 100644 --- a/router/pkg/mcpserver/operation_manager.go +++ b/router/pkg/mcpserver/operation_manager.go @@ -3,10 +3,10 @@ package mcpserver import ( "fmt" + "github.com/google/jsonschema-go/jsonschema" "go.uber.org/zap" nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" - "github.com/wundergraph/cosmo/router/internal/jsonschema" "github.com/wundergraph/cosmo/router/pkg/schemaloader" "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" @@ -20,11 +20,11 @@ type OperationsManager struct { excludeMutations bool // scalarSchemas overrides the JSON schema emitted for custom scalar types // in generated tool input schemas, keyed by scalar type name. - scalarSchemas map[string]*jsonschema.JsonSchema + scalarSchemas map[string]*jsonschema.Schema } // NewOperationsManager creates a new operations manager -func NewOperationsManager(schemaDoc *ast.Document, logger *zap.Logger, excludeMutations bool, scalarSchemas map[string]*jsonschema.JsonSchema) *OperationsManager { +func NewOperationsManager(schemaDoc *ast.Document, logger *zap.Logger, excludeMutations bool, scalarSchemas map[string]*jsonschema.Schema) *OperationsManager { if logger == nil { logger = zap.NewNop() } diff --git a/router/pkg/mcpserver/scalar_mappings.go b/router/pkg/mcpserver/scalar_mappings.go index 06ea708cc..74aba1c9d 100644 --- a/router/pkg/mcpserver/scalar_mappings.go +++ b/router/pkg/mcpserver/scalar_mappings.go @@ -3,18 +3,18 @@ package mcpserver import ( "fmt" - "github.com/wundergraph/cosmo/router/internal/jsonschema" + "github.com/google/jsonschema-go/jsonschema" ) // allowedScalarMappingTypes are the JSON schema type names an mcp.scalar_mappings // entry may map a custom scalar to. -var allowedScalarMappingTypes = map[string]jsonschema.SchemaType{ - "string": jsonschema.TypeString, - "integer": jsonschema.TypeInteger, - "number": jsonschema.TypeNumber, - "boolean": jsonschema.TypeBoolean, - "object": jsonschema.TypeObject, - "array": jsonschema.TypeArray, +var allowedScalarMappingTypes = map[string]bool{ + "string": true, + "integer": true, + "number": true, + "boolean": true, + "object": true, + "array": true, } // scalarSchemasFromMappings translates config scalar mappings (scalar name -> @@ -22,17 +22,16 @@ var allowedScalarMappingTypes = map[string]jsonschema.SchemaType{ // YAML config is enum-checked by the config JSON schema, but env-sourced // config (MCP_SCALAR_MAPPINGS) bypasses schema validation entirely - this // runtime check is the only guard on that path. -func scalarSchemasFromMappings(mappings map[string]string) (map[string]*jsonschema.JsonSchema, error) { +func scalarSchemasFromMappings(mappings map[string]string) (map[string]*jsonschema.Schema, error) { if len(mappings) == 0 { return nil, nil } - schemas := make(map[string]*jsonschema.JsonSchema, len(mappings)) + schemas := make(map[string]*jsonschema.Schema, len(mappings)) for scalar, typeName := range mappings { - schemaType, ok := allowedScalarMappingTypes[typeName] - if !ok { + if !allowedScalarMappingTypes[typeName] { return nil, fmt.Errorf("invalid scalar mapping for scalar %q: %q is not a JSON schema type (allowed: string, integer, number, boolean, object, array)", scalar, typeName) } - schemas[scalar] = &jsonschema.JsonSchema{Type: schemaType} + schemas[scalar] = &jsonschema.Schema{Type: typeName} } return schemas, nil } diff --git a/router/pkg/mcpserver/scalar_mappings_test.go b/router/pkg/mcpserver/scalar_mappings_test.go index 3ec5733c1..570e8638a 100644 --- a/router/pkg/mcpserver/scalar_mappings_test.go +++ b/router/pkg/mcpserver/scalar_mappings_test.go @@ -3,10 +3,9 @@ package mcpserver import ( "testing" + "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "github.com/wundergraph/cosmo/router/internal/jsonschema" ) func TestScalarMappingsTranslateToSchemas(t *testing.T) { @@ -16,9 +15,9 @@ func TestScalarMappingsTranslateToSchemas(t *testing.T) { "BigInt": "integer", }) require.NoError(t, err) - assert.Equal(t, map[string]*jsonschema.JsonSchema{ - "JSON": {Type: jsonschema.TypeObject}, - "BigInt": {Type: jsonschema.TypeInteger}, + assert.Equal(t, map[string]*jsonschema.Schema{ + "JSON": {Type: "object"}, + "BigInt": {Type: "integer"}, }, schemas) }) diff --git a/router/pkg/mcpserver/server.go b/router/pkg/mcpserver/server.go index 70cc7eadb..b78f513ca 100644 --- a/router/pkg/mcpserver/server.go +++ b/router/pkg/mcpserver/server.go @@ -13,6 +13,7 @@ import ( "strings" "time" + googlejsonschema "github.com/google/jsonschema-go/jsonschema" "github.com/hashicorp/go-retryablehttp" "github.com/iancoleman/strcase" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -21,7 +22,6 @@ import ( nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" "github.com/wundergraph/cosmo/router/internal/headers" - internaljsonschema "github.com/wundergraph/cosmo/router/internal/jsonschema" "github.com/wundergraph/cosmo/router/pkg/authentication" "github.com/wundergraph/cosmo/router/pkg/config" "github.com/wundergraph/cosmo/router/pkg/cors" @@ -131,7 +131,7 @@ type GraphQLSchemaServer struct { enableArbitraryOperations bool exposeSchema bool omitToolNamePrefix bool - scalarSchemas map[string]*internaljsonschema.JsonSchema + scalarSchemas map[string]*googlejsonschema.Schema stateless bool operationsManager *OperationsManager schemaCompiler *SchemaCompiler diff --git a/router/pkg/schemaloader/schema_builder.go b/router/pkg/schemaloader/schema_builder.go index d1a8bc751..868bc84e8 100644 --- a/router/pkg/schemaloader/schema_builder.go +++ b/router/pkg/schemaloader/schema_builder.go @@ -1,17 +1,21 @@ package schemaloader import ( + "bytes" + "encoding/json" "fmt" "sort" - "github.com/wundergraph/cosmo/router/internal/jsonschema" + "github.com/google/jsonschema-go/jsonschema" + + internaljsonschema "github.com/wundergraph/cosmo/router/internal/jsonschema" "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" ) // SchemaBuilder builds JSON schema from GraphQL operations type SchemaBuilder struct { schemaDoc *ast.Document - scalarSchemas map[string]*jsonschema.JsonSchema + scalarSchemas map[string]*jsonschema.Schema // defaultedScalars accumulates, across every operation built so far, the // custom scalar names that fell back to the default "string" schema. defaultedScalars map[string]bool @@ -22,8 +26,9 @@ type SchemaBuilderOption func(*SchemaBuilder) // WithScalarSchemas overrides the JSON schema emitted per custom scalar type // name in generated operation schemas. Unmapped custom scalars default to -// "string" and are reported via DefaultedScalars. -func WithScalarSchemas(schemas map[string]*jsonschema.JsonSchema) SchemaBuilderOption { +// "string" and are reported via DefaultedScalars. Each override must set Type +// (a single JSON type name), never Types. +func WithScalarSchemas(schemas map[string]*jsonschema.Schema) SchemaBuilderOption { return func(b *SchemaBuilder) { b.scalarSchemas = schemas } @@ -59,8 +64,8 @@ func (b *SchemaBuilder) BuildSchemasForOperations(operations []Operation) error // buildSchemaForOperation builds JSON schema for input objects in a single operation func (b *SchemaBuilder) buildSchemaForOperation(operation *Operation) error { - builder := jsonschema.NewVariablesSchemaBuilder(&operation.Document, b.schemaDoc, - jsonschema.WithScalarSchemas(b.scalarSchemas)) + builder := internaljsonschema.NewVariablesSchemaBuilder(&operation.Document, b.schemaDoc, + internaljsonschema.WithScalarSchemas(b.scalarSchemas)) schema, err := builder.Build() if err != nil { return fmt.Errorf("failed to build JSON schema: %w", err) @@ -70,10 +75,14 @@ func (b *SchemaBuilder) buildSchemaForOperation(operation *Operation) error { } if schema != nil { - s, err := schema.MarshalJSON() + s, err := json.Marshal(schema) if err != nil { return fmt.Errorf("failed to marshal schema: %w", err) } + s, err = canonicalJSON(s) + if err != nil { + return fmt.Errorf("failed to canonicalize schema: %w", err) + } operation.JSONSchema = s // Use operation description if provided, otherwise fall back to schema description @@ -87,6 +96,19 @@ func (b *SchemaBuilder) buildSchemaForOperation(operation *Operation) error { return nil } +// canonicalJSON re-encodes JSON with object keys sorted. The schema bytes are +// exposed verbatim in MCP tool output, which must stay byte-stable regardless +// of the schema marshaler's field order. +func canonicalJSON(data []byte) ([]byte, error) { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() // preserve number literals exactly + var value any + if err := decoder.Decode(&value); err != nil { + return nil, err + } + return json.Marshal(value) +} + // DefaultedScalars returns the sorted names of custom scalars that fell back // to the default "string" schema across all operations built so far. func (b *SchemaBuilder) DefaultedScalars() []string { diff --git a/router/pkg/schemaloader/schema_builder_test.go b/router/pkg/schemaloader/schema_builder_test.go index 6397a3a57..83b7f4397 100644 --- a/router/pkg/schemaloader/schema_builder_test.go +++ b/router/pkg/schemaloader/schema_builder_test.go @@ -4,9 +4,9 @@ import ( "encoding/json" "testing" + "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/require" - "github.com/wundergraph/cosmo/router/internal/jsonschema" "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" "github.com/wundergraph/graphql-go-tools/v2/pkg/asttransform" ) @@ -33,8 +33,8 @@ type Query { ops := []Operation{{Name: "Items", Document: opDoc}} - builder := NewSchemaBuilder(&schemaDoc, WithScalarSchemas(map[string]*jsonschema.JsonSchema{ - "JSON": {Type: jsonschema.TypeObject}, + builder := NewSchemaBuilder(&schemaDoc, WithScalarSchemas(map[string]*jsonschema.Schema{ + "JSON": {Type: "object"}, })) require.NoError(t, builder.BuildSchemasForOperations(ops)) @@ -81,8 +81,8 @@ type Query { {Name: "MoreItems", Document: op2Doc}, } - builder := NewSchemaBuilder(&schemaDoc, WithScalarSchemas(map[string]*jsonschema.JsonSchema{ - "JSON": {Type: jsonschema.TypeObject}, + builder := NewSchemaBuilder(&schemaDoc, WithScalarSchemas(map[string]*jsonschema.Schema{ + "JSON": {Type: "object"}, })) require.NoError(t, builder.BuildSchemasForOperations(ops)) From 88de1e425db0b81de61109802ea9ece058837e7b Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Sat, 8 Aug 2026 12:25:06 +0100 Subject: [PATCH 17/24] docs: note true-schema marshaling and the canonical bytes contract --- router/internal/jsonschema/variables_schema.go | 2 +- router/pkg/schemaloader/schema_builder.go | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/router/internal/jsonschema/variables_schema.go b/router/internal/jsonschema/variables_schema.go index d11bb6e98..ac153872a 100644 --- a/router/internal/jsonschema/variables_schema.go +++ b/router/internal/jsonschema/variables_schema.go @@ -305,7 +305,7 @@ func (v *VariablesSchemaBuilder) namedTypeSchema(typeName string, nullable bool) default: // If we can't determine the type, emit the empty schema, which accepts - // any value. + // any value. The zero-value Schema marshals as the boolean schema true, not {}. return &jsonschema.Schema{} } } diff --git a/router/pkg/schemaloader/schema_builder.go b/router/pkg/schemaloader/schema_builder.go index 868bc84e8..4980eb91e 100644 --- a/router/pkg/schemaloader/schema_builder.go +++ b/router/pkg/schemaloader/schema_builder.go @@ -98,7 +98,9 @@ func (b *SchemaBuilder) buildSchemaForOperation(operation *Operation) error { // canonicalJSON re-encodes JSON with object keys sorted. The schema bytes are // exposed verbatim in MCP tool output, which must stay byte-stable regardless -// of the schema marshaler's field order. +// of the schema marshaler's field order. Canonical bytes also insulate the persisted +// schema layout from changes in the library's struct field order across upgrades, so +// keep this even if the tests that forced it change. func canonicalJSON(data []byte) ([]byte, error) { decoder := json.NewDecoder(bytes.NewReader(data)) decoder.UseNumber() // preserve number literals exactly From 042b563149ffe0799881cc00b3fa8af1ca7ee095 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Sat, 8 Aug 2026 13:26:28 +0100 Subject: [PATCH 18/24] fix: keep graphql nullability for object-mapped scalars and guard nil overrides Top-level non-null forcing for object-typed variables was applied to any schema whose emitted JSON type was "object", including a custom scalar mapped to object via WithScalarSchemas. That collapsed a nullable mapped scalar (e.g. $filter: JSON) to a bare non-nullable object type, rejecting an explicit null that GraphQL allows. Gate the forcing on provenance instead of shape: a topLevel flag threaded through typeRefSchema/namedTypeSchema (topLevelTypeRefSchema is the sole entry point with topLevel=true, only for a variable's own direct, non-list type) forces non-null only when the resolved node is a GraphQL NodeKindInputObjectTypeDefinition. Nullability is still decided before construction and no stored node is mutated. Also treat a nil WithScalarSchemas map value as unmapped instead of dereferencing it, falling back to the string default and counting it in DefaultedScalars. Adds three tests: a nullable object-mapped scalar keeps its null union, a non-null one emits a bare object type, and a nil override value falls back to string. --- .../internal/jsonschema/variables_schema.go | 61 +++++-- .../jsonschema/variables_schema_test.go | 149 ++++++++++++++++++ 2 files changed, 194 insertions(+), 16 deletions(-) diff --git a/router/internal/jsonschema/variables_schema.go b/router/internal/jsonschema/variables_schema.go index ac153872a..bc1ee6b34 100644 --- a/router/internal/jsonschema/variables_schema.go +++ b/router/internal/jsonschema/variables_schema.go @@ -152,8 +152,10 @@ func (v *VariablesSchemaBuilder) EnterVariableDefinition(ref int) { varName := v.operationDocument.VariableDefinitionNameString(ref) typeRef := v.operationDocument.VariableDefinitions[ref].Type - // Convert type to schema starting from the operation document - varSchema := v.typeRefSchema(v.operationDocument, typeRef) + // Convert type to schema starting from the operation document. topLevel + // forces GraphQL input object types to non-null; a scalar mapped to + // "object" keeps its own declared nullability (see topLevelTypeRefSchema). + varSchema := v.topLevelTypeRefSchema(v.operationDocument, typeRef) // Skip this variable if its type could not be resolved to a schema if varSchema == nil { @@ -177,16 +179,6 @@ func (v *VariablesSchemaBuilder) EnterVariableDefinition(ref int) { varSchema = v.adorned(varSchema, description, defaultValue) - // Top-level object-typed variable schemas are always non-nullable: strict - // consumers reject tool inputs whose top-level objects admit null, and an - // omitted variable is expressed by leaving it out, not by passing null. - if len(varSchema.Types) > 0 && varSchema.Types[0] == "object" { - nonNull := *varSchema - nonNull.Type = "object" - nonNull.Types = nil - varSchema = &nonNull - } - // Add variable to schema if v.schema.Properties == nil { v.schema.Properties = make(map[string]*jsonschema.Schema) @@ -241,6 +233,26 @@ func (v *VariablesSchemaBuilder) Build() (*jsonschema.Schema, error) { // nullability of the node is computed from the NonNull wrapper before the node // is built, so it is baked in at construction. func (v *VariablesSchemaBuilder) typeRefSchema(doc *ast.Document, typeRef int) *jsonschema.Schema { + return v.typeRefSchemaProvenance(doc, typeRef, false) +} + +// topLevelTypeRefSchema builds the schema node for a variable definition's +// declared type, the sole entry point where topLevel is true. A variable's +// declared type is top-level provenance for a GraphQL input object; a list of +// input objects, or an input object nested inside one, is not, so topLevel +// never propagates into list items or field types (see +// typeRefSchemaProvenance and processInputField). +func (v *VariablesSchemaBuilder) topLevelTypeRefSchema(doc *ast.Document, typeRef int) *jsonschema.Schema { + return v.typeRefSchemaProvenance(doc, typeRef, true) +} + +// typeRefSchemaProvenance builds the schema node for a type reference in doc. +// The nullability of the node is computed from the NonNull wrapper before the +// node is built, so it is baked in at construction. topLevel is true only for +// the direct, non-list type of a variable definition; namedTypeSchema uses it +// to force non-null on GraphQL input object types (never on mapped scalars), +// so a custom scalar mapped to "object" keeps its own GraphQL nullability. +func (v *VariablesSchemaBuilder) typeRefSchemaProvenance(doc *ast.Document, typeRef int, topLevel bool) *jsonschema.Schema { nullable := true for doc.Types[typeRef].TypeKind == ast.TypeKindNonNull { nullable = false @@ -249,6 +261,8 @@ func (v *VariablesSchemaBuilder) typeRefSchema(doc *ast.Document, typeRef int) * switch doc.Types[typeRef].TypeKind { case ast.TypeKindList: + // List items are never top-level: "top-level" only forces the + // non-null-object rule for the variable's own direct type. itemSchema := v.typeRefSchema(doc, doc.Types[typeRef].OfType) if itemSchema == nil { return nil @@ -256,7 +270,7 @@ func (v *VariablesSchemaBuilder) typeRefSchema(doc *ast.Document, typeRef int) * return newArraySchema(itemSchema, nullable) case ast.TypeKindNamed: - return v.namedTypeSchema(doc.TypeNameString(typeRef), nullable) + return v.namedTypeSchema(doc.TypeNameString(typeRef), nullable, topLevel) default: return nil @@ -264,8 +278,11 @@ func (v *VariablesSchemaBuilder) typeRefSchema(doc *ast.Document, typeRef int) * } // namedTypeSchema builds the schema node for a named type, looking it up in -// the definition document. -func (v *VariablesSchemaBuilder) namedTypeSchema(typeName string, nullable bool) *jsonschema.Schema { +// the definition document. topLevel forces non-null only on GraphQL input +// object types (an at-least-empty object is required at the tool boundary); +// a custom scalar mapped to "object" via WithScalarSchemas is not a GraphQL +// input object and so keeps its declared nullability. +func (v *VariablesSchemaBuilder) namedTypeSchema(typeName string, nullable bool, topLevel bool) *jsonschema.Schema { // Handle built-in scalars switch typeName { case "String", "ID": @@ -285,6 +302,15 @@ func (v *VariablesSchemaBuilder) namedTypeSchema(typeName string, nullable bool) return newObjectSchema(nullable) } + // A top-level GraphQL input object is always non-null in the emitted + // schema: an at-least-empty object is expected by strict tool-input + // consumers, and an omitted variable already expresses "no value" without + // needing null too. This does not apply to mapped scalars (handled in + // scalarTypeSchema below), which keep their declared GraphQL nullability. + if topLevel && node.Kind == ast.NodeKindInputObjectTypeDefinition { + nullable = false + } + // Recursive input types are emitted once under "$defs" and referenced via // "$ref" so that nesting is permitted to any depth. if node.Kind == ast.NodeKindInputObjectTypeDefinition && v.recursiveTypes[typeName] { @@ -319,7 +345,10 @@ func (v *VariablesSchemaBuilder) scalarTypeSchema(typeName string, node ast.Node } override, ok := v.scalarSchemas[typeName] - if !ok { + if !ok || override == nil { + // A nil map value is treated the same as no entry: WithScalarSchemas + // takes a map of pointers, and a nil pointer here must not be + // dereferenced below. // Custom scalars are opaque to JSON Schema. Emit a best-effort "string" // type: MCP/LLM tool consumers reject or degrade on untyped properties, // and opaque scalars are overwhelmingly strings on the wire. Callers can diff --git a/router/internal/jsonschema/variables_schema_test.go b/router/internal/jsonschema/variables_schema_test.go index e2a692b13..023cbcbfe 100644 --- a/router/internal/jsonschema/variables_schema_test.go +++ b/router/internal/jsonschema/variables_schema_test.go @@ -1964,4 +1964,153 @@ func TestBuildJsonSchema(t *testing.T) { // JSON is mapped -> not reported; Cursor used twice -> reported once assert.Equal(t, []string{"BigInt", "Cursor"}, builder.DefaultedScalars()) }) + + t.Run("nullable object-mapped scalar at top level keeps its null union", func(t *testing.T) { + // The top-level non-null-object rule exists for GraphQL INPUT OBJECTs, + // not for scalars mapped to "object": a nullable $filter must still + // admit null. + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + scalar JSON + + type Query { + search(filter: JSON): String + } + ` + operation := ` + query Search($filter: JSON) { + search(filter: $filter) + } + ` + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operation) + require.False(t, report.HasErrors(), "operation parsing failed") + + overrides := map[string]*jsonschema.Schema{ + "JSON": {Type: "object"}, + } + + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc, WithScalarSchemas(overrides)) + require.NoError(t, err) + + actualJSON, err := json.Marshal(schema) + require.NoError(t, err) + + expectedJSON := `{ + "additionalProperties": false, + "properties": { + "filter": { + "type": [ + "object", + "null" + ] + } + }, + "type": "object" +}` + assert.JSONEq(t, expectedJSON, string(actualJSON)) + }) + + t.Run("non-null object-mapped scalar at top level emits bare object type", func(t *testing.T) { + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + scalar JSON + + type Query { + search(filter: JSON!): String + } + ` + operation := ` + query Search($filter: JSON!) { + search(filter: $filter) + } + ` + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operation) + require.False(t, report.HasErrors(), "operation parsing failed") + + overrides := map[string]*jsonschema.Schema{ + "JSON": {Type: "object"}, + } + + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc, WithScalarSchemas(overrides)) + require.NoError(t, err) + + actualJSON, err := json.Marshal(schema) + require.NoError(t, err) + + expectedJSON := `{ + "additionalProperties": false, + "properties": { + "filter": { + "type": "object" + } + }, + "required": [ + "filter" + ], + "type": "object" +}` + assert.JSONEq(t, expectedJSON, string(actualJSON)) + }) + + t.Run("nil override value falls back to the string default", func(t *testing.T) { + // WithScalarSchemas(map[string]*jsonschema.Schema{"JSON": nil}) must not + // dereference the nil override: it is treated as unmapped. + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + scalar JSON + + type Query { + search(filter: JSON): String + } + ` + operation := ` + query Search($filter: JSON) { + search(filter: $filter) + } + ` + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operation) + require.False(t, report.HasErrors(), "operation parsing failed") + + builder := NewVariablesSchemaBuilder(&operationDoc, &definitionDoc, WithScalarSchemas(map[string]*jsonschema.Schema{ + "JSON": nil, + })) + schema, err := builder.Build() + require.NoError(t, err) + + actualJSON, err := json.Marshal(schema) + require.NoError(t, err) + + expectedJSON := `{ + "additionalProperties": false, + "properties": { + "filter": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" +}` + assert.JSONEq(t, expectedJSON, string(actualJSON)) + assert.Equal(t, []string{"JSON"}, builder.DefaultedScalars()) + }) } From e2233a222ff2c7de1726eb191eca17bfaf6469c8 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Sat, 8 Aug 2026 13:26:32 +0100 Subject: [PATCH 19/24] docs: state nullable schema forms for custom scalars and mappings Document both nullability forms for MCP tool input schemas: a non-nullable variable emits the type alone, a nullable one adds "null" (e.g. ["string", "null"], ["object", "null"] for mappings). Rewrite the top-level-object note in configuration.mdx to match the provenance-gated forcing: only GraphQL input object variables are forced non-nullable at the top level; a scalar mapped to object keeps its own declared nullability. --- docs-website/router/mcp/configuration.mdx | 6 ++++-- docs-website/router/mcp/tools.mdx | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs-website/router/mcp/configuration.mdx b/docs-website/router/mcp/configuration.mdx index 19a47fd72..b5a637b10 100644 --- a/docs-website/router/mcp/configuration.mdx +++ b/docs-website/router/mcp/configuration.mdx @@ -43,7 +43,7 @@ storage_providers: | `enable_arbitrary_operations` | Enables the `execute_graphql` built-in tool, allowing clients to run arbitrary GraphQL operations beyond the pre-defined operation set. | `false` | | `expose_schema` | Enables the `get_schema` built-in tool, exposing the full GraphQL schema to MCP clients. | `false` | | `omit_tool_name_prefix` | When enabled, MCP tool names omit the `execute_operation_` prefix. For example, `GetUser` becomes `get_user` instead of `execute_operation_get_user`. See [Tools - Omitting the Tool Name Prefix](/router/mcp/tools#omitting-the-tool-name-prefix). | `false` | -| `scalar_mappings` | Maps custom scalar type names to the JSON Schema type advertised in MCP tool input schemas. Allowed values: `string`, `integer`, `number`, `boolean`, `object`, `array`. Custom scalars without a mapping default to `string`. See [Custom Scalar Mappings](#custom-scalar-mappings). | - | +| `scalar_mappings` | Maps custom scalar type names to the JSON Schema type advertised in MCP tool input schemas. Allowed values: `string`, `integer`, `number`, `boolean`, `object`, `array`. Custom scalars without a mapping default to `string`. A non-nullable variable uses the mapped type alone, for example `"object"`. A nullable variable adds `"null"`, for example `["object", "null"]`. See [Custom Scalar Mappings](#custom-scalar-mappings). | - | For OAuth-specific configuration, see [OAuth 2.1 Authorization](/router/mcp/oauth/overview). @@ -88,13 +88,15 @@ mcp: BigInt: integer ``` +A mapped scalar's schema states its GraphQL nullability. A non-nullable variable, such as `$filter: JSON!`, emits the mapped type alone: `"type": "object"`. A nullable variable, such as `$filter: JSON`, emits the type with `"null"`: `"type": ["object", "null"]`. + Two behaviors help you keep mappings correct: - On startup, the router logs a warning that lists every custom scalar that fell back to the `string` default. Non-string arguments for these scalars are rejected by input validation, so add a mapping for any scalar with a different wire format. - A mapping with a value outside the allowed set fails router startup. A wrong schema contract is a configuration error, not a warning. - Top-level variables with an `object` type are always advertised as non-nullable in the generated schema. A nullable variable mapped to `object` therefore loses its `null` union in the tool's input schema. + The router forces only GraphQL input object variables to a non-nullable `object` type at the top level. A scalar mapped to `object` is not a GraphQL input object, so it keeps its own declared nullability. ## Storage Providers diff --git a/docs-website/router/mcp/tools.mdx b/docs-website/router/mcp/tools.mdx index a434415d8..d75cae73f 100644 --- a/docs-website/router/mcp/tools.mdx +++ b/docs-website/router/mcp/tools.mdx @@ -154,7 +154,7 @@ If no description is provided, the description of the queried root field from yo The tool's input schema is automatically generated from your GraphQL operation's variables, ensuring type safety. AI models use this schema to understand what parameters are required and their types. -Custom scalar variables are advertised as `string` by default. See [Custom Scalar Mappings](/router/mcp/configuration#custom-scalar-mappings) to map scalars with a different wire format. +Custom scalar variables are advertised as `string` by default. A non-nullable custom scalar variable emits `"type": "string"`. A nullable one emits `"type": ["string", "null"]`. See [Custom Scalar Mappings](/router/mcp/configuration#custom-scalar-mappings) to map scalars with a different wire format. The generated schema reflects your operation and graph schema: From ee31ab5257e21f5fe68ca21cf2d26202ef783842 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Sat, 8 Aug 2026 13:42:20 +0100 Subject: [PATCH 20/24] refactor: validate mcp tool arguments with google jsonschema and pass schema values to the sdk The generated schema value now flows from the schema builder to the MCP SDK and to input validation, with no re-parsing of the canonical bytes. santhosh-tekuri/jsonschema leaves the mcp server path; google/jsonschema-go resolves each schema once at registration and validates tool arguments. The validator swap changes the suffix wording of input validation errors behind the unchanged "Input validation error: " prefix. --- router-tests/protocol/mcp_test.go | 2 +- router/pkg/mcpserver/input_validation.go | 42 ++++++++++ router/pkg/mcpserver/schema_compiler.go | 93 ----------------------- router/pkg/mcpserver/server.go | 60 +++++---------- router/pkg/schemaloader/loader.go | 13 +++- router/pkg/schemaloader/schema_builder.go | 4 + 6 files changed, 78 insertions(+), 136 deletions(-) create mode 100644 router/pkg/mcpserver/input_validation.go delete mode 100644 router/pkg/mcpserver/schema_compiler.go diff --git a/router-tests/protocol/mcp_test.go b/router-tests/protocol/mcp_test.go index 5d87f4603..eba37fd38 100644 --- a/router-tests/protocol/mcp_test.go +++ b/router-tests/protocol/mcp_test.go @@ -464,7 +464,7 @@ Important Notes: assert.True(t, ok) assert.Equal(t, content.Type, "text") - assert.Equal(t, content.Text, "Input validation error: validation error: at '/criteria': got null, want object") + assert.Equal(t, content.Text, "Input validation error: validation error: validating root: validating /properties/criteria: type: has type \"null\", want \"object\"") }) }) }) diff --git a/router/pkg/mcpserver/input_validation.go b/router/pkg/mcpserver/input_validation.go new file mode 100644 index 000000000..b6026aaf9 --- /dev/null +++ b/router/pkg/mcpserver/input_validation.go @@ -0,0 +1,42 @@ +package mcpserver + +import ( + "encoding/json" + "fmt" + + "github.com/google/jsonschema-go/jsonschema" +) + +// resolveSchema prepares an operation's built variables schema for input +// validation. A nil schema resolves to nil: operations without input skip +// validation. A schema that fails to resolve is a registration error; the +// caller must not register the tool. +func resolveSchema(schema *jsonschema.Schema) (*jsonschema.Resolved, error) { + if schema == nil { + return nil, nil + } + resolved, err := schema.Resolve(nil) + if err != nil { + return nil, fmt.Errorf("failed to resolve JSON schema: %w", err) + } + return resolved, nil +} + +// validateInput validates raw JSON tool arguments against a resolved schema. +// A nil resolved schema validates nothing. +func validateInput(data []byte, resolved *jsonschema.Resolved) error { + if resolved == nil { + return nil + } + + var v any + if err := json.Unmarshal(data, &v); err != nil { + return fmt.Errorf("failed to parse JSON input: %w", err) + } + + if err := resolved.Validate(v); err != nil { + return fmt.Errorf("validation error: %s", err) + } + + return nil +} diff --git a/router/pkg/mcpserver/schema_compiler.go b/router/pkg/mcpserver/schema_compiler.go deleted file mode 100644 index 8ec341244..000000000 --- a/router/pkg/mcpserver/schema_compiler.go +++ /dev/null @@ -1,93 +0,0 @@ -package mcpserver - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - - "github.com/santhosh-tekuri/jsonschema/v6" - "go.uber.org/zap" -) - -// SchemaCompiler handles JSON schema compilation and validation -type SchemaCompiler struct { - logger *zap.Logger -} - -// NewSchemaCompiler creates a new schema compiler with the given logger -func NewSchemaCompiler(logger *zap.Logger) *SchemaCompiler { - if logger == nil { - logger = zap.NewNop() - } - return &SchemaCompiler{ - logger: logger, - } -} - -// CompileJSONSchema compiles a JSON schema from raw bytes -func (sc *SchemaCompiler) CompileJSONSchema(jsonSchema []byte, schemaName string) (*jsonschema.Schema, error) { - if len(jsonSchema) == 0 { - return nil, nil - } - - c := jsonschema.NewCompiler() - - // Load the JSON schema from the bytes - schema, err := jsonschema.UnmarshalJSON(bytes.NewReader(jsonSchema)) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal JSON schema: %w", err) - } - - if schemaName == "" { - schemaName = "schema.json" - } - - err = c.AddResource(schemaName, schema) - if err != nil { - return nil, fmt.Errorf("failed to add resource to JSON schema compiler: %w", err) - } - - sch, err := c.Compile(schemaName) - if err != nil { - return nil, fmt.Errorf("failed to compile JSON schema: %w", err) - } - - return sch, nil -} - -// ValidateInput validates input data against a compiled schema -func (sc *SchemaCompiler) ValidateInput(data []byte, compiledSchema *jsonschema.Schema) error { - if compiledSchema == nil { - return nil - } - - var v any - if err := json.Unmarshal(data, &v); err != nil { - return fmt.Errorf("failed to parse JSON input: %w", err) - } - - if err := compiledSchema.Validate(v); err != nil { - var validationErr *jsonschema.ValidationError - if errors.As(err, &validationErr) { - if len(validationErr.Causes) > 0 { - return fmt.Errorf("validation error: %s", validationErr.Causes[0].Error()) - } - return fmt.Errorf("validation error: %s", validationErr.Error()) - } - return fmt.Errorf("schema validation failed: %w", err) - } - - return nil -} - -// ValidateJSONSchema validates that the provided bytes are a valid JSON schema -func (sc *SchemaCompiler) ValidateJSONSchema(jsonSchema []byte) error { - if len(jsonSchema) == 0 { - return nil - } - - // Attempt to compile the schema to verify it's valid - _, err := sc.CompileJSONSchema(jsonSchema, "") - return err -} diff --git a/router/pkg/mcpserver/server.go b/router/pkg/mcpserver/server.go index b78f513ca..eaacfb95e 100644 --- a/router/pkg/mcpserver/server.go +++ b/router/pkg/mcpserver/server.go @@ -17,7 +17,6 @@ import ( "github.com/hashicorp/go-retryablehttp" "github.com/iancoleman/strcase" "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/santhosh-tekuri/jsonschema/v6" "go.uber.org/zap" nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" @@ -134,7 +133,6 @@ type GraphQLSchemaServer struct { scalarSchemas map[string]*googlejsonschema.Schema stateless bool operationsManager *OperationsManager - schemaCompiler *SchemaCompiler registeredTools []string corsConfig cors.Config cancel context.CancelFunc @@ -155,10 +153,10 @@ type ExecuteGraphQLInput struct { Variables json.RawMessage `json:"variables,omitempty"` } -// operationHandler holds an operation and its compiled JSON schema +// operationHandler holds an operation and its resolved variables schema type operationHandler struct { operation schemaloader.Operation - compiledSchema *jsonschema.Schema + resolvedSchema *googlejsonschema.Resolved } // OperationInfo contains metadata about a GraphQL operation @@ -594,7 +592,6 @@ func (s *GraphQLSchemaServer) Reload(schema *ast.Document, fieldConfigs []*nodev return fmt.Errorf("server is not started") } - s.schemaCompiler = NewSchemaCompiler(s.logger) s.operationsManager = NewOperationsManager(schema, s.logger, s.excludeMutations, s.scalarSchemas) if s.operationsDir != "" { @@ -720,35 +717,22 @@ func (s *GraphQLSchemaServer) registerTools() error { toolScopes := make(map[string][][]string) for _, op := range operations { - var compiledSchema *jsonschema.Schema - var err error - graphqlOperationNames = append(graphqlOperationNames, op.Name) - if len(op.JSONSchema) > 0 { - // Validate the JSON schema before compiling it - if err := s.schemaCompiler.ValidateJSONSchema(op.JSONSchema); err != nil { - s.logger.Error("invalid schema for operation", - zap.String("operation", op.Name), - zap.Error(err)) - continue - } - - // Now compile the validated schema - schemaName := fmt.Sprintf("schema-%s.json", op.Name) - compiledSchema, err = s.schemaCompiler.CompileJSONSchema(op.JSONSchema, schemaName) - if err != nil { - s.logger.Error("failed to compile schema for operation", - zap.String("operation", op.Name), - zap.Error(err)) - continue - } + // Resolve the built schema value once at registration; a schema that + // fails to resolve is a registration error and the tool is skipped. + resolvedSchema, err := resolveSchema(op.Schema) + if err != nil { + s.logger.Error("invalid schema for operation", + zap.String("operation", op.Name), + zap.Error(err)) + continue } - // Create handler with pre-compiled schema + // Create handler with the pre-resolved schema handler := &operationHandler{ operation: op, - compiledSchema: compiledSchema, + resolvedSchema: resolvedSchema, } // Convert the operation name to snake_case for consistent tool naming @@ -772,15 +756,13 @@ func (s *GraphQLSchemaServer) registerTools() error { ) continue } - // Parse JSON schema into map for the official SDK + // Hand the SDK the schema value; the SDK accepts *jsonschema.Schema + // directly and marshals it for tools/list. The nil check must stay a + // typed branch: a nil *Schema stored in the any field would panic in + // AddTool. var inputSchema any - if len(op.JSONSchema) > 0 { - if err := json.Unmarshal(op.JSONSchema, &inputSchema); err != nil { - s.logger.Error("failed to parse JSON schema for operation", - zap.String("operation", op.Name), - zap.Error(err)) - continue - } + if op.Schema != nil { + inputSchema = op.Schema } else { inputSchema = map[string]any{"type": "object", "properties": map[string]any{}} } @@ -853,9 +835,9 @@ func (s *GraphQLSchemaServer) handleOperation(handler *operationHandler) func(ct jsonBytes := request.Params.Arguments - // Validate the JSON input against the pre-compiled schema derived from the operation input type - if handler.compiledSchema != nil { - if err := s.schemaCompiler.ValidateInput(jsonBytes, handler.compiledSchema); err != nil { + // Validate the JSON input against the pre-resolved schema derived from the operation input type + if handler.resolvedSchema != nil { + if err := validateInput(jsonBytes, handler.resolvedSchema); err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("Input validation error: %v", err)}}, IsError: true, diff --git a/router/pkg/schemaloader/loader.go b/router/pkg/schemaloader/loader.go index 98f8e7b44..7bdb5f662 100644 --- a/router/pkg/schemaloader/loader.go +++ b/router/pkg/schemaloader/loader.go @@ -10,6 +10,8 @@ import ( "go.uber.org/zap" + "github.com/google/jsonschema-go/jsonschema" + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" "github.com/wundergraph/graphql-go-tools/v2/pkg/astprinter" @@ -24,9 +26,14 @@ type Operation struct { Document ast.Document OperationString string Description string - JSONSchema json.RawMessage - OperationType string // "query", "mutation", or "subscription" - RequiredScopes [][]string // OR-of-AND scope groups from @requiresScopes (nil = no scope check) + // Schema is the built variables schema value, consumed directly by the MCP + // SDK and by input validation. JSONSchema holds its canonical marshaled + // bytes, the stable display and persistence format. Both are populated + // together by SchemaBuilder; nil/empty means the operation takes no input. + Schema *jsonschema.Schema + JSONSchema json.RawMessage + OperationType string // "query", "mutation", or "subscription" + RequiredScopes [][]string // OR-of-AND scope groups from @requiresScopes (nil = no scope check) } // OperationLoader loads GraphQL operations from files in a directory diff --git a/router/pkg/schemaloader/schema_builder.go b/router/pkg/schemaloader/schema_builder.go index 4980eb91e..612c1a8f7 100644 --- a/router/pkg/schemaloader/schema_builder.go +++ b/router/pkg/schemaloader/schema_builder.go @@ -83,6 +83,10 @@ func (b *SchemaBuilder) buildSchemaForOperation(operation *Operation) error { if err != nil { return fmt.Errorf("failed to canonicalize schema: %w", err) } + // The schema value and its canonical bytes are populated together: + // consumers pass the value to the MCP SDK and the validator, and print + // the bytes verbatim in tool output. + operation.Schema = schema operation.JSONSchema = s // Use operation description if provided, otherwise fall back to schema description From b3e7fb41ec0ba9309a09a7ddb3acae1462082d30 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Sat, 8 Aug 2026 13:48:22 +0100 Subject: [PATCH 21/24] fix: sanitize validator internals from tool input error text The upstream google validator renders a JSON null instance as the Go artifact ""; validateInput now substitutes the exact substring with "null" before wrapping, a no-op when absent. The tools/list schema assignment gains a guard comment: key order is not guaranteed and must not be restored via the removed bytes round trip. --- router-tests/protocol/mcp_test.go | 2 +- router/pkg/mcpserver/input_validation.go | 10 +++++++++- router/pkg/mcpserver/server.go | 9 ++++++--- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/router-tests/protocol/mcp_test.go b/router-tests/protocol/mcp_test.go index eba37fd38..50320133a 100644 --- a/router-tests/protocol/mcp_test.go +++ b/router-tests/protocol/mcp_test.go @@ -464,7 +464,7 @@ Important Notes: assert.True(t, ok) assert.Equal(t, content.Type, "text") - assert.Equal(t, content.Text, "Input validation error: validation error: validating root: validating /properties/criteria: type: has type \"null\", want \"object\"") + assert.Equal(t, content.Text, "Input validation error: validation error: validating root: validating /properties/criteria: type: null has type \"null\", want \"object\"") }) }) }) diff --git a/router/pkg/mcpserver/input_validation.go b/router/pkg/mcpserver/input_validation.go index b6026aaf9..7cab98f14 100644 --- a/router/pkg/mcpserver/input_validation.go +++ b/router/pkg/mcpserver/input_validation.go @@ -3,6 +3,7 @@ package mcpserver import ( "encoding/json" "fmt" + "strings" "github.com/google/jsonschema-go/jsonschema" ) @@ -35,7 +36,14 @@ func validateInput(data []byte, resolved *jsonschema.Resolved) error { } if err := resolved.Validate(v); err != nil { - return fmt.Errorf("validation error: %s", err) + // The error text is read by AI tool-callers. The upstream validator + // formats a JSON null instance as the Go artifact + // "" (a zero reflect.Value printed with %v); + // the substitution excises that Go-internals leak. It is a no-op when + // the substring is absent, so upstream wording changes degrade + // gracefully. + msg := strings.ReplaceAll(err.Error(), "", "null") + return fmt.Errorf("validation error: %s", msg) } return nil diff --git a/router/pkg/mcpserver/server.go b/router/pkg/mcpserver/server.go index eaacfb95e..d8883dbcb 100644 --- a/router/pkg/mcpserver/server.go +++ b/router/pkg/mcpserver/server.go @@ -757,9 +757,12 @@ func (s *GraphQLSchemaServer) registerTools() error { continue } // Hand the SDK the schema value; the SDK accepts *jsonschema.Schema - // directly and marshals it for tools/list. The nil check must stay a - // typed branch: a nil *Schema stored in the any field would panic in - // AddTool. + // directly and marshals it for tools/list. Key order in that output is + // not guaranteed and intentionally differs from get_operation_info's + // canonical bytes; do not restore it by unmarshaling op.JSONSchema, + // which would reintroduce the bytes round trip this design removed. + // The nil check must stay a typed branch: a nil *Schema stored in the + // any field would panic in AddTool. var inputSchema any if op.Schema != nil { inputSchema = op.Schema From fc64e434867d898bbbe40d38c8525f6bd005f621 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Sat, 8 Aug 2026 21:10:39 +0100 Subject: [PATCH 22/24] docs: use an explicitly custom scalar name in scalar mapping examples --- docs-website/router/mcp/configuration.mdx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs-website/router/mcp/configuration.mdx b/docs-website/router/mcp/configuration.mdx index b5a637b10..9c3fd9f53 100644 --- a/docs-website/router/mcp/configuration.mdx +++ b/docs-website/router/mcp/configuration.mdx @@ -70,7 +70,7 @@ All MCP options can also be set via environment variables: | `MCP_OMIT_TOOL_NAME_PREFIX` | `mcp.omit_tool_name_prefix` | | `MCP_SCALAR_MAPPINGS` | `mcp.scalar_mappings` | -Map-valued options use comma-separated `key:value` pairs. For example: `MCP_SCALAR_MAPPINGS=JSON:object,BigInt:integer`. +Map-valued options use comma-separated `key:value` pairs. For example: `MCP_SCALAR_MAPPINGS=Foo:object,BigInt:integer`. For OAuth-related environment variables, see [OAuth Configuration Reference](/router/mcp/oauth/configuration#environment-variables). @@ -78,17 +78,19 @@ For OAuth-related environment variables, see [OAuth Configuration Reference](/ro The router generates each tool's input schema from the variables of the GraphQL operation. Custom scalars are opaque to JSON Schema, but MCP clients require every property to declare a type. The router therefore advertises custom scalar variables as `string` by default. This matches the wire format of most opaque scalars, such as cursors, IDs, and timestamps. +GraphQL has five built-in scalars: `Int`, `Float`, `String`, `Boolean`, and `ID`. Every other scalar in your schema is a custom scalar. The examples below use two custom scalars: `scalar Foo`, whose wire format is an object, and `scalar BigInt`, whose wire format is an integer. + Use `scalar_mappings` for custom scalars whose wire format is not a string: ```yaml mcp: enabled: true scalar_mappings: - JSON: object + Foo: object BigInt: integer ``` -A mapped scalar's schema states its GraphQL nullability. A non-nullable variable, such as `$filter: JSON!`, emits the mapped type alone: `"type": "object"`. A nullable variable, such as `$filter: JSON`, emits the type with `"null"`: `"type": ["object", "null"]`. +A mapped scalar's schema states its GraphQL nullability. A non-nullable variable, such as `$filter: Foo!`, emits the mapped type alone: `"type": "object"`. A nullable variable, such as `$filter: Foo`, emits the type with `"null"`: `"type": ["object", "null"]`. Two behaviors help you keep mappings correct: @@ -170,7 +172,7 @@ mcp: expose_schema: false omit_tool_name_prefix: false scalar_mappings: - JSON: object + Foo: object BigInt: integer storage: provider_id: 'mcp' From bf7a0aa27d8a7d9ce6a7fadf5b4e3cdc82eab7c2 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Sat, 8 Aug 2026 22:08:45 +0100 Subject: [PATCH 23/24] docs: state that the router does not generate the openai strict mode schema shape --- docs-website/router/mcp/tools.mdx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs-website/router/mcp/tools.mdx b/docs-website/router/mcp/tools.mdx index d75cae73f..e78bb360e 100644 --- a/docs-website/router/mcp/tools.mdx +++ b/docs-website/router/mcp/tools.mdx @@ -156,6 +156,10 @@ The tool's input schema is automatically generated from your GraphQL operation's Custom scalar variables are advertised as `string` by default. A non-nullable custom scalar variable emits `"type": "string"`. A nullable one emits `"type": ["string", "null"]`. See [Custom Scalar Mappings](/router/mcp/configuration#custom-scalar-mappings) to map scalars with a different wire format. + + OpenAI strict mode (`strict: true` in function calling) uses a different schema contract: every property must appear in `required`, and optionality is modeled with null types. The router does not generate this shape. Tools work with OpenAI models without strict mode. + + The generated schema reflects your operation and graph schema: - Non-nullable variables are listed as `required`. From 7957dfebf0730e5e6421831a89cf16677ef39ccf Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Sat, 8 Aug 2026 22:44:33 +0100 Subject: [PATCH 24/24] docs: state why the anthropic probe pins api version 2023-06-01 --- router/internal/jsonschema/vendor_compat_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/router/internal/jsonschema/vendor_compat_test.go b/router/internal/jsonschema/vendor_compat_test.go index b0745fc55..4d6f0e900 100644 --- a/router/internal/jsonschema/vendor_compat_test.go +++ b/router/internal/jsonschema/vendor_compat_test.go @@ -109,6 +109,9 @@ func anthropicProbe(t *testing.T, key string, strict bool, inputSchema json.RawM "description": "list events", "input_schema": inputSchema, } + // anthropic-version pins the API wire format. 2023-06-01 is the current + // stable version; Anthropic ships new capabilities as anthropic-beta + // flags on top of it, not as new version dates. headers := map[string]string{ "x-api-key": key, "anthropic-version": "2023-06-01",