About the apps/graphql server in this monorepo, deployed to graphql.mcp.cloudflare.com.
Summary
The graphql app validates every Cloudflare GraphQL API response with a Zod schema that requires each error object's path to be a non-nullable array, then runs it through .parse() (which throws). Per the GraphQL spec, path is optional — it's only present when an error maps to a specific response field. Request-level errors (authorization, query validation, "mutations not supported", rate limiting) carry no path.
The result: whenever the API returns one of those errors, the tool throws a ZodError (invalid_union) instead of surfacing the real message. The user sees an opaque schema-validation dump and has no idea the actual problem is, e.g., "not authorized for that account."
These tools exist to relay the GraphQL API's response. Validating it with a throwing .parse() turns every actionable API error into an internal crash instead.
Impact
- Severity: High for affected queries — the tool is unusable and gives no clue why.
- Any
graphql_query whose response contains a path-less error fails this way. Triggers include:
- Authorization errors (token can authenticate but lacks permission for the account/dataset) →
{ "message": "not authorized for that account", "path": null, ... }
- Query validation errors (typo'd field, wrong argument)
- "Mutations are not supported" (the code even tries to handle this case — see below — but never reaches it)
- Rate-limit / request-level errors
- The failure is silent about its true cause, so users burn time re-authenticating, rewriting queries, or assuming the API is down.
Affected code
All line numbers pinned to main @ cb0186135e2f2c00d91b9ad2fcab54d630eeb911.
The schema — apps/graphql/src/tools/graphql.tools.ts#L69-L83:
// Define the structure of a single error
const graphQLErrorSchema = z.object({
message: z.string(),
path: z.array(z.union([z.string(), z.number()])), // ← required, non-nullable
extensions: z.object({ // ← strict shape, all required
code: z.string(),
timestamp: z.string(),
ray_id: z.string(),
}),
})
// Define the overall GraphQL response schema
const graphQLResponseSchema = z.object({
data: z.union([z.record(z.string(), z.unknown()), z.null()]),
errors: z.union([z.array(graphQLErrorSchema), z.null()]),
})
The throwing parse calls — both code paths use .parse(), which throws on any mismatch:
#L201 in executeGraphQLRequest (used by graphql_schema_overview / graphql_type_details)
#L244 in executeGraphQLQuery (used by graphql_query)
Unreachable error handling — #L203-L212:
const data = graphQLResponseSchema.parse(await response.json()) // L201 — THROWS here first
// Check for GraphQL errors in the response
if (data && data.errors && Array.isArray(data.errors) && data.errors.length > 0) {
const errorMessages = data.errors.map((e) => e.message).join(', ')
console.warn(`GraphQL errors: ${errorMessages}`)
// If the error is about mutations not being supported, we can handle it gracefully
if (errorMessages.includes('Mutations are not supported')) {
console.info('Mutations are not supported by the Cloudflare GraphQL API')
}
}
The block that inspects data.errors — including the explicit "Mutations are not supported" handling — is dead code. A mutation (or any path-less error) makes .parse() throw at L201, so execution never reaches it.
zod version: 4.4.3 (apps/graphql/package.json).
Steps to reproduce
Any query whose response contains a path-less error reproduces it. Two reliable triggers:
A. Authorization error (what we hit): call graphql_query for an account the credential can authenticate to but isn't authorized to read analytics for:
query($accountTag: String!) {
viewer {
accounts(filter: { accountTag: $accountTag }) {
workersInvocationsAdaptive(limit: 1, orderBy: [datetimeHour_DESC]) {
dimensions { datetimeHour }
max { memoryUsageBytes }
}
}
}
}
B. Mutation / invalid field (no auth dependency): send a mutation, or a query referencing a non-existent field. The API returns a request-level error with no path.
Actual behavior
The tool returns a raw ZodError (invalid_union at the errors field):
[
{
"code": "invalid_union",
"errors": [
[ { "expected": "array", "code": "invalid_type", "path": [0, "path"], "message": "Invalid input: expected array, received null" } ],
[ { "expected": "null", "code": "invalid_type", "path": [], "message": "Invalid input: expected null, received array" } ]
],
"path": ["errors"],
"message": "Invalid input"
}
]
The actual API response (read with a non-validating client) was a perfectly ordinary, actionable GraphQL error:
{
"data": null,
"errors": [
{
"message": "not authorized for that account",
"path": null,
"extensions": { "code": "authz", "timestamp": "2026-06-19T...Z", "ray_id": "..." }
}
]
}
Note: extensions here actually satisfies the strict schema — it's only path: null that trips the union. (Other error classes would also fail the extensions shape; see below.)
Expected behavior
The tool should surface the API's error message — e.g. not authorized for that account — so the user can act on it. Worst case, an unrecognized response shape should degrade gracefully to "here is what the API returned," never an internal parse crash.
Root cause
graphQLErrorSchema.path is z.array(...) — required and non-nullable — but the GraphQL spec makes path optional: "If an error can be associated to a particular field in the GraphQL result, it must contain an entry with the key path." Errors not tied to a field omit it. message is the only required key; locations, path, and extensions are all optional.
graphQLResponseSchema is validated with .parse(), which throws rather than returning a result. For a relay tool, that's the wrong posture — a response the schema didn't anticipate becomes an unhandled crash instead of passthrough.
- Secondary strictness in the same schema is fragile for the same reason:
extensions requires exactly { code, timestamp, ray_id }. Errors with different/missing extensions (validation errors, non-standard producers) will throw invalid_type.
- There is no
locations field, which GraphQL errors commonly include (harmless today since unknown keys are stripped, but it signals the schema wasn't modeled against the spec).
data and errors are non-optional; a response that omits errors on success would throw.
History — when this was introduced
- Origin: the schema has required
path since the GraphQL MCP server's first commit — ecbeee88, PR #158 (2025-05-14). This is a latent day-one bug, not a regression — it only fires when a response carries a path-less error.
- Why the message reads as
invalid_union today: PR #384 (2026-06-01) upgraded zod 3.24.2 → 4.4.3. The crash predates it, but zod 4's error formatting produces the specific invalid_union / "Invalid input: expected array, received null" shape shown above.
Related issues (same class)
This is the recurring "Zod schema is stricter than real Cloudflare API responses" pattern:
- #352 —
auditlogs_by_account_id: zod validation fails on a new actor.context value.
- #270 —
auditlogs_by_account_id returns "Expected object, received array".
Worth considering a shared, spec-accurate GraphQL-error schema (and a general "validate leniently, fall back to passthrough" convention) across apps.
Proposed solution
1. Make the error schema match the GraphQL spec (primary fix)
const graphQLErrorSchema = z.object({
message: z.string(),
path: z.array(z.union([z.string(), z.number()])).nullish(), // optional + nullable
locations: z
.array(z.object({ line: z.number(), column: z.number() }))
.nullish(),
extensions: z.record(z.string(), z.unknown()).nullish(), // tolerate any/absent extensions
})
const graphQLResponseSchema = z.object({
data: z.union([z.record(z.string(), z.unknown()), z.null()]).nullish(),
errors: z.array(graphQLErrorSchema).nullish(), // optional + nullable
})
This alone fixes the reported crash: a path-less error now validates, and the existing data.errors handling (L203+) finally runs.
2. Don't throw on unexpected shapes — safeParse + passthrough (defense-in-depth)
A relay tool should never crash on a response it didn't model. At both call sites:
const json = await response.json()
const parsed = graphQLResponseSchema.safeParse(json)
const result = parsed.success ? parsed.data : (json as GraphQLResponse)
if (!parsed.success) {
console.warn('GraphQL response did not match schema; passing through raw', parsed.error)
}
3. Surface GraphQL errors to the user, not just console.warn
Today errors are only logged. The graphql_query tool should return the API's errors[].message in the tool result so the user sees actionable text (not authorized for that account, Cannot query field "x", Mutations are not supported) instead of a silent success-shaped payload or a crash.
4. Add regression tests
- A response with
errors: [{ message, path: null, extensions: { code: "authz", ... } }] → tool returns the message, does not throw.
- A mutation-style
{ message: "Mutations are not supported" } (no path, no extensions) → graceful handling runs.
- A validation error with
locations but no path.
- A success response (
data present, errors absent) → still parses.
How we found it (and the companion issue)
We hit this querying workersInvocationsAdaptive through https://graphql.mcp.cloudflare.com/mcp. Every attempt returned invalid_union; reading the raw response through a non-validating client showed the real message was a plain authorization error (not authorized for that account). It took four re-auth attempts to find that out, precisely because this bug hid it.
This issue stands on its own: the user should see the API's actual error immediately, not a ZodError dump — regardless of how the scope question is resolved.
About the
apps/graphqlserver in this monorepo, deployed tographql.mcp.cloudflare.com.Summary
The
graphqlapp validates every Cloudflare GraphQL API response with a Zod schema that requires each error object'spathto be a non-nullable array, then runs it through.parse()(which throws). Per the GraphQL spec,pathis optional — it's only present when an error maps to a specific response field. Request-level errors (authorization, query validation, "mutations not supported", rate limiting) carry nopath.The result: whenever the API returns one of those errors, the tool throws a
ZodError(invalid_union) instead of surfacing the real message. The user sees an opaque schema-validation dump and has no idea the actual problem is, e.g., "not authorized for that account."These tools exist to relay the GraphQL API's response. Validating it with a throwing
.parse()turns every actionable API error into an internal crash instead.Impact
graphql_querywhose response contains a path-less error fails this way. Triggers include:{ "message": "not authorized for that account", "path": null, ... }Affected code
All line numbers pinned to
main@cb0186135e2f2c00d91b9ad2fcab54d630eeb911.The schema —
apps/graphql/src/tools/graphql.tools.ts#L69-L83:The throwing parse calls — both code paths use
.parse(), which throws on any mismatch:#L201inexecuteGraphQLRequest(used bygraphql_schema_overview/graphql_type_details)#L244inexecuteGraphQLQuery(used bygraphql_query)Unreachable error handling —
#L203-L212:The block that inspects
data.errors— including the explicit "Mutations are not supported" handling — is dead code. A mutation (or any path-less error) makes.parse()throw at L201, so execution never reaches it.zod version:
4.4.3(apps/graphql/package.json).Steps to reproduce
Any query whose response contains a path-less error reproduces it. Two reliable triggers:
A. Authorization error (what we hit): call
graphql_queryfor an account the credential can authenticate to but isn't authorized to read analytics for:B. Mutation / invalid field (no auth dependency): send a mutation, or a query referencing a non-existent field. The API returns a request-level error with no
path.Actual behavior
The tool returns a raw
ZodError(invalid_unionat theerrorsfield):[ { "code": "invalid_union", "errors": [ [ { "expected": "array", "code": "invalid_type", "path": [0, "path"], "message": "Invalid input: expected array, received null" } ], [ { "expected": "null", "code": "invalid_type", "path": [], "message": "Invalid input: expected null, received array" } ] ], "path": ["errors"], "message": "Invalid input" } ]The actual API response (read with a non-validating client) was a perfectly ordinary, actionable GraphQL error:
{ "data": null, "errors": [ { "message": "not authorized for that account", "path": null, "extensions": { "code": "authz", "timestamp": "2026-06-19T...Z", "ray_id": "..." } } ] }Note:
extensionshere actually satisfies the strict schema — it's onlypath: nullthat trips the union. (Other error classes would also fail theextensionsshape; see below.)Expected behavior
The tool should surface the API's error message — e.g.
not authorized for that account— so the user can act on it. Worst case, an unrecognized response shape should degrade gracefully to "here is what the API returned," never an internal parse crash.Root cause
graphQLErrorSchema.pathisz.array(...)— required and non-nullable — but the GraphQL spec makespathoptional: "If an error can be associated to a particular field in the GraphQL result, it must contain an entry with the keypath." Errors not tied to a field omit it.messageis the only required key;locations,path, andextensionsare all optional.graphQLResponseSchemais validated with.parse(), which throws rather than returning a result. For a relay tool, that's the wrong posture — a response the schema didn't anticipate becomes an unhandled crash instead of passthrough.extensionsrequires exactly{ code, timestamp, ray_id }. Errors with different/missing extensions (validation errors, non-standard producers) will throwinvalid_type.locationsfield, which GraphQL errors commonly include (harmless today since unknown keys are stripped, but it signals the schema wasn't modeled against the spec).dataanderrorsare non-optional; a response that omitserrorson success would throw.History — when this was introduced
pathsince the GraphQL MCP server's first commit —ecbeee88, PR #158 (2025-05-14). This is a latent day-one bug, not a regression — it only fires when a response carries a path-less error.invalid_uniontoday: PR #384 (2026-06-01) upgraded zod3.24.2 → 4.4.3. The crash predates it, but zod 4's error formatting produces the specificinvalid_union/ "Invalid input: expected array, received null" shape shown above.Related issues (same class)
This is the recurring "Zod schema is stricter than real Cloudflare API responses" pattern:
auditlogs_by_account_id: zod validation fails on a newactor.contextvalue.auditlogs_by_account_idreturns "Expected object, received array".Worth considering a shared, spec-accurate GraphQL-error schema (and a general "validate leniently, fall back to passthrough" convention) across apps.
Proposed solution
1. Make the error schema match the GraphQL spec (primary fix)
This alone fixes the reported crash: a path-less error now validates, and the existing
data.errorshandling (L203+) finally runs.2. Don't throw on unexpected shapes —
safeParse+ passthrough (defense-in-depth)A relay tool should never crash on a response it didn't model. At both call sites:
3. Surface GraphQL errors to the user, not just
console.warnToday errors are only logged. The
graphql_querytool should return the API'serrors[].messagein the tool result so the user sees actionable text (not authorized for that account,Cannot query field "x",Mutations are not supported) instead of a silent success-shaped payload or a crash.4. Add regression tests
errors: [{ message, path: null, extensions: { code: "authz", ... } }]→ tool returns the message, does not throw.{ message: "Mutations are not supported" }(nopath, noextensions) → graceful handling runs.locationsbut nopath.datapresent,errorsabsent) → still parses.How we found it (and the companion issue)
We hit this querying
workersInvocationsAdaptivethroughhttps://graphql.mcp.cloudflare.com/mcp. Every attempt returnedinvalid_union; reading the raw response through a non-validating client showed the real message was a plain authorization error (not authorized for that account). It took four re-auth attempts to find that out, precisely because this bug hid it.This issue stands on its own: the user should see the API's actual error immediately, not a
ZodErrordump — regardless of how the scope question is resolved.