1. A tool as a contract: what exactly we are describing
When you register a tool on an MCP server, you describe it with a small object. A simplified structure for the TypeScript SDK looks like this:
server.registerTool(
"suggest_gifts",
{
title: "Suggest gifts",
description: "Selects gifts based on the recipient’s profile.",
inputSchema: {
type: "object",
// this is what we’re about to dive into
},
},
async ({ input }) => {
// your code
}
);
The model doesn’t know what’s inside the async handler ({ input }) => { ... }. For it, there are only three things:
- name/title — what the tool is called.
- description — when it is appropriate to use it.
- inputSchema — what arguments to pass and in what format.
Everything we do in this lecture relates to point 3 (and a bit to the _meta/annotations metadata we’ll discuss later).
It’s important to understand: JSON Schema in the context of a ChatGPT App is not a boring validator; it is part of the model’s prompt. The model does read description on fields, understands what an enum is, and notices minItems, format, etc.
So you’re not just protecting the backend from malformed data—you’re explaining to the AI model how to correctly call your function.
2. Basic JSON Schema for the suggest_gifts tool
Let’s start simple. Suppose we have this scenario:
A user writes:
“Pick a gift for my 25-year-old brother, budget 50–70 dollars, likes video games and board games.”
The suggest_gifts tool should accept roughly these arguments:
- the recipient’s age;
- relationship type (brother, colleague, partner, etc.);
- minimum and maximum budget;
- a list of interests.
Let’s describe this as a straightforward JSON Schema, without Zod, as a plain object:
const suggestGiftsInputSchema = {
type: "object",
properties: {
age: {
type: "integer",
minimum: 0,
maximum: 120,
description: "Recipient’s age in years.",
},
relationship: {
type: "string",
enum: ["friend", "partner", "sibling", "colleague", "parent"],
description:
"Relationship to the recipient: friend, partner, sibling (brother/sister), colleague, parent.",
},
minBudget: {
type: "number",
minimum: 0,
description: "Minimum budget in the user’s currency.",
},
maxBudget: {
type: "number",
minimum: 0,
description: "Maximum budget in the user’s currency.",
},
interests: {
type: "array",
items: {
type: "string",
description:
"Short name of an interest, for example: videogames, boardgames, books.",
},
minItems: 1,
description: "List of the recipient’s interests.",
},
},
required: ["relationship", "maxBudget"],
};
A few important points worth stating upfront.
First, description on fields. In a typical API, you might skip these—a front-end developer can read Swagger and infer the rest. Here the “client” is the model, which tries to extract meaning from names and descriptions. The clearer you say “age in years,” “budget in the user’s currency,” “enum with fixed values,” the fewer odd arguments you’ll see at runtime.
Second, enum is one of the most powerful tools to steer the model. If you allow the model to write any string in relationship, you’ll get “bro,” “girlfriend,” “bestie,” “teammate,” and something even more creative. If you define an enum, the model is very likely to select only from those values. This directly reduces “hallucinations” in arguments.
Third, you don’t have to make everything required. For example, age can be optional: if the user didn’t provide it, the model won’t invent an “approximate age” out of thin air (if you phrase the description that way). Here’s where the art begins: balancing flexibility and strictness.
Now let’s use this schema when registering the tool:
server.registerTool(
"suggest_gifts",
{
title: "Suggest gifts",
description:
"Selects gift ideas by budget, relationship type, and recipient interests.",
inputSchema: suggestGiftsInputSchema,
},
async ({ input }) => {
// here input already roughly matches the schema
// ...
}
);
This “manual” object works well for quick experiments. But as the application grows, it becomes its own world that can easily drift away from your TypeScript types. We’ll return to this problem a bit later and see how to solve it with Zod and by generating JSON Schema from types.
3. JSON Schema as a prompt: how to write description so the model doesn’t struggle
Formally, JSON Schema is about validation. Informally, in the world of LLMs, it’s also a structured prompt. A few practical rules:
- The description field must answer “what to put here and in what format.”
A label like “Date” doesn’t help. A label like “ISO 8601 date in the format YYYY-MM-DD, for example "2025-02-14"” helps a lot. - If a field involves money—specify the units.
It’s better to write explicitly “Amount in the user’s currency” or “Amount in US dollars.” Otherwise the model may dutifully write 50 and you’ll be guessing whether that’s 50 yen or 50 euros. - String “categories” are almost always better as enum.
If a field is a string “category,” it’s better to make it an enum and describe each value in the tool’s description. For example, for relationship you can write in the tool description: “relationship: one of friend, partner, sibling (brother or sister), colleague (co-worker), parent. Do not invent other values.” - For arrays it’s helpful to set minItems and explain what the list represents.
If a field is an array, it’s useful to specify minItems and briefly explain what the list is. For example, interests is not “a prose description of a person,” but “a set of short tags.”
All of this sounds a bit pedantic, but in practice the difference between “has descriptions” and “no descriptions” is the difference between a stable app and a constant lottery of “what the model will send today.”
Insight
MCP tools have strict size limits—and these are most often the reason for “mystical” crashes, strange errors, and cases where the assistant suddenly stops seeing your tools.
The key rule is simple: the entire tool must fit into ~4 KB of JSON. This is not only the description text but the whole structure:
- tool description,
- argument schema (inputSchema),
- nested objects and enums,
- _meta and annotations.
If your tool grows too large, the platform starts behaving unpredictably: you’ll see errors like "Tool description is too long", "Schema validation failed", "Manifest exceeds size limits", and sometimes ChatGPT simply stops loading the tool or “forgets” it exists.
Recommendation: keep description within 1000–2000 characters, and the whole tool within a “safe” ~4 KB. If the description is getting too long, that’s almost always a sign the tool is doing too many things at once. Separate tools should be narrow and very clear—this helps the model understand their boundaries more reliably and reduces input mistakes.
4. TypeScript and Zod: one source of truth instead of two
Writing JSON Schema by hand is painful for a TypeScript developer. You end up maintaining two parallel worlds:
- types in your TS code;
- JSON Schema for the model.
As the application grows, they drift apart. Today you change a field in the TypeScript type, tomorrow you forget to update the schema—and a week later you hit a prod crash.
The de facto standard approach in the TS world is to use Zod and convert Zod -> JSON Schema.
Install the dependencies (if you haven’t yet):
npm install zod zod-to-json-schema
Describe the input schema for suggest_gifts with Zod:
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
const SuggestGiftsInputZod = z.object({
age: z
.number()
.int()
.min(0)
.max(120)
.describe("Recipient’s age in years."),
relationship: z
.enum(["friend", "partner", "sibling", "colleague", "parent"])
.describe(
"Relationship type: friend, partner, sibling (brother/sister), colleague, parent."
),
minBudget: z
.number()
.min(0)
.optional()
.describe("Minimum budget in the user’s currency."),
maxBudget: z
.number()
.min(0)
.describe("Maximum budget in the user’s currency."),
interests: z
.array(
z
.string()
.min(1)
.describe(
"A short interest tag, for example: videogames, boardgames, books."
)
)
.min(1)
.describe("List of the recipient’s interests."),
});
Now you have:
- Runtime validation: SuggestGiftsInputZod.parse(input);
- TypeScript type: type SuggestGiftsInput = z.infer<typeof SuggestGiftsInputZod>;
- JSON Schema for the model: zodToJsonSchema(SuggestGiftsInputZod).
Use it when registering the tool:
type SuggestGiftsInput = z.infer<typeof SuggestGiftsInputZod>;
const suggestGiftsInputSchemaJson = zodToJsonSchema(
SuggestGiftsInputZod,
"SuggestGiftsInput"
);
server.registerTool(
"suggest_gifts",
{
title: "Suggest gifts",
description:
"Selects gift ideas by budget, relationship type, and recipient interests.",
inputSchema: suggestGiftsInputSchemaJson,
},
async ({ input }) => {
// here input can additionally be validated with Zod:
const args = SuggestGiftsInputZod.parse(input) as SuggestGiftsInput;
// then work with the typed args
}
);
This approach gives you that single source of truth: you describe the schema once, and the TypeScript type and JSON Schema are generated automatically.
In the real world, you’ll also add tests that verify zodToJsonSchema yields the expected structure, but that’s a topic for the testing module.
Insight: ChatGPT handles optional parameters poorly
One of the most painful practices in production: as soon as you start actively using optional fields in tool schemas, the quality of tool calls visibly drops. In theory, the model “understands” optional parameters, but in practice it often just doesn’t send them at all—even when your business logic needs them.
Response API tackled this neatly: they simply removed optional fields—every tool parameter must be declared as required. But the problem remains: the idea “I’ll mark half the fields optional and the model will decide what to fill” collides with reality—usually it just sends nothing.
5. Where “schema” ends and “interface design” begins
Up to now we’ve been talking about inputSchema—what arguments the model must generate to execute the tool. But life doesn’t end after the tool call: the result still needs to be rendered in the UI.
It’s helpful to separate two layers:
- The tool schema describes the input arguments the model must generate. This is always JSON that lives in the MCP / tool-call space.
- A UI component (widget) reads toolOutput.structuredContent and builds the interface on top of it. The format of structuredContent is also something you design, but it is not the model’s JSON Schema (though you can formalize it for yourself).
Sometimes developers try to cover both at once with a single JSON object—combine both inputs for the model and the data format for the UI. This rarely ends well. It’s more convenient to split:
- inputSchema — about what the model needs to run the tool;
- structuredContent — about what the UI needs to render the result.
For example, the inputSchema for suggest_gifts contains no gift ids. But structuredContent does—it contains a list of cards with id, title, price, a purchase link, etc.
6. Annotations and _meta: how to influence UX and safety
Beyond the parameter schema and response structure there’s another layer—how the platform treats the tool and shows it to the user. This is controlled by metadata and annotations.
In addition to the standard title, description, inputSchema, a tool can have extra metadata and annotations. In the Apps SDK and MCP, some of these live in _meta (for example, securitySchemes), and others in special fields such as OpenAI‑specific hints like readOnlyHint and destructiveHint.
It’s important to understand: these annotations don’t change the JSON Schema, but they do affect how ChatGPT presents the tool to the user and how it treats its invocation.
Example: readOnlyHint and destructiveHint
Suppose you have two tools:
- list_gifts — just fetch a list of gifts (safe);
- create_order — create an order (potentially dangerous: money, address, serious stuff).
You can mark them roughly like this (pseudocode):
server.registerTool(
"list_gifts",
{
title: "List gift suggestions",
description: "Retrieves a list of available gifts for the given filters.",
inputSchema: listGiftsInputSchema,
_meta: {
readOnlyHint: true,
},
},
async ({ input }) => { /* ... */ }
);
server.registerTool(
"create_order",
{
title: "Create gift order",
description:
"Creates an order for a specific gift on behalf of the user. Use only after explicit confirmation.",
inputSchema: createOrderInputSchema,
_meta: {
destructiveHint: true,
},
},
async ({ input }) => { /* ... */ }
);
The semantics are as follows. readOnlyHint signals to ChatGPT that the tool doesn’t change anything and is safe; the model and UI can call it more freely. destructiveHint indicates that the tool performs irreversible or critical actions, so the user will see confirmations more often and the model will be more cautious.
In your Gift app, suggest_gifts is clearly read‑only, whereas tools for placing orders, charging money, and changing user data are better marked as potentially destructive.
openWorldHint and similar fields
In some cases you want to hint to the model that the tool operates in an “open world,” meaning its results are not exhaustive. For example, search_products will never return every product that exists—it will only return relevant ones.
Such annotations help the model avoid strong conclusions like “if the product wasn’t found by search_products, then it doesn’t exist.” This is a subtle UX detail, but in production apps the difference is noticeable.
_meta around UI display
When your tool returns a result, you can also specify settings in _meta that influence the widget. For example: which HTML template to use as the output template, whether borders are needed, what message to show during invocation, etc.
In the official example, the server separately registers the widget’s HTML as an MCP resource and then references it via _meta["openai/outputTemplate"].
server.registerTool(
"suggest_gifts",
{
title: "Suggest gifts",
description: "Selects gift ideas.",
inputSchema: suggestGiftsInputSchemaJson,
_meta: {
"openai/outputTemplate": "ui://widget/gifts.html", // This is the id of an MCP resource: server.registerResource(...)
"openai/toolInvocation/invoking": "Picking gifts…", // Displayed during the search
"openai/toolInvocation/invoked": "Found gift options", // Displayed when the search is complete
},
},
async ({ input }) => {
// ...
return {
content: [],
structuredContent: { items: gifts },
};
}
);
Thus, in one place you describe:
- the shape of inputs for the model (inputSchema);
- how the tool will look and behave in the UI (_meta).
7. Schema design: what to ask the model for—and what not to
A typical trap is to try to offload all the work to the model. For example, you describe a giftId field in inputSchema and write in its description: “UUID of the gift in our database.” The model will dutifully try to generate a UUID like "0f21b5f0-5a3a-4d1b-8f0b-9f1a6e3c1234", but the problem is that such a gift likely doesn’t exist in your system.
A good rule: don’t ask the model to generate technical identifiers and data tied to your internal world.
Instead, design a multi-step flow:
- suggest_gifts returns a list of gifts with id, title, price, etc.;
- the UI/model lets the user select one of the proposed options;
- create_order accepts a giftId from the already existing set.
From a schema standpoint, this means:
- inputSchema for tools that face “outwards” (toward the user) describe only what a human can reasonably enter: search parameters, filters, criteria;
- inputSchema for tools that operate on internal entities rely on already known ids rather than asking the model to invent them.
For your Gift app, this means that in suggest_gifts you don’t ask the model to “invent an SKU,” only the query parameters. You attach the SKUs on the backend, and the UI shows them to the user.
Note: SKU is a stock keeping unit code. Example "GFT-CHC-500-BS".
8. A small practical section: put it all together
Let’s put in one place everything we’ve discussed: the Zod schema, JSON Schema generation, tool registration with _meta, and using the schema in business logic. We’ll build a minimal but connected example for the Gift app.
First, the Zod schema and type:
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
const SuggestGiftsInputZod = z.object({
relationship: z
.enum(["friend", "partner", "sibling", "colleague", "parent"])
.describe("Relationship to the gift recipient."),
maxBudget: z
.number()
.min(0)
.describe("Maximum budget in the user’s currency."),
interests: z
.array(
z
.string()
.min(1)
.describe("Short interest tag, for example: videogames.")
)
.min(1)
.describe("List of the recipient’s interests."),
});
type SuggestGiftsInput = z.infer<typeof SuggestGiftsInputZod>;
const suggestGiftsInputSchemaJson = zodToJsonSchema(
SuggestGiftsInputZod,
"SuggestGiftsInput"
);
Next—register the tool with _meta for the UI:
server.registerTool(
"suggest_gifts",
{
title: "Suggest gifts",
description:
"Use when you need to select gift ideas by budget, relationship, and interests.",
inputSchema: suggestGiftsInputSchemaJson,
_meta: {
"openai/outputTemplate": "ui://widget/gifts.html",
"openai/toolInvocation/invoking": "Picking gifts…",
"openai/toolInvocation/invoked": "Found gift options",
readOnlyHint: true,
},
},
async ({ input }) => {
const args = SuggestGiftsInputZod.parse(input) as SuggestGiftsInput;
const gifts = await findGifts(args); // your business logic
return {
content: [],
structuredContent: {
items: gifts,
},
};
}
);
Somewhere nearby you’ll have a typed business function:
async function findGifts(input: SuggestGiftsInput) {
// here you can use input.relationship, input.maxBudget, input.interests
// and return an array of Gift objects
return [
{
id: "gift-1",
title: "Board game themed on video games",
price: 45,
currency: "USD",
},
];
}
On the widget side you’ll then take window.openai.toolOutput.structuredContent.items and render cards, but we’ll talk about that in more detail in a couple of lectures.
9. Common mistakes when describing tools
Mistake #1: Descriptions that are too generic or meaningless.
If you write description: "Date" or description: "Filter parameter", the model gets approximately zero useful information. It’s like documentation that says “the method does something important.” Use descriptions that answer “what goes here” and “in what format.” For example: “ISO 8601 date in the format YYYY-MM-DD, e.g. "2025-02-14"” or “Amount in the user’s currency, example: 49.99.”
Mistake #2: No enum where it obviously belongs.
Developers often skip turning strings into an enum and leave type: "string". As a result, the model invents its own values, the backend is surprised, and the UI breaks. If you have a fixed set of options (relationship, status types, sorting methods) it almost always makes sense to use an enum and list the possible values. This greatly improves tool-call predictability.
Mistake #3: Two sources of truth for schemas and types.
Classic: in TypeScript you change maxBudget to priceMax, but forget to update the JSON Schema. The model keeps sending maxBudget, the code expects priceMax, and everything crashes. Often such issues are discovered only in prod. So it’s better from the start to use Zod or a similar tool that generates both the type and the JSON Schema from a single declaration.
Mistake #4: Asking the model to generate internal identifiers.
Fields like userId, giftId, orderId, if you describe them as “UUID of the user in our system,” will inevitably be filled by the model with made-up values. Even if you add a UUID pattern, the model will just start generating “correct-looking” UUIDs that correspond to nothing. It’s better to fill such fields on the backend from context (authentication, previous tool call) rather than asking the model to do it.
Mistake #5: Huge “god” schemas for every occasion.
Sometimes you want to build a single do_everything tool with a giant object, half the fields nullable, half optional. The model drowns in it. It’s better to split functionality across several tools with narrower, clearer schemas: one handles gift search, another fetches details for a specific gift, a third creates an order.
Mistake #6: Ignoring _meta and annotations.
Many developers stop at name, description and inputSchema, overlooking _meta fields like openai/outputTemplate and hints like destructiveHint. As a result, tools that silently perform dangerous actions aren’t accompanied by hints and confirmations in the UI. This reduces user trust and creates a risk of unexpected operations. Use annotations to explicitly mark read-only and dangerous tools, and to set friendly execution statuses.
Mistake #7: No server-side input validation.
Even if JSON Schema and Zod seemingly describe everything, relying solely on the model is risky. Sometimes the model may output partially valid data, or you yourself change the schema and forget about business constraints. Wrapping the handler in try { parse } catch { ... } with a friendly error gives the model a chance to correct arguments and gives you a chance not to bring the whole service down because of a single failed tool call.
GO TO FULL VERSION