How-to › Expose your API to agents

How to map one tool definition to MCP, OpenAI, Claude, and Gemini#

Emit the MCP, Claude, OpenAI, and Gemini formats from one tool definition, and prove each with a test that validates one known-good call.

Audience
Agent builder
Level
intermediate
Topic
Design tool schemas agents can use
Languages
TypeScript and Python
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
Hand-written mappingOne tool set published to several vendors, each in its strict modeAbout two hundred lines to own, and a test to rerun whenever a vendor changes its subsetOne vendor, or an SDK whose defaults already pass your round-trip test
OpenAI Agents SDK MCP supportAgents built on that SDK, reading tools straight from your MCP serverDefault mode reopens the closed root object, and strict conversion falls back with a one-word log lineYou need the Claude or Gemini format, or the annotations in front of the model
Vercel AI SDK tool adaptersOne loop over several providers, with tools from MCP or from codeSends schemas unchanged and non-strict, and leaves annotations and output schemas out of the requestA 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.

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

  1. 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

  2. OpenAI’s own strict-mode example in its function calling guide types units as ["string", "null"] and leaves null out of its enum. The JSON Schema validation specification passes an instance against enum only 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

  3. Gemini’s API reference explains why its Schema has a default field 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 files default among 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.