Skip to content

feat: default custom scalar variables to string type in JSON schema with per-scalar overrides - #1625

Closed
asoorm wants to merge 9 commits into
masterfrom
ahmet/eng-9903-mcp-custom-scalars-produce-untyped-json-schema-in-mcp-tool
Closed

feat: default custom scalar variables to string type in JSON schema with per-scalar overrides#1625
asoorm wants to merge 9 commits into
masterfrom
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

Motivation

BuildJsonSchema (v2/pkg/engine/jsonschema) maps custom scalar variables ($after: Cursor) to an untyped {} schema. Downstream, that shape lands in MCP tool inputSchema, where properties without a "type" are rejected or degraded by strict LLM tool consumers (Anthropic marketplace submission validation, Claude Code >=2.0.21 client-side schema validation, OpenAI strict mode, GitHub Copilot CLI). A GraphQL operation using any custom scalar as a variable currently produces a tool the model can't reason about and some clients refuse outright.

Existing GraphQL-to-MCP implementations don't solve this out of the box: unmapped custom scalars are left as {} or hardcoded to {"type":"object"} - the latter actively wrong for string-serialized scalars.

Changes

  • Custom scalar variables and input-object fields now default to "type": "string" (["string","null"] when nullable) - the wire format of virtually all opaque scalars (cursors, IDs, dates, URLs). The unknown-node fallback still returns the untyped any-schema; built-in scalars are unaffected.
  • New WithScalarSchemas(map[string]*JsonSchema) variadic option on BuildJsonSchema / NewVariablesSchemaBuilder for per-scalar overrides (e.g. JSON -> {"type":"object"}, BigInt -> {"type":"integer"}). Built-in scalars cannot be overridden. Override schemas are deep-cloned per use: the builder mutates Nullable on returned schemas, so a shared map value would leak nullability between variables - guarded by a regression test that fails if the clone is dropped.
  • New (*VariablesSchemaBuilder).DefaultedScalars() []string (sorted, unique) reporting which custom scalars fell back to the default, so integrators can log a startup warning naming missing mappings.
  • New deep (*JsonSchema).Clone(), nil-safe.

Behavioral change (intentional)

Output for custom scalars changes from {} to "type": "string". Consumers that validate inputs against the generated schema will start rejecting non-string values for unmapped custom scalars; WithScalarSchemas is the escape hatch. Signature additions are variadic - call sites compile unchanged, but code holding BuildJsonSchema as a typed function value needs the new signature. Minor version bump.

Usage

schema, err := jsonschema.BuildJsonSchema(operationDoc, definitionDoc,
    jsonschema.WithScalarSchemas(map[string]*jsonschema.JsonSchema{
        "JSON":   {Type: jsonschema.TypeObject},
        "BigInt": {Type: jsonschema.TypeInteger},
    }))

Limitations

  • A pre-existing rule forces top-level object-typed variable schemas to non-nullable (variables_schema.go, EnterVariableDefinition). An object-mapped override on a nullable variable therefore loses its null union. Deliberately untouched here; will be documented at the integration layer.
  • Override description precedence (override wins, SDL description as fallback) is implemented; the SDL-fallback half is untested - follow-up below.

Deliberately not in scope

  • Router integration (config surface, startup warning wiring, docs) - lands as a coordinated follow-up PR in the consuming repo together with the dependency bump, so the behavior change and its config escape hatch ship in the same release.
  • Three cheap test additions (list-of-custom-scalar [Cursor], SDL-description fallback, built-in-name-in-overrides ignored) - ~30 lines, follow-up.

Test plan

cd v2 && go build ./pkg/... && go vet ./pkg/engine/jsonschema/... && go test ./pkg/engine/jsonschema/... -count=1
  • go test ./pkg/engine/jsonschema/ -run 'TestBuildJsonSchema' -v - includes: custom scalar defaults (nullable + non-null, variable + nested input field), override mapping, same-overridden-scalar-at-two-nullabilities (fails if Clone() is removed - verified by mutation), DefaultedScalars dedup + sort.
  • grep -rn "NewAnySchema()" v2/pkg/engine/jsonschema/variables_schema.go -> exactly one hit, the unknown-node fallback.
  • grep -rn "engine/jsonschema" --include="*.go" v2/ | grep -v pkg/engine/jsonschema -> no in-repo consumers; the API addition breaks nothing internally.

Fixes ENG-9903.

asoorm added 7 commits August 7, 2026 10:48
Custom scalars previously mapped to an untyped {} schema, which MCP/LLM
tool consumers reject (properties must declare a type). Default to
"string" — the wire format of virtually all opaque scalars.
The test 'custom scalar types are represented as objects' was renamed to
'custom scalar variables with descriptions default to string type' to
accurately reflect the test assertion that custom scalars emit
"type": ["string", "null"] rather than an empty object.
Callers can override the JSON schema per custom scalar name; unmapped
custom scalars default to string and are reported via DefaultedScalars
so integrators (e.g. Cosmo router) can log a startup warning.
Add a subtest using the same overridden scalar at two different
non-null contexts in one operation. Without Clone() the second
variable's Nullable mutation leaks into the first via the shared
override pointer; sanity-checked by temporarily removing the Clone()
call locally and confirming the new subtest fails.
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b91bef16-cf36-40ef-ac11-e7e45eab5c17

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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

@asoorm

asoorm commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Closing. The schema generator now lives in the Cosmo router as an internal package (wundergraph/cosmo#3147). The library package stays unchanged.

@asoorm asoorm closed this Aug 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant