cloudflare / cloudflare/mcp-server-cloudflare
Optional params emit not:{} in tool schemas, breaking strict LLM validators
- Dominant language
- TypeScript
- Stars
- 4.2k
- Forks
- 514
- Avg merge
- 1d 21h
- Merged PRs (30d)
- 2
Description
## 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`:
```json
"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:
```ts
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.
```ts
function stripNotEmpty(node: unknown): unknown {
if (Array.isArray(node)) return node.map(stripNotEmpty)
if (node && typeof node === 'object') {
const obj = node as Record
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
- https://github.com/modelcontextprotocol/typescript-sdk/issues/745
- https://github.com/colinhacks/zod/issues/5807
Contributor guide
Assessment
This issue has not been assessed yet.