Schemas Are Contracts
A tool's inputSchema is a contract. The model promises to send arguments that match it, FrontMCP checks that promise before your code runs, and execute() gets exactly the fields you declared. The model can only keep promises it can see, so every rule and every hint about what a field means belongs in the schema, where the model reads it, not in your code, where it can't.
You will learn
- How a Zod schema becomes the JSON Schema a model reads
- Why limits belong in the schema instead of in
ifstatements - What FrontMCP checks, drops and passes on before
execute()runs - What the model sees for optional fields and defaults
- How to declare nested objects and lists
What the model reads
This tool opens a support ticket, and it works. Open the Capabilities tab to see what a model gets to work with:
import { Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({
name: "create_ticket",
description: "Open a new support ticket for a customer.",
inputSchema: {
title: z.string(),
customer: z.string(),
priority: z.string(),
},
})
export class CreateTicket extends ToolContext {
async execute(input: { title: string; customer: string; priority: string }) {
return { id: "T-4", status: "open", ...input };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
A user writes: "Dana from Acme can't log in, and it's urgent." The model has to fill in three strings. Is customer a name ("Dana"), a company ("Acme"), or an email address? Is priority "urgent", "high" or "P1"? Nothing says, so the model guesses, and your code stores whatever it guessed.
.describe() attaches a note to a field. Say what the value is, in what format, with an example:
import { Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({
name: "create_ticket",
description: "Open a new support ticket for a customer.",
inputSchema: {
title: z.string().describe("One-line summary of the problem"),
customer: z.string().describe("The customer's email address, like dana@acme.com"),
priority: z.string().describe("low, normal or high. Use high only when the customer can't work at all."),
},
})
export class CreateTicket extends ToolContext {
async execute(input: { title: string; customer: string; priority: string }) {
return { id: "T-4", status: "open", ...input };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
FrontMCP converts inputSchema to JSON Schema and sends it in tools/list. Open the Wire tab and expand tools/list to see it. This is the part the model reads:
{
"name": "create_ticket",
"description": "Open a new support ticket for a customer.",
"inputSchema": {
"type": "object",
"properties": {
"title": { "type": "string", "description": "One-line summary of the problem" },
"customer": { "type": "string", "description": "The customer's email address, like dana@acme.com" },
"priority": { "type": "string", "description": "low, normal or high. Use high only when ..." }
},
"required": ["title", "customer", "priority"]
}
}
That JSON is the whole contract. The model never sees your Zod code, your types or your execute(). Every field is in required because none of them is marked optional.
A good .describe() note says what the name can't: the format (an email address), an example (like dana@acme.com), the unit, or where the value comes from. Don't repeat the name back: title: "The title" tells the model nothing it didn't already know.
Deep diveWhy import z from @frontmcp/sdk and not from zod?Show detailsHide details
The z in @frontmcp/sdk is Zod 4 with one difference you rarely notice: z.object(), z.union() and the other factories for compound schemas put off building the schema until it's first used. A server with many tools starts without building their input schemas; FrontMCP builds them when a client first lists the tools or calls one. Everything else works as in Zod: .describe(), .optional(), parsing, instanceof, z.infer.
A project made with frontmcp create has zod 4 installed too, and schemas from import { z } from "zod" work in inputSchema the same way. Zod 3 schemas don't: the tool is listed with no properties, and every call fails with INVALID_INPUT. The one place the difference shows is Zod's own z.toJSONSchema(), which throws for a z schema with an optional object in it, like customer: z.object({ email: z.string() }).optional(). Use toJSONSchema from @frontmcp/sdk instead, or build that schema with eagerZ, which is Zod's own z. See z, eagerZ and lazyZ.
Put rules in the schema, not in if statements
The priority note says "low, normal or high", but nothing enforces it. The obvious fix is a check in execute():
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({
name: "create_ticket",
description: "Open a new support ticket for a customer.",
inputSchema: {
title: z.string().describe("One-line summary of the problem"),
customer: z.string().describe("The customer's email address, like dana@acme.com"),
priority: z.string().describe("low, normal or high. Use high only when the customer can't work at all."),
},
})
export class CreateTicket extends ToolContext {
async execute(input: { title: string; customer: string; priority: string }) {
if (!["low", "normal", "high"].includes(input.priority)) {
this.fail(new PublicMcpError(`priority must be low, normal or high, not "${input.priority}"`));
}
if (input.title.length < 5) this.fail(new PublicMcpError("title is too short"));
if (!input.customer.includes("@")) this.fail(new PublicMcpError("customer must be an email address"));
return { id: "T-4", status: "open", ...input };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
This call has three problems, and the model hears about one of them. It will fix priority, call again, find out about title, call a third time, and find out about customer. The Capabilities tab still says priority: string, so the next conversation starts from the same guess.
Here are the same rules as Zod constraints:
import { Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({
name: "create_ticket",
description: "Open a new support ticket for a customer.",
inputSchema: {
title: z.string().min(5).max(120).describe("One-line summary of the problem"),
customer: z.string().email().describe("The customer's email address, like dana@acme.com"),
priority: z.enum(["low", "normal", "high"]).describe("Use high only when the customer can't work at all."),
},
})
export class CreateTicket extends ToolContext {
async execute(input: { title: string; customer: string; priority: "low" | "normal" | "high" }) {
return { id: "T-4", status: "open", ...input };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Three things changed:
- The model sees the rules before it calls. The Capabilities card now shows
"low" | "normal" | "high"instead ofstring, andtools/listcarriesminLength,maxLength,formatandenum, so a model can get the arguments right the first time. - All the problems come back at once. FrontMCP checks every field and returns one
INVALID_INPUTerror that lists each problem with itspath, so a model can fix everything in one retry. execute()got simpler. The checks are gone, andpriorityhas the narrow type"low" | "normal" | "high".
The priority note changed too: z.enum() already lists the values, so the note only says when to pick high.
Here is what each common Zod constraint becomes in the JSON Schema the model reads:
| Zod | JSON Schema in tools/list |
|---|---|
z.string().min(5).max(120) | "minLength": 5, "maxLength": 120 |
z.string().regex(/^T-\d+$/) | "pattern": "^T-\\d+$" |
z.string().email() | "format": "email", plus a pattern |
z.enum(["low", "normal", "high"]) | "enum": ["low", "normal", "high"] |
z.number().int().min(1).max(50) | "type": "integer", "minimum": 1, "maximum": 50 |
z.array(z.string()).min(1).max(10) | "type": "array", "minItems": 1, "maxItems": 10 |
.describe("...") | "description": "..." |
.optional() | the field is left out of required |
z.enum() is worth reaching for whenever a field has a fixed set of values. A model picks from a list far more reliably than it guesses a spelling.
What reaches execute()
FrontMCP runs every tools/call through your schema before execute() runs. If the arguments don't match, execute() never runs and the model gets the list of problems you saw above. If they do match, execute() gets the parsed result, which is not always exactly what the client sent. This call sends an assignee field that isn't in the schema:
import { Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({
name: "create_ticket",
description: "Open a new support ticket for a customer.",
inputSchema: {
title: z.string().min(5).max(120).describe("One-line summary of the problem"),
customer: z.string().email().describe("The customer's email address, like dana@acme.com"),
priority: z.enum(["low", "normal", "high"]).describe("Use high only when the customer can't work at all."),
},
})
export class CreateTicket extends ToolContext {
async execute(input: { title: string; customer: string; priority: "low" | "normal" | "high" }) {
// Echo back exactly what execute() received.
return { id: "T-4", status: "open", ...input };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The result has no assignee: FrontMCP dropped it before execute() ran, without an error. The Tests tab checks three rules:
- Unknown fields are dropped, not rejected. Your code only ever sees the fields you declared, so a client can't slip extra data past your schema.
- Every problem is reported at once. The error text is
Invalid tool input, then one entry per problem with the field'spathand amessage. - Nothing is converted. A value of the wrong type is an error, not something FrontMCP tries to fix:
["high"]is not"high", and"5"is not a number.
Your schema can also clean up values on the way in. .trim(), .toLowerCase() and .toUpperCase() change a string while it's parsed, and .transform() runs any function you give it. tools/list describes what a client should send, and execute() gets the value after the clean-up:
import { Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({
name: "create_ticket",
description: "Open a new support ticket for a customer.",
inputSchema: {
title: z.string().min(5).max(120).describe("One-line summary of the problem"),
customer: z
.string()
.email()
.transform((email) => email.toLowerCase())
.describe("The customer's email address, like dana@acme.com"),
},
})
export class CreateTicket extends ToolContext {
async execute(input: { title: string; customer: string }) {
return { id: "T-4", status: "open", ...input };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The model doesn't need to know about the lowercasing, and the schema doesn't tell it: JSON Schema can't describe a function, so a .transform() is as invisible as a .refine(). Here .toLowerCase() would do the same job in fewer characters; keep .transform() for clean-ups the built-in methods don't cover.
Optional fields and defaults
Most tools have arguments the model can leave out. Zod gives you two ways to say so. Look at status and limit in the Capabilities tab and the Tests tab:
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { tickets } from "./tickets";
@Tool({
name: "search_tickets",
description: "Search support tickets by words in their title.",
inputSchema: {
query: z.string().min(3).describe("Words to look for in ticket titles"),
status: z.enum(["open", "closed"]).optional().describe("Leave out to search both"),
limit: z.number().int().min(1).max(50).default(10).describe("The most tickets to return"),
},
})
export class SearchTickets extends ToolContext {
async execute({ query, status, limit }: { query: string; status?: "open" | "closed"; limit: number }) {
const q = query.toLowerCase();
const matches = tickets.filter((t) => t.title.toLowerCase().includes(q) && (!status || t.status === status));
return { tickets: matches.slice(0, limit), total: matches.length };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
.optional()leaves the field out ofrequired. When the model leaves it out,execute()getsundefined, so your code decides what "no value" means. Here it means "search both"..default(10)also leaves the field out ofrequired, and adds"default": 10to it, so the model knows what happens if it says nothing. When the field is missing, FrontMCP fills in 10 beforeexecute()runs. The Capabilities card shows optional and default 10.
Use .default() when one value is right most of the time, and .optional() when leaving a field out means something of its own, like "search both". With a default, execute() always gets a value, so type the parameter as limit: number, not limit?: number.
Deep diveWhy is limit optional in the schema but not in execute()?Show detailsHide details
A schema can describe a value before it's parsed (what a client may send) or after (what your code receives). tools/list uses the "before" view: a client may leave limit out, so it isn't required. execute() gets the "after" view: by then limit always has a value.
@Tool checks execute()'s parameter against the "after" view when TypeScript compiles. limit?: number fails that check, because it allows undefined, which execute() never gets.
Objects and lists
When several values belong together, like a customer's email and name, group them in a z.object(). When the model should send several values of one kind, use z.array() with rules for the items. An object shows up in the JSON Schema with its own properties and required list, and a list with an items schema, so the model sees the rules at every level:
import { Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({
name: "create_ticket",
description: "Open a new support ticket for a customer.",
inputSchema: {
title: z.string().min(5).max(120).describe("One-line summary of the problem"),
customer: z
.object({
email: z.string().email().describe("Like dana@acme.com"),
name: z.string().optional().describe("Full name, if the customer gave it"),
})
.describe("Who reported the problem"),
tags: z
.array(z.enum(["billing", "login", "bug", "feature"]))
.max(3)
.optional()
.describe("Up to 3 labels"),
},
})
export class CreateTicket extends ToolContext {
async execute(input: {
title: string;
customer: { email: string; name?: string };
tags?: ("billing" | "login" | "bug" | "feature")[];
}) {
return { id: "T-4", status: "open", ...input };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The error lists two problems, and each path points into the structure: ["customer", "email"] for the email, and ["tags", 1] for the second tag, "urgent", which isn't one of the four labels. Fix both in the form and call again.
The rules you've seen apply at every level. Nested fields can have .describe() notes, nested objects get their own required list, and unknown fields inside customer are dropped just like unknown fields at the top.
Prefer a list over a string the model has to format. tags: z.array(...) says exactly what to send. A tags: z.string().describe("Comma-separated labels") leaves the model to guess about spaces, quotes and brackets, and leaves your code to parse whatever it chose.
Recap
- FrontMCP converts
inputSchemato JSON Schema intools/list. That JSON is all the model knows about a tool's arguments. - Use
.describe()on any field whose name doesn't say its format, unit, or where its value comes from. - Put rules in the schema with constraints like
.min(),.regex(),.email()andz.enum(). The model sees them before it calls, and FrontMCP enforces them beforeexecute()runs, reporting every problem at once. - Unknown fields are dropped, and values of the wrong type are rejected, not converted.
.refine()rules aren't visible to the model; repeat them in.describe()..trim(),.toLowerCase()and.transform()clean up values beforeexecute()gets them..optional()and.default()both leave a field out ofrequired. With.default(),tools/listshows the value, and FrontMCP fills it in beforeexecute()runs.- Use
z.object()andz.array()for structured values instead of strings the model has to format.
Try some challenges
Challenge 1 of 3
Move the checks into the schema
This tool checks priority and title with if statements, so the model can't see the rules until it breaks them. Move both rules into inputSchema and delete the checks from execute().
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({
name: "create_ticket",
description: "Open a new support ticket.",
inputSchema: {
title: z.string().describe("One-line summary of the problem"),
priority: z.string().describe("low, normal or high"),
},
})
export class CreateTicket extends ToolContext {
async execute({ title, priority }: { title: string; priority: string }) {
if (title.length < 5 || title.length > 120) {
this.fail(new PublicMcpError("title must be 5 to 120 characters"));
}
if (!["low", "normal", "high"].includes(priority)) {
this.fail(new PublicMcpError("priority must be low, normal or high"));
}
return { id: "T-4", title, priority };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.