If you've worked with OpenAI's function calling or structured outputs, you've probably encountered strict mode. It's a powerful feature that guarantees the model's output will conform exactly to your JSON schema—no hallucinated fields, no missing properties, no type mismatches. When it works, it's magic. When it doesn't, you get cryptic validation errors that make you question your understanding of JSON Schema.
I recently ran into one of these gotchas while working with the Model Context Protocol (MCP) TypeScript SDK, and the root cause turned out to be a subtle quirk in how JSON Schema handles empty objects. Here's what happened, why it matters, and how we fixed it.
What Is OpenAI Strict Mode?
When you define tools or structured outputs for OpenAI's API, you provide a JSON schema describing the expected shape of the data. By default, OpenAI uses this schema as a guideline—the model tries to conform to it, but there's no guarantee.
Strict mode changes this. When enabled, OpenAI validates your schema upfront and guarantees that every response will match it exactly. This is incredibly useful for building reliable applications:
const response = await openai.chat.completions.create({
model: "gpt-4o",
messages: [{ role: "user", content: "Get the weather in Tokyo" }],
tools: [{
type: "function",
function: {
name: "get_weather",
description: "Get current weather for a location",
parameters: {
type: "object",
properties: {
location: { type: "string" }
},
required: ["location"],
additionalProperties: false
},
strict: true // <-- The magic flag
}
}]
});The catch? Strict mode has stricter requirements for your schemas than standard JSON Schema validation. One of those requirements: the required field must always be present on object schemas, even if it's an empty array.
The Bug: Empty Zod Objects
The MCP SDK uses Zod for schema validation, then converts Zod schemas to JSON Schema for wire transmission. This is a common pattern—Zod provides a great developer experience with TypeScript inference, and JSON Schema is the lingua franca for schema interchange.
Here's where things went wrong. Consider a tool that takes no input parameters:
server.registerTool(
"ping",
{
description: "Simple health check",
inputSchema: z.object({}).strict(),
},
async () => {
return { status: "ok" };
}
);The SDK's schemaToJson() function would convert this to:
{
"type": "object",
"properties": {},
"additionalProperties": false
}This is valid JSON Schema. An empty object with no properties and no additional properties allowed. The JSON Schema specification doesn't require the required field—if it's absent, it simply means no properties are required.
But OpenAI strict mode disagrees:
Schema validation failed
The schema has structural issues:
root: Schema must have the following keys: required
The fix seems trivial: just add "required": []. But the devil is in the details.
Why This Is Easy to Miss
This bug is particularly insidious because:
-
The schema is technically valid. Standard JSON Schema validators accept it without complaint.
-
It only affects empty objects. If you have even one property,
requiredgets populated (either with that property or as an empty array, depending on optionality). -
The error message is unhelpful. "Schema must have the following keys: required" doesn't tell you which object in your schema is the culprit, especially with nested structures.
-
It works in non-strict mode. If you test without
strict: true, everything works fine.
The original issue (#1659) described developers resorting to workarounds like adding dummy parameters just to force the required field to appear:
// Ugly workaround
inputSchema: z.object({
_noop: z.string().optional() // Just to make the schema "non-empty"
}).strict()Not great.
The Fix
The solution in PR #1702 ensures that schemaToJson() always includes the required field on object schemas, even when empty. But it's not just a one-line fix—the function needs to handle nested schemas recursively.
The key insight is that object schemas can appear in many places:
- Top-level schemas
- Nested
properties - Array
items additionalProperties(when it's a schema, not a boolean)allOf,anyOf,oneOfcompositions$defsfor reusable schema definitions
The fix walks the entire schema tree, ensuring every object type gets the required treatment:
function ensureRequiredField(schema: JsonSchema): JsonSchema {
if (schema.type === 'object') {
// Always include required, even if empty
if (!('required' in schema)) {
schema.required = [];
}
// Recurse into properties
if (schema.properties) {
for (const prop of Object.values(schema.properties)) {
ensureRequiredField(prop);
}
}
}
// Handle arrays
if (schema.items) {
ensureRequiredField(schema.items);
}
// Handle compositions
for (const key of ['allOf', 'anyOf', 'oneOf']) {
if (schema[key]) {
schema[key].forEach(ensureRequiredField);
}
}
return schema;
}The test suite covers the key scenarios:
it('should include empty required array for empty object schemas', () => {
const schema = z.object({}).strict();
const jsonSchema = schemaToJson(schema);
expect(jsonSchema.required).toEqual([]);
});
it('should add required field to deeply nested object schemas', () => {
const schema = z.object({
level1: z.object({
level2: z.object({
level3: z.object({}).strict()
})
})
});
const jsonSchema = schemaToJson(schema);
// ... verify level3 has required: []
});JSON Schema Quirks Worth Knowing
This bug highlights a broader truth: JSON Schema is more flexible than most consumers expect. Here are a few other quirks that can bite you:
1. additionalProperties defaults to true
If you don't explicitly set additionalProperties: false, your schema allows any extra fields. OpenAI strict mode requires it to be false (for good reason—it ensures deterministic output).
2. type can be an array
Valid JSON Schema: { "type": ["string", "null"] }. Most Zod-to-JSON-Schema converters handle this, but it's worth knowing.
3. Draft versions matter
JSON Schema has evolved through multiple drafts (Draft-04, Draft-06, Draft-07, Draft 2019-09, Draft 2020-12). Keywords like $defs vs definitions, prefixItems vs items, and others changed between drafts. OpenAI uses a subset that's mostly Draft 2020-12 compatible.
4. const vs enum with one value
{ "const": "value" } and { "enum": ["value"] } are semantically equivalent, but some validators handle them differently.
Takeaways
-
Test with strict mode enabled. If you're building tools for OpenAI, always test with
strict: truefrom the start. Bugs like this only surface under strict validation. -
Empty objects are edge cases. Many schema converters are optimized for the common case (objects with properties). Empty objects, single-property objects, and deeply nested structures deserve extra testing attention.
-
Read the error messages carefully. "Schema must have the following keys: required" points directly at the problem, even if it doesn't tell you where in the schema tree.
-
Contribute upstream. When you find a bug in open-source tooling, consider contributing a fix. The MCP SDK serves a lot of developers, and this fix helps everyone who was silently bitten by the same issue.
The fix is now merged and will ship in the next patch release of @modelcontextprotocol/core. If you've been adding dummy parameters to your empty Zod schemas, you can finally remove them.
Found this helpful? The MCP TypeScript SDK is a great project for building AI tool servers. Check it out, and maybe you'll find the next bug to fix.