Skip to content

feat(router): typed custom scalars in MCP tool schemas with scalar_mappings overrides - #3147

Draft
asoorm wants to merge 25 commits into
mainfrom
ahmet/eng-9903-mcp-custom-scalars-produce-untyped-json-schema-in-mcp-tool
Draft

feat(router): typed custom scalars in MCP tool schemas with scalar_mappings overrides#3147
asoorm wants to merge 25 commits into
mainfrom
ahmet/eng-9903-mcp-custom-scalars-produce-untyped-json-schema-in-mcp-tool

Conversation

@asoorm

@asoorm asoorm commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

What this PR does

The Cosmo Router can expose GraphQL operations as MCP tools. Each tool publishes a tool input schema (JSON Schema). AI clients read this schema to build tool arguments. The router also validates incoming tool arguments against the same schema.

Without this PR, a custom scalar variable (for example $after: Cursor) produces a schema property with no type field. Strict MCP clients reject tools that have untyped properties. Examples of strict clients: Anthropic's marketplace submission checks, Claude Code (2.0.21 and later), OpenAI strict mode, and GitHub Copilot CLI. Operators have no workaround: if they change the variable to String in the operation, the operation fails GraphQL validation and the router drops the tool.

With this PR, every schema property carries a type. Custom scalars default to string. A new config option, the scalar mapping, sets a different JSON type for scalars whose values are not strings (for example objects or numbers).

Changes

  1. Custom scalar variables default to "type": "string" in tool input schemas. Nullable variables have "type": ["string", "null"]. Built-in scalars do not change.
  2. New router config mcp.scalar_mappings maps a scalar name to a JSON Schema type. Allowed values: string, integer, number, boolean, object, array. The environment-variable form is MCP_SCALAR_MAPPINGS=Foo:object,BigInt:integer. Mapping does not change nullability: a nullable variable of a scalar mapped to object has "type": ["object", "null"]. Mappings are per scalar name: a scalar has one wire format across the graph, so there is nothing to configure per operation.
  3. The router validates mapping values when it starts. An invalid value stops the router with an error that names the scalar and the value. Config-file validation cannot catch a bad value that arrives through the environment variable, so the router checks the values itself.
  4. The router logs one warning that lists every custom scalar that uses the string default. The warning states the consequence: input validation rejects non-string arguments for these scalars. It names the remedy: mcp.scalar_mappings.
  5. The schema generator moves into the router as router/internal/jsonschema. Before, the router imported it from the graphql-go-tools library as v2/pkg/engine/jsonschema. The router no longer uses graphql-go-tools for JSON Schema generation. router/go.mod and router-tests/go.mod stay on the released library version (v2.14.1). The library is unchanged.
  6. The generator builds schemas with github.com/google/jsonschema-go. That is the schema model the MCP Go SDK uses. This PR deletes the hand-written schema struct and serializer. The generator builds each schema once and never modifies it afterwards. Because no schema changes after it is built, two variables can point at the same mapping entry and one can never corrupt the other's schema.
  7. The MCP server hands the generated schema directly to the MCP SDK and to input validation. It no longer serializes the schema to JSON and parses it back, and it validates tool arguments with google/jsonschema-go instead of santhosh-tekuri/jsonschema. One consequence: the wording of validation error messages changes, because the validator changed. The Input validation error: prefix stays. The built-in get_operation_info tool, which shows an operation's details as text, still prints byte-for-byte the same schema JSON as before.
  8. New documentation: a Custom Scalar Mappings section on the MCP configuration page, and a cross-reference on the MCP tools page.

Why google/jsonschema-go

I picked github.com/google/jsonschema-go for four reasons:

  1. The MCP Go SDK uses this exact schema type. The router now hands the schema to the SDK with no conversion.
  2. It was already in the router's dependency tree, through the SDK. This PR adds no new dependency. v0.4.3 is the latest release.
  3. It supports everything the generator emits: type arrays like ["string", "null"], $defs and $ref for recursive types, anyOf, and enums that include null. I checked each of these against the package source and with test runs, including that it marshals a false-schema as literal additionalProperties: false.
  4. Google maintains it, and it has no dependencies outside the Go standard library.

I also considered keeping our own schema model. I rejected that: it needed a custom serializer and a deep-copy method to stay correct, and the MCP server converted its output to bytes and back for the SDK anyway.

Behavior change (intentional; add to release notes)

All custom scalar properties in tool input schemas change from {} to a concrete type. Input validation then rejects non-string values for unmapped custom scalars. The tool call still succeeds at the network level; the error text sits inside the tool response. A client recovers by fetching the tool list again and correcting its arguments. One scalar_mappings entry per scalar restores acceptance. The example maps two custom scalars: Foo carries objects, BigInt carries integers:

mcp:
  enabled: true
  scalar_mappings:
    Foo: object
    BigInt: integer

Two smaller behavior corrections ride along:

  • A nullable variable of a scalar mapped to object now generates the correct schema: "type": ["object", "null"]. Before, the schema said only "type": "object", so the router rejected a null argument that GraphQL allows. GraphQL input object variables are not affected: they stay non-nullable on purpose, so a client always sends an object (possibly empty), never null.
  • Nullable recursive input object variables now get the same schema as non-recursive ones: non-nullable at the top level. The old check missed the recursive case.

How to review 3,900 lines

Most of this diff is one package: router/internal/jsonschema. Most of that package is tests.

History: I first built this fix into graphql-go-tools - and decided to close it without merge (wundergraph/graphql-go-tools#1625). I thought that the router must own this code - for two reasons. It only exists for the MCP server, and the rules it implements are MCP-client rules, not engine concerns. And code that lives in the router ships immediately - a library change needs a graphql-go-tools release plus a dependency bump here. So I moved the package into the router, rewrote the generator to build google/jsonschema-go schemas, and closed the library PR.

Where to spend review time, in order:

  1. router/internal/jsonschema/variables_schema.go - the generator. This file is a rewrite. The test files predate the rewrite and assert exact JSON output, and their assertions are unchanged. Unchanged assertions are strong evidence that the rewrite produces the same output.
  2. The production changes outside the package - about 150 lines across pkg/config, pkg/schemaloader, pkg/mcpserver, and core/router.go.
  3. The docs pages.

The test files come from the closed library PR (commit 979913e3 on branch ahmet/eng-9903-mcp-custom-scalars-produce-untyped-json-schema-in-mcp-tool). They differ in three ways:

  • Two files migrate a test helper from santhosh-tekuri/jsonschema v5 to v6.
  • One file changes how test code constructs scalar mappings.
  • vendor_compat_test.go is new.

Out of scope (tracked in ENG-9929)

  • Today the generator emits one schema shape, and the MCP-client rules it follows (every property has a type; the root is always a non-nullable object) sit inline in the generator code. If we later support a second shape - for example OpenAI strict mode, which requires every property in required - the rules should move into one profile function per vendor, each rule with a test and a link to the vendor document that requires it. That restructuring changes no output today, so it waits until a second shape exists.

Test plan

cd router && go test ./internal/jsonschema/... ./pkg/config/... ./pkg/schemaloader/... ./pkg/mcpserver/... -count=1
cd router-tests && go test ./protocol/ -run 'TestMCP'
  • End-to-end: Custom scalar variables carry a JSON Schema type in tool input schemas and Scalar mapping overrides the string default for a mapped custom scalar. Both pass. The first fails without the generator change.
  • Unit tests cover: the mapping translator (valid, invalid, nil); startup abort on an invalid mapping; nullable and non-null object-mapped scalars at top level; nil mapping values falling back to the string default; the warning list of scalars that use the string default (sorted, no duplicates); and shared-mapping independence: two variables that use one mapping entry with different nullability get two correct schemas, and the test fails if the code modifies the shared entry.
  • Live vendor checks: router/internal/jsonschema/vendor_compat_test.go sends generated schemas to the real Anthropic and OpenAI APIs with max_tokens: 1. The tests start with a t.Skip call so no automated run touches the network; a comment explains how to run them locally. Every assertion matched the live API responses when we ran them on 2026-08-08; the test file's comments record this.
  • grep -rn "engine/jsonschema" --include="*.go" router/ router-tests/ returns no hits (the old library import path is gone).
  • grep -rn santhosh router/pkg/mcpserver/ returns no hits. Input validation runs on google/jsonschema-go. The santhosh-tekuri validator remains only inside the generator tests, as a second, independent check that generated schemas are valid.
  • grep -n graphql-go-tools router/go.mod router-tests/go.mod shows v2.14.1 in both, with no replace lines pointing at a local copy of the library.

Fixes ENG-9903.

@github-actions github-actions Bot added the router label Aug 7, 2026
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Changes

The PR adds JSON Schema generation for GraphQL variables, including custom scalar defaults and configurable mappings. MCP configuration, server wiring, validation, tests, fixtures, and documentation now support scalar-to-schema type mappings.

MCP scalar schema mappings

Layer / File(s) Summary
JSON Schema model and serialization
router/internal/jsonschema/schema.go, router/internal/jsonschema/schema_test.go
Adds JSON Schema types, constructors, nullable serialization, fluent setters, deep cloning, and comprehensive serialization tests.
GraphQL variable schema generation
router/internal/jsonschema/variables_schema.go, router/internal/jsonschema/variables_schema_test.go, router/pkg/schemaloader/..., router/internal/jsonschema/*_test.go
Builds schemas for GraphQL variables, input objects, enums, lists, defaults, nullability, recursive references, and custom scalar overrides. Unmapped custom scalars use nullable string schemas and are tracked.
MCP mapping configuration and validation
router/pkg/config/config.go, router/pkg/config/config.schema.json, router/pkg/config/fixtures/full.yaml, router/pkg/config/testdata/*, router/pkg/mcpserver/scalar_mappings.go, router/pkg/mcpserver/scalar_mappings_test.go
Adds scalar_mappings configuration and validates mappings against the supported JSON Schema types.
MCP server propagation and operation schemas
router/core/router.go, router/pkg/mcpserver/server.go, router/pkg/mcpserver/operation_manager.go, router-tests/protocol/mcp_test.go
Passes scalar mappings from router configuration through MCP server construction and reloads into operation schema generation. Protocol tests cover default and overridden scalar types.
Protocol documentation
docs-website/router/mcp/configuration.mdx, docs-website/router/mcp/tools.mdx
Documents scalar mappings, environment configuration, default string behavior, startup warnings, validation errors, and nullable object behavior.

Estimated code review effort: 5 (Critical) | ~90 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 58.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes typed custom scalars and scalar mapping overrides for MCP tool schemas.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

@mintlify

mintlify Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
wundergraphinc 🟢 Ready View Preview Aug 7, 2026, 10:45 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
router/pkg/mcpserver/scalar_mappings.go (1)

9-18: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Keep the scalar mapping allowlist consistent across validation paths.

The same six values are maintained independently in the runtime mapper and configuration schema. A future edit can make YAML validation and environment validation disagree.

  • router/pkg/mcpserver/scalar_mappings.go#L9-L18: add a parity test or centralize the allowed values.
  • router/pkg/config/config.schema.json#L2736-L2743: derive or update the enum from the same source.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@router/pkg/mcpserver/scalar_mappings.go` around lines 9 - 18, Keep the scalar
mapping allowlist synchronized across both validation paths: in
router/pkg/mcpserver/scalar_mappings.go lines 9-18, centralize or test the six
allowed values; in router/pkg/config/config.schema.json lines 2736-2743, derive
or update the enum from that same source. Ensure YAML and environment validation
accept exactly the same values.
router/pkg/config/config.go (1)

1352-1357: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make the environment map format explicit.

ScalarMappings reads one environment variable, but the environment encoding is not stated in the field comment. The caarlos0/env v11 documentation defines comma-separated entries and colon-separated key/value pairs. (pkg.go.dev) Add explicit separator tags and a test or documentation example such as JSON:object,BigInt:integer. Verify the tag behavior against the pinned env/v11 version.

Proposed configuration contract
 // number, boolean, object, array.
+// Environment format: scalar:type,scalar:type.
-ScalarMappings map[string]string     `yaml:"scalar_mappings,omitempty" env:"MCP_SCALAR_MAPPINGS"`
+ScalarMappings map[string]string     `yaml:"scalar_mappings,omitempty" env:"MCP_SCALAR_MAPPINGS" envSeparator:"," envKeyValSeparator:":"`
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@router/pkg/config/config.go` around lines 1352 - 1357, Make the
ScalarMappings environment encoding explicit by adding the caarlos0/env v11
separator tags for comma-separated entries and colon-separated key/value pairs
to ScalarMappings. Update its comment or add a focused test using a value such
as JSON:object,BigInt:integer, and verify parsing against the pinned env/v11
behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@router/pkg/schemaloader/schema_builder.go`:
- Around line 62-63: Update the schema construction around
NewVariablesSchemaBuilder to preserve nullability when scalarSchemas maps a
nullable scalar to object, emitting both object and null types so MCP validation
accepts null. Add a regression test in the schema builder tests covering a
nullable object-mapped scalar and its generated schema.

---

Nitpick comments:
In `@router/pkg/config/config.go`:
- Around line 1352-1357: Make the ScalarMappings environment encoding explicit
by adding the caarlos0/env v11 separator tags for comma-separated entries and
colon-separated key/value pairs to ScalarMappings. Update its comment or add a
focused test using a value such as JSON:object,BigInt:integer, and verify
parsing against the pinned env/v11 behavior.

In `@router/pkg/mcpserver/scalar_mappings.go`:
- Around line 9-18: Keep the scalar mapping allowlist synchronized across both
validation paths: in router/pkg/mcpserver/scalar_mappings.go lines 9-18,
centralize or test the six allowed values; in
router/pkg/config/config.schema.json lines 2736-2743, derive or update the enum
from that same source. Ensure YAML and environment validation accept exactly the
same values.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 67bd3f19-4d93-4d9b-bff1-2d0c1f93cf6e

📥 Commits

Reviewing files that changed from the base of the PR and between 5edbee2 and 8d9eb30.

⛔ Files ignored due to path filters (2)
  • router-tests/go.sum is excluded by !**/*.sum
  • router/go.sum is excluded by !**/*.sum
📒 Files selected for processing (16)
  • router-tests/go.mod
  • router-tests/protocol/mcp_test.go
  • router-tests/protocol/testdata/mcp_operations_custom_scalar/UploadFile.graphql
  • router/core/router.go
  • router/go.mod
  • router/pkg/config/config.go
  • router/pkg/config/config.schema.json
  • router/pkg/config/fixtures/full.yaml
  • router/pkg/config/testdata/config_defaults.json
  • router/pkg/config/testdata/config_full.json
  • router/pkg/mcpserver/operation_manager.go
  • router/pkg/mcpserver/scalar_mappings.go
  • router/pkg/mcpserver/scalar_mappings_test.go
  • router/pkg/mcpserver/server.go
  • router/pkg/schemaloader/schema_builder.go
  • router/pkg/schemaloader/schema_builder_test.go

Comment thread router/pkg/schemaloader/schema_builder.go Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs-website/router/mcp/configuration.mdx`:
- Line 42: Update the scalar_mappings documentation in
docs-website/router/mcp/configuration.mdx lines 42-42 and 71-71, and the tool
schema reference in docs-website/router/mcp/operations.mdx lines 105-106, to
state that unmapped custom scalars default to string for non-nullable fields and
["string", "null"] for nullable fields.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d500dee7-c9fb-4935-acd8-3d1f67e69904

📥 Commits

Reviewing files that changed from the base of the PR and between 8d9eb30 and dd6857d.

📒 Files selected for processing (4)
  • docs-website/router/mcp/configuration.mdx
  • docs-website/router/mcp/operations.mdx
  • router/pkg/mcpserver/operation_manager.go
  • router/pkg/mcpserver/scalar_mappings.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • router/pkg/mcpserver/scalar_mappings.go
  • router/pkg/mcpserver/operation_manager.go

Comment thread docs-website/router/mcp/configuration.mdx Outdated
asoorm added 3 commits August 8, 2026 01:03
…nal package

The MCP tool schema generator moves from graphql-go-tools engine/jsonschema into router/internal/jsonschema. The router no longer depends on graphql-go-tools for JSON schema generation. The library package is unchanged.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (7)
router/internal/jsonschema/recursive_input_test.go (1)

84-88: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add a negative assertion for the recursive $ref.

The doc comment at Lines 17-21 motivates the test with additionalProperties: false rejecting valid nested payloads. The test only asserts that a valid depth-2 payload passes. A schema that dropped the recursive fields but also dropped additionalProperties: false would still pass.

Assert that an unknown property inside a nested node is rejected. The test then proves that the $ref resolves at depth and that the strict object constraint is still in place.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@router/internal/jsonschema/recursive_input_test.go` around lines 84 - 88, Add
a negative validation case alongside the existing compiled.Validate assertion in
the recursive input test: construct a depth-2 payload containing an unknown
property inside a nested node, then assert validation returns an error. Keep the
valid nested payload assertion unchanged so the test verifies both recursive
fields and additionalProperties: false.
router/internal/jsonschema/variables_schema.go (2)

515-582: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Deduplicate the two value converters.

convertOperationValueToNative and convertDefinitionValueToNative are identical except for the document they read from. Two copies of the same ast.Value switch will drift when a new ast.ValueKind is handled.

Take the document as a parameter and keep one implementation.

♻️ Proposed refactor
-// 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)
-	...
-	}
-	return nil
+	return convertValueToNative(v.operationDocument, value)
 }
 
-// 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 {
-	...
-	}
-	return nil
+	return convertValueToNative(v.definitionDocument, value)
 }
+
+// convertValueToNative converts a GraphQL AST value from the given document to a native Go value.
+func convertValueToNative(doc *ast.Document, value ast.Value) any {
+	switch value.Kind {
+	case ast.ValueKindString:
+		return doc.StringValueContentString(value.Ref)
+	case ast.ValueKindInteger:
+		return doc.IntValueAsInt(value.Ref)
+	case ast.ValueKindFloat:
+		return doc.FloatValueAsFloat32(value.Ref)
+	case ast.ValueKindBoolean:
+		return doc.BooleanValue(value.Ref)
+	case ast.ValueKindNull:
+		return nil
+	case ast.ValueKindEnum:
+		return doc.EnumValueNameString(value.Ref)
+	case ast.ValueKindList:
+		list := make([]any, 0)
+		for _, itemRef := range doc.ListValues[value.Ref].Refs {
+			list = append(list, convertValueToNative(doc, doc.Value(itemRef)))
+		}
+		return list
+	case ast.ValueKindObject:
+		obj := make(map[string]any)
+		for _, fieldRef := range doc.ObjectValues[value.Ref].Refs {
+			obj[doc.ObjectFieldNameString(fieldRef)] = convertValueToNative(doc, doc.ObjectFieldValue(fieldRef))
+		}
+		return obj
+	}
+	return nil
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@router/internal/jsonschema/variables_schema.go` around lines 515 - 582,
Deduplicate convertOperationValueToNative and convertDefinitionValueToNative by
introducing one converter that accepts the relevant AST document as a parameter.
Route both operationDocument and definitionDocument callers through this shared
implementation, including recursive list and object conversions, and remove the
duplicate switch logic.

337-362: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Recompute the recursive-type set once per definition document.

EnterDocument calls computeRecursiveInputTypes on every build (Line 78), and schema_builder.go creates one VariablesSchemaBuilder per operation. The result depends only on definitionDocument, which is the same for every operation in a build. The graph scan and the per-node reachableFromSelf traversal therefore repeat once per operation, at roughly O(N*E) each.

This runs at startup and at reload, not per request, so it does not affect request latency. On a large federated schema with many input types and many operations it still adds avoidable startup time.

Consider computing the set once and passing it in through a VariablesSchemaOption, so the per-operation builders reuse it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@router/internal/jsonschema/variables_schema.go` around lines 337 - 362,
Compute the recursive input-type set once per definition document in the build
flow, then pass it to each VariablesSchemaBuilder through a
VariablesSchemaOption. Update EnterDocument and the schema_builder.go
construction path to reuse this shared set, and make computeRecursiveInputTypes
run only at the document-level rather than once per operation.
router/internal/jsonschema/variables_schema_test.go (1)

1805-1865: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add a case for a nullable object-mapped scalar.

Every object-mapped assertion here uses JSON!, which is non-null. The nullable case is the one that exposes the forced non-nullability in EnterVariableDefinition (variables_schema.go Lines 172-177): an optional variable of an object-mapped scalar emits "type": "object" and rejects null.

Add $meta: JSON to this subtest and assert the emitted type. The test then pins the current behavior and fails when the limitation is fixed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@router/internal/jsonschema/variables_schema_test.go` around lines 1805 -
1865, Extend the test case around the overridden JSON scalar to include an
optional `$meta: JSON` variable and its corresponding operation usage. Update
the expected schema to assert that the nullable object-mapped scalar currently
emits the object type without nullability, covering the behavior in
EnterVariableDefinition while preserving the existing required filter and cursor
assertions.
router/internal/jsonschema/schema.go (1)

271-307: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document or handle the Default field in Clone.

Clone deep-copies every reference-typed field except Default. Default is typed any and can hold a map[string]any or []any, because convertOperationValueToNative and convertDefinitionValueToNative produce those values for object and list defaults. The clone then aliases that value.

No current caller triggers this: the builder assigns a new value to Default instead of mutating it in place, and scalar overrides carry no default. The struct doc at Line 21 states the invariant for reference-typed fields, so record the intentional exception to keep the invariant reviewable.

♻️ Proposed comment
 	if s.Maximum != nil {
 		val := *s.Maximum
 		clone.Maximum = &val
 	}
+	// Default is shared, not deep-copied: callers replace it rather than mutate
+	// it in place, so a shared map/slice default cannot be observed as aliasing.
 	return &clone
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@router/internal/jsonschema/schema.go` around lines 271 - 307, Document the
intentional shallow-copy exception for the any-typed Default field in
JsonSchema.Clone, noting that it may contain reference values but current
callers replace rather than mutate them. Keep the existing Clone behavior
unchanged and place the explanation alongside the Default handling or relevant
struct documentation so the invariant remains reviewable.
router/internal/jsonschema/schema_test.go (1)

621-655: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Extend the Clone test to cover Defs.

The test asserts isolation for Properties, Required, AdditionalProperties, Items, Enum, and Minimum. It does not cover Defs or Maximum. Defs is the field that carries recursive input type definitions, so a regression there would affect generated $defs output.

♻️ Proposed test addition
 		additionalProps := false
 		minimum := 1.0
+		maximum := 10.0
 		original := &JsonSchema{
 			Type:                 TypeObject,
 			Properties:           map[string]*JsonSchema{"name": NewStringSchema()},
 			Required:             []string{"name"},
 			AdditionalProperties: &additionalProps,
 			Description:          "original",
 			Nullable:             true,
 			Items:                NewStringSchema(),
 			Enum:                 []string{"a", "b"},
 			Minimum:              &minimum,
+			Maximum:              &maximum,
+			Defs:                 map[string]*JsonSchema{"Node": NewStringSchema()},
 		}
 
 		clone := original.Clone()
@@
 		*clone.Minimum = 99
+		*clone.Maximum = 99
+		clone.Defs["Node"].Type = TypeBoolean
@@
 		assert.Equal(t, 1.0, *original.Minimum)
+		assert.Equal(t, 10.0, *original.Maximum)
+		assert.Equal(t, TypeString, original.Defs["Node"].Type)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@router/internal/jsonschema/schema_test.go` around lines 621 - 655, Extend the
“mutating the clone does not affect the original” test around JsonSchema.Clone
to initialize a Defs entry, mutate the corresponding cloned definition, and
assert the original definition remains unchanged. Focus on Defs isolation; do
not add coverage for Maximum unless separately requested.
router/go.mod (1)

83-84: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use github.com/santhosh-tekuri/jsonschema/v6 in both tests.

v6 supports the required $ref and null validation. Replace CompileString with UnmarshalJSON, NewCompiler, AddResource, and Compile, then remove the v5 requirement.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@router/go.mod` around lines 83 - 84, Update both test implementations to use
github.com/santhosh-tekuri/jsonschema/v6: replace CompileString with the
NewCompiler, AddResource, UnmarshalJSON, and Compile workflow, and remove the v5
dependency from go.mod while preserving the existing schema validation behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@router/internal/jsonschema/variables_schema.go`:
- Around line 170-177: Update the top-level nullability handling around
processInputObjectType so Nullable is forced false only for schemas originating
from GraphQL input objects, not every schema with TypeObject. Carry an
input-object provenance flag through the builder and use it at the Nullable
assignment, preserving GraphQL nullability for custom scalars mapped to object
while retaining empty-object defaults for actual input objects.
- Around line 306-315: Update the scalar override lookup in the
ast.NodeKindScalarTypeDefinition branch to treat a nil value from
v.scalarSchemas[typeName] as unmapped. Only clone and apply the override
description when the schema is non-nil; otherwise continue to the existing
string-default path.

---

Nitpick comments:
In `@router/go.mod`:
- Around line 83-84: Update both test implementations to use
github.com/santhosh-tekuri/jsonschema/v6: replace CompileString with the
NewCompiler, AddResource, UnmarshalJSON, and Compile workflow, and remove the v5
dependency from go.mod while preserving the existing schema validation behavior.

In `@router/internal/jsonschema/recursive_input_test.go`:
- Around line 84-88: Add a negative validation case alongside the existing
compiled.Validate assertion in the recursive input test: construct a depth-2
payload containing an unknown property inside a nested node, then assert
validation returns an error. Keep the valid nested payload assertion unchanged
so the test verifies both recursive fields and additionalProperties: false.

In `@router/internal/jsonschema/schema_test.go`:
- Around line 621-655: Extend the “mutating the clone does not affect the
original” test around JsonSchema.Clone to initialize a Defs entry, mutate the
corresponding cloned definition, and assert the original definition remains
unchanged. Focus on Defs isolation; do not add coverage for Maximum unless
separately requested.

In `@router/internal/jsonschema/schema.go`:
- Around line 271-307: Document the intentional shallow-copy exception for the
any-typed Default field in JsonSchema.Clone, noting that it may contain
reference values but current callers replace rather than mutate them. Keep the
existing Clone behavior unchanged and place the explanation alongside the
Default handling or relevant struct documentation so the invariant remains
reviewable.

In `@router/internal/jsonschema/variables_schema_test.go`:
- Around line 1805-1865: Extend the test case around the overridden JSON scalar
to include an optional `$meta: JSON` variable and its corresponding operation
usage. Update the expected schema to assert that the nullable object-mapped
scalar currently emits the object type without nullability, covering the
behavior in EnterVariableDefinition while preserving the existing required
filter and cursor assertions.

In `@router/internal/jsonschema/variables_schema.go`:
- Around line 515-582: Deduplicate convertOperationValueToNative and
convertDefinitionValueToNative by introducing one converter that accepts the
relevant AST document as a parameter. Route both operationDocument and
definitionDocument callers through this shared implementation, including
recursive list and object conversions, and remove the duplicate switch logic.
- Around line 337-362: Compute the recursive input-type set once per definition
document in the build flow, then pass it to each VariablesSchemaBuilder through
a VariablesSchemaOption. Update EnterDocument and the schema_builder.go
construction path to reuse this shared set, and make computeRecursiveInputTypes
run only at the document-level rather than once per operation.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2fb7e157-a529-48ef-ab03-faadb3c7fc3a

📥 Commits

Reviewing files that changed from the base of the PR and between dd6857d and f83c463.

📒 Files selected for processing (13)
  • router/go.mod
  • router/internal/jsonschema/nullable_2020_12_test.go
  • router/internal/jsonschema/recursive_input_test.go
  • router/internal/jsonschema/schema.go
  • router/internal/jsonschema/schema_test.go
  • router/internal/jsonschema/variables_schema.go
  • router/internal/jsonschema/variables_schema_test.go
  • router/pkg/mcpserver/operation_manager.go
  • router/pkg/mcpserver/scalar_mappings.go
  • router/pkg/mcpserver/scalar_mappings_test.go
  • router/pkg/mcpserver/server.go
  • router/pkg/schemaloader/schema_builder.go
  • router/pkg/schemaloader/schema_builder_test.go
🚧 Files skipped from review as they are similar to previous changes (6)
  • router/pkg/mcpserver/scalar_mappings.go
  • router/pkg/schemaloader/schema_builder.go
  • router/pkg/mcpserver/scalar_mappings_test.go
  • router/pkg/mcpserver/server.go
  • router/pkg/mcpserver/operation_manager.go
  • router/pkg/schemaloader/schema_builder_test.go

Comment thread router/internal/jsonschema/variables_schema.go Outdated
Comment thread router/internal/jsonschema/variables_schema.go Outdated
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

Router-nonroot image scan passed

✅ No security vulnerabilities found in image:

ghcr.io/wundergraph/cosmo/router:sha-2019c25ad6faeacfbc94c0d635d588c470c84dee-nonroot

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

Router image scan passed

✅ No security vulnerabilities found in image:

ghcr.io/wundergraph/cosmo/router:sha-36d52e3ce1b7530b048e8e20dbfdd8c25f6da34f

@codecov

codecov Bot commented Aug 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 79.05882% with 89 lines in your changes missing coverage. Please review.
✅ Project coverage is 62.59%. Comparing base (64eaf60) to head (7957dfe).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
router/internal/jsonschema/variables_schema.go 78.82% 60 Missing and 12 partials ⚠️
router/pkg/mcpserver/input_validation.go 52.94% 4 Missing and 4 partials ⚠️
router/pkg/mcpserver/server.go 73.68% 4 Missing and 1 partial ⚠️
router/pkg/schemaloader/schema_builder.go 87.87% 2 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3147      +/-   ##
==========================================
+ Coverage   62.37%   62.59%   +0.22%     
==========================================
  Files         262      264       +2     
  Lines       31003    31355     +352     
==========================================
+ Hits        19337    19628     +291     
- Misses      10158    10209      +51     
- Partials     1508     1518      +10     
Files with missing lines Coverage Δ
router/core/router.go 71.00% <100.00%> (+0.01%) ⬆️
router/pkg/config/config.go 83.00% <ø> (ø)
router/pkg/mcpserver/operation_manager.go 71.15% <100.00%> (+2.40%) ⬆️
router/pkg/mcpserver/scalar_mappings.go 100.00% <100.00%> (ø)
router/pkg/schemaloader/loader.go 70.14% <ø> (ø)
router/pkg/schemaloader/schema_builder.go 79.59% <87.87%> (+8.16%) ⬆️
router/pkg/mcpserver/server.go 71.95% <73.68%> (+1.40%) ⬆️
router/pkg/mcpserver/input_validation.go 52.94% <52.94%> (ø)
router/internal/jsonschema/variables_schema.go 78.82% <78.82%> (ø)

... and 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs-website/router/mcp/tools.mdx (1)

161-162: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Exclude defaulted non-null variables from required. The generator adds every non-null variable to required before processing defaults. List only non-null variables without defaults as required.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs-website/router/mcp/tools.mdx` around lines 161 - 162, Update the
generator logic described around the non-nullable variable and default-value
handling so a variable is added to required only when it is non-nullable and has
no default. Ensure defaulted non-null variables are represented solely through
their default value and excluded from required.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs-website/router/mcp/tools.mdx`:
- Line 157: Update the custom scalar variables documentation to distinguish
nullable and non-nullable unmapped schemas: document `"type": "string"` for
non-nullable variables and `"type": ["string", "null"]` for nullable variables.
Keep the existing SearchInput example unchanged.

---

Outside diff comments:
In `@docs-website/router/mcp/tools.mdx`:
- Around line 161-162: Update the generator logic described around the
non-nullable variable and default-value handling so a variable is added to
required only when it is non-nullable and has no default. Ensure defaulted
non-null variables are represented solely through their default value and
excluded from required.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1723ce5a-945b-401f-bfd1-73f6a774bf33

📥 Commits

Reviewing files that changed from the base of the PR and between cfc999c and 90268f0.

⛔ Files ignored due to path filters (1)
  • router/go.sum is excluded by !**/*.sum
📒 Files selected for processing (10)
  • docs-website/router/mcp/configuration.mdx
  • docs-website/router/mcp/tools.mdx
  • router-tests/protocol/mcp_test.go
  • router/core/router.go
  • router/pkg/config/config.go
  • router/pkg/config/config.schema.json
  • router/pkg/config/fixtures/full.yaml
  • router/pkg/config/testdata/config_defaults.json
  • router/pkg/config/testdata/config_full.json
  • router/pkg/mcpserver/server.go
🚧 Files skipped from review as they are similar to previous changes (9)
  • router/pkg/config/config.schema.json
  • router/pkg/config/testdata/config_defaults.json
  • router/core/router.go
  • router/pkg/config/testdata/config_full.json
  • router/pkg/config/config.go
  • docs-website/router/mcp/configuration.mdx
  • router-tests/protocol/mcp_test.go
  • router/pkg/mcpserver/server.go
  • router/pkg/config/fixtures/full.yaml

Comment thread docs-website/router/mcp/tools.mdx Outdated
asoorm added 4 commits August 8, 2026 11:05
…d openai

Skipped unless SCHEMA_VENDOR_LIVE_TEST=1 and the vendor API key are set.
Each probe uses max_tokens=1. The vendors validate tool schemas before
inference, so a probe costs a fraction of a cent.
…e api

All three Anthropic subtests verified live 2026-08-08: the generated
schema is accepted, strict mode accepts type-array nullability, and a
schema without a root type is rejected with 400.
asoorm added 2 commits August 8, 2026 12:18
Deletes the hand-written JsonSchema model (struct, custom MarshalJSON, deep Clone). Nullability is decided at node construction, so schema nodes are immutable after creation and sharing needs no copies. Output is unchanged: the existing golden tests pass without assertion edits.
@asoorm
asoorm force-pushed the ahmet/eng-9903-mcp-custom-scalars-produce-untyped-json-schema-in-mcp-tool branch from 807957b to 88de1e4 Compare August 8, 2026 11:31
asoorm added 2 commits August 8, 2026 13:26
… overrides

Top-level non-null forcing for object-typed variables was applied to any
schema whose emitted JSON type was "object", including a custom scalar
mapped to object via WithScalarSchemas. That collapsed a nullable mapped
scalar (e.g. $filter: JSON) to a bare non-nullable object type, rejecting
an explicit null that GraphQL allows.

Gate the forcing on provenance instead of shape: a topLevel flag threaded
through typeRefSchema/namedTypeSchema (topLevelTypeRefSchema is the sole
entry point with topLevel=true, only for a variable's own direct,
non-list type) forces non-null only when the resolved node is a GraphQL
NodeKindInputObjectTypeDefinition. Nullability is still decided before
construction and no stored node is mutated.

Also treat a nil WithScalarSchemas map value as unmapped instead of
dereferencing it, falling back to the string default and counting it in
DefaultedScalars.

Adds three tests: a nullable object-mapped scalar keeps its null union,
a non-null one emits a bare object type, and a nil override value falls
back to string.
Document both nullability forms for MCP tool input schemas: a
non-nullable variable emits the type alone, a nullable one adds
"null" (e.g. ["string", "null"], ["object", "null"] for
mappings).

Rewrite the top-level-object note in configuration.mdx to match the
provenance-gated forcing: only GraphQL input object variables are
forced non-nullable at the top level; a scalar mapped to object keeps
its own declared nullability.
asoorm added 3 commits August 8, 2026 13:42
… schema values to the sdk

The generated schema value now flows from the schema builder to the MCP SDK
and to input validation, with no re-parsing of the canonical bytes.
santhosh-tekuri/jsonschema leaves the mcp server path; google/jsonschema-go
resolves each schema once at registration and validates tool arguments.
The validator swap changes the suffix wording of input validation errors
behind the unchanged "Input validation error: " prefix.
The upstream google validator renders a JSON null instance as the Go
artifact "<invalid reflect.Value>"; validateInput now substitutes the
exact substring with "null" before wrapping, a no-op when absent.
The tools/list schema assignment gains a guard comment: key order is
not guaranteed and must not be restored via the removed bytes round trip.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant