Schemas Are Contracts

Beginner

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 if statements
  • 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:

Open
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:

Open
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():

Open
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:

Open
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:

  1. The model sees the rules before it calls. The Capabilities card now shows "low" | "normal" | "high" instead of string, and tools/list carries minLength, maxLength, format and enum, so a model can get the arguments right the first time.
  2. All the problems come back at once. FrontMCP checks every field and returns one INVALID_INPUT error that lists each problem with its path, so a model can fix everything in one retry.
  3. execute() got simpler. The checks are gone, and priority has 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:

ZodJSON 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:

Open
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's path and a message.
  • 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:

Open
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:

Open
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 of required. When the model leaves it out, execute() gets undefined, so your code decides what "no value" means. Here it means "search both".
  • .default(10) also leaves the field out of required, and adds "default": 10 to it, so the model knows what happens if it says nothing. When the field is missing, FrontMCP fills in 10 before execute() 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:

Open
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 inputSchema to JSON Schema in tools/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() and z.enum(). The model sees them before it calls, and FrontMCP enforces them before execute() 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 before execute() gets them.
  • .optional() and .default() both leave a field out of required. With .default(), tools/list shows the value, and FrontMCP fills it in before execute() runs.
  • Use z.object() and z.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().

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