diff --git a/docs-website/router/mcp/configuration.mdx b/docs-website/router/mcp/configuration.mdx
index d257a4e5fa..9c3fd9f535 100644
--- a/docs-website/router/mcp/configuration.mdx
+++ b/docs-website/router/mcp/configuration.mdx
@@ -43,6 +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`. 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).
@@ -67,9 +68,39 @@ 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=Foo: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.
+
+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:
+ Foo: object
+ BigInt: integer
+```
+
+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:
+
+- 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.
+
+
+ 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
MCP loads operations from a configured storage provider. Currently, only the `file_system` provider is supported:
@@ -140,6 +171,9 @@ mcp:
enable_arbitrary_operations: false
expose_schema: false
omit_tool_name_prefix: false
+ scalar_mappings:
+ Foo: object
+ BigInt: integer
storage:
provider_id: 'mcp'
diff --git a/docs-website/router/mcp/tools.mdx b/docs-website/router/mcp/tools.mdx
index 6523d97cf2..e78bb360e2 100644
--- a/docs-website/router/mcp/tools.mdx
+++ b/docs-website/router/mcp/tools.mdx
@@ -154,6 +154,12 @@ 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. 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`.
diff --git a/router-tests/protocol/mcp_test.go b/router-tests/protocol/mcp_test.go
index fab165f048..50320133a2 100644
--- a/router-tests/protocol/mcp_test.go
+++ b/router-tests/protocol/mcp_test.go
@@ -250,6 +250,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{
@@ -398,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: null has type \"null\", want \"object\"")
})
})
})
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 0000000000..198d316032
--- /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/core/router.go b/router/core/router.go
index 3491a7e516..930bd49ab0 100644
--- a/router/core/router.go
+++ b/router/core/router.go
@@ -1212,6 +1212,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),
mcpserver.WithInstructions(r.mcp.Server.Discover.Instructions),
mcpserver.WithServerVersion(cmp.Or(r.mcp.Server.Version, Version)),
diff --git a/router/go.mod b/router/go.mod
index 09fc818ae5..76cbeaa0f6 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/go.sum b/router/go.sum
index 011ec26bfd..f5e2b223bf 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
new file mode 100644
index 0000000000..ea06873224
--- /dev/null
+++ b/router/internal/jsonschema/nullable_2020_12_test.go
@@ -0,0 +1,108 @@
+package jsonschema
+
+import (
+ "bytes"
+ "encoding/json"
+ "testing"
+
+ "github.com/santhosh-tekuri/jsonschema/v6"
+ "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)
+
+ 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.
+ 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 0000000000..8334e56e0b
--- /dev/null
+++ b/router/internal/jsonschema/recursive_input_test.go
@@ -0,0 +1,94 @@
+package jsonschema
+
+import (
+ "bytes"
+ "encoding/json"
+ "testing"
+
+ "github.com/santhosh-tekuri/jsonschema/v6"
+ "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)
+
+ 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
+ // 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/variables_schema.go b/router/internal/jsonschema/variables_schema.go
new file mode 100644
index 0000000000..bc1ee6b348
--- /dev/null
+++ b/router/internal/jsonschema/variables_schema.go
@@ -0,0 +1,711 @@
+// 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"
+ "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.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
+ // "$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.Schema
+ // scalarSchemas overrides the schema emitted per custom scalar type name.
+ 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
+}
+
+// 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.
+// 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
+ }
+}
+
+// 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: newRootSchema(),
+ report: &operationreport.Report{},
+ recursiveTypes: make(map[string]bool),
+ defs: make(map[string]*jsonschema.Schema),
+ 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 = 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
+
+ // 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 = strings.Join(descriptions, " ")
+ }
+}
+
+// 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. 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 {
+ return
+ }
+
+ // Add variable to required list if it's non-nullable
+ if v.operationDocument.TypeIsNonNull(typeRef) {
+ v.schema.Required = append(v.schema.Required, varName)
+ }
+
+ var description string
+ if v.operationDocument.VariableDefinitions[ref].Description.IsDefined {
+ description = v.operationDocument.VariableDefinitionDescriptionString(ref)
+ }
+
+ var defaultValue any
+ if v.operationDocument.VariableDefinitionHasDefaultValue(ref) {
+ defaultValue = v.convertOperationValueToNative(v.operationDocument.VariableDefinitionDefaultValue(ref))
+ }
+
+ varSchema = v.adorned(varSchema, description, defaultValue)
+
+ // Add variable to schema
+ if v.schema.Properties == nil {
+ v.schema.Properties = make(map[string]*jsonschema.Schema)
+ }
+ v.schema.Properties[varName] = varSchema
+}
+
+// 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
+ }
+ 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.Schema, 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
+}
+
+// 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 {
+ 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
+ typeRef = doc.Types[typeRef].OfType
+ }
+
+ 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
+ }
+ return newArraySchema(itemSchema, nullable)
+
+ case ast.TypeKindNamed:
+ return v.namedTypeSchema(doc.TypeNameString(typeRef), nullable, topLevel)
+
+ default:
+ return nil
+ }
+}
+
+// namedTypeSchema builds the schema node for a named type, looking it up in
+// 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":
+ return newTypedSchema("string", nullable)
+ case "Int":
+ return newTypedSchema("integer", nullable)
+ case "Float":
+ return newTypedSchema("number", nullable)
+ case "Boolean":
+ 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(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] {
+ v.ensureDef(typeName, node)
+ return newRefSchema(typeName, nullable)
+ }
+
+ // Process the type based on its kind
+ switch node.Kind {
+ case ast.NodeKindEnumTypeDefinition:
+ return v.enumTypeSchema(node, nullable)
+
+ case ast.NodeKindInputObjectTypeDefinition:
+ return v.inputObjectTypeSchema(node, nullable)
+
+ case ast.NodeKindScalarTypeDefinition:
+ return v.scalarTypeSchema(typeName, node, nullable)
+
+ default:
+ // If we can't determine the type, emit the empty schema, which accepts
+ // any value. The zero-value Schema marshals as the boolean schema true, not {}.
+ 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 || 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
+ // override per scalar via WithScalarSchemas.
+ v.defaultedScalars[typeName] = true
+ schema := newTypedSchema("string", nullable)
+ schema.Description = sdlDescription
+ return schema
+ }
+
+ if override.Description != "" {
+ sdlDescription = "" // the override's own description wins
+ }
+
+ 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
+ }
+
+ schema := *override
+ if nullable {
+ schema.Type = ""
+ schema.Types = []string{override.Type, "null"}
+ }
+ if sdlDescription != "" {
+ schema.Description = sdlDescription
+ }
+ return &schema
+}
+
+// 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 {
+ values = append(values, v.definitionDocument.EnumValueDefinitionNameString(valueRef))
+ }
+
+ schema := newEnumSchema(values, nullable)
+
+ // Add description if available
+ if enumDef.Description.IsDefined {
+ schema.Description = v.definitionDocument.EnumTypeDefinitionDescriptionString(node.Ref)
+ }
+
+ return schema
+}
+
+// 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
+ 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 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.typeRefSchema(v.definitionDocument, 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) {
+ parent.Required = append(parent.Required, fieldName)
+ }
+
+ var description string
+ if v.definitionDocument.InputValueDefinitions[fieldRef].Description.IsDefined {
+ description = v.definitionDocument.InputValueDefinitionDescriptionString(fieldRef)
+ }
+
+ var defaultValue any
+ if v.definitionDocument.InputValueDefinitionHasDefaultValue(fieldRef) {
+ defaultValue = v.convertDefinitionValueToNative(v.definitionDocument.InputValueDefinitionDefaultValue(fieldRef))
+ }
+
+ // Add field to schema
+ if parent.Properties == nil {
+ parent.Properties = make(map[string]*jsonschema.Schema)
+ }
+ parent.Properties[fieldName] = v.adorned(fieldSchema, description, defaultValue)
+}
+
+// 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
+}
+
+// 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
+ }
+ 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; 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
+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
+}
+
+// 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.Schema, 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 0000000000..023cbcbfea
--- /dev/null
+++ b/router/internal/jsonschema/variables_schema_test.go
@@ -0,0 +1,2116 @@
+package jsonschema
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/google/jsonschema-go/jsonschema"
+ "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.Schema{
+ "JSON": {Type: "object", 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 nullability: 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 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
+ // 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.Schema{
+ "BigInt": {Type: "integer"},
+ }
+
+ 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. Nullability must
+ // land on a per-use node, never on the shared override.
+ 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.Schema{
+ "JSON": {Type: "object"},
+ }))
+ _, 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())
+ })
+
+ 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())
+ })
+}
diff --git a/router/internal/jsonschema/vendor_compat_test.go b/router/internal/jsonschema/vendor_compat_test.go
new file mode 100644
index 0000000000..4d6f0e9006
--- /dev/null
+++ b/router/internal/jsonschema/vendor_compat_test.go
@@ -0,0 +1,199 @@
+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 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
+// 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).
+//
+// All subtests were verified against the live vendor APIs on 2026-08-08.
+
+func requireLiveTest(t *testing.T, keyEnv string) string {
+ t.Helper()
+ // 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)
+ }
+ 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,
+ }
+ // 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",
+ }
+ 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 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, "input_schema.type", "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).
+ // 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)
+ })
+}
diff --git a/router/pkg/config/config.go b/router/pkg/config/config.go
index 781709a25d..ba946a90a3 100644
--- a/router/pkg/config/config.go
+++ b/router/pkg/config/config.go
@@ -1353,8 +1353,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 8428a4a0e4..256df1fda5 100644
--- a/router/pkg/config/config.schema.json
+++ b/router/pkg/config/config.schema.json
@@ -2756,6 +2756,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 6e33f4f779..729ba6f43d 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 080b9f7a30..eeca044ab2 100644
--- a/router/pkg/config/testdata/config_defaults.json
+++ b/router/pkg/config/testdata/config_defaults.json
@@ -211,6 +211,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 446dcc3958..8c6a07eb6b 100644
--- a/router/pkg/config/testdata/config_full.json
+++ b/router/pkg/config/testdata/config_full.json
@@ -280,6 +280,10 @@
"ExposeSchema": false,
"RouterURL": "https://cosmo-router.wundergraph.com",
"OmitToolNamePrefix": false,
+ "ScalarMappings": {
+ "BigInt": "integer",
+ "JSON": "object"
+ },
"OAuth": {
"Enabled": false,
"JWKS": null,
diff --git a/router/pkg/mcpserver/input_validation.go b/router/pkg/mcpserver/input_validation.go
new file mode 100644
index 0000000000..7cab98f143
--- /dev/null
+++ b/router/pkg/mcpserver/input_validation.go
@@ -0,0 +1,50 @@
+package mcpserver
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "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 {
+ // 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/operation_manager.go b/router/pkg/mcpserver/operation_manager.go
index 85e4b3a1c7..f5f67bc44e 100644
--- a/router/pkg/mcpserver/operation_manager.go
+++ b/router/pkg/mcpserver/operation_manager.go
@@ -3,6 +3,7 @@ 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"
@@ -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.Schema
}
// 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.Schema) *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 0000000000..74aba1c9df
--- /dev/null
+++ b/router/pkg/mcpserver/scalar_mappings.go
@@ -0,0 +1,37 @@
+package mcpserver
+
+import (
+ "fmt"
+
+ "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]bool{
+ "string": true,
+ "integer": true,
+ "number": true,
+ "boolean": true,
+ "object": true,
+ "array": true,
+}
+
+// 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
+// runtime check is the only guard on that path.
+func scalarSchemasFromMappings(mappings map[string]string) (map[string]*jsonschema.Schema, error) {
+ if len(mappings) == 0 {
+ return nil, nil
+ }
+ schemas := make(map[string]*jsonschema.Schema, len(mappings))
+ for scalar, typeName := range mappings {
+ 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.Schema{Type: typeName}
+ }
+ 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 0000000000..570e8638ad
--- /dev/null
+++ b/router/pkg/mcpserver/scalar_mappings_test.go
@@ -0,0 +1,57 @@
+package mcpserver
+
+import (
+ "testing"
+
+ "github.com/google/jsonschema-go/jsonschema"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+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.Schema{
+ "JSON": {Type: "object"},
+ "BigInt": {Type: "integer"},
+ }, 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)
+ })
+}
+
+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"`)
+ })
+}
diff --git a/router/pkg/mcpserver/schema_compiler.go b/router/pkg/mcpserver/schema_compiler.go
deleted file mode 100644
index 8ec3412447..0000000000
--- 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 1a118a6858..d8883dbcb8 100644
--- a/router/pkg/mcpserver/server.go
+++ b/router/pkg/mcpserver/server.go
@@ -13,10 +13,10 @@ 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"
- "github.com/santhosh-tekuri/jsonschema/v6"
"go.uber.org/zap"
nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1"
@@ -84,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
@@ -127,9 +130,9 @@ type GraphQLSchemaServer struct {
enableArbitraryOperations bool
exposeSchema bool
omitToolNamePrefix bool
+ scalarSchemas map[string]*googlejsonschema.Schema
stateless bool
operationsManager *OperationsManager
- schemaCompiler *SchemaCompiler
registeredTools []string
corsConfig cors.Config
cancel context.CancelFunc
@@ -150,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
@@ -236,6 +239,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
@@ -337,6 +347,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,
@@ -445,6 +456,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
@@ -573,8 +592,7 @@ 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.operationsManager = NewOperationsManager(schema, s.logger, s.excludeMutations, s.scalarSchemas)
if s.operationsDir != "" {
if err := s.operationsManager.LoadOperationsFromDirectory(s.operationsDir); err != nil {
@@ -699,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
@@ -751,15 +756,16 @@ 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. 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 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{}}
}
@@ -832,9 +838,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 98f8e7b442..7bdb5f6621 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 2ce204a4a9..612c1a8f7a 100644
--- a/router/pkg/schemaloader/schema_builder.go
+++ b/router/pkg/schemaloader/schema_builder.go
@@ -1,22 +1,51 @@
package schemaloader
import (
+ "bytes"
+ "encoding/json"
"fmt"
+ "sort"
+ "github.com/google/jsonschema-go/jsonschema"
+
+ internaljsonschema "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
type SchemaBuilder struct {
- schemaDoc *ast.Document
+ schemaDoc *ast.Document
+ 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
+}
+
+// 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. 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
+ }
}
// 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,16 +64,29 @@ 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 := 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)
}
+ for _, name := range builder.DefaultedScalars() {
+ b.defaultedScalars[name] = true
+ }
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)
+ }
+ // 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
@@ -57,3 +99,29 @@ 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. 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
+ 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 {
+ 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 0000000000..83b7f43975
--- /dev/null
+++ b/router/pkg/schemaloader/schema_builder_test.go
@@ -0,0 +1,92 @@
+package schemaloader
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/google/jsonschema-go/jsonschema"
+ "github.com/stretchr/testify/require"
+
+ "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser"
+ "github.com/wundergraph/graphql-go-tools/v2/pkg/asttransform"
+)
+
+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.Schema{
+ "JSON": {Type: "object"},
+ }))
+ 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.Schema{
+ "JSON": {Type: "object"},
+ }))
+ 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")
+ })
+}