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

Source: https://voxgig.com/howto/convert-tool-definitions-between-mcp-openai-claude-gemini

- Audience: agent-builder
- Level: intermediate
- Languages: typescript, python
- Verified: 2026-10-15
- Published: 2026-10-15

## 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](https://json-schema.org/understanding-json-schema/reference) | 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](https://openai.github.io/openai-agents-js/guides/mcp/) | 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](https://ai-sdk.dev/docs/ai-sdk-core/mcp-tools) | 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.

```ts title="convert.mjs"
// 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](https://developers.openai.com/api/docs/guides/structured-outputs#supported-schemas)
name `anyOf` and not `oneOf`, and Claude's
[JSON Schema limitations](https://platform.claude.com/docs/en/build-with-claude/structured-outputs#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](https://ai.google.dev/api/generate-content#FunctionDeclaration) 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](https://modelcontextprotocol.io/specification/2026-07-28/server/tools)
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](https://developers.openai.com/api/docs/guides/function-calling) requires
every property in `required`, and marks an optional one by adding `null` to its type.

```ts title="convert.mjs"
// 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:

```bash
node emit.mjs
```

```text output
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`](https://platform.claude.com/docs/en/agents-and-tools/tool-use/define-tools), 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](https://developers.openai.com/api/reference/resources/responses/methods/create)
lists `output_schema` on a function tool, and Gemini has `response`. Claude has no field for it.

## Carry the annotations as sentences

MCP [annotations](https://modelcontextprotocol.io/specification/2026-07-28/schema) 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.

```ts title="convert.mjs"
// 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](https://ajv.js.org/json-schema.html), and refuses four bad calls against each. Gemini's
Schema is read back into JSON Schema first, so one validator judges all five.

```ts title="roundtrip.test.mjs"
// 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)
  })
}
```

```bash
node --test roundtrip.test.mjs
```

```text output
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.

```bash
node adapters.mjs
```

```text output
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](https://openai.github.io/openai-agents-js/guides/config/) documents, to read
the reason:

```bash
OPENAI_AGENTS_DONT_LOG_TOOL_DATA=0 node adapters.mjs
```

```text output
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

- [Name tools so an agent picks the right one from fifty](/howto/name-tools-so-agents-pick-the-right-one)

- [Wire an API client into the OpenAI Agents SDK](/howto/wire-an-api-client-into-openai-agents-sdk)

- [Upgrade an MCP server to a newer protocol revision](/howto/upgrade-an-mcp-server-to-a-newer-revision)

- [Add a dry-run mode to an agent's write tools](/howto/add-dry-run-to-agent-write-tools)

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

[^1]: The specification's own list of valid tool names includes `admin.tools.list`. Claude's
[define tools page](https://platform.claude.com/docs/en/agents-and-tools/tool-use/define-tools)
gives the name rule as `^[a-zA-Z0-9_-]{1,128}$`, and OpenAI's
[function reference](https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create)
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.

[^2]: OpenAI's own strict-mode example in its
[function calling guide](https://developers.openai.com/api/docs/guides/function-calling) types
`units` as `["string", "null"]` and leaves `null` out of its enum. The
[JSON Schema validation specification](https://json-schema.org/draft/2020-12/json-schema-validation)
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.

[^3]: Gemini's [API reference](https://ai.google.dev/api/generate-content#FunctionDeclaration)
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.