Your Zod .refine() never reaches the model
Measured on ai 7.0.118 and zod 4.6.5: a .refine() on a tool input schema is stripped before the schema reaches the model, with no warning. z.string().startsWith('acct_') arrives as a pattern; the same rule written as a refinement arrives as nothing.
On this page
Quick answer
As of September 2026, a .refine() on a tool input schema is never shown to the model. It is stripped when your Zod schema is converted to the JSON Schema that goes on the wire, with no warning, no error and nothing in result.warnings. The model is told only the parts of your schema that JSON Schema happens to be able to express.
The sharp version of the problem is that two ways of writing the same rule behave completely differently:
z.string().startsWith('acct_')arrives at the model as"pattern": "^acct_.*".z.string().refine(function (s) { return s.startsWith('acct_'); })arrives as{"type": "string"}.
Your validation still runs. It just runs after the model has already committed to an answer, which is the expensive place to find out.
This is not a bug in any one SDK. The same schema loses the same rule in the Vercel AI SDK and in the official Model Context Protocol TypeScript SDK, because both hand the schema to the same converter.
The reproduction, in about a minute
No API key is needed. A small local HTTP server stands in for the provider, so every schema below is read off the wire rather than out of a docstring.
mkdir zod-refine-probe && cd zod-refine-probe
npm init -y
npm install --save-exact ai@7.0.118 @ai-sdk/anthropic@4.0.65 zod@4.6.5
Measured on Node 24.8.0.
The stand-in provider
This server speaks just enough of the Anthropic Messages shape for the provider package to parse it. It is deliberately stateless: it appends every request it receives to a log and answers every request the same way, so nothing carries over between runs.
const http = require('http');
const fs = require('fs');
const path = require('path');
const LOG = path.join(__dirname, 'requests.jsonl');
const TOOL_USE = {
id: 'msg_stub',
type: 'message',
role: 'assistant',
model: 'claude-sonnet-5',
content: [
{
type: 'tool_use',
id: 'toolu_stub',
name: 'transfer',
input: {
accountId: 'acct_abc',
email: 'ops@example.com',
amount: 900,
currency: 'eur'
}
}
],
stop_reason: 'tool_use',
usage: { input_tokens: 10, output_tokens: 10 }
};
const server = http.createServer(function (req, res) {
let body = '';
req.on('data', function (c) { body += c; });
req.on('end', function () {
fs.appendFileSync(LOG, body.replace(/\n/g, ' ') + '\n');
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify(TOOL_USE));
});
});
server.listen(8787, '127.0.0.1', function () {
console.log('capture server on 8787');
});
Save it as capture-server.js and start it in its own terminal with node capture-server.js.
The probe
A payments tool with a rule that any reviewer would call the most important one on the schema: euro transfers are capped at 500.
import { createAnthropic } from '@ai-sdk/anthropic';
import { generateText, tool } from 'ai';
import { z } from 'zod';
import fs from 'fs';
globalThis.AI_SDK_LOG_WARNINGS = false;
const anthropic = createAnthropic({
apiKey: 'no-key-needed',
baseURL: 'http://127.0.0.1:8787/v1'
});
const schema = z.object({
accountId: z.string().regex(/^acct_[a-z0-9]+$/).describe('Stripe-style account id'),
email: z.email(),
amount: z.number().min(1).max(10000),
currency: z.enum(['usd', 'eur']),
memo: z.string().min(3).max(120).optional(),
idempotencyKey: z.string().default('auto')
}).refine(function (v) {
if (v.currency !== 'eur') return true;
return Math.min(v.amount, 500) === v.amount;
}, { message: 'eur transfers are capped at 500' });
await generateText({
model: anthropic('claude-sonnet-5'),
maxOutputTokens: 256,
stopWhen: function () { return true; },
tools: {
transfer: tool({
description: 'Move money',
inputSchema: schema,
execute: async function (a) { return a; }
})
},
prompt: 'send 900 eur to acct_abc'
});
const body = JSON.parse(fs.readFileSync('requests.jsonl', 'utf8').trim().split('\n')[0]);
console.log(JSON.stringify(body.tools[0].input_schema, null, 2));
Save it as probe.mjs and run node probe.mjs.
Finding 1: everything survives except the rule that mattered
This is the schema the model actually receives, verbatim off the wire. The only edit is that the long generated email pattern has been replaced with an ellipsis for width:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"accountId": {
"type": "string",
"pattern": "^acct_[a-z0-9]+$",
"description": "Stripe-style account id"
},
"email": {
"type": "string",
"format": "email",
"pattern": "..."
},
"amount": {
"type": "number",
"minimum": 1,
"maximum": 10000
},
"currency": {
"type": "string",
"enum": [
"usd",
"eur"
]
},
"memo": {
"type": "string",
"minLength": 3,
"maxLength": 120
},
"idempotencyKey": {
"default": "auto",
"type": "string"
}
},
"required": [
"accountId",
"email",
"amount",
"currency"
],
"additionalProperties": false
}
Look at what is there. The regex made it. The description made it. The minimum and maximum made it. The enum made it. The string length bounds made it. The default made it, and correctly kept idempotencyKey out of required.
Now search that document for the words "capped at 500". They do not appear, in any form. The model is told that amount may be anything from 1 to 10000 and that currency may be eur, and it is told nothing at all about the relationship between them. From where the model is sitting, sending 900 eur is an obviously valid call.
Finding 2: the survival table
I ran the same capture against twenty single-field schemas to find out exactly where the line falls. Every row below is read off the wire.
Scroll to see more
| Zod construct | reaches the model as |
|---|---|
z.number().min(1).max(10) | minimum, maximum |
z.string().min(3).max(12) | minLength, maxLength |
z.string().regex(...) | pattern |
z.email() | format: "email" plus a pattern |
z.url() | format: "uri" |
z.uuid() | format: "uuid" plus a pattern |
z.number().int() | type: "integer" plus safe-integer bounds |
z.enum(['a','b']) | enum |
z.literal('fixed') | const |
z.string().describe(...) | description |
z.string().default('auto') | default, and drops out of required |
z.string().optional() | drops out of required |
z.string().nullable() | type: ["string","null"] |
z.array(z.string()).min(1).max(3) | minItems, maxItems |
z.number().multipleOf(5) | multipleOf |
z.string().startsWith('acct_') | format: "starts_with" plus pattern: "^acct_.*" |
z.string().refine(...) | nothing. Bare {"type": "string"} |
z.string().superRefine(...) | nothing. Bare {"type": "string"} |
z.string().transform(...) | nothing. Bare {"type": "string"} |
z.string().trim() | nothing. Bare {"type": "string"} |
Sixteen of twenty constructs cross the boundary intact. Four do not, and none of the four produces a warning.
The pair worth sitting with is startsWith and refine. Both express "this string must begin with acct_". One is a declarative check Zod knows how to translate into a JSON Schema keyword. The other is an opaque JavaScript predicate, and there is no keyword in JSON Schema that means "run this function". So one reaches the model and one cannot, and nothing in the authoring experience distinguishes them.
Finding 3: why, in one function
The conversion happens in @ai-sdk/provider-utils. The relevant call is short enough to read in full:
toJSONSchema(zodSchema2, {
target: "draft-7",
io: "input",
reused: useReferences ? "ref" : "inline"
})
The same function that produces that schema also builds a validate member, and that member does something completely different: it calls Zod's own safeParseAsync on the value the model returns.
That is the whole mechanism. One Zod object, two derivations. The model-facing derivation goes through JSON Schema, which can only carry what JSON Schema can express. The validation derivation runs Zod itself, which carries everything. Refinements exist only in the second one.
The vendor's own tool documentation describes this field in a single sentence that is true and hides the split, quoted verbatim:
"inputSchema: A Zod schema or a JSON schema that defines the input parameters. The schema is consumed by the LLM, and also used to validate the LLM tool calls."
One schema, two jobs. What the sentence does not say is that the two jobs are served by two different conversions of it, and that only one of them can represent a predicate. Measured on that page: 55,543 visible characters, zod appears 11 times, inputSchema 21 times, describe 6 times, and refine exactly zero times.
Finding 4: the same call is loud on its own and silent inside the SDK
Zod's own JSON Schema documentation has a prominent section on constructs it cannot represent. It lists z.bigint(), z.symbol(), z.undefined(), z.void(), z.date(), z.map(), z.set(), z.transform() and others, each marked with a cross, and says plainly that by default an error is thrown when one is encountered.
So a transform is supposed to be a loud failure. Run it yourself:
import { z } from 'zod';
import { toJSONSchema } from 'zod/v4/core';
const s = z.object({ f: z.string().transform(function (v) { return v.length; }) });
try {
console.log(JSON.stringify(toJSONSchema(s, { target: 'draft-7' }).properties.f));
} catch (e) {
console.log('THREW: ' + String(e.message).split('\n')[0]);
}
console.log(JSON.stringify(toJSONSchema(s, { target: 'draft-7', io: 'input' }).properties.f));
THREW: Transforms cannot be represented in JSON Schema
{"type":"string"}
The default really does throw. The AI SDK passes io: "input", and under that option the same schema converts quietly to {"type": "string"}. Zod documents why, verbatim:
"Some schema types have different input and output types, e.g. ZodPipe, ZodDefault, and coerced primitives. By default, the result of z.toJSONSchema represents the output type; use "io": "input" to extract the input type instead."
That option is the right choice for a tool schema. A tool schema describes what the model should send, which is the input side, so io: "input" is correct and the alternative would be wrong. The side effect is simply that a category of loud failure becomes a silent one inside the SDK. If you have read advice that transforms will blow up on conversion, that advice is accurate about z.toJSONSchema on its own and does not transfer into the SDK.
A refinement is a different case again, and a stricter one. Run the same script with a .refine() instead of a transform and both options return {"type": "string"}. Refinements are not on Zod's unrepresentable list at all, because Zod does not classify them as unrepresentable types; they are checks, and a check that JSON Schema has no keyword for is simply not emitted. Measured on that page: refine appears zero times.
Finding 5: what happens when the model breaks the invisible rule
The stand-in server above returns exactly the call the model would reasonably make: 900 eur. Here is what the SDK does with it.
const result = await generateText({ /* as above */ });
console.log('execute() calls :', executed);
console.log('toolCalls :', result.toolCalls.length);
console.log('toolResults :', result.toolResults.length);
console.log('finishReason :', result.finishReason);
console.log('warnings :', JSON.stringify(result.warnings));
console.log('content part types :', result.content.map(function (p) { return p.type; }).join('+'));
execute() calls : 0
toolCalls : 1
toolResults : 0
finishReason : tool-calls
warnings : []
content part types : tool-call+tool-error
Several things worth separating out here.
The guard holds. execute() is not called. Your money does not move. This is the part that works, and it is worth saying clearly, because the rest of this article is about a gap and this is not one.
Nothing throws. generateText resolves normally. result.warnings is an empty array. The failure is sitting in result.content as a tool-error part, which you have to go looking for.
toolResults is empty while toolCalls is one. If your code iterates toolResults to decide what happened, this run looks like nothing happened at all.
And on a multi-step run the error does get sent back to the provider, which is genuinely good design and deserves credit:
{
"type": "tool_result",
"tool_use_id": "toolu_stub",
"content": "AI_InvalidToolInputError: Invalid input for tool transfer: AI_TypeValidationError: Type validation failed: ... Error message: [ { \"code\": \"custom\", \"path\": [], \"message\": \"eur transfers are capped at 500\" } ]",
"is_error": true
}
So the model is eventually told the rule, in your own words, after breaking it. That is a workable recovery loop. It is also a full extra round trip, paid for on every call where the model guesses wrong, to communicate a constraint you had already written down before the conversation started.
Finding 6: the documented try/catch does not fire
The vendor page names four tool-call errors, including this one, quoted verbatim:
"InvalidToolInputError: the model calls a tool with inputs that do not match the tool's input schema"
and then says, also verbatim:
"generateText throws errors for tool schema validation issues and other errors, and can be handled using a try / catch block."
with a sample that wraps generateText in try/catch and tests the caught error with InvalidToolInputError.isInstance(error).
I could not make that fire. Four configurations, all resolving rather than throwing:
Scroll to see more
| configuration | outcome |
|---|---|
default step budget, execute present | resolved, tool-call+tool-error |
stopWhen: stepCountIs(3), execute present | resolved, three tool-error parts |
default step budget, no execute | resolved, tool-call+tool-error |
stopWhen: stepCountIs(3), no execute | resolved, three tool-error parts |
NoSuchToolError, the first error on the same list, behaves the same way: point the stand-in server at a tool name that is not defined and you get a resolved result with a tool-error part, not an exception.
That looks like the docs describing an older shape. The current behaviour is the better one, because routing these into content parts is exactly what enables the automated round trip shown above, and the same page says so a few lines earlier. But if you wrote the documented try/catch, it is dead code. Check result.content for tool-error parts instead.
I tested two of the four named errors. ToolCallRepairError and ToolChoiceViolationError were not exercised, so I am not claiming anything about those.
This is not an AI SDK problem
The obvious next question is whether this is one library's quirk. It is not. In a fresh directory:
npm install --save-exact @modelcontextprotocol/sdk@1.30.1 zod@4.6.5
Here is the same pair of fields registered as a tool on the official Model Context Protocol TypeScript SDK, read back through a real client over an in-memory transport:
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { z } from 'zod';
const server = new McpServer({ name: 'probe', version: '1.0.0' });
server.registerTool('transfer', {
description: 'Move money',
inputSchema: {
startsWithField: z.string().startsWith('acct_'),
refineField: z.string().refine(function (s) { return s.startsWith('acct_'); }, 'must start with acct_')
}
}, async function () { return { content: [{ type: 'text', text: 'ok' }] }; });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: 'probe-client', version: '1.0.0' });
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
const listed = await client.listTools();
console.log(JSON.stringify(listed.tools[0].inputSchema, null, 2));
await client.close();
{
"type": "object",
"properties": {
"startsWithField": {
"type": "string",
"format": "starts_with",
"pattern": "^acct_.*"
},
"refineField": {
"type": "string"
}
},
"required": [
"startsWithField",
"refineField"
],
"$schema": "http://json-schema.org/draft-07/schema#"
}
Identical outcome on @modelcontextprotocol/sdk 1.30.1. Anything that publishes a tool contract as JSON Schema inherits the same ceiling, because the ceiling belongs to JSON Schema and not to the framework. Any MCP client, from any vendor, reading that tools/list response sees a plain string.
What I checked and found was NOT a problem
Recording the things that turned out fine is half the point of measuring.
- Descriptions do cross.
.describe()lands asdescriptionon the exact field. This is the supported channel for anything JSON Schema cannot express, and it works. .default()is handled correctly. It emitsdefaultand removes the field fromrequired, so the model is properly told the field is optional.- Optional and nullable are handled correctly. No surprises in either.
startsWithis not a special case. It is one of a family of representable string checks that convert cleanly, alongsideregex,email,urlanduuid.- The validation itself is not skipped. Every refinement I wrote was enforced. The complaint in this article is about when, never about whether.
io: "input"is the correct option for a tool schema, not a mistake to be reported. A tool schema describes what the model should send.
What to do
Put the rule where the model can read it. If a constraint matters, it belongs in a .describe() on the field or in the tool description, in plain language, whether or not you also enforce it in code:
const schema = z.object({
amount: z.number().min(1).max(10000)
.describe('Amount in minor units. For EUR transfers the maximum is 500.'),
currency: z.enum(['usd', 'eur'])
.describe('Use eur only for amounts of 500 or less.')
}).refine(function (v) {
if (v.currency !== 'eur') return true;
return Math.min(v.amount, 500) === v.amount;
}, { message: 'eur transfers are capped at 500' });
Keep the .refine(). It is your backstop and it works. Add the description so the model does not need the backstop.
Prefer the representable form when one exists. z.string().startsWith('acct_') is strictly better than a refinement that does the same job, because it is enforced locally and declared to the model. The same goes for min, max, regex, enum, literal, multipleOf and the string formats. Reach for a refinement only for rules that genuinely cannot be expressed as a keyword, which in practice means rules spanning more than one field.
Move cross-field rules out of the schema where you can. The euro cap is a relationship between two fields, and JSON Schema draft-7 has no comfortable way to express it. Splitting one overloaded tool into transfer_usd and transfer_eur, each with its own honest maximum, turns an invisible rule into a visible one.
Read the tool description as part of the contract. The vendor also supports inputExamples, and describes their purpose in terms that fit this problem exactly: input examples help "when JSON schema itself does not fully specify the intended usage". That sentence is an acknowledgement that the gap exists and a pointer at the intended workaround.
Print your schema once. The single most useful habit is to log what actually goes on the wire the first time you write a tool. Twenty lines of capture server, once, and you never have to wonder which half of your schema the model can see.
Related reading
- Tool Calling in AI SDK Core, the vendor page quoted above.
- Zod: JSON Schema conversion, including the
iooption and the unrepresentable list. - The JSON Schema specification, for what the keywords can and cannot express.
- The Model Context Protocol TypeScript SDK, used for the cross-framework check.
Written by
Sofia NievesSofia works on agent evaluation and reliability. She writes about measuring LLM systems before and after they reach production.
Frequently asked questions
Does Zod .refine() get sent to the model in a tool schema?
No. Measured in September 2026 on ai 7.0.118 and zod 4.6.5, a .refine() is stripped when the Zod schema is converted to the JSON Schema that goes on the wire. The field arrives at the model as a bare type with no trace of the predicate, and there is no warning, no error and nothing in result.warnings. The same is true of .superRefine(), .transform() and .trim().
Why does z.string().startsWith('acct_') reach the model when .refine() does not?
Because startsWith is a declarative check that Zod can translate into JSON Schema keywords, and it arrives as format starts_with plus pattern ^acct_.*. A .refine() is an opaque JavaScript predicate, and JSON Schema has no keyword meaning run this function, so there is nothing to emit. Prefer the representable form whenever one exists: it is enforced locally and declared to the model.
Is my Zod validation still enforced if the constraint never reaches the model?
Yes. The refinement runs on the value the model returns, your execute function is not called when it fails, and the failure appears as an AI_InvalidToolInputError. The problem is timing, not enforcement: the rule is checked after the model has already committed to an answer, which costs a round trip on every wrong guess.
Why does .transform() throw on z.toJSONSchema but not inside the AI SDK?
Because the AI SDK calls toJSONSchema with io set to input. Zod documents that the default conversion represents the output type, and a transform has no representable output, so the default throws. The input side of a transform is just its input type, so under io input it converts quietly to that type. The option is the correct one for a tool schema, since a tool schema describes what the model should send.
Does generateText throw InvalidToolInputError so I can catch it?
Not in the version measured. Across four configurations on ai 7.0.118, generateText resolved every time and the failure appeared as a tool-error part in result.content, with finishReason tool-calls and an empty warnings array. NoSuchToolError behaves the same way. The vendor documentation still shows a try and catch block for these, so that code path is dead. Inspect result.content for tool-error parts instead.
Is this a bug in the Vercel AI SDK?
No. The same schema loses the same rule in the official Model Context Protocol TypeScript SDK 1.30.1, measured over an in-memory transport through a real client. The ceiling belongs to JSON Schema, which cannot express an arbitrary predicate, so anything that publishes a tool contract as JSON Schema inherits it.
How do I make a cross-field rule visible to the model?
Put it in plain language in a .describe() on the fields it affects, or in the tool description, and keep the .refine() as a backstop. Where the rule can be expressed as a keyword, use the representable form. Where a rule spans two fields, consider splitting one overloaded tool into two tools that each carry an honest minimum and maximum.
Related tutorials
generateText defaults to one step, so your agent returns an empty string
On ai 7.0.116, generateText and streamText run exactly one step by default. Pass tools without stopWhen and the model calls a tool, your tool runs, and you get an empty string with no error. The same package defaults ToolLoopAgent to twenty. Measured on the wire, with the bundle lines that cause it.
toModelOutput is not called when your tool throws, and the raw error goes to the provider
On ai 7.0.114, whatever your tool execute function throws is serialised and sent to the model provider in a tool_result block marked is_error. The documented toModelOutput hook is skipped on that path. Measured first-party with a local capture server, no API key.
Your MCP stdio server keeps running after the client dies (Node)
An MCP stdio server that holds a timer or a pool open does not exit when its client dies. Measured on SDK 1.30.0 and Node 24.8.0: it is reparented to init and keeps running. StdioServerTransport registers no stdin end-of-file listener. Here is the reproduction and the four-line fix.