Skip to content

Optional params emit not:{} in tool schemas, breaking strict LLM validators #391

Description

@fuleinist

Bug: Optional params emit {"not":{}} in tool schemas, breaking strict LLM validators

Severity: High — renders the MCP server unusable with affected models

Summary

The workers-bindings MCP server returns tool input schemas in which every optional parameter is encoded as {"not":{}} inside an anyOf:

"primary_location_hint": {
  "anyOf": [
    { "not": {} },
    { "type": "string", "enum": ["wnam","enam","weur","eeur","apac","oc"] }
  ]
}

Several LLM providers reject the not keyword outright and fail the entire request:

  • Kimi / Moonshot: JSON Schema not supported: could not understand the instance {'not': {}}
  • Google Gemini: rejects anyOf/not tool schemas
  • OpenAI strict mode / Fireworks: same class of rejection

Because the failure aborts the whole request, enabling this MCP server makes the agent unusable with these models.

Affected tools: d1_database_create, d1_database_query, hyperdrive_configs_list, hyperdrive_config_edit, and 14 others. A tools/list against the live server shows 18 distinct {"not":{}} instances.

Root Cause

Tool params use Zod .optional() / .optional().nullable(). The MCP SDK (@modelcontextprotocol/sdk@1.20.2) converts schemas with zod-to-json-schema, which renders optionals as:

return { anyOf: [ { not: parseAnyDef(refs) }, innerSchema ] }
// parseAnyDef(refs) === {}  ->  { not: {} }

strictUnions: true does not remove this — it only filters catch-all union members, not the not: {} arm.

Reference: zod-to-json-schema parseOptionalDef (https://github.com/StefanTerdell/zod-to-json-schema/blob/master/src/parsers/optional.ts)

Proposed Fix

Post-process each tool's generated JSON Schema before registration — recursively, for any anyOf containing a {"not":{}} member, drop that member; if only one branch remains, inline it. The field is already absent from required, so semantics are preserved.

function stripNotEmpty(node: unknown): unknown {
  if (Array.isArray(node)) return node.map(stripNotEmpty)
  if (node && typeof node === 'object') {
    const obj = node as Record<string, unknown>
    if (Array.isArray(obj.anyOf)) {
      const kept = obj.anyOf.filter(
        (m) => !(m && typeof m === 'object'
          && 'not' in m
          && Object.keys((m as any).not ?? {}).length === 0
          && Object.keys(m).length === 1),
      )
      if (kept.length === 1) return stripNotEmpty(kept[0])
      obj.anyOf = kept.map(stripNotEmpty)
    }
    for (const k of Object.keys(obj)) if (k !== 'anyOf') obj[k] = stripNotEmpty(obj[k])
  }
  return node
}

This is a minimal, low-risk change that unblocks affected users immediately. Long-term, migrating to Zod v4 native z.toJSONSchema() would avoid the issue entirely.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions