Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
0526d55
feat: add mcp.scalar_mappings config for custom scalar JSON schema types
asoorm Aug 7, 2026
156bdf1
feat: support custom scalar schema overrides and defaulted-scalar rep…
asoorm Aug 7, 2026
9ece97c
feat: plumb mcp scalar mappings into tool schema generation with star…
asoorm Aug 7, 2026
f74105d
test: cover custom scalar typing and scalar mapping overrides in mcp …
asoorm Aug 7, 2026
8d9eb30
test: guard invalid scalar mappings at server construction and fix en…
asoorm Aug 7, 2026
15770d7
docs: document mcp scalar_mappings and custom scalar type defaults
asoorm Aug 7, 2026
dd6857d
chore: use ascii punctuation in scalar mapping warning and comment
asoorm Aug 7, 2026
d23fe4d
feat: vendor graphql operation json schema generation as router-inter…
asoorm Aug 8, 2026
90a6ad3
chore: restore released graphql-go-tools version in router and router…
asoorm Aug 8, 2026
f83c463
chore: rename stale enginejsonschema alias to internaljsonschema
asoorm Aug 8, 2026
cfc999c
chore: use jsonschema v6 in vendored schema tests to drop the v5 depe…
asoorm Aug 8, 2026
90268f0
chore: merge main (mcp tools docs refactor, server discover) and reso…
asoorm Aug 8, 2026
0c752fe
test: add opt-in live vendor schema acceptance tests for anthropic an…
asoorm Aug 8, 2026
9f13b01
test: always skip live vendor schema tests instead of env-flag gating
asoorm Aug 8, 2026
dbe6671
test: assert the root-type rejection message verified against the liv…
asoorm Aug 8, 2026
ec46353
test: record live verification of openai schema acceptance probes
asoorm Aug 8, 2026
eb3a464
refactor: emit google/jsonschema-go schemas from the graphql walk
asoorm Aug 8, 2026
88de1e4
docs: note true-schema marshaling and the canonical bytes contract
asoorm Aug 8, 2026
042b563
fix: keep graphql nullability for object-mapped scalars and guard nil…
asoorm Aug 8, 2026
e2233a2
docs: state nullable schema forms for custom scalars and mappings
asoorm Aug 8, 2026
ee31ab5
refactor: validate mcp tool arguments with google jsonschema and pass…
asoorm Aug 8, 2026
b3e7fb4
fix: sanitize validator internals from tool input error text
asoorm Aug 8, 2026
fc64e43
docs: use an explicitly custom scalar name in scalar mapping examples
asoorm Aug 8, 2026
bf7a0aa
docs: state that the router does not generate the openai strict mode …
asoorm Aug 8, 2026
7957dfe
docs: state why the anthropic probe pins api version 2023-06-01
asoorm Aug 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs-website/router/mcp/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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.

<Note>
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.
</Note>

## Storage Providers

MCP loads operations from a configured storage provider. Currently, only the `file_system` provider is supported:
Expand Down Expand Up @@ -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'

Expand Down
6 changes: 6 additions & 0 deletions docs-website/router/mcp/tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Note>
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.
</Note>

The generated schema reflects your operation and graph schema:

- Non-nullable variables are listed as `required`.
Expand Down
68 changes: 67 additions & 1 deletion router-tests/protocol/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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\"")
})
})
})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# This mutation uploads a single file.
mutation UploadFile($file: Upload!) {
singleUpload(file: $file)
}
1 change: 1 addition & 0 deletions router/core/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
2 changes: 1 addition & 1 deletion router/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 0 additions & 2 deletions router/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
108 changes: 108 additions & 0 deletions router/internal/jsonschema/nullable_2020_12_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}
94 changes: 94 additions & 0 deletions router/internal/jsonschema/recursive_input_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading
Loading