Your MCP server defines meter_retire once, and a team on the OpenAI API needs it as a function
tool. Their hand-made copy left out additionalProperties: false and the annotation that marks
the tool destructive. Their model sends a field your server refuses, and it retires meters
without asking anyone first.
What you get
You will end up with one tool definition that emits the MCP, Claude, OpenAI, and Gemini formats, and a test that validates a known-good call against each. This is for you if one tool set has to reach more than one model vendor.
Short answer
Keep the tool in MCP’s shape, restrict its schema to keywords every target can carry, and write
one small mapping per format. Claude takes input_schema and strict. OpenAI’s strict mode wants
every property required, so optional ones become nullable. Gemini’s parameters need upper-case
types and inlined references. Annotations have no field outside MCP, so turn them into
description sentences, then validate one known-good call against every emitted schema.
You will need
Node 22 or later, an MCP tool definition, and Python 3.11 or later for the Python twin. Verified 2026-10-15 against Node 22.22.0 and the packages listed under Last verified. Start from an argument schema an agent already fills correctly: this page moves a schema between formats and does not improve it. No provider API is called. Every request below goes to a local fake that records the body and answers in that API’s shape.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Hand-written mapping | One tool set published to several vendors, each in its strict mode | About two hundred lines to own, and a test to rerun whenever a vendor changes its subset | One vendor, or an SDK whose defaults already pass your round-trip test |
| OpenAI Agents SDK MCP support | Agents built on that SDK, reading tools straight from your MCP server | Default mode reopens the closed root object, and strict conversion falls back with a one-word log line | You need the Claude or Gemini format, or the annotations in front of the model |
| Vercel AI SDK tool adapters | One loop over several providers, with tools from MCP or from code | Sends schemas unchanged and non-strict, and leaves annotations and output schemas out of the request | A provider refuses a keyword your schema uses, or strict mode matters |
The two adapters convert at runtime and cost nothing to adopt. The AI SDK sends your schema through untouched, so whatever a provider does with an unsupported keyword happens on your first live call. The Agents SDK reshapes schemas for OpenAI only, and loosens them by default. A hand-written mapping is code you maintain against four references that move. In exchange each format gets its strict mode and the behavior hints reach the model.
Fix the subset before writing a mapping
The subset is the list of JSON Schema keywords the source may use. Choose keywords every target either accepts or can carry as a sentence, then enforce the list, so a schema that drifts outside it fails in your build instead of at a provider.
// The JSON Schema subset the source may use. Every keyword here has a mapping for every target;
// a keyword outside it stops the conversion instead of reaching a provider that rejects it.
export const SUBSET = new Set([
'type', 'properties', 'required', 'additionalProperties', 'items', 'enum', 'const', 'anyOf',
'$ref', '$defs', 'description', 'title', 'format', 'default', 'minimum', 'maximum', 'pattern',
])
// The string formats OpenAI's strict mode lists; Claude's holds these nine and uri. Both strict
// modes answer a schema outside their lists with an error, so any other format stops here.
export const FORMATS = new Set(['date-time', 'time', 'date', 'duration', 'email', 'hostname', 'ipv4', 'ipv6', 'uuid'])
// Regex features Claude's strict mode does not support: lookaround, backreferences, word boundaries.
const UNSUPPORTED_PATTERN = /\(\?<?[=!]|\\(?:[1-9bB]|k<)/
export function assertSubset(schema, path = '#') {
if (typeof schema !== 'object' || schema === null) throw new Error(`${path}: not a schema`)
for (const [key, value] of Object.entries(schema)) {
if (!SUBSET.has(key)) throw new Error(`${path}: "${key}" is outside the subset`)
// No null in the source, so a null in a strict OpenAI call can only mean "left out".
if (key === 'type' && (typeof value !== 'string' || value === 'null')) throw new Error(`${path}: one non-null type only`)
if (key === '$ref' && !/^#\/\$defs\/[\w-]+$/.test(value)) throw new Error(`${path}: only local refs into $defs`)
if (key === 'enum' && !value.every((v) => typeof v === 'string')) throw new Error(`${path}: enums hold strings only`)
if (key === 'const' && typeof value !== 'string') throw new Error(`${path}: const holds a string only`)
if (key === 'format' && !FORMATS.has(value)) throw new Error(`${path}: format "${value}" is not one both strict modes accept`)
if (key === 'pattern' && UNSUPPORTED_PATTERN.test(value)) throw new Error(`${path}: pattern uses a lookaround, a backreference, or a word boundary`)
if (key === 'properties' || key === '$defs') {
for (const [name, sub] of Object.entries(value)) assertSubset(sub, `${path}/${key}/${name}`)
}
if (key === 'anyOf') value.forEach((sub, i) => assertSubset(sub, `${path}/anyOf/${i}`))
if (key === 'items') assertSubset(value, `${path}/items`)
}
// Gemini has no $ref, so each reference is inlined. A constraint beside one would have to be
// merged into the definition's own, so only a description may sit there.
if ('$ref' in schema && Object.keys(schema).some((key) => key !== '$ref' && key !== 'description')) {
throw new Error(`${path}: only a description may sit beside $ref`)
}
if (schema.type === 'object' && schema.additionalProperties !== false) {
throw new Error(`${path}: objects must be closed with additionalProperties: false`)
}
}
Each rule traces to a reference. OpenAI’s
supported schemas
name anyOf and not oneOf, and Claude’s
JSON Schema limitations
name anyOf and allOf. When two branches cannot both match, anyOf says what oneOf said. Both
strict modes want every object closed, list the string formats they accept, and answer anything
outside their lists with an error. OpenAI’s list is the shorter, so FORMATS holds it, and the
pattern rule refuses the regular-expression features Claude lists as unsupported, such as (?=,
\1, and \b. Gemini’s
Schema object holds enum values as
strings, so the subset does too.
Gemini’s Schema has no $ref, so the converter inlines each definition. A minimum beside a
reference would then need merging with the definition’s own, so the subset lets a $ref carry
only a description. Move the constraint into the definition instead.
Names have rules of their own. The MCP tools specification allows dots, while Claude and OpenAI allow letters, digits, underscores, and hyphens, and OpenAI stops at 64 characters.1 The converter checks the name against each target’s rule as well.
Map each target from the same source
OpenAI’s strict mode requires
every property in required, and marks an optional one by adding null to its type.
// OpenAI strict mode wants every property required, so an optional one becomes nullable.
// A nullable enum needs null in the enum too, or null fails validation against it.
function nullable(schema) {
if (typeof schema.type === 'string' && !('const' in schema)) {
const out = { ...schema, type: [schema.type, 'null'] }
if (out.enum) out.enum = [...out.enum, null]
return out
}
return { anyOf: [schema, { type: 'null' }] }
}
function openaiStrictSchema(schema) {
return mapSchema(schema, (s) => {
if (s.type !== 'object' || !s.properties) return s
const required = new Set(s.required ?? [])
const properties = {}
for (const [name, prop] of Object.entries(s.properties)) {
if (required.has(name)) {
properties[name] = prop
continue
}
// The supported-schema list does not name default, so its value moves into the text.
const { default: value, ...rest } = prop
const hint = 'default' in prop ? `Send null for the default, ${JSON.stringify(value)}.` : 'Send null to leave it out.'
properties[name] = nullable(note(rest, hint))
}
return { ...s, properties, required: Object.keys(properties) }
})
}
Without null in the enum, a standard validator refuses the null the type allows.2 A const has
the same problem, so a constant takes the anyOf branch instead. Emit all four formats from the one
source and print what changed:
node emit.mjs
top-level fields
mcp name, title, description, inputSchema, outputSchema, annotations
claude name, description, input_schema, strict, input_examples
openai type, name, description, parameters, strict, output_schema
gemini name, description, parameters, response
description the three non-MCP targets receive
Retire a meter so it stops accepting readings, and record its final reading. Destructive: it can remove or overwrite data, so confirm with the user before calling it. Idempotent: repeating a call with the same arguments has no further effect.
notify_via, optional with a default
mcp {"type":"string","enum":["email","sms","none"],"default":"email"}
claude {"type":"string","enum":["email","sms","none"],"default":"email"}
openai {"type":["string","null"],"enum":["email","sms","none",null],"description":"Send null for the default, \"email\"."}
gemini {"type":"STRING","default":"email","enum":["email","sms","none"]}
final_reading.value, a number with a minimum
mcp {"type":"number","minimum":0}
claude {"type":"number","description":"Minimum 0."}
openai {"type":"number","minimum":0}
gemini {"type":"NUMBER","minimum":0}
final_reading, second branch
mcp {"type":"string","const":"estimate"}
claude {"type":"string","const":"estimate"}
openai {"type":"string","const":"estimate"}
gemini {"type":"STRING","enum":["estimate"]}
outputSchema
mcp outputSchema
claude no field, left out
openai output_schema
gemini response
Claude’s strict mode refuses minimum, so the bound moves into the description. Claude also takes
input_examples, and
an example that fails input_schema is a 400. Gemini’s Schema has no const and no
additionalProperties, so the constant becomes a one-value enum and the closed object becomes a
sentence. It keeps default, a field its reference documents as accepted and then ignored.3 The
output schema survives in two places: OpenAI’s
Responses reference
lists output_schema on a function tool, and Gemini has response. Claude has no field for it.
Carry the annotations as sentences
MCP annotations tell a client whether a tool is read-only, destructive, idempotent, or open-world. None of the other three formats has a field for them, so a destructive tool looks like any other tool unless the description says so.
// MCP annotations have no field in the other three formats, so they travel as sentences.
// The defaults are the MCP schema's: readOnly false, destructive true, idempotent false, openWorld true.
// Destructive and idempotent mean something only when readOnly is false; openWorld always does.
export function behaviorText(annotations = {}) {
const { readOnlyHint = false, destructiveHint = true, idempotentHint = false, openWorldHint = true } = annotations
const parts = readOnlyHint
? ['Read-only: it changes nothing.']
: [
destructiveHint
? 'Destructive: it can remove or overwrite data, so confirm with the user before calling it.'
: 'Additive: it creates data and never overwrites it.',
idempotentHint
? 'Idempotent: repeating a call with the same arguments has no further effect.'
: 'Not idempotent: do not repeat a call that may already have succeeded.',
]
if (openWorldHint) parts.push('It reaches systems outside this service.')
return parts.join(' ')
}
The defaults matter. A tool with no annotations is destructive and not idempotent under the MCP schema, so the function writes the cautious sentence when the server says nothing.
The schema calls destructiveHint and idempotentHint meaningful only when readOnlyHint is
false, so a read-only tool gets one sentence in their place. openWorldHint stands alone, so a
read-only tool that queries an outside service still says it reaches one.
Check it worked
The round-trip test validates one known-good call against every emitted schema with Ajv, and refuses four bad calls against each. Gemini’s Schema is read back into JSON Schema first, so one validator judges all five.
// Each target: the schema a model fills, and the known-good call as that model would send it.
// A strict OpenAI call carries null for every optional argument it leaves out.
const targets = {
mcp: { schema: toMcp(retireMeter).inputSchema, call: goodCall },
claude: { schema: toClaude(retireMeter).input_schema, call: goodCall },
openai: { schema: toOpenAI(retireMeter).parameters, call: { ...goodCall, effective_at: null, notify_via: null } },
gemini: { schema: fromGeminiSchema(toGemini(retireMeter).parameters), call: goodCall },
'gemini, parametersJsonSchema': { schema: toGemini(retireMeter, { jsonSchema: true }).parametersJsonSchema, call: goodCall },
}
for (const [name, { schema, call }] of Object.entries(targets)) {
test(`${name}: accepts the known-good call and refuses every bad one`, () => {
assert.ok(accepts(schema, call), ajv.errorsText())
for (const [why, bad] of Object.entries(badCalls)) assert.equal(accepts(schema, bad), false, why)
})
}
node --test roundtrip.test.mjs
1..17
# tests 17
# suites 0
# pass 17
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 690.342208
The other twelve tests pin the rest. A strict OpenAI call, nulls removed, satisfies the source
schema. Every Claude example validates, and the dotted name and the oneOf are refused, as are a
uri format, a pattern holding (?!, and a minimum beside a $ref, in both languages. A
read-only tool keeps its open-world sentence in both languages too. Two
places where a target accepts more than the source are pinned on purpose. Claude strict accepts a
negative reading, and Gemini’s Schema accepts an extra property. One test serves the MCP
definition from an in-process server, calls it through the MCP client, and checks the result
against outputSchema. The last runs convert.py and asserts the Python twin emits the same
JSON, for the tool as written and for a copy with no output schema.
Compare the adapters on the same tool
The same definition, served by the same MCP server, through each adapter. A fake fetch records what reached each API.
node adapters.mjs
label field strict closed optional hints output
hand-written, OpenAI parameters true false nullable described output_schema
hand-written, Claude input_schema true false omittable described dropped
hand-written, Gemini parameters - - omittable described response
AI SDK, OpenAI Responses parameters false false omittable dropped dropped
AI SDK, Anthropic Messages input_schema - false omittable dropped dropped
AI SDK, Gemini parametersJsonSchema - false omittable dropped dropped
Agents SDK, default parameters false true omittable dropped dropped
Agents SDK, strict parameters true false nullable dropped dropped
Agents SDK, strict, oneOf parameters false false omittable dropped dropped
warning: Error converting MCP schema to strict mode: object
The AI SDK sends strict: false to OpenAI, which the function calling guide documents as the
opt-out from strict normalization. It keeps the annotations on the tool’s metadata for your approval
code, as its MCP guide says, and sends none of them to the model. The Agents SDK’s default turns the
root’s additionalProperties: false into true, so the model is told extra keys are fine while
your server refuses any it sends. With convertSchemasToStrict and an anyOf source, it produces
an equivalent strict schema, with every property required and each optional one made nullable
through an anyOf with null. Publish oneOf and it falls back.
When it goes wrong
Strict conversion falls back and the log says object. The Agents SDK withholds tool data from
its logs by default and prints the error’s type. Set OPENAI_AGENTS_DONT_LOG_TOOL_DATA=0, as its
configuration guide documents, to read
the reason:
OPENAI_AGENTS_DONT_LOG_TOOL_DATA=0 node adapters.mjs
Agents SDK, strict, oneOf parameters false false omittable dropped dropped
warning: Error converting MCP schema to strict mode: UserError: Cannot convert a JSON schema using unsupported keyword `oneOf` to strict mode. Remove the keyword or disable strict mode.
Your server refuses effective_at: null. A strict OpenAI call fills every optional argument with
null. Pass the arguments through fromOpenAICall before they reach code written against the
source schema. That is safe only because the subset bans null in the source.
A nullable argument refuses null. The optional property has an enum or a const, and the
converter made its type nullable without adding null to the allowed values. The test with
an optional confirm constant fails on that code.
When not to do this
Do not write the mapping if one SDK talks to one provider for you. Run the round-trip test against what that SDK sends, and keep its conversion while the test passes.
Do not read the description sentence as a guard. The MCP specification says clients must treat annotations as untrusted unless the server is trusted, and a sentence in a description is weaker still. It changes what a model tends to do. An approval step in the host decides what it may do.
Do not widen the subset for one awkward tool. Every keyword you add needs a mapping for all four
targets and a test row. Rewrite the tool’s schema instead: split a oneOf into two tools, or
replace a recursive structure with an id the model passes back.
Do not hand-maintain four JSON files. Emit them from one source in the build, so a change to the tool cannot reach one vendor and miss the others.
Related how-tos
Last verified
Verified 2026-10-15 against Node 22.22.0, @modelcontextprotocol/server and
@modelcontextprotocol/client 2.3.1, @openai/agents 0.20.0, openai 7.34.0, ai 7.0.142,
@ai-sdk/mcp 2.0.77, @ai-sdk/openai 4.0.95, @ai-sdk/anthropic 4.0.81, @ai-sdk/google 4.0.98,
Ajv 8.20.0, and ajv-formats 3.0.1, with convert.py on Python 3.13. Every output block is what
the command preceding it printed. No provider API was called; the adapters’ requests went to a local
fake that recorded each body.
Footnotes
-
The specification’s own list of valid tool names includes
admin.tools.list. Claude’s define tools page gives the name rule as^[a-zA-Z0-9_-]{1,128}$, and OpenAI’s function reference allows letters, digits, underscores, and dashes up to 64 characters. So the specification offers, as an example of a good name, one that two of the vendors downstream refuse, which is why the converter tests the name as well as the schema. ↩︎ Back to text -
OpenAI’s own strict-mode example in its function calling guide types
unitsas["string", "null"]and leavesnullout of its enum. The JSON Schema validation specification passes an instance againstenumonly if it equals one of the listed values. Whatever the API’s decoder makes of that pair, a standard validator in your own server refuses the null the example was written to allow. ↩︎ Back to text -
Gemini’s API reference explains why its Schema has a
defaultfield at all. The keyword affects no validation, so the field is “included here and ignored so that developers who send schemas with a default field don’t get unknown-field errors,” in the reference’s words. The JSON Schema specification filesdefaultamong its meta-data annotations, kept for documentation and display. A field accepted so that it can be ignored is the keyword working as specified. ↩︎ Back to text