1. Big picture: the tool invocation path through the server
Before writing any code, let’s pin down the architecture. This will help you avoid drowning in details.
In Apps SDK + MCP terminology it looks like this: we have an MCP server (in our course this is the Route Handler app/mcp/route.ts in Next.js) that registers tools and resources and implements handlers for these tools.
High-level diagram:
sequenceDiagram
participant User as User
participant Chat as ChatGPT (model)
participant App as ChatGPT App
participant MCP as MCP server / backend
participant DB as Catalog/external APIs
User->>Chat: "Pick a gift..."
Chat->>App: decides to call the tool `suggest_gifts`
App->>MCP: JSON-RPC call_tool (name + arguments)
MCP->>MCP: Validation, authorization
MCP->>DB: Catalog query/filtering
DB-->>MCP: List of candidates
MCP-->>App: structuredContent + content + _meta
App-->>Chat: Passes the result to the model + to the widget
Chat-->>User: Explains the choice, shows the widget
The key idea: the server knows nothing about the model’s “magic”. It sees a normal request: tool name + arguments, and must return a structured response. The model does not see your code at all; it only sees:
- what tools exist and their schemas;
- the arguments it formed itself;
- the JSON response you returned.
Therefore our task in this lecture is to carefully implement the middle piece: the MCP server and tool handlers.
Insight: mcp-tools limit
In an MCP server, the number of tools is just as constrained a metric as memory or context tokens. Formally you can register dozens or even hundreds of tools, but the platform and the model don’t work with them linearly: every new tool increases routing “noise”.
Practice shows some benchmarks:
- hard ceiling for ChatGPT ≈ up to 128 MCP tools per server;
- working range — up to 50 tools. Beyond that, quality clearly degrades: the model starts confusing similarly described tools, recalls rare ones less often, and more frequently picks the wrong one.
Anthropic looks similar: a limit of about 100 tools max, and they themselves recommend staying around up to 50.
2. Where the server logic lives in the Next.js + Apps SDK template
In module 2 we already deployed the official Next.js template for a ChatGPT App and briefly reviewed its structure. Now let’s see where the MCP server lives there and how it connects to the widget.
If you use this template, the MCP server is usually implemented in app/mcp/route.ts (App Router). That’s where JSON-RPC calls from ChatGPT arrive: tools/call, resources/list, handshake, etc.
Typical project structure:
my-chatgpt-app/
├─ app/
│ ├─ mcp/
│ │ └─ route.ts # MCP server + tool registration
│ ├─ page.tsx # React widget (UI)
│ ├─ layout.tsx # Root layout, Bootstrap SDK
│ └─ globals.css # Global styles
│
├─ proxy.ts # CORS and more
├─ next.config.ts
├─ package.json
├─ tsconfig.json
└─ .env
In route.ts we:
- create an MCP server instance (via @modelcontextprotocol/sdk);
- register tools (server.registerTool(...));
- define an HTTP handler that accepts requests from ChatGPT and forwards them to the MCP server.
Next we’ll write TypeScript code based on this structure.
3. Minimal MCP server and a tool handler
Let’s start with the simplest: create a server and add our training tool suggest_gifts, which will return a stub.
Assume the MCP SDK is already installed:
pnpm add @modelcontextprotocol/sdk
And create a simple app/mcp/route.ts:
// app/mcp/route.ts
import { NextRequest } from "next/server";
import { McpServer } from "@modelcontextprotocol/sdk/server";
const server = new McpServer({ name: "giftgenius-mcp" });
// Register the tool with a minimal schema
server.registerTool(
"suggest_gifts",
{
title: "Gift suggestions",
description: "Selects gifts based on interests and budget.",
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "A brief description of the recipient." },
},
required: ["query"],
},
},
async ({ input }) => {
// Business logic goes here
return {
content: [
{
type: "text",
text: `Placeholder: gifts for "${input.query}".`,
},
],
structuredContent: {},
};
}
);
// Next.js HTTP handler
export async function POST(req: NextRequest) {
const body = await req.text(); // JSON-RPC string
const response = await server.handle(body);
return new Response(response, {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
This is already a working variant: ChatGPT will be able to call suggest_gifts, and the server will return a textual stub.
Importantly, server.registerTool takes:
- the tool name;
- metadata and the input JSON Schema;
- a handler — an async function that receives the input arguments.
But there’s still no validation, proper structured output, or authorization. That’s what we’ll add now.
4. Input validation and layering
Why a single JSON Schema isn’t enough
Yes, the platform itself validates basic things by the schema: field types, required properties, etc. But:
- the model can pass logically incorrect data (for example, a budget of −100 or a list of 1000 interests);
- you have business constraints (max budget, supported currencies, etc.);
- sometimes ChatGPT or another client can behave oddly and send something completely unexpected.
So you still need additional validation inside the handler.
Split the code: handler ↔ business logic
To keep server code from turning into spaghetti, it’s convenient to store business logic separately. For example, create app/mcp/gifts.ts:
// app/mcp/gifts.ts
export type SuggestGiftsInput = {
age?: number | null;
relationship: "friend" | "partner" | "colleague";
maxBudget: number;
interests: string[];
};
export type GiftItem = {
id: string;
title: string;
price: number;
currency: "USD";
score: number;
tags: string[];
shortDescription: string;
};
// Simple gift "database"
const CATALOG: GiftItem[] = [
{
id: "board-game-1",
title: "Board game \"Space Strategy\"",
price: 39,
currency: "USD",
score: 0.93,
tags: ["board_games", "strategy", "2-4_players"],
shortDescription: "A great gift for board game lovers.",
},
// ...
];
export function suggestGifts(input: SuggestGiftsInput): GiftItem[] {
if (input.maxBudget <= 0) {
throw new Error("The budget must be a positive number.");
}
const filtered = CATALOG.filter(
(item) => item.price <= input.maxBudget
);
// Simplified: just sort by score and take the top 3
return filtered.sort((a, b) => b.score - a.score).slice(0, 3);
}
Now in the MCP tool handler we focus on:
- parsing input;
- mapping to the SuggestGiftsInput type;
- safely calling suggestGifts;
- packing the result into a format understood by ChatGPT and our UI.
5. Implementing the handler: from input to structuredContent
Let’s rewrite registerTool in route.ts using our business logic:
// app/mcp/route.ts (fragment)
import { suggestGifts, SuggestGiftsInput } from "./gifts";
server.registerTool(
"suggest_gifts",
{
title: "Gift suggestions",
description:
"Use when you need to select gifts by interests, budget, and relationship type.",
inputSchema: {
type: "object",
properties: {
age: {
type: "integer",
minimum: 0,
maximum: 120,
description: "Recipient's age, if known.",
},
relationship: {
type: "string",
enum: ["friend", "partner", "colleague"],
description: "Your relationship to the recipient.",
},
maxBudget: {
type: "number",
minimum: 1,
description: "Maximum budget in US dollars.",
},
interests: {
type: "array",
items: { type: "string" },
description: "Recipient interests (e.g., board games, hiking).",
},
},
required: ["relationship", "maxBudget", "interests"],
},
},
async ({ input }) => {
// Basic logical validation
if (!Array.isArray(input.interests) || input.interests.length === 0) {
return {
isError: true,
content: [
{
type: "text",
text: "You need to specify at least one recipient interest.",
},
],
structuredContent: { errorCode: "NO_INTERESTS" },
};
}
const payload: SuggestGiftsInput = {
age: input.age ?? null,
relationship: input.relationship,
maxBudget: input.maxBudget,
interests: input.interests,
};
const items = suggestGifts(payload);
if (items.length === 0) {
return {
content: [
{
type: "text",
text:
"I couldn't find suitable gifts within the specified budget. Try increasing the budget or changing interests.",
},
],
structuredContent: {
items: [],
emptyReason: "NO_MATCHES",
},
};
}
return {
content: [
{
type: "text",
text: `Found ${items.length} suitable gift options.`,
},
],
structuredContent: {
items: items.map((item) => ({
id: item.id,
title: item.title,
price: item.price,
currency: item.currency,
shortDescription: item.shortDescription,
tags: item.tags,
})),
},
};
}
);
There are a few important points here.
First, we explicitly check that interests is not an empty list. Even if the JSON Schema technically allows an empty array, such a request still doesn’t make sense for us. It’s better to return a clear error immediately than to try to build a random list.
Second, we return two sets of data:
- content — for the model. This is a short textual summary: “found N options.” The model will use this in its answer to the user.
- structuredContent — for the model and for the UI. This is structured JSON with a list of gifts that our widget can render as cards.
A common mistake is to stuff the entire JSON blob into content. Don’t do that: the model wastes tokens and can get confused. Keep content short and put details into structuredContent.
6. Add a UI template and _meta/openai/outputTemplate
At the Apps SDK level, the server also tells ChatGPT which UI template to use to render the tool result. This is done via resources and _meta["openai/outputTemplate"]: the server registers an HTML resource with mimeType: "text/html+skybridge", and the tool response refers to it.
In the Next.js template this is usually hidden behind a convenient wrapper, but roughly looks like this:
// somewhere during MCP server initialization
server.registerResource("ui://widget/gifts.html", {
name: "Gift suggestions widget",
mimeType: "text/html+skybridge",
// then: how to serve the HTML (inline template or file)
});
And in the tool response:
return {
content: [{ type: "text", text: `Found ${items.length} gifts.` }],
structuredContent: { items: /* ... */ },
_meta: {
"openai/outputTemplate": "ui://widget/gifts.html",
},
};
Then ChatGPT will not only understand the structure of the result but also load the required HTML/JS for the widget, and our React component inside an iframe will read window.openai.toolOutput and render the list of gifts.
We’ll talk about the UI part in more detail in the lectures about handling ToolOutput → UI (in this same module), so for now just note the connection: the tool handler is responsible not only for business data but also for which UI template to bind the result to. Here we look at this linkage from the MCP server’s point of view: which template to specify and what to put into structuredContent.
Insight
The creators of ChatGPT conceived the widget as a template for displaying JSON. That’s why they use the name outputTemplate for it. The original idea is this: ChatGPT calls an MCP tool, and the MCP tool returns JSON and sometimes returns a widget. If there is no widget, ChatGPT decides how to display the JSON.
And if a widget is specified, then ChatGPT shows the widget, passes the JSON to the widget as toolOutput, and the widget should display the JSON. A widget is a template for displaying JSON. That’s exactly why it is cached already at the application registration stage in the Store.
You can use the widget however you like: you can call fetch() inside it. But if you understand the original intent of the ChatGPT developers, it will be easier to accept certain limitations and, likely, future changes.
7. Authorization and access in the handler
So far we’ve pretended everything in the world is public data. In practice, some tools require authorization: access to a user’s account, their orders, payments, documents, etc.
In Apps SDK / MCP terminology you can set securitySchemes for a tool and then, in the handler, check tokens and context.
A simplest example:
server.registerTool(
"list_user_orders",
{
title: "User orders list",
description: "Returns the latest orders for the authenticated user.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
_meta: {
securitySchemes: [{ type: "oauth2", scopes: ["orders.read"] }],
}
},
async ({ auth }) => {
if (!auth?.accessToken) {
return {
isError: true,
content: [
{
type: "text",
text: "You need to sign in to view orders.",
},
],
_meta: {
// Ask ChatGPT to launch the OAuth UI
"mcp/www_authenticate": [
'Bearer resource_metadata="https://your-mcp.example.com/.well-known/oauth-protected-resource", error="insufficient_scope", error_description="Please authenticate to continue."',
],
},
};
}
// Here we validate the token, issuer, audience, scope...
const orders = await fetchUserOrders(auth.accessToken);
return {
content: [
{
type: "text",
text: `Found ${orders.length} recent orders.`,
},
],
structuredContent: { orders },
};
}
);
It’s important to understand that:
- ChatGPT doesn’t “guess” your checks. It only passes tokens and context, and you must perform proper authorization.
- The special field _meta["mcp/www_authenticate"] tells the platform: “the user needs to see a sign-in / token refresh UI.” Without this, ChatGPT will simply see an error.
We’ll talk about authorization complexities separately in module 10; for now the basic concept is enough: check the token in the handler and don’t take the model’s word for it.
8. Working with external APIs and DB: layers and practices
The temptation to “do everything in the handler” is great: parse arguments, query the database, filter, map into structuredContent, log, and add a bit of philosophy — all in a single 150-line function. That’s about as wise as writing your whole app in pages/index.tsx — possible, but painful.
It’s much better to separate layers:
// gifts-repository.ts
import type { GiftItem } from "./gifts";
export async function fetchGiftsFromApi(
maxBudget: number,
interests: string[]
): Promise<GiftItem[]> {
const resp = await fetch("https://example.com/api/gifts", {
method: "POST",
body: JSON.stringify({ maxBudget, interests }),
headers: { "Content-Type": "application/json" },
});
if (!resp.ok) {
throw new Error(`Gift API error: ${resp.status}`);
}
const data = (await resp.json()) as GiftItem[];
return data;
}
// gifts.ts (updated)
import { fetchGiftsFromApi } from "./gifts-repository";
export async function suggestGifts(input: SuggestGiftsInput): Promise<GiftItem[]> {
if (input.maxBudget <= 0) {
throw new Error("The budget must be a positive number.");
}
const items = await fetchGiftsFromApi(input.maxBudget, input.interests);
return items.sort((a, b) => b.score - a.score).slice(0, 3);
}
// route.ts (handler fragment)
async ({ input }) => {
try {
const payload: SuggestGiftsInput = {
age: input.age ?? null,
relationship: input.relationship,
maxBudget: input.maxBudget,
interests: input.interests,
};
const items = await suggestGifts(payload);
// ...
} catch (err) {
console.error("suggest_gifts failed", err);
return {
isError: true,
content: [
{
type: "text",
text: "An error occurred while selecting gifts. Please try again later.",
},
],
structuredContent: {
errorCode: "INTERNAL_ERROR",
},
};
}
}
This approach gives several benefits.
- Testability: you can write unit tests for suggestGifts and fetchGiftsFromApi without spinning up the MCP server.
- Readability: the handler remains a thin adapter between the protocol (MCP) and your logic.
- Reuse: if you later need the same gift selection elsewhere (e.g., in a separate REST API), you won’t have to “rip out” logic from MCP.
9. Logging and basic observability
The server-side implementation of tools is a great place to set up minimal observability right away. In production you’ll want to know:
- which tools are invoked;
- with what arguments (without PII, of course);
- how long processing takes;
- how many errors and of what kind.
We’re exploring the ChatGPT App internals, so we’ll leave professional loggers for later. A simplest wrapper logger around handlers could look like this:
// simple-logger.ts
export function logToolInvocationStart(tool: string, args: unknown) {
console.log(
JSON.stringify({
level: "info",
event: "tool_invocation_started",
tool,
timestamp: new Date().toISOString(),
// Never log PII in production!
args,
})
);
}
export function logToolInvocationEnd(tool: string, ms: number, success: boolean) {
console.log(
JSON.stringify({
level: "info",
event: "tool_invocation_finished",
tool,
durationMs: ms,
success,
timestamp: new Date().toISOString(),
})
);
}
// route.ts (handler wrapper)
import { logToolInvocationStart, logToolInvocationEnd } from "./simple-logger";
server.registerTool(
"suggest_gifts",
{ /* ...meta... */ },
async ({ input }) => {
const startedAt = Date.now();
logToolInvocationStart("suggest_gifts", {
relationship: input.relationship,
maxBudget: input.maxBudget,
interestsCount: Array.isArray(input.interests)
? input.interests.length
: 0,
});
try {
// ... main logic ...
const duration = Date.now() - startedAt;
logToolInvocationEnd("suggest_gifts", duration, true);
return result;
} catch (err) {
const duration = Date.now() - startedAt;
logToolInvocationEnd("suggest_gifts", duration, false);
throw err;
}
}
);
Later, in modules about metrics, SLOs, and monitoring, you’ll be able to build charts and alerts based on these logs. But it’s worth building the habit of logging now.
10. How the server result reaches the widget (and back)
In section 6 we already bound the tool result to a UI template via _meta["openai/outputTemplate"]. Now let’s look at the same path from the other side — how this structuredContent ends up inside the React widget and what to do with it in the UI.
Although this lecture focuses on the server, it’s important to understand that you’re designing not only an “API for the model” but also an “API for the UI”. The server returns:
- structuredContent — data that both the model and the widget see (via toolOutput);
- content — a “compressed” result description for the model;
- _meta — widget-private fields: openai/outputTemplate, openai/widgetCSP, openai/widgetDomain, etc.
Inside the React widget you then do something like:
// app/page.tsx (fragment)
type ToolOutput = {
items?: {
id: string;
title: string;
price: number;
currency: string;
shortDescription: string;
tags: string[];
}[];
emptyReason?: string;
};
declare global {
interface Window {
openai?: {
toolOutput?: ToolOutput;
};
}
}
export default function GiftWidget() {
const output = typeof window !== "undefined"
? window.openai?.toolOutput
: undefined;
if (!output) {
return <div>Waiting for gift suggestions…</div>;
}
if (!output.items || output.items.length === 0) {
return <div>No suitable gifts. Try changing the criteria.</div>;
}
return (
<ul>
{output.items.map((item) => (
<li key={item.id}>
<strong>{item.title}</strong> — {item.price} {item.currency}
</li>
))}
<ul>
);
}
That’s why it’s so important that structuredContent has a stable contract and is UI-friendly: distinct fields, not a 10-level nested hell.
We cover this path in detail in a separate lecture of module 4; here we fix the idea: the server and the widget rely on the same structuredContent structure.
11. Server-side error handling: format and strategy
In sections 8–9 we already touched a bit on errors and logging inside the handler. Now let’s bring this into a unified format: how exactly to return tool errors so that both the model and the UI can work with them.
Errors in handlers are inevitable: an external API will fail somewhere, bad input will arrive somewhere, and you personally will mistype somewhere. The main thing is not to turn them into “500 Internal Server Error without explanation” for the model and the user.
A good server-side tool implementation:
- distinguishes user/model validation errors from internal errors;
- returns a clear isError field and a meaningful errorCode in structuredContent;
- gives the human a user-friendly message in content.
Example (assume the tool metadata — title, description, inputSchema, etc. — has already been extracted to a meta variable so we don’t duplicate it here):
function makeErrorResult(message: string, code: string) {
return {
isError: true,
content: [
{
type: "text",
text: message,
},
],
structuredContent: {
errorCode: code,
},
};
}
server.registerTool(
"suggest_gifts",
meta,
async ({ input }) => {
try {
if (input.maxBudget > 10000) {
return makeErrorResult(
"Budget is too high. Refine your request (up to 10,000 USD).",
"BUDGET_TOO_HIGH"
);
}
const items = await suggestGifts({
age: input.age ?? null,
relationship: input.relationship,
maxBudget: input.maxBudget,
interests: input.interests,
});
if (!items.length) {
return {
content: [
{
type: "text",
text:
"No gifts found for this budget. Try changing interests or increasing the budget.",
},
],
structuredContent: {
items: [],
emptyReason: "NO_MATCHES",
},
};
}
return {/* normal result */};
} catch (err) {
console.error(err);
return makeErrorResult(
"Internal server error while selecting gifts.",
"INTERNAL_ERROR"
);
}
}
);
This format helps both the model (it can try to adjust arguments) and the UI (the widget can show specific messages for different errorCode values).
We’ll discuss resilience, idempotency, and safe tool design in a couple of lectures, but it’s already useful to get used to this: it’s better to return an explicit error than to silently do something odd.
At the end of the lecture we’ll also collect these and other points into a list of common mistakes in server-side tool implementations so it’s convenient to use it as a checklist.
12. A short end-to-end example: from request to response
Let’s put together everything we’ve done into a logical chain for our GiftGenius app.
- The user writes in ChatGPT:
“Pick a gift for a friend; he loves board games; budget up to 50 dollars.” - The model, knowing about the suggest_gifts tool and its schema, decides to call it and forms a tool_call:
{ "tool": "suggest_gifts", "arguments": { "relationship": "friend", "maxBudget": 50, "interests": ["board games"], "age": null } } - The platform sends this JSON-RPC to our MCP server (POST /app/mcp), and Next.js passes the body into server.handle(...).
- Our suggest_gifts handler:
- validates that interests is not empty;
- calls suggestGifts(payload);
- receives a GiftItem[] array (top 3 by score);
- packages it into structuredContent.items and adds _meta["openai/outputTemplate"] = "ui://widget/gifts.html".
- ChatGPT receives the response, puts structuredContent into context, loads the widget HTML resource gifts.html, and passes toolOutput into it.
- Our React widget reads window.openai.toolOutput.items and renders the list of gifts; based on content and structuredContent, the model writes a textual explanation to the user about why these gifts fit.
- The user clicks, for example, “Show more” in the widget — the widget calls callTool via the SDK → it lands in our handler again, but now with different arguments (for example, an increased budget).
This entire chain relies on the fact that the server-side implementation of a tool:
- accepts structured input as per the agreed JSON Schema;
- carefully validates data;
- calls isolated business logic;
- returns a stable structured output;
- specifies a UI template and metadata when needed.
13. Common mistakes in server-side tool implementations
Error #1: “Everything in one place” — a gigantic handler.
When all logic and calls to external APIs live inside server.registerTool(..., async () => { ... }), the code quickly grows and turns into an unreadable monolith. Any minor change breaks everything at once. It’s better to move business logic into separate functions/modules and keep the handler as a thin adapter.
Error #2: Blind faith in the JSON Schema.
Developers often think: “If there’s a schema, then input is always valid.” But the model can send odd values, and external clients — even more so. You can’t rely only on types and JSON Schema — you need logical validation (budget bounds, array lengths, allowed values, etc.).
Error #3: Dumping everything into content and ignoring structuredContent.
Sometimes people put a huge JSON string into content “just in case”. This makes model prompts noisy and token-expensive, and the UI suffers because it has to decode a string instead of getting a proper structure. It’s much better to keep content short and put details into structuredContent.
Error #4: Unstable structured output format.
Today items is an array of objects with id, title, price, and tomorrow you suddenly rename price to amount, and the widget crashes. Or you add another nesting level. You can do this, but you should either version the contract or evolve the schema in smaller steps. Otherwise the UI and tests break constantly.
Error #5: No meaningful error handling.
Throwing an exception and hoping the platform will “somehow handle it” is not a good strategy. The model sees an unclear JSON-RPC error, the user sees a red banner, and you lose problem context. It’s much better to return an explicit isError, an errorCode, and a human-readable message, while logging details on the server.
Error #6: Ignoring authorization and trusting the model.
Sometimes developers think: “The model is smart — it won’t call this tool if the user isn’t authorized.” In reality the model doesn’t know your ACLs and limits; it only sees tool descriptions. All permission checks must be in the server handler, regardless of how the tool is described.
Error #7: Logging everything including PII.
It’s very easy to habitually log the entire input. With a ChatGPT App this can include PII (names, e-mail, addresses, etc.), which violates both OpenAI policy and common sense. It’s better to log only aggregated/de-identified information: relationship type, budget range, number of interests.
Error #8: No timeouts or retries when calling external APIs.
If a tool inside a handler does a fetch to an external API without timeouts and retries, any delay in that API will look like “ChatGPT hung.” The user will think the whole app is broken. On the server side you need to set time limits, handle timeouts, and return a meaningful error.
GO TO FULL VERSION