<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Rowan Vale]]></title><description><![CDATA[Rowan Vale]]></description><link>https://rowanvale.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sun, 25 Oct 2026 05:28:17 GMT</lastBuildDate><atom:link href="https://rowanvale.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[One tool, five schemas: converting function definitions between OpenAI, Anthropic, Gemini and MCP]]></title><description><![CDATA[Real differences, the pitfalls, and a dependency-free TypeScript converter]]></description><link>https://rowanvale.hashnode.dev/one-tool-five-schemas-converting-function-definitions-between-openai-anthropic-gemini-and-mcp</link><guid isPermaLink="true">https://rowanvale.hashnode.dev/one-tool-five-schemas-converting-function-definitions-between-openai-anthropic-gemini-and-mcp</guid><category><![CDATA[AI]]></category><category><![CDATA[llm]]></category><category><![CDATA[mcp]]></category><category><![CDATA[TypeScript]]></category><dc:creator><![CDATA[Rowan Vale]]></dc:creator><pubDate>Wed, 21 Oct 2026 10:17:19 GMT</pubDate><content:encoded><![CDATA[<p><em>Drafted with AI assistance and reviewed/tested by the author. The code below was run on Node 22 and type-checked with <code>tsc --strict</code>.</em></p>
<p>If you support more than one model provider, you've probably written the same tool definition several times. The idea is the same every time: a name, a description, and a JSON Schema for the arguments. But each provider wraps it differently and accepts a slightly different dialect of JSON Schema. A schema that works in one place can be rejected outright in another. Worse, it can be accepted and then quietly behave differently.</p>
<p>This post covers what actually differs, the pitfalls that cost me the most time, and a small standalone converter (one TypeScript file, no dependencies). It turns one canonical definition into all five shapes.</p>
<h2>The canonical form</h2>
<p>Keep one source of truth per tool: a name, a description, and a plain JSON Schema whose root is an object. Everything else is a build artefact you generate from it. Here's the example used throughout (descriptions and one nested field trimmed; the full version is at the bottom of the code):</p>
<pre><code class="language-json">{
  "name": "create_reminder",
  "description": "Schedule a reminder for the user. Use when the user asks to be reminded; not for calendar events with attendees.",
  "parameters": {
    "type": "object",
    "properties": {
      "message":   { "type": "string" },
      "remind_at": { "type": "string", "format": "date-time" },
      "priority":  { "type": "integer", "enum": [1, 2, 3] },
      "repeat":    { "$ref": "#/$defs/Repeat" },
      "channel":   { "type": "string", "enum": ["push", "sms"], "default": "push" }
    },
    "required": ["message", "remind_at"],
    "$defs": { "Repeat": { "type": "object", "properties": { "every": { "type": "string", "enum": ["day", "week", "month"] } }, "required": ["every"] } }
  }
}
</code></pre>
<p>It's deliberately awkward. It has optional fields, a numeric enum, a <code>$ref</code> and a <code>default</code>, which are the things that tend to break.</p>
<h2>What actually differs</h2>
<p><strong>OpenAI (Chat Completions).</strong> Each tool is <code>{"type": "function", "function": {name, description, parameters}}</code>. The Responses API flattens this to <code>{"type": "function", name, description, parameters, strict}</code>, so watch which endpoint you're targeting. Without strict mode, OpenAI is fairly tolerant about ordinary JSON Schema.</p>
<p><strong>OpenAI strict mode</strong> (<code>"strict": true</code>). This is where most conversions break. With strict mode, the model's arguments are constrained to your schema, but only a subset of JSON Schema is supported, and it has rules:</p>
<ul>
<li>every object must set <code>"additionalProperties": false</code>, including objects nested inside arrays;</li>
<li>every property must appear in <code>required</code>;</li>
<li>so an <em>optional</em> field has to become a <em>required, nullable</em> one: <code>"type": ["string", "null"]</code>, or an <code>anyOf</code> with a <code>{"type": "null"}</code> branch;</li>
<li>the root must be an object, not an <code>anyOf</code>.</li>
</ul>
<p>The set of supported keywords (<code>pattern</code>, <code>format</code> values, length and range bounds) has changed over time and differs for fine-tuned models. Check the current Structured Outputs docs rather than trusting a blog post, including this one.</p>
<p><strong>Anthropic.</strong> Each tool is flat: <code>{name, description, input_schema}</code>. The name must match <code>^[a-zA-Z0-9_-]{1,64}$</code>, so no spaces, dots or slashes. A tool called <code>get weather.v2</code> will be rejected. If you're adapting code written for OpenAI, the two mechanical traps are the <code>input_schema</code> key and the missing <code>function</code> wrapper.</p>
<p><strong>Gemini.</strong> Declarations go in <code>tools: [{functionDeclarations: [...]}]</code>. Each one is flat: <code>{name, description, parameters}</code>. The classic <code>parameters</code> field takes an OpenAPI 3.0-style Schema object, not full JSON Schema. In practice:</p>
<ul>
<li>one <code>type</code> per schema, with <code>nullable: true</code> instead of <code>["string", "null"]</code>;</li>
<li>enum values are strings;</li>
<li>no <code>$ref</code> or <code>$defs</code>; inline them;</li>
<li>no <code>additionalProperties</code>, <code>const</code> or <code>default</code>;</li>
<li>property names should be letters, digits and underscores (so a <code>page-size</code> parameter is a problem);</li>
<li>a tool with no arguments simply omits <code>parameters</code>.</li>
</ul>
<p>Newer Gemini models also accept a separate <code>parametersJsonSchema</code> field that takes regular JSON Schema. It's mutually exclusive with <code>parameters</code> in the same declaration. If your models support it, it removes most of the down-conversion below. The classic field is still the most widely accepted, so the converter targets that.</p>
<p><strong>MCP.</strong> A tool returned by <code>tools/list</code> has <code>name</code>, an optional <code>title</code>, <code>description</code>, <code>inputSchema</code> (an object schema), and optionally <code>outputSchema</code> and <code>annotations</code>. The annotations are <code>readOnlyHint</code>, <code>destructiveHint</code>, <code>idempotentHint</code> and <code>openWorldHint</code>. The spec says clients must treat annotations as untrusted unless they come from a trusted server, so they're hints for the UI, not a security boundary.</p>
<h2>The pitfalls that bite</h2>
<ol>
<li><strong>Optional fields under strict mode.</strong> The canonical schema says <code>priority</code> is optional. Strict mode says every key is required. The fix changes your wire contract: the model will now send <code>"priority": null</code> instead of leaving it out. Your executor has to accept both shapes if you ship both formats.</li>
<li><strong><code>enum</code> plus <code>null</code>.</strong> If you make an enum field nullable by changing its type to <code>["integer", "null"]</code> but leave the enum as <code>[1, 2, 3]</code>, then <code>null</code> is still invalid. Add <code>null</code> to the enum too.</li>
<li><strong><code>default</code> is not applied for you.</strong> Models often omit optional fields, and validators don't fill in defaults. If <code>channel</code> defaults to <code>push</code>, your code has to apply that. The converter strips <code>default</code> where it isn't supported and warns you.</li>
<li><strong>Numeric enums on Gemini.</strong> <code>[1, 2, 3]</code> becomes <code>["1", "2", "3"]</code>. The model will send a string, so convert it back in your code.</li>
<li><strong><code>$ref</code>.</strong> Inline local references before generating Gemini output, and preferably for every target, since not every client resolves them. Recursive schemas can't be inlined. The converter throws instead of looping forever.</li>
<li><strong>Nested objects.</strong> The strict-mode rules apply at every level. A common bug is to lock down the root and leave <code>repeat</code> (or the items of an array) open.</li>
<li><strong>Silently dropped keywords.</strong> Every converter has to remove things. A good one tells you what it removed. Each function below returns a <code>warnings</code> list with a JSON-pointer-style path, so nothing disappears silently.</li>
</ol>
<h2>The converter</h2>
<p>One file, no dependencies. Save it as <code>convert.ts</code> and run <code>node convert.ts</code> (Node 22.18+ strips the types itself), or use <code>npx tsx convert.ts</code> on older versions. It exports <code>toOpenAI</code>, <code>toOpenAIStrict</code>, <code>toAnthropic</code>, <code>toGemini</code> and <code>toMCP</code>, and prints all five for the example when run directly.</p>
<pre><code class="language-typescript">// convert.ts: convert one canonical tool definition (name + description + JSON Schema)
// into OpenAI, OpenAI strict, Anthropic, Gemini and MCP tool formats.
// No dependencies. Run with: node convert.ts   (Node 22.18+ runs TypeScript directly)
//                       or: npx tsx convert.ts

type Schema = { [key: string]: any };

export interface CanonicalTool {
  name: string;
  description: string;
  parameters: Schema; // JSON Schema for the arguments; root must be an object
}

export interface Result&lt;T&gt; {
  tool: T;
  warnings: string[];
}

const NAME_RE = /^[a-zA-Z0-9_-]{1,64}$/; // Anthropic's rule; also a safe portable choice

// Inline local "#/$defs/..." / "#/definitions/..." refs. Recursive schemas can't be inlined: throw.
function inlineRefs(schema: Schema, root: Schema = schema, seen: string[] = []): Schema {
  if (Array.isArray(schema)) return schema.map((s) =&gt; inlineRefs(s, root, seen)) as any;
  if (!schema || typeof schema !== "object") return schema;
  if (typeof schema.$ref === "string") {
    const ref = schema.$ref;
    if (!ref.startsWith("#/")) throw new Error(`external $ref not supported: ${ref}`);
    if (seen.includes(ref)) throw new Error(`recursive $ref cannot be inlined: ${ref}`);
    const target = ref.slice(2).split("/").reduce((node: any, key) =&gt; node?.[key.replace(/~1/g, "/").replace(/~0/g, "~")], root);
    if (target === undefined) throw new Error(`unresolved $ref: ${ref}`);
    const { $ref, ...siblings } = schema;
    return inlineRefs({ ...target, ...siblings }, root, [...seen, ref]);
  }
  const out: Schema = {};
  for (const [key, value] of Object.entries(schema)) {
    if (key === "$defs" || key === "definitions") continue;
    out[key] = typeof value === "object" &amp;&amp; value !== null ? inlineRefs(value, root, seen) : value;
  }
  return out;
}

function prepare(tool: CanonicalTool): { params: Schema; warnings: string[] } {
  const warnings: string[] = [];
  if (!NAME_RE.test(tool.name)) warnings.push(`name "${tool.name}" does not match ${NAME_RE}`);
  const params = inlineRefs(structuredClone(tool.parameters ?? { type: "object", properties: {} }));
  delete params.$schema;
  if (params.type !== "object") throw new Error("parameters root must be {type: 'object'}");
  for (const r of params.required ?? []) {
    if (!(r in (params.properties ?? {}))) throw new Error(`required lists unknown property "${r}"`);
  }
  return { params, warnings };
}

// ---------- OpenAI (Chat Completions) ----------
export function toOpenAI(tool: CanonicalTool): Result&lt;Schema&gt; {
  const { params, warnings } = prepare(tool);
  return { tool: { type: "function", function: { name: tool.name, description: tool.description, parameters: params } }, warnings };
}

// ---------- OpenAI strict mode ----------
// Every object: all properties required + additionalProperties:false. Optional fields become nullable.
function makeNullable(s: Schema): Schema {
  if (Array.isArray(s.type)) return s.type.includes("null") ? s : { ...s, type: [...s.type, "null"], ...(s.enum ? { enum: [...s.enum, null] } : {}) };
  if (typeof s.type === "string") {
    if (s.type === "null") return s;
    // Objects and arrays: wrap in anyOf so the nested keywords stay attached to their own branch.
    if (s.type === "object" || s.type === "array") return { anyOf: [s, { type: "null" }] };
    return { ...s, type: [s.type, "null"], ...(s.enum &amp;&amp; !s.enum.includes(null) ? { enum: [...s.enum, null] } : {}) };
  }
  // no type (e.g. anyOf): add a null branch
  if (Array.isArray(s.anyOf)) return { ...s, anyOf: [...s.anyOf, { type: "null" }] };
  return { anyOf: [s, { type: "null" }] };
}

function strictify(s: Schema, path: string, warnings: string[]): Schema {
  if (!s || typeof s !== "object") return s;
  const out: Schema = { ...s };
  if (out.oneOf) { out.anyOf = out.oneOf; delete out.oneOf; warnings.push(`${path}: oneOf rewritten as anyOf`); }
  if (out.anyOf) out.anyOf = out.anyOf.map((b: Schema, i: number) =&gt; strictify(b, `${path}/anyOf/${i}`, warnings));
  if (out.items) out.items = strictify(out.items, `${path}/items`, warnings);
  const isObject = out.type === "object" || (Array.isArray(out.type) &amp;&amp; out.type.includes("object")) || out.properties;
  if (isObject) {
    const props: Schema = out.properties ?? {};
    const required = new Set&lt;string&gt;(out.required ?? []);
    const newProps: Schema = {};
    for (const [key, child] of Object.entries(props)) {
      let c = strictify(child as Schema, `${path}/properties/${key}`, warnings);
      if (!required.has(key)) {
        c = makeNullable(c);
        warnings.push(`${path}/properties/${key}: optional -&gt; required + nullable`);
      }
      newProps[key] = c;
    }
    out.properties = newProps;
    out.required = Object.keys(newProps);
    if (out.additionalProperties !== false) {
      if (out.additionalProperties !== undefined) warnings.push(`${path}: additionalProperties forced to false`);
      out.additionalProperties = false;
    }
  }
  if ("default" in out) { delete out.default; warnings.push(`${path}: default removed (apply it in your code)`); }
  return out;
}

export function toOpenAIStrict(tool: CanonicalTool): Result&lt;Schema&gt; {
  const { params, warnings } = prepare(tool);
  const parameters = strictify(params, "#", warnings);
  return { tool: { type: "function", function: { name: tool.name, description: tool.description, strict: true, parameters } }, warnings };
}

// ---------- Anthropic ----------
export function toAnthropic(tool: CanonicalTool): Result&lt;Schema&gt; {
  const { params, warnings } = prepare(tool);
  return { tool: { name: tool.name, description: tool.description, input_schema: params }, warnings };
}

// ---------- Gemini (functionDeclarations[].parameters, OpenAPI-style subset) ----------
const GEMINI_KEYS = new Set([
  "type", "format", "title", "description", "nullable", "enum", "properties", "required", "items",
  "minItems", "maxItems", "minimum", "maximum", "minLength", "maxLength", "pattern", "anyOf",
  "minProperties", "maxProperties", "propertyOrdering",
]);

function geminify(s: Schema, path: string, warnings: string[]): Schema {
  if (!s || typeof s !== "object") return s;
  let src: Schema = { ...s };
  if ("const" in src) { src.enum = [src.const]; delete src.const; }
  if (src.oneOf) { src.anyOf = src.oneOf; delete src.oneOf; }
  if (Array.isArray(src.type)) {
    const types = src.type.filter((t: string) =&gt; t !== "null");
    if (src.type.includes("null")) src.nullable = true;
    if (types.length === 1) src.type = types[0];
    else { src.anyOf = types.map((t: string) =&gt; ({ type: t })); delete src.type; warnings.push(`${path}: type array -&gt; anyOf`); }
  }
  if (src.enum) {
    const values = src.enum.filter((v: unknown) =&gt; v !== null);
    if (src.enum.includes(null)) src.nullable = true;
    if (values.some((v: unknown) =&gt; typeof v !== "string")) {
      warnings.push(`${path}: non-string enum converted to strings (convert back in your code)`);
      src.type = "string";
    }
    src.enum = values.map(String);
  }
  const out: Schema = {};
  for (const [key, value] of Object.entries(src)) {
    if (!GEMINI_KEYS.has(key)) { warnings.push(`${path}: "${key}" removed`); continue; }
    if (key === "properties") {
      out.properties = {};
      for (const [k, v] of Object.entries(value as Schema)) {
        if (!/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(k)) warnings.push(`${path}/properties/${k}: property name may be rejected by Gemini`);
        out.properties[k] = geminify(v as Schema, `${path}/properties/${k}`, warnings);
      }
    } else if (key === "items") out.items = geminify(value, `${path}/items`, warnings);
    else if (key === "anyOf") out.anyOf = (value as Schema[]).map((b, i) =&gt; geminify(b, `${path}/anyOf/${i}`, warnings));
    else out[key] = value;
  }
  return out;
}

export function toGemini(tool: CanonicalTool): Result&lt;Schema&gt; {
  const { params, warnings } = prepare(tool);
  const decl: Schema = { name: tool.name, description: tool.description };
  // A tool with no arguments omits "parameters" entirely.
  if (Object.keys(params.properties ?? {}).length &gt; 0) decl.parameters = geminify(params, "#", warnings);
  return { tool: decl, warnings };
}

// ---------- MCP ----------
export function toMCP(tool: CanonicalTool, annotations?: Schema): Result&lt;Schema&gt; {
  const { params, warnings } = prepare(tool);
  return { tool: { name: tool.name, description: tool.description, inputSchema: params, ...(annotations ? { annotations } : {}) }, warnings };
}

// ---------- demo ----------
const example: CanonicalTool = {
  name: "create_reminder",
  description: "Schedule a reminder for the user. Use when the user asks to be reminded; not for calendar events with attendees.",
  parameters: {
    $schema: "https://json-schema.org/draft/2020-12/schema",
    type: "object",
    properties: {
      message: { type: "string", description: "What to remind the user about." },
      remind_at: { type: "string", format: "date-time", description: "ISO 8601 timestamp with offset." },
      priority: { type: "integer", enum: [1, 2, 3], description: "1 = low, 3 = high." },
      repeat: { $ref: "#/$defs/Repeat" },
      channel: { type: "string", enum: ["push", "sms"], default: "push" },
    },
    required: ["message", "remind_at"],
    additionalProperties: false,
    $defs: {
      Repeat: {
        type: "object",
        properties: { every: { type: "string", enum: ["day", "week", "month"] }, count: { type: "integer", minimum: 1 } },
        required: ["every"],
      },
    },
  },
};

if (import.meta.url === `file://${process.argv[1]}`) {
  for (const [label, fn] of Object.entries({ openai: toOpenAI, openai_strict: toOpenAIStrict, anthropic: toAnthropic, gemini: toGemini, mcp: toMCP })) {
    const { tool, warnings } = (fn as (t: CanonicalTool) =&gt; Result&lt;Schema&gt;)(example);
    console.log(`\n=== ${label} ===`);
    console.log(JSON.stringify(tool, null, 2));
    if (warnings.length) console.log("warnings:\n  " + warnings.join("\n  "));
  }
}
</code></pre>
<p>Running it on the example gives, among other things, this strict-mode version of <code>repeat</code> and <code>priority</code>:</p>
<pre><code class="language-json">"priority": { "type": ["integer", "null"], "enum": [1, 2, 3, null], "description": "1 = low, 3 = high." },
"repeat": {
  "anyOf": [
    { "type": "object", "properties": { "every": { "type": "string", "enum": ["day", "week", "month"] }, "count": { "type": ["integer", "null"], "minimum": 1 } },
      "required": ["every", "count"], "additionalProperties": false },
    { "type": "null" }
  ]
}
</code></pre>
<p>Notice that <code>count</code>, which was optional, is now required inside <code>repeat</code> and nullable. The Gemini version of <code>priority</code> comes out as <code>{"type": "string", "enum": ["1", "2", "3"]}</code>, with a warning that you need to convert it back.</p>
<p>A few design choices worth stating:</p>
<ul>
<li><strong>Nullable objects use <code>anyOf</code>.</strong> For objects and arrays I wrap the schema in <code>anyOf: [schema, {"type": "null"}]</code> rather than using a type array, so the nested <code>properties</code> and <code>required</code> stay clearly attached to the object branch.</li>
<li><strong><code>oneOf</code> becomes <code>anyOf</code>.</strong> Neither OpenAI strict mode nor the Gemini subset supports <code>oneOf</code>, and for model arguments the difference rarely matters. It's reported as a warning anyway.</li>
<li><strong>Gemini uses an allow-list, not a deny-list.</strong> Unknown keywords are removed and reported. That's safer than trying to list everything Gemini rejects.</li>
<li><strong>It doesn't validate arguments.</strong> That's your executor's job, and it should check everything the schema can't express: path containment, ID formats, read-only SQL, and so on.</li>
</ul>
<h2>Testing it</h2>
<p>The converter itself has been run and type-checked. That doesn't mean the output will be accepted. To check a conversion properly:</p>
<ul>
<li>Snapshot the five outputs for each tool in your repo and diff them in code review.</li>
<li>Send each generated tool to its provider once in CI, with a cheap model and <code>tool_choice</code> forced, and assert the request isn't rejected.</li>
<li>Feed the arguments the model returns back through your own validator, especially the <code>null</code> versus missing cases.</li>
</ul>
<p>Provider rules change, which is the main reason to keep the canonical form and regenerate everything else instead of hand-editing four files.</p>
<hr />
<p><em>If you'd rather not maintain this yourself:</em> I run a small paid API that does these conversions and lints them, including batch and OpenAPI-to-tools: <a href="https://toolschema-forge.toolschemaforge.workers.dev">ToolSchema Forge</a>. Agents pay per call in USDC via x402. There's also a <a href="https://toolschema-forge.toolschemaforge.workers.dev/cookbook">$7 cookbook and template pack</a> with 23 ready-made tools in all these formats. The converter above is free to copy either way.</p>
<p><em>Rowan Vale</em></p>
]]></content:encoded></item></channel></rss>