Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
26 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
42b76e0
Merge branch 'main' into ahmet/eng-9903-mcp-custom-scalars-produce-un…
asoorm Aug 12, 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
2 changes: 1 addition & 1 deletion router-tests/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ require (
github.com/wundergraph/cosmo/router v0.0.0-20260710155145-803a4bc06d92
github.com/wundergraph/cosmo/router-plugin v0.0.0-20250808194725-de123ba1c65e
github.com/wundergraph/cosmo/speedtrap v0.0.0-00010101000000-000000000000
github.com/wundergraph/graphql-go-tools/v2 v2.14.1
github.com/wundergraph/graphql-go-tools/v2 v2.14.3-0.20260807102141-88073b801ad2
go.opentelemetry.io/otel v1.44.0
go.opentelemetry.io/otel/sdk v1.44.0
go.opentelemetry.io/otel/sdk/metric v1.44.0
Expand Down
4 changes: 2 additions & 2 deletions router-tests/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -386,8 +386,8 @@ github.com/wundergraph/astjson v1.1.0 h1:xORDosrZ87zQFJwNGe/HIHXqzpdHOFmqWgykCLV
github.com/wundergraph/astjson v1.1.0/go.mod h1:h12D/dxxnedtLzsKyBLK7/Oe4TAoGpRVC9nDpDrZSWw=
github.com/wundergraph/go-arena v1.3.0 h1:n0ng5a1vbd8YGq1u3rMr0vPU5f6AZ1BXIiUhL1UIok8=
github.com/wundergraph/go-arena v1.3.0/go.mod h1:ROOysEHWJjLQ8FSfNxZCziagb7Qw2nXY3/vgKRh7eWw=
github.com/wundergraph/graphql-go-tools/v2 v2.14.1 h1:TecnvTyhskoeiqo6W+1xh7HqAPL/RG5vfbFhezu7RLs=
github.com/wundergraph/graphql-go-tools/v2 v2.14.1/go.mod h1:zREIKLmpjfNcGSubndaW/913r0Y8XbbYOXQeZFkwHdo=
github.com/wundergraph/graphql-go-tools/v2 v2.14.3-0.20260807102141-88073b801ad2 h1:L7FceLC3ZApDOrGan5x6fh7tTophfE+rYbUSAj/B690=
github.com/wundergraph/graphql-go-tools/v2 v2.14.3-0.20260807102141-88073b801ad2/go.mod h1:zREIKLmpjfNcGSubndaW/913r0Y8XbbYOXQeZFkwHdo=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
Expand Down
66 changes: 66 additions & 0 deletions router-tests/protocol/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,72 @@ func TestMCP(t *testing.T) {
})
})

t.Run("Custom scalar variables carry a JSON Schema type in tool input schemas", func(t *testing.T) {
testenv.Run(t, &testenv.Config{
MCPOperationsPath: "testdata/mcp_operations_custom_scalar",
MCP: config.MCPConfiguration{
Enabled: true,
},
}, func(t *testing.T, xEnv *testenv.Environment) {

toolsRequest := mcp.ListToolsRequest{}
resp, err := xEnv.MCPClient.ListTools(xEnv.Context, toolsRequest)
require.NoError(t, err)
require.NotNil(t, resp)

var tool *mcp.Tool
for i := range resp.Tools {
if resp.Tools[i].Name == "execute_operation_upload_file" {
tool = &resp.Tools[i]
break
}
}
require.NotNil(t, tool, "expected the UploadFile operation to be registered as a tool")

fileSchema, ok := tool.InputSchema.Properties["file"].(map[string]any)
require.True(t, ok, "expected a schema object for the 'file' variable, got: %#v", tool.InputSchema.Properties)

actual, err := json.Marshal(fileSchema)
require.NoError(t, err)

// Custom scalars are opaque to JSON Schema, but MCP tool consumers
// (Anthropic directory validation, OpenAI strict mode) reject input
// schema properties that declare no "type". Opaque scalars must carry
// a best-effort primitive type: string.
require.Contains(t, fileSchema, "type", "custom scalar variable 'file' must declare a JSON Schema type, got: %s", actual)
assert.Equal(t, "string", fileSchema["type"], "custom scalar variable 'file' should be typed as a string, got: %s", actual)
})
})

t.Run("Scalar mapping overrides the string default for a mapped custom scalar", func(t *testing.T) {
testenv.Run(t, &testenv.Config{
MCPOperationsPath: "testdata/mcp_operations_custom_scalar",
MCP: config.MCPConfiguration{
Enabled: true,
ScalarMappings: map[string]string{"Upload": "object"},
},
}, func(t *testing.T, xEnv *testenv.Environment) {
toolsRequest := mcp.ListToolsRequest{}
resp, err := xEnv.MCPClient.ListTools(xEnv.Context, toolsRequest)
require.NoError(t, err)

var tool *mcp.Tool
for i := range resp.Tools {
if resp.Tools[i].Name == "execute_operation_upload_file" {
tool = &resp.Tools[i]
break
}
}
require.NotNil(t, tool, "expected the UploadFile operation to be registered as a tool")

fileSchema, ok := tool.InputSchema.Properties["file"].(map[string]any)
require.True(t, ok, "expected a schema object for the 'file' variable")

// The mapping replaces the string default; Upload! is non-null so no null union.
require.Equal(t, "object", fileSchema["type"], "mapped custom scalar should carry the configured type")
})
})

t.Run("List user Operations / Static operations of type mutation aren't exposed when excludeMutations is set", func(t *testing.T) {
testenv.Run(t, &testenv.Config{
MCP: config.MCPConfiguration{
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 @@ -1200,6 +1200,7 @@ func (r *Router) startMCPServer(ctx context.Context) error {
mcpserver.WithEnableArbitraryOperations(r.mcp.EnableArbitraryOperations),
mcpserver.WithExposeSchema(r.mcp.ExposeSchema),
mcpserver.WithOmitToolNamePrefix(r.mcp.OmitToolNamePrefix),
mcpserver.WithScalarMappings(r.mcp.ScalarMappings),
mcpserver.WithStateless(r.mcp.Session.Stateless),
}

Expand Down
2 changes: 1 addition & 1 deletion router/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ require (
github.com/tidwall/gjson v1.18.0
github.com/tidwall/sjson v1.2.5
github.com/twmb/franz-go v1.16.1
github.com/wundergraph/graphql-go-tools/v2 v2.14.1
github.com/wundergraph/graphql-go-tools/v2 v2.14.3-0.20260807102141-88073b801ad2
// Do not upgrade, it renames attributes we rely on
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0
go.opentelemetry.io/contrib/propagators/b3 v1.44.0
Expand Down
4 changes: 2 additions & 2 deletions router/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -334,8 +334,8 @@ github.com/wundergraph/astjson v1.1.0 h1:xORDosrZ87zQFJwNGe/HIHXqzpdHOFmqWgykCLV
github.com/wundergraph/astjson v1.1.0/go.mod h1:h12D/dxxnedtLzsKyBLK7/Oe4TAoGpRVC9nDpDrZSWw=
github.com/wundergraph/go-arena v1.3.0 h1:n0ng5a1vbd8YGq1u3rMr0vPU5f6AZ1BXIiUhL1UIok8=
github.com/wundergraph/go-arena v1.3.0/go.mod h1:ROOysEHWJjLQ8FSfNxZCziagb7Qw2nXY3/vgKRh7eWw=
github.com/wundergraph/graphql-go-tools/v2 v2.14.1 h1:TecnvTyhskoeiqo6W+1xh7HqAPL/RG5vfbFhezu7RLs=
github.com/wundergraph/graphql-go-tools/v2 v2.14.1/go.mod h1:zREIKLmpjfNcGSubndaW/913r0Y8XbbYOXQeZFkwHdo=
github.com/wundergraph/graphql-go-tools/v2 v2.14.3-0.20260807102141-88073b801ad2 h1:L7FceLC3ZApDOrGan5x6fh7tTophfE+rYbUSAj/B690=
github.com/wundergraph/graphql-go-tools/v2 v2.14.3-0.20260807102141-88073b801ad2/go.mod h1:zREIKLmpjfNcGSubndaW/913r0Y8XbbYOXQeZFkwHdo=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
Expand Down
9 changes: 7 additions & 2 deletions router/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -1348,8 +1348,13 @@ type MCPConfiguration struct {
RouterURL string `yaml:"router_url,omitempty" env:"MCP_ROUTER_URL"`
// OmitToolNamePrefix removes the "execute_operation_" prefix from MCP tool names.
// When enabled, GetUser becomes get_user. When disabled (default), GetUser becomes execute_operation_get_user.
OmitToolNamePrefix bool `yaml:"omit_tool_name_prefix" envDefault:"false" env:"MCP_OMIT_TOOL_NAME_PREFIX"`
OAuth MCPOAuthConfiguration `yaml:"oauth,omitempty" envPrefix:"MCP_OAUTH_"`
OmitToolNamePrefix bool `yaml:"omit_tool_name_prefix" envDefault:"false" env:"MCP_OMIT_TOOL_NAME_PREFIX"`
// ScalarMappings overrides the JSON Schema type advertised for custom scalar
// variables in MCP tool input schemas, keyed by scalar type name. Unmapped
// custom scalars default to "string". Allowed values: string, integer,
// number, boolean, object, array.
ScalarMappings map[string]string `yaml:"scalar_mappings,omitempty" env:"MCP_SCALAR_MAPPINGS"`
OAuth MCPOAuthConfiguration `yaml:"oauth,omitempty" envPrefix:"MCP_OAUTH_"`
// ResourceDocumentation is a URL to a human-readable page describing this MCP resource,
// its access policies, and how to get started. Included in RFC 9728 Protected Resource Metadata if set.
ResourceDocumentation string `yaml:"resource_documentation,omitempty" env:"MCP_RESOURCE_DOCUMENTATION"`
Expand Down
8 changes: 8 additions & 0 deletions router/pkg/config/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2733,6 +2733,14 @@
"default": false,
"description": "When enabled, MCP tool names generated from GraphQL operations omit the 'execute_operation_' prefix. For example, the GraphQL operation 'GetUser' results in a tool named 'get_user' instead of 'execute_operation_get_user'."
},
"scalar_mappings": {
"type": "object",
"description": "Overrides the JSON Schema type advertised for custom scalar variables in MCP tool input schemas, keyed by scalar type name (e.g. JSON: object). Unmapped custom scalars default to string.",
"additionalProperties": {
"type": "string",
"enum": ["string", "integer", "number", "boolean", "object", "array"]
}
},
"resource_documentation": {
"type": "string",
"description": "A URL to a human-readable page describing this MCP resource, its access policies, and how to get started. Included in the RFC 9728 Protected Resource Metadata response if set.",
Expand Down
3 changes: 3 additions & 0 deletions router/pkg/config/fixtures/full.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions router/pkg/config/testdata/config_defaults.json
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@
"ExposeSchema": false,
"RouterURL": "",
"OmitToolNamePrefix": false,
"ScalarMappings": null,
"OAuth": {
"Enabled": false,
"JWKS": null,
Expand Down
4 changes: 4 additions & 0 deletions router/pkg/config/testdata/config_full.json
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,10 @@
"ExposeSchema": false,
"RouterURL": "https://cosmo-router.wundergraph.com",
"OmitToolNamePrefix": false,
"ScalarMappings": {
"BigInt": "integer",
"JSON": "object"
},
"OAuth": {
"Enabled": false,
"JWKS": null,
Expand Down
14 changes: 12 additions & 2 deletions router/pkg/mcpserver/operation_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/wundergraph/cosmo/router/pkg/schemaloader"

"github.com/wundergraph/graphql-go-tools/v2/pkg/ast"
"github.com/wundergraph/graphql-go-tools/v2/pkg/engine/jsonschema"
)

// OperationsManager handles the loading and preparation of GraphQL operations
Expand All @@ -17,10 +18,13 @@ type OperationsManager struct {
operations []schemaloader.Operation
logger *zap.Logger
excludeMutations bool
// scalarSchemas overrides the JSON schema emitted for custom scalar types
// in generated tool input schemas, keyed by scalar type name.
scalarSchemas map[string]*jsonschema.JsonSchema
}

// NewOperationsManager creates a new operations manager
func NewOperationsManager(schemaDoc *ast.Document, logger *zap.Logger, excludeMutations bool) *OperationsManager {
func NewOperationsManager(schemaDoc *ast.Document, logger *zap.Logger, excludeMutations bool, scalarSchemas map[string]*jsonschema.JsonSchema) *OperationsManager {
if logger == nil {
logger = zap.NewNop()
}
Expand All @@ -29,6 +33,7 @@ func NewOperationsManager(schemaDoc *ast.Document, logger *zap.Logger, excludeMu
schemaDoc: schemaDoc,
logger: logger,
excludeMutations: excludeMutations,
scalarSchemas: scalarSchemas,
}
}

Expand All @@ -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
Expand Down
38 changes: 38 additions & 0 deletions router/pkg/mcpserver/scalar_mappings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package mcpserver

import (
"fmt"

"github.com/wundergraph/graphql-go-tools/v2/pkg/engine/jsonschema"
)

// allowedScalarMappingTypes are the JSON schema type names an mcp.scalar_mappings
// entry may map a custom scalar to.
var allowedScalarMappingTypes = map[string]jsonschema.SchemaType{
"string": jsonschema.TypeString,
"integer": jsonschema.TypeInteger,
"number": jsonschema.TypeNumber,
"boolean": jsonschema.TypeBoolean,
"object": jsonschema.TypeObject,
"array": jsonschema.TypeArray,
}

// scalarSchemasFromMappings translates config scalar mappings (scalar name ->
// JSON schema type name) into schema overrides.
// YAML config is enum-checked by the config JSON schema, but env-sourced
// config (MCP_SCALAR_MAPPINGS) bypasses schema validation entirely — this
// runtime check is the only guard on that path.
func scalarSchemasFromMappings(mappings map[string]string) (map[string]*jsonschema.JsonSchema, error) {
if len(mappings) == 0 {
return nil, nil
}
schemas := make(map[string]*jsonschema.JsonSchema, len(mappings))
for scalar, typeName := range mappings {
schemaType, ok := allowedScalarMappingTypes[typeName]
if !ok {
return nil, fmt.Errorf("invalid scalar mapping for scalar %q: %q is not a JSON schema type (allowed: string, integer, number, boolean, object, array)", scalar, typeName)
}
schemas[scalar] = &jsonschema.JsonSchema{Type: schemaType}
}
return schemas, nil
}
58 changes: 58 additions & 0 deletions router/pkg/mcpserver/scalar_mappings_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package mcpserver

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/wundergraph/graphql-go-tools/v2/pkg/engine/jsonschema"
)

func TestScalarMappingsTranslateToSchemas(t *testing.T) {
t.Run("valid mappings translate to typed schemas", func(t *testing.T) {
schemas, err := scalarSchemasFromMappings(map[string]string{
"JSON": "object",
"BigInt": "integer",
})
require.NoError(t, err)
assert.Equal(t, map[string]*jsonschema.JsonSchema{
"JSON": {Type: jsonschema.TypeObject},
"BigInt": {Type: jsonschema.TypeInteger},
}, schemas)
})

t.Run("unknown JSON schema type returns an error naming the scalar and the value", func(t *testing.T) {
schemas, err := scalarSchemasFromMappings(map[string]string{
"JSON": "blob",
})
require.Error(t, err)
assert.Nil(t, schemas)
assert.ErrorContains(t, err, `scalar "JSON"`)
assert.ErrorContains(t, err, `"blob"`)
})

t.Run("empty and nil mappings translate to nil", func(t *testing.T) {
schemas, err := scalarSchemasFromMappings(nil)
require.NoError(t, err)
assert.Nil(t, schemas)

schemas, err = scalarSchemasFromMappings(map[string]string{})
require.NoError(t, err)
assert.Nil(t, schemas)
})
}

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"`)
})
}
Loading
Loading