1. La imagen completa: el recorrido de la invocación de la herramienta a través del servidor
Antes de escribir código, asentemos la arquitectura. Así evitaremos perdernos en los detalles.
En la terminología de Apps SDK + MCP, todo se ve así: tenemos un servidor MCP (en nuestro curso es el Route Handler app/mcp/route.ts en Next.js) que registra herramientas y recursos, y que implementa los manejadores de esas herramientas.
Esquema de alto nivel:
sequenceDiagram
participant User as Usuario
participant Chat as ChatGPT (modelo)
participant App as ChatGPT App
participant MCP as Servidor MCP / backend
participant DB as Catálogo/APIs externas
User->>Chat: "Elige un regalo..."
Chat->>App: decide invocar la herramienta `suggest_gifts`
App->>MCP: JSON-RPC call_tool (nombre + argumentos)
MCP->>MCP: Validación, autorización
MCP->>DB: Petición al catálogo/filtrado
DB-->>MCP: Lista de candidatos
MCP-->>App: structuredContent + content + _meta
App-->>Chat: Entrega el resultado al modelo y al widget
Chat-->>User: Explica la elección, muestra el widget
La idea clave: el servidor no sabe nada de la «magia» del modelo. Ve una petición normal: nombre de la herramienta + argumentos, y debe devolver una respuesta estructurada. El modelo no ve tu código; solo ve:
- qué herramientas existen y sus esquemas;
- los argumentos que él mismo ha formado;
- la respuesta JSON que devolviste.
Por eso, nuestra tarea en esta lección es implementar con cuidado la parte intermedia: el servidor MCP y los manejadores de herramientas.
Insight: límite de mcp‑tools
En un servidor MCP, el número de herramientas es una métrica limitada igual que la memoria o los tokens de contexto. Formalmente puedes registrar decenas e incluso cientos de herramientas, pero la plataforma y el modelo no trabajan con ellas de forma lineal: cada nueva herramienta incrementa el «ruido» en el enrutamiento.
La práctica sugiere estas referencias:
- límite estricto para ChatGPT ≈ hasta 128 MCP-tools por servidor;
- rango operativo — hasta 50 herramientas. A partir de ahí la calidad cae notablemente: el modelo empieza a confundir herramientas con descripciones similares, recuerda menos las raras y elige más a menudo la que no es.
En Anthropic la imagen es parecida: límite del orden de 100 tools como máximo; además, ellos recomiendan mantenerse en torno a hasta 50.
2. Dónde vive la lógica del servidor en la plantilla Next.js + Apps SDK
En el módulo 2 ya desplegamos la plantilla oficial de Next.js para ChatGPT App y repasamos su estructura. Ahora veremos dónde vive el servidor MCP en ella y cómo se relaciona con el widget.
Si usas esta plantilla, el servidor MCP suele implementarse en el archivo app/mcp/route.ts (App Router). Es ahí donde llegan las llamadas JSON‑RPC de ChatGPT: tools/call, resources/list, handshake, etc.
Estructura típica del proyecto:
my-chatgpt-app/
├─ app/
│ ├─ mcp/
│ │ └─ route.ts # Servidor MCP + registro de herramientas
│ ├─ page.tsx # Widget de React (UI)
│ ├─ layout.tsx # Root layout, Bootstrap SDK
│ └─ globals.css # Estilos globales
│
├─ proxy.ts # CORS y otros
├─ next.config.ts
├─ package.json
├─ tsconfig.json
└─ .env
En route.ts nosotros:
- creamos una instancia del servidor MCP (mediante @modelcontextprotocol/sdk);
- registramos las herramientas (server.registerTool(...));
- definimos el manejador HTTP que recibe las peticiones de ChatGPT y las reenvía al servidor MCP.
A continuación escribiremos código en TypeScript basándonos en esta estructura.
3. Servidor MCP mínimo y manejador de una herramienta
Empecemos por lo más simple: crearemos el servidor y añadiremos nuestra herramienta de ejemplo suggest_gifts, que devolverá un marcador de posición.
Supongamos que ya tenemos instalado el MCP‑SDK:
pnpm add @modelcontextprotocol/sdk
Y creamos un sencillo 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" });
// Registro de la herramienta con un esquema mínimo
server.registerTool(
"suggest_gifts",
{
title: "Selección de regalos",
description: "Selecciona regalos por intereses y presupuesto.",
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Breve descripción del destinatario." },
},
required: ["query"],
},
},
async ({ input }) => {
// Aquí irá la lógica de negocio
return {
content: [
{
type: "text",
text: `Marcador de posición: regalos para "${input.query}".`,
},
],
structuredContent: {},
};
}
);
// Manejador HTTP de Next.js
export async function POST(req: NextRequest) {
const body = await req.text(); // Cadena JSON-RPC
const response = await server.handle(body);
return new Response(response, {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
Es una versión ya funcional: ChatGPT podrá invocar suggest_gifts y el servidor devolverá un texto de marcador de posición.
Es importante que server.registerTool recibe:
- el nombre de la herramienta;
- los metadatos y el JSON Schema de entrada;
- el handler — una función asíncrona a la que llegan los argumentos en input.
Pero por ahora aquí no hay ni validación, ni un structured output en condiciones, ni autorización. Vamos a ocuparnos de ello.
4. Validación de entrada y separación por capas
Por qué un único JSON Schema no basta
Sí, la plataforma valida por sí misma cosas básicas según el esquema: tipos de campos, propiedades obligatorias, etc. Pero:
- el modelo puede pasar datos lógicamente incorrectos (por ejemplo, presupuesto −100 o una lista de intereses de 1000 elementos);
- tienes restricciones de negocio (presupuesto máximo, divisas soportadas, etc.);
- a veces ChatGPT u otro cliente pueden comportarse de forma extraña y enviar algo totalmente inesperado.
Por eso, dentro del handler sigue siendo necesaria validación adicional.
Separemos el código: handler ↔ lógica de negocio
Para que el código del servidor no se convierta en un espagueti, conviene guardar la lógica de negocio aparte. Por ejemplo, creemos 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;
};
// "Base" simple de regalos
const CATALOG: GiftItem[] = [
{
id: "board-game-1",
title: "Juego de mesa «Estrategia espacial»",
price: 39,
currency: "USD",
score: 0.93,
tags: ["board_games", "strategy", "2-4_players"],
shortDescription: "Un regalo excelente para amantes de los juegos de mesa.",
},
// ...
];
export function suggestGifts(input: SuggestGiftsInput): GiftItem[] {
if (input.maxBudget <= 0) {
throw new Error("El presupuesto debe ser un número positivo.");
}
const filtered = CATALOG.filter(
(item) => item.price <= input.maxBudget
);
// Simplificado: ordenamos por score y tomamos el top-3
return filtered.sort((a, b) => b.score - a.score).slice(0, 3);
}
Ahora, en el handler de la herramienta MCP, nos encargamos de:
- parsear input;
- mapearlo al tipo SuggestGiftsInput;
- invocar de forma segura suggestGifts;
- empaquetar el resultado en un formato comprensible para ChatGPT y nuestro UI.
5. Implementación del handler: del input a structuredContent
Reescribamos registerTool en route.ts, usando nuestra lógica de negocio:
// app/mcp/route.ts (fragmento)
import { suggestGifts, SuggestGiftsInput } from "./gifts";
server.registerTool(
"suggest_gifts",
{
title: "Selección de regalos",
description:
"Úsalo cuando haya que seleccionar regalos por intereses, presupuesto y tipo de relación.",
inputSchema: {
type: "object",
properties: {
age: {
type: "integer",
minimum: 0,
maximum: 120,
description: "Edad del destinatario, si se conoce.",
},
relationship: {
type: "string",
enum: ["friend", "partner", "colleague"],
description: "Tipo de relación con el destinatario.",
},
maxBudget: {
type: "number",
minimum: 1,
description: "Presupuesto máximo en dólares estadounidenses.",
},
interests: {
type: "array",
items: { type: "string" },
description: "Intereses del destinatario (por ejemplo, board games, hiking).",
},
},
required: ["relationship", "maxBudget", "interests"],
},
},
async ({ input }) => {
// Validación lógica básica
if (!Array.isArray(input.interests) || input.interests.length === 0) {
return {
isError: true,
content: [
{
type: "text",
text: "Es necesario indicar al menos un interés del destinatario.",
},
],
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:
"No encontré regalos adecuados dentro del presupuesto indicado. Intenta aumentar el presupuesto o cambiar los intereses.",
},
],
structuredContent: {
items: [],
emptyReason: "NO_MATCHES",
},
};
}
return {
content: [
{
type: "text",
text: `He encontrado ${items.length} opciones de regalo adecuadas.`,
},
],
structuredContent: {
items: items.map((item) => ({
id: item.id,
title: item.title,
price: item.price,
currency: item.currency,
shortDescription: item.shortDescription,
tags: item.tags,
})),
},
};
}
);
Aquí hay varios puntos importantes.
En primer lugar, comprobamos explícitamente que interests no sea una lista vacía. Incluso si JSON Schema permite formalmente un array vacío, para nosotros esa petición no tiene sentido. Es mejor devolver un error claro que tratar de construir una lista al azar.
En segundo lugar, devolvemos dos conjuntos de datos:
- content — para el modelo. Es un breve resumen en texto: «he encontrado N opciones». El modelo lo usará en su respuesta al usuario.
- structuredContent — para el modelo y para el UI. Es un JSON estructurado con la lista de regalos que nuestro widget puede renderizar como tarjetas.
Un error común es volcar todo el JSON en content. No hace falta: el modelo gasta tokens y puede confundirse. Es mejor mantener content corto y poner los detalles en structuredContent.
6. Añadimos la plantilla de UI y _meta/openai/outputTemplate
A nivel de Apps SDK, el servidor también le dice a ChatGPT qué plantilla de UI usar para visualizar el resultado de la herramienta. Se hace mediante recursos y _meta["openai/outputTemplate"]: el servidor registra un recurso HTML con mimeType: "text/html+skybridge", y la herramienta en su respuesta hace referencia a él.
En la plantilla de Next.js esto suele estar envuelto en una abstracción cómoda, pero simplificando se ve así:
// en algún lugar durante la inicialización del servidor MCP
server.registerResource("ui://widget/gifts.html", {
name: "Gift suggestions widget",
mimeType: "text/html+skybridge",
// luego: el modo de entregar el HTML (plantilla embebida o archivo)
});
Y en la respuesta de la herramienta:
return {
content: [{ type: "text", text: `He encontrado ${items.length} regalos.` }],
structuredContent: { items: /* ... */ },
_meta: {
"openai/outputTemplate": "ui://widget/gifts.html",
},
};
Entonces ChatGPT no solo entenderá la estructura del resultado, sino que también cargará el HTML/JS del widget necesario, y nuestro componente de React dentro de un iframe leerá window.openai.toolOutput y renderizará la lista de regalos.
Hablaremos con más detalle de la parte de UI en las lecciones sobre el flujo ToolOutput → UI (en este mismo módulo), así que ahora solo nos fijamos en el vínculo: el handler de la herramienta responde no solo con los datos de negocio, sino también con a qué plantilla de UI debe vincularse el resultado. Aquí lo vemos con ojos de servidor MCP: qué plantilla indicar y qué poner en structuredContent.
Insight
Los creadores de ChatGPT concibieron el widget como una plantilla para mostrar JSON. Por eso usan el nombre outputTemplate. La idea original es así: ChatGPT invoca un mcp‑tool, y el mcp‑tool devuelve JSON y, a veces, también devuelve un widget. Si no hay widget, ChatGPT decide cómo mostrar el JSON.
Y si hay widget, ChatGPT muestra el widget, le pasa el JSON como toolOutput y el widget debe mostrar ese JSON. El widget es una plantilla para mostrar JSON. Precisamente por eso se almacena en caché ya en la fase de registro de la aplicación en el Store.
Puedes usar el widget como te resulte conveniente: en él se puede llamar a fetch(). Pero si entiendes la idea original de los desarrolladores de ChatGPT, te será más fácil aceptar ciertas limitaciones y, probablemente, cambios futuros.
7. Autorización y acceso en el handler
Hasta ahora hemos fingido que todo en el mundo son datos públicos. En la práctica, parte de las herramientas requieren autorización: acceso a la cuenta del usuario, sus pedidos, pagos, documentos, etc.
En la terminología de Apps SDK / MCP, a la herramienta se le pueden definir securitySchemes y luego, en el handler, comprobar los tokens y el contexto.
Ejemplo sencillo:
server.registerTool(
"list_user_orders",
{
title: "Lista de pedidos del usuario",
description: "Devuelve los pedidos recientes del usuario autenticado.",
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: "Es necesario iniciar sesión en la cuenta para ver los pedidos.",
},
],
_meta: {
// Pedimos a ChatGPT lanzar el UI de OAuth
"mcp/www_authenticate": [
'Bearer resource_metadata="https://your-mcp.example.com/.well-known/oauth-protected-resource", error="insufficient_scope", error_description="Autentícate para continuar."',
],
},
};
}
// Aquí comprobamos token, issuer, audience, scope...
const orders = await fetchUserOrders(auth.accessToken);
return {
content: [
{
type: "text",
text: `He encontrado ${orders.length} pedidos recientes.`,
},
],
structuredContent: { orders },
};
}
);
Aquí es importante entender que:
- ChatGPT no «adivina por sí solo» tus comprobaciones. Solo pasa tokens y contexto, y tú estás obligado a hacer una autorización correcta.
- El campo especial _meta["mcp/www_authenticate"] le dice a la plataforma: «hay que mostrar al usuario el UI de inicio de sesión/actualización de token». Sin esto, ChatGPT solo verá un error.
Hablaremos de las complejidades de la autorización aparte, en el módulo 10, así que por ahora basta con la idea básica: comprobamos el token en el handler; no confiamos ciegamente en el modelo.
8. Interacción con APIs externas y BD: capas y prácticas
La tentación de «hacerlo todo en el handler» es grande: parseo de argumentos, consulta a la base, filtrado, mapeo a structuredContent, registro y un poco de filosofía — todo en una sola función de 150 líneas. Es como escribir toda la aplicación en pages/index.tsx: se puede, pero duele.
Es mucho mejor separar en capas:
// 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 (actualizado)
import { fetchGiftsFromApi } from "./gifts-repository";
export async function suggestGifts(input: SuggestGiftsInput): Promise<GiftItem[]> {
if (input.maxBudget <= 0) {
throw new Error("El presupuesto debe ser un número positivo.");
}
const items = await fetchGiftsFromApi(input.maxBudget, input.interests);
return items.sort((a, b) => b.score - a.score).slice(0, 3);
}
// route.ts (fragmento del handler)
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: "Se produjo un error al seleccionar regalos. Inténtalo de nuevo más tarde.",
},
],
structuredContent: {
errorCode: "INTERNAL_ERROR",
},
};
}
}
Este enfoque aporta varias ventajas.
- Testabilidad: puedes escribir tests unitarios para suggestGifts y fetchGiftsFromApi sin levantar el servidor MCP.
- Legibilidad: el handler queda como un adaptador delgado entre el protocolo (MCP) y tu lógica.
- Reutilización: si más tarde necesitas la misma selección de regalos en otro sitio (por ejemplo, en una API REST aparte), no tendrás que «arrancar» la lógica del MCP.
9. Registro (logging) y observabilidad básica
La implementación de herramientas en el servidor es un lugar excelente para ocuparse de una observabilidad mínima desde el principio. En producción querrás saber:
- qué herramientas se invocan;
- con qué argumentos (sin PII, por supuesto);
- cuánto tiempo tarda el procesamiento;
- cuántos errores y de qué tipo.
Ahora estamos entendiendo cómo funciona ChatGPT App, por lo que dejaremos el uso de loggers profesionales para más adelante. Un logger envoltorio sencillo alrededor de los handlers puede tener este aspecto:
// 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(),
// ¡Nunca registres PII en producción!
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 (envoltura del handler)
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 {
// ... lógica principal ...
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;
}
}
);
Más adelante, en los módulos sobre métricas, SLO y monitorización, podrás construir gráficos y alertas a partir de estos logs. Pero conviene adquirir ya el hábito de registrar.
10. Cómo llega el resultado del servidor al widget (y viceversa)
En la sección 6 ya vinculamos el resultado de la herramienta a la plantilla de UI mediante _meta["openai/outputTemplate"]. Ahora veamos el mismo recorrido desde el otro lado: cómo ese structuredContent acaba dentro del widget de React y qué hacer con él en el UI.
Aunque esta lección se centra en el servidor, es importante entender que estás diseñando no solo un «API para el modelo», sino también un «API para el UI». El servidor devuelve:
- structuredContent — datos que ven tanto el modelo como el widget (a través de toolOutput);
- content — una descripción «comprimida» del resultado para el modelo;
- _meta — campos privados para el widget: openai/outputTemplate, openai/widgetCSP, openai/widgetDomain, etc.
Dentro del widget de React después harás algo como:
// app/page.tsx (fragmento)
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>Esperando los resultados de la selección de regalos…</div>;
}
if (!output.items || output.items.length === 0) {
return <div>No hay regalos adecuados. Prueba a cambiar las condiciones.</div>;
}
return (
<ul>
{output.items.map((item) => (
<li key={item.id}>
<strong>{item.title}</strong> — {item.price} {item.currency}
</li>
))}
<ul>
);
}
Por eso es tan importante que structuredContent tenga un contrato estable y sea amigable con el UI: campos separados, no un infierno de anidación a 10 niveles.
De este recorrido hablamos con detalle en otra lección del módulo 4; aquí solo fijamos la idea: el servidor y el widget se apoyan en la misma estructura structuredContent.
11. Manejo de errores en el servidor: formato y estrategia
En las secciones 8–9 ya tocamos ligeramente los errores y el registro dentro del handler. Ahora unifiquémoslo en un formato: cómo devolver exactamente los errores de las herramientas para que tanto el modelo como el UI puedan trabajar con ellos.
Los errores en los handlers son inevitables: fallará alguna API externa, llegará una entrada mala, o te equivocarás tú. Lo importante es no convertirlos en un «500 Internal Server Error sin explicación» para el modelo y el usuario.
Una buena implementación de herramienta en el servidor:
- distingue los errores de validación del usuario/modelo y los errores internos;
- devuelve un campo claro isError y un errorCode comprensible en structuredContent;
- da a la persona en content un mensaje amigable.
Ejemplo (supongamos que los metadatos de la herramienta — title, description, inputSchema, etc. — ya los hemos extraído a la variable meta para no duplicarlos aquí):
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(
"Presupuesto demasiado alto. Precisa la solicitud (hasta 10000 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 se han encontrado regalos con este presupuesto. Prueba a cambiar los intereses o aumentar el presupuesto.",
},
],
structuredContent: {
items: [],
emptyReason: "NO_MATCHES",
},
};
}
return {/* resultado normal */};
} catch (err) {
console.error(err);
return makeErrorResult(
"Error interno del servidor al seleccionar regalos.",
"INTERNAL_ERROR"
);
}
}
);
Este formato ayuda tanto al modelo (puede intentar cambiar argumentos) como al UI (el widget puede mostrar mensajes específicos para distintos errorCode).
Hablaremos en un par de lecciones sobre resiliencia, idempotencia y diseño seguro de herramientas, pero ya aquí conviene acostumbrarse: es mejor devolver un error explícito que hacer algo extraño en silencio.
Al final de la lección recopilaremos estos y otros puntos en una lista de errores típicos en la implementación de herramientas del lado del servidor, para usarla como checklist.
12. Ejemplo corto end‑to‑end: de la petición a la respuesta
Reunamos todo lo hecho en una secuencia lógica sobre nuestra aplicación GiftGenius.
- El usuario escribe en ChatGPT:
«Elige un regalo para un amigo; le gustan los juegos de mesa; presupuesto hasta 50 dólares». - El modelo, conociendo la herramienta suggest_gifts y su esquema, decide invocarla y forma el tool_call:
{ "tool": "suggest_gifts", "arguments": { "relationship": "friend", "maxBudget": 50, "interests": ["board games"], "age": null } } - La plataforma envía este JSON‑RPC a nuestro servidor MCP (POST /app/mcp), Next.js pasa el cuerpo a server.handle(...).
- Nuestro handler suggest_gifts:
- valida que interests no esté vacío;
- invoca suggestGifts(payload);
- recibe un array GiftItem[] (top‑3 por score);
- lo empaqueta en structuredContent.items y añade _meta["openai/outputTemplate"] = "ui://widget/gifts.html".
- ChatGPT recibe la respuesta, coloca el structuredContent en el contexto, carga el recurso HTML del widget gifts.html y le pasa el toolOutput.
- Nuestro widget de React lee window.openai.toolOutput.items y renderiza la lista de regalos; el modelo, basándose en content y structuredContent, escribe al usuario una explicación de por qué esos regalos encajan.
- El usuario pulsa, por ejemplo, «Mostrar más» en el widget — el widget llama a callTool a través del SDK → vuelve a nuestro handler, pero ya con otros argumentos (por ejemplo, presupuesto aumentado).
Toda esta cadena se sostiene en que la implementación de la herramienta en el servidor:
- recibe un input estructurado según un JSON Schema acordado;
- valida los datos con cuidado;
- invoca la lógica de negocio aislada;
- devuelve un structured output estable;
- indica, si es necesario, la plantilla de UI y los metadatos.
13. Errores típicos al implementar herramientas en el servidor
Error n.º 1: «Todo en un solo sitio» — handler gigantesco.
Cuando toda la lógica y el trabajo con APIs externas viven dentro de server.registerTool(..., async () => { ... }), el código crece rápido y se convierte en un monolito ilegible. Con el mínimo cambio se rompe todo a la vez. Es mejor extraer la lógica de negocio a funciones/módulos aparte y dejar el handler como un adaptador delgado.
Error n.º 2: Fe ciega en el JSON Schema.
Muchos desarrolladores piensan: «Si existe un esquema, la entrada siempre será válida». Pero el modelo puede enviar valores extraños, y los clientes externos, con más razón. No se puede depender solo de los tipos y del JSON Schema: hace falta validación lógica (límites de presupuesto, longitud de arrays, valores permitidos, etc.).
Error n.º 3: Volcar todo en content e ignorar structuredContent.
A veces se mete un JSON enorme en content «por si acaso». Eso hace ruidosas y costosas en tokens las indicaciones al modelo, y el UI sufre porque tiene que decodificar una cadena en lugar de recibir una estructura normal. Mucho mejor mantener content corto y poner los detalles en structuredContent.
Error n.º 4: Formato inestable del structured output.
Hoy items es un array de objetos con campos id, title, price, y mañana renombraste price a amount y el widget se cae. O añadiste un nuevo nivel de anidación. Se puede hacer, pero entonces hay que versionar el contrato o evolucionar el esquema en pasos pequeños. Si no, el UI y los tests se rompen constantemente.
Error n.º 5: Ausencia de un manejo de errores significativo.
Lanzar una excepción y esperar que la plataforma «lo maneje de alguna manera» no es buena estrategia. El modelo verá un JSON‑RPC error incomprensible, el usuario una banda roja, y tú perderás el contexto del problema. Es mucho mejor devolver un isError, un errorCode y un mensaje comprensible, registrando los detalles en el servidor.
Error n.º 6: Ignorar la autorización y confiar en el modelo.
A veces los desarrolladores piensan: «El modelo es inteligente; no invocará esta herramienta si el usuario no está autenticado». En realidad el modelo no conoce tus ACL ni límites; solo ve descripciones de herramientas. Todas las comprobaciones de permisos deben estar en el handler del servidor, independientemente de cómo esté descrita la herramienta.
Error n.º 7: Registrar absolutamente todo, incluida PII.
Es muy fácil, por costumbre, registrar todo el input completo. En el caso de ChatGPT App, puede incluir PII (nombres, e‑mail, direcciones, etc.), lo cual incumple tanto la política de OpenAI como el sentido común. Mejor registra solo información agregada/anonimizada: tipo de relación, rango de presupuesto, cantidad de intereses.
Error n.º 8: Ausencia de timeouts y reintentos al trabajar con APIs externas.
Si la herramienta dentro del handler hace fetch a una API externa sin timeouts ni reintentos, cualquier latencia de esa API parecerá «ChatGPT colgado». El usuario pensará que se ha roto toda la aplicación. En el lado del servidor hay que poner límite de tiempo, manejar los timeouts y devolver un error con sentido.
GO TO FULL VERSION