cloudflare / cloudflare/mcp-server-cloudflare

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

Open
#391 0 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.