# Schemas Are Contracts

> How a tool's Zod input schema becomes the JSON Schema a model reads, what FrontMCP checks before execute() runs, and how to write schemas models fill in correctly.

Source: https://frontmcp.dev/learn/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 `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:

```ts create-ticket.tool.ts
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 };
  }
}
```

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:

```ts create-ticket.tool.ts
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 };
  }
}
```

FrontMCP converts `inputSchema` to [JSON Schema](https://json-schema.org) and sends it in `tools/list`. Open the **Wire** tab and expand `tools/list` to see it. This is the part the model reads:

```json
{
  "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 dive: Why import z from @frontmcp/sdk and not from zod?**
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`](https://frontmcp.dev/reference/sdk#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()`:

```ts create-ticket.tool.ts
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 };
  }
}
```

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:

```ts create-ticket.tool.ts
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 };
  }
}
```

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:

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

```ts create-ticket.tool.ts active
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 };
  }
}
```

```ts create-ticket.test.ts
import { test, expect } from "@frontmcp/testing";

const ticket = { title: "Cannot log in", customer: "dana@acme.com", priority: "high" };

test("fields that aren't in the schema are dropped", async ({ mcp }) => {
  const result = await mcp.tools.call("create_ticket", { ...ticket, assignee: "Nour" });
  expect(result).toBeSuccessful();
  expect(result.json()).not.toHaveProperty("assignee");
});

test("every problem is reported in one error", async ({ mcp }) => {
  const result = await mcp.tools.call("create_ticket", { title: "Help", customer: "dana", priority: "urgent" });
  expect(result).toBeError();
  expect(result.raw._meta?.code).toBe("INVALID_INPUT");
  expect(result.text()).toContain('"title"');
  expect(result.text()).toContain('"customer"');
  expect(result.text()).toContain('"priority"');
});

test("values aren't converted to the right type", async ({ mcp }) => {
  const result = await mcp.tools.call("create_ticket", { ...ticket, priority: ["high"] });
  expect(result.raw._meta?.code).toBe("INVALID_INPUT");
});
```

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.

> **Pitfall: A renamed field disappears without an error**
Because unknown fields are dropped, renaming a field doesn't produce an "unknown field" error for callers that still use the old name. If you rename `customer` to `customer_email`, a client that sends `customer` gets `customer_email` reported as missing, which is a confusing message. If the new field is optional, the value just vanishes. Treat field names like the tool's name: once models and prompts use them, change them carefully.

> **Pitfall: Refinements are invisible to the model**
`.refine()` runs your own function during validation, but JSON Schema has no way to describe a function, so the model never sees the rule:

```ts reply.tool.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({
  name: "reply_to_ticket",
  description: "Send a reply to the customer on a ticket.",
  inputSchema: {
    id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1"),
    body: z
      .string()
      .min(1)
      .refine((text) => !/password/i.test(text), "Never ask a customer for their password")
      .describe("The reply, in plain text"),
  },
})
export class ReplyToTicket extends ToolContext {
  async execute({ id, body }: { id: string; body: string }) {
    return { id, sent: true, length: body.length };
  }
}
```

The call fails with your message, but the Capabilities card only says `body: string`. Keep the refinement, and also say the rule in `.describe()` (`"The reply, in plain text. Never ask for a password."`), so the model can follow it before it calls.

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:

```ts create-ticket.tool.ts active
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 };
  }
}
```

```ts create-ticket.test.ts
import { test, expect } from "@frontmcp/testing";

test("the model sees `customer` as an email address", async ({ mcp }) => {
  const [tool] = await mcp.tools.list();
  expect(tool.inputSchema.properties?.customer).toMatchObject({ type: "string", format: "email" });
  expect(tool.inputSchema.required).toEqual(["title", "customer"]);
});

test("execute() gets it in lowercase", async ({ mcp }) => {
  const result = await mcp.tools.call("create_ticket", { title: "Cannot log in", customer: "Dana@Acme.com" });
  expect(result.json().customer).toBe("dana@acme.com");
});
```

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:

```ts search.tool.ts active
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 };
  }
}
```

```ts tickets.ts
export const tickets = Array.from({ length: 30 }, (_, i) => ({
  id: `T-${i + 1}`,
  title: i % 2 ? "Login link expired" : "Cannot log in",
  status: i % 3 ? "open" : "closed",
}));
```

```ts search.test.ts
import { test, expect } from "@frontmcp/testing";

async function schema(mcp: any) {
  const tool = (await mcp.tools.list()).find((t: any) => t.name === "search_tickets");
  return tool.inputSchema;
}

test("`status` is optional", async ({ mcp }) => {
  expect((await schema(mcp)).required).not.toContain("status");
});

test("`limit` is optional, and the schema shows its default", async ({ mcp }) => {
  const s = await schema(mcp);
  expect(s.required).not.toContain("limit");
  expect(s.properties.limit.default).toBe(10);
});

test("leaving `limit` out: execute() gets 10", async ({ mcp }) => {
  const result = await mcp.tools.call("search_tickets", { query: "log" });
  expect(result).toBeSuccessful();
  expect(result.json().tickets).toHaveLength(10);
});

test("`limit` must be a number, not a string", async ({ mcp }) => {
  const result = await mcp.tools.call("search_tickets", { query: "log", limit: "5" });
  expect(result.raw._meta?.code).toBe("INVALID_INPUT");
});
```

- **`.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 dive: Why is limit optional in the schema but not in execute()?**
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:

```ts create-ticket.tool.ts
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 };
  }
}
```

```ts create-ticket.test.ts
import { test, expect } from "@frontmcp/testing";

test("each problem's path points into the structure", async ({ mcp }) => {
  const result = await mcp.tools.call("create_ticket", {
    title: "Invoice total is wrong",
    customer: { email: "dana@acme" },
    tags: ["billing", "urgent"],
  });
  const problems = JSON.parse(result.text().split("Details: ")[1]);
  expect(problems.map((p: { path: unknown[] }) => p.path)).toEqual([["customer", "email"], ["tags", 1]]);
});

test("unknown fields inside `customer` are dropped", async ({ mcp }) => {
  const result = await mcp.tools.call("create_ticket", {
    title: "Invoice total is wrong",
    customer: { email: "dana@acme.com", vip: true },
  });
  expect(result.json().customer).toEqual({ email: "dana@acme.com" });
});
```

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: 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()`.

```ts create-ticket.tool.ts
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 };
  }
}
```

```ts create-ticket.tool.ts solution
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({
  name: "create_ticket",
  description: "Open a new support ticket.",
  inputSchema: {
    title: z.string().min(5).max(120).describe("One-line summary of the problem"),
    priority: z.enum(["low", "normal", "high"]),
  },
})
export class CreateTicket extends ToolContext {
  async execute({ title, priority }: { title: string; priority: "low" | "normal" | "high" }) {
    return { id: "T-4", title, priority };
  }
}
```

```ts create-ticket.test.ts hidden
import { test, expect } from "@frontmcp/testing";

async function schema(mcp: any) {
  const tool = (await mcp.tools.list()).find((t: any) => t.name === "create_ticket");
  return tool.inputSchema;
}

test("`priority` lists its allowed values in the schema", async ({ mcp }) => {
  expect((await schema(mcp)).properties.priority.enum).toEqual(["low", "normal", "high"]);
});

test("`title` is 5 to 120 characters, in the schema", async ({ mcp }) => {
  const title = (await schema(mcp)).properties.title;
  expect(title.minLength).toBe(5);
  expect(title.maxLength).toBe(120);
});

test("a bad priority is rejected before `execute()` runs", async ({ mcp }) => {
  const result = await mcp.tools.call("create_ticket", { title: "Cannot log in", priority: "urgent" });
  expect(result).toBeError();
  expect(result.raw._meta?.code).toBe("INVALID_INPUT");
});

test("a valid ticket is still created", async ({ mcp }) => {
  const result = await mcp.tools.call("create_ticket", { title: "Cannot log in", priority: "high" });
  expect(result).toBeSuccessful();
  expect(result.json()).toEqual({ id: "T-4", title: "Cannot log in", priority: "high" });
});
```

**Hint:**
`z.enum([...])` replaces the list check, and `.min()` and `.max()` on the string replace the length check. Change the type of `priority` in `execute()` to match.

**Solution:**
The rules now appear in `tools/list` as `enum`, `minLength` and `maxLength`, so a model can follow them before it calls. A call that breaks them fails with `INVALID_INPUT` instead of your own `PUBLIC_ERROR`, and it lists every problem at once. The `.describe()` note on `priority` became redundant, because the enum lists the values.

### Challenge: Show the model the default priority
Most tickets are `normal`, and `create_ticket` already treats a missing `priority` as `normal`. But that rule is a `??` inside `execute()`, where the model can't see it. Move the default into the schema, so `tools/list` shows it, and keep `priority` optional.

```ts create-ticket.tool.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({
  name: "create_ticket",
  description: "Open a new support ticket.",
  inputSchema: {
    title: z.string().min(5).max(120).describe("One-line summary of the problem"),
    priority: z
      .enum(["low", "normal", "high"])
      .optional()
      .describe("Use high only when the customer can't work at all"),
  },
})
export class CreateTicket extends ToolContext {
  async execute({ title, priority }: { title: string; priority?: "low" | "normal" | "high" }) {
    return { id: "T-4", title, priority: priority ?? "normal" };
  }
}
```

```ts create-ticket.tool.ts solution
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({
  name: "create_ticket",
  description: "Open a new support ticket.",
  inputSchema: {
    title: z.string().min(5).max(120).describe("One-line summary of the problem"),
    priority: z
      .enum(["low", "normal", "high"])
      .default("normal")
      .describe("Use high only when the customer can't work at all"),
  },
})
export class CreateTicket extends ToolContext {
  async execute({ title, priority }: { title: string; priority: "low" | "normal" | "high" }) {
    return { id: "T-4", title, priority };
  }
}
```

```ts create-ticket.test.ts hidden
import { test, expect } from "@frontmcp/testing";

async function schema(mcp: any) {
  const tool = (await mcp.tools.list()).find((t: any) => t.name === "create_ticket");
  return tool.inputSchema;
}

test("`priority` isn't required", async ({ mcp }) => {
  expect((await schema(mcp)).required ?? []).not.toContain("priority");
});

test("the schema shows the default, \"normal\"", async ({ mcp }) => {
  expect((await schema(mcp)).properties.priority.default).toBe("normal");
});

test("a ticket without a priority is \"normal\"", async ({ mcp }) => {
  const result = await mcp.tools.call("create_ticket", { title: "Cannot log in" });
  expect(result).toBeSuccessful();
  expect(result.json().priority).toBe("normal");
});

test("a ticket can still be \"high\"", async ({ mcp }) => {
  const result = await mcp.tools.call("create_ticket", { title: "Cannot log in", priority: "high" });
  expect(result.json().priority).toBe("high");
});
```

**Hint:**
`.default()` takes the place of `.optional()`, and of the `??`. Change the type of `priority` in `execute()` to match.

**Solution:**
`.default("normal")` keeps `priority` out of `required`, puts `"default": "normal"` in `tools/list` so the model knows what happens if it says nothing, and fills in `"normal"` before `execute()` runs. The `??` is gone, and `priority` is never `undefined` in `execute()`, so its type loses the `?`.

### Challenge: Take a list, not a comma-separated string
`close_tickets` asks the model for a comma-separated string of ids, and then parses it. Change `ids` to a list of ticket ids, where each id looks like `T-123`, and the list has 1 to 10 items.

```ts close-tickets.tool.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

const tickets = [
  { id: "T-1", status: "open" },
  { id: "T-2", status: "closed" },
  { id: "T-3", status: "open" },
];

@Tool({
  name: "close_tickets",
  description: "Close one or more support tickets.",
  inputSchema: {
    ids: z.string().describe("Comma-separated ticket ids, like T-1, T-3"),
  },
})
export class CloseTickets extends ToolContext {
  async execute({ ids }: { ids: string }) {
    const list = ids.split(",").map((id) => id.trim());
    for (const t of tickets) if (list.includes(t.id)) t.status = "closed";
    return { closed: list };
  }
}
```

```ts close-tickets.tool.ts solution
import { Tool, ToolContext, z } from "@frontmcp/sdk";

const tickets = [
  { id: "T-1", status: "open" },
  { id: "T-2", status: "closed" },
  { id: "T-3", status: "open" },
];

@Tool({
  name: "close_tickets",
  description: "Close one or more support tickets.",
  inputSchema: {
    ids: z
      .array(z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1"))
      .min(1)
      .max(10)
      .describe("The tickets to close"),
  },
})
export class CloseTickets extends ToolContext {
  async execute({ ids }: { ids: string[] }) {
    for (const t of tickets) if (ids.includes(t.id)) t.status = "closed";
    return { closed: ids };
  }
}
```

```ts close-tickets.test.ts hidden
import { test, expect } from "@frontmcp/testing";

async function ids(mcp: any) {
  const tool = (await mcp.tools.list()).find((t: any) => t.name === "close_tickets");
  return tool.inputSchema.properties.ids;
}

test("`ids` is a list in the schema", async ({ mcp }) => {
  expect((await ids(mcp)).type).toBe("array");
});

test("the list holds 1 to 10 ids", async ({ mcp }) => {
  const s = await ids(mcp);
  expect(s.minItems).toBe(1);
  expect(s.maxItems).toBe(10);
});

test("an id that doesn't look like T-123 is rejected", async ({ mcp }) => {
  const result = await mcp.tools.call("close_tickets", { ids: ["T-1", "ticket 3"] });
  expect(result.raw._meta?.code).toBe("INVALID_INPUT");
});

test("closing [\"T-1\", \"T-3\"] closes both", async ({ mcp }) => {
  const result = await mcp.tools.call("close_tickets", { ids: ["T-1", "T-3"] });
  expect(result).toBeSuccessful();
  expect(result.json()).toEqual({ closed: ["T-1", "T-3"] });
});
```

**Hint:**
`z.array(item)` takes the schema for one item. Put the id's `.regex()` on the item, and `.min()` and `.max()` on the array.

**Solution:**
`z.array(z.string().regex(/^T-\d+$/)).min(1).max(10)` becomes `"type": "array"` with `minItems`, `maxItems` and an `items` schema that has the `pattern`. The model now knows exactly what to send, a bad id is rejected with its position in the list, and `execute()` no longer parses anything.
