Skip to content

ai-openai: strict-mode tool schemas 400 when a tool declares keywords OpenAI forbids (e.g. propertyNames, free-form objects) #976

Description

@AlemTuzlak

Summary

@tanstack/ai-openai sends every function tool with strict: true, but its schema converter does not remove JSON-Schema keywords that OpenAI's strict function-calling validator forbids, nor does it handle free-form (open-map) objects that are unrepresentable in strict mode. When any tool's inputSchema contains such a construct, OpenAI rejects the entire request with a 400 before the model runs, so the whole chat turn dies — not just that one tool.

This is easy to hit with MCP tools, whose schemas are authored by third-party servers and routinely use keywords TanStack's converter doesn't touch.

Versions

  • @tanstack/ai@0.32.0
  • @tanstack/ai-openai@0.15.2
  • @tanstack/ai-mcp@0.1.3

Real-world repro

Connect to Linear's hosted MCP and let the model see its tools:

const mcp = await createMCPClient({
  transport: { type: "http", url: "https://mcp.linear.app/mcp",
               headers: { Authorization: `Bearer ${process.env.LINEAR_API_KEY}` } },
});

const stream = chat({
  adapter: openaiText("gpt-5.5"),
  messages,
  mcp: { clients: [mcp] }, // or: tools: [...await mcp.tools()]
});

Linear's save_diff_comment tool declares:

{
  "type": "object",
  "properties": {
    "anchor": {
      "type": "object",
      "additionalProperties": {},
      "propertyNames": { "type": "string" },   // <-- open map
      "description": "Optional comment anchor..."
    },
    "body": { "type": "string", "minLength": 1 }
  },
  "required": ["body"]
}

Actual result

400 Invalid schema for function 'save_diff_comment':
In context=('properties','anchor'), 'propertyNames' is not permitted.

Every turn fails, because OpenAI validates the whole tool array up front.

Expected result

The request succeeds. Either the tool's schema is made strict-safe automatically, or the tool falls back to strict: false, so one third-party tool with an over-rich schema can't take down the entire chat.

Root cause

Two behaviors in @tanstack/ai-openai combine:

  1. convertFunctionToolToAdapterFormat hardcodes strict: true on every tool (tools/function-tool.js), so OpenAI's strictest validation always applies.
  2. makeOpenAIStructuredOutputCompatible (utils/schema-converter.js) normalizes shape but not keywords. It wraps optionals as nullable, sets additionalProperties: false and marks all keys required — but it passes through non-whitelisted keywords verbatim (propertyNames, patternProperties, unevaluatedProperties, $schema, $id, …), and it only sets additionalProperties: false on objects that already have properties, so a property-less / open-map object keeps its open additionalProperties and gets no properties. Both are invalid in strict mode.

(Line references are from 0.8.2 source, but the observed 400 on 0.15.2 confirms the behavior is unchanged.)

Suggested fix

In the strict-mode converter:

  • Strip keywords OpenAI's strict validator rejects (propertyNames, patternProperties, unevaluatedProperties, $schema, $id) at every level.
  • Coerce free-form objects (type object with no properties) into a closed empty object (properties: {}, additionalProperties: false, required: []) so they validate.
  • Alternatively, detect strict-incompatible schemas and emit that tool with strict: false instead of forcing strict: true universally, preserving the tool's full expressiveness.

Workaround (for others hitting this)

Discover MCP tools manually and scrub their schemas before passing to chat():

const UNSUPPORTED = new Set(["propertyNames","patternProperties","unevaluatedProperties","$schema","$id"]);

function toStrictSafe(s: any): any {
  if (Array.isArray(s)) return s.map(toStrictSafe);
  if (!s || typeof s !== "object") return s;
  const out: any = {};
  for (const [k, v] of Object.entries(s)) if (!UNSUPPORTED.has(k)) out[k] = toStrictSafe(v);
  const isObj = out.type === "object" || (Array.isArray(out.type) && out.type.includes("object"));
  if (isObj && !out.properties) { out.properties = {}; out.additionalProperties = false; out.required = []; }
  return out;
}

const tools = (await mcp.tools()).map(t =>
  t.inputSchema ? { ...t, inputSchema: toStrictSafe(t.inputSchema) } : t
);
chat({ adapter: openaiText("gpt-5.5"), messages, tools /* + your close middleware */ });

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions