1. La herramienta como contrato: qué estamos describiendo exactamente
Cuando registras una herramienta en un servidor MCP, la describes con un pequeño objeto. La estructura simplificada para el SDK de TypeScript se ve así:
server.registerTool(
"suggest_gifts",
{
title: "Suggest gifts",
description: "Sugiere regalos según el perfil del destinatario.",
inputSchema: {
type: "object",
// aquí vamos a profundizar ahora
},
},
async ({ input }) => {
// tu código
}
);
El modelo no sabe qué hay dentro del manejador async ({ input }) => { ... }. Para él solo existen tres cosas:
- name/title — cómo se llama la herramienta.
- description — cuándo es apropiado usarla.
- inputSchema — qué argumentos hay que pasar y en qué formato.
Todo lo que hacemos en esta lección se refiere al punto 3 (y un poco a los metadatos _meta/annotations, de los que hablaremos más adelante).
Es importante entender: JSON Schema en el contexto de ChatGPT App no es un validador aburrido, sino parte del prompt para el modelo. El modelo realmente lee el description de los campos, entiende qué es un enum, se fija en minItems, format, etc.
Es decir, no solo proteges el backend de datos defectuosos, sino que le explicas al modelo de IA cómo invocar correctamente tu función.
2. JSON Schema básico para la herramienta suggest_gifts
Empecemos por algo sencillo. Supongamos que tenemos este escenario:
El usuario escribe:
«Elige un regalo para mi hermano de 25 años, presupuesto 50–70 dólares, le gustan los videojuegos y los juegos de mesa».
La herramienta suggest_gifts debería aceptar aproximadamente estos argumentos:
- la edad del destinatario;
- el tipo de relación (hermano, colega, pareja, etc.);
- presupuesto mínimo y máximo;
- lista de intereses.
Describámoslo como JSON Schema «tal cual», sin Zod, con un objeto puro:
const suggestGiftsInputSchema = {
type: "object",
properties: {
age: {
type: "integer",
minimum: 0,
maximum: 120,
description: "Edad del destinatario del regalo en años.",
},
relationship: {
type: "string",
enum: ["friend", "partner", "sibling", "colleague", "parent"],
description:
"Tipo de relación con el destinatario: friend, partner, sibling (hermano/hermana), colleague, parent.",
},
minBudget: {
type: "number",
minimum: 0,
description: "Presupuesto mínimo en la moneda del usuario.",
},
maxBudget: {
type: "number",
minimum: 0,
description: "Presupuesto máximo en la moneda del usuario.",
},
interests: {
type: "array",
items: {
type: "string",
description:
"Nombre breve del interés, por ejemplo: videogames, boardgames, books.",
},
minItems: 1,
description: "Lista de intereses del destinatario.",
},
},
required: ["relationship", "maxBudget"],
};
Algunos puntos importantes que conviene destacar de inmediato.
En primer lugar, el description de los campos. En una API normal podrías no escribirlos: el desarrollador frontend leerá Swagger y lo entenderá. Pero aquí el «cliente» es el modelo, que intenta inferir el significado a partir del nombre y la descripción. Cuanto más claro digas: «edad en años», «presupuesto en la moneda del usuario», «enum con valores fijos», menos argumentos extraños verás en tiempo de ejecución.
En segundo lugar, enum es una de las herramientas más potentes para dirigir al modelo. Si permites que el modelo escriba cualquier cadena en relationship, obtendrás «bro», «girlfriend», «bestie», «teammate» y algo aún más creativo. Si defines un enum, el modelo con muchísima probabilidad elegirá solo entre esos valores. Esto reduce directamente la cantidad de «alucinaciones» en los argumentos.
En tercer lugar, no es obligatorio marcar todo como required. Por ejemplo, age puede ser opcional: si el usuario no la indicó, el modelo no inventará una «edad aproximada» de la nada (si lo formulas así en la descripción). Aquí empieza el arte: el equilibrio entre flexibilidad y rigor.
Ahora usemos este esquema en el registro de la herramienta:
server.registerTool(
"suggest_gifts",
{
title: "Suggest gifts",
description:
"Sugiere ideas de regalos según el presupuesto, el tipo de relación y los intereses del destinatario.",
inputSchema: suggestGiftsInputSchema,
},
async ({ input }) => {
// aquí input ya corresponde aproximadamente al esquema
// ...
}
);
Este objeto «manual» va bien para experimentos rápidos. Pero a medida que la aplicación crece, se convierte en un mundo aparte que puede desalinearse fácilmente respecto a tus tipos de TypeScript. Volveremos a este problema un poco más adelante y veremos cómo resolverlo con Zod y la generación de JSON Schema a partir de tipos.
3. JSON Schema como prompt: cómo escribir description para que el modelo no sufra
Formalmente, JSON Schema trata de validación. E informalmente, en el mundo de las LLM, también es un prompt estructurado. Algunas reglas prácticas:
- El campo description debe responder a «qué poner aquí y en qué formato».
Una redacción como «Fecha» no ayuda. Una redacción como «Fecha ISO 8601 en formato YYYY-MM-DD, por ejemplo "2025-02-14"» ayuda muchísimo. - Si el campo está relacionado con dinero, especifica las unidades.
Mejor escribe explícitamente «Importe en la moneda del usuario» o «Importe en dólares estadounidenses». De lo contrario, el modelo puede escribir honestamente 50 y tú dudarás si son 50 yenes o 50 euros. - Las «categorías» de tipo string casi siempre es mejor hacerlas con enum.
Si el campo es una cadena con «categoría», mejor usa enum y describe cada valor en el description de la herramienta. Por ejemplo, para relationship puedes escribir en la descripción de la herramienta: «relationship: uno de friend (amigo), partner (pareja), sibling (hermano o hermana), colleague (colega de trabajo), parent (progenitor). No inventes otros valores.» - Para los arrays es útil establecer minItems y explicar qué lista es.
Si el campo es un array, conviene indicar minItems y explicar brevemente qué lista es exactamente. Por ejemplo, interests no es «una descripción de la persona en prosa», sino «un conjunto de etiquetas cortas».
Todo esto suena un poco pesado, pero en la práctica la diferencia entre «hay descripciones» y «no hay descripciones» es la diferencia entre una aplicación estable y la eterna lotería de «qué enviará hoy el modelo».
Insight
Las herramientas MCP tienen límites estrictos de tamaño — y son precisamente los que más a menudo causan caídas «místicas», errores extraños y que el asistente deje de ver tus tools de repente.
La regla clave es sencilla: la herramienta debe caber en ~4 KB de JSON en su totalidad. Esto no es solo el texto de description, sino toda la estructura:
- descripción de la herramienta,
- el esquema de argumentos (inputSchema),
- objetos anidados y enum,
- _meta y anotaciones.
Si tu herramienta crece demasiado, la plataforma empieza a comportarse de forma impredecible: aparecen errores como "Tool description is too long", "Schema validation failed", "Manifest exceeds size limits", y a veces ChatGPT simplemente deja de cargar la herramienta o «se olvida» de su existencia.
Recomendación: mantén el description dentro de 1000–2000 caracteres, y toda la herramienta dentro de unos «seguros» ~4 KB. Si la descripción se hace demasiado larga, casi siempre es señal de que la herramienta hace demasiadas cosas a la vez. Las herramientas separadas deben ser estrechas y muy claras — así el modelo entiende mejor sus límites y se equivoca menos en los datos de entrada.
4. TypeScript y Zod: una única fuente de verdad en lugar de dos
Escribir JSON Schema a mano es doloroso para un desarrollador de TypeScript. Hay que mantener dos mundos en paralelo:
- los tipos en el código TS;
- el JSON Schema para el modelo.
Con el crecimiento de la aplicación empiezan a divergir. Hoy cambiaste un campo en el tipo de TypeScript, mañana olvidaste actualizar el esquema — y dentro de una semana te encuentras una caída en producción.
El enfoque estándar de facto en el mundo TS es usar Zod y convertir Zod -> JSON Schema.
Instalamos dependencias (si aún no):
npm install zod zod-to-json-schema
Describamos el esquema de entrada para suggest_gifts con 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("Edad del destinatario del regalo en años."),
relationship: z
.enum(["friend", "partner", "sibling", "colleague", "parent"])
.describe(
"Tipo de relación: friend (amigo), partner (pareja), sibling (hermano/hermana), colleague (colega), parent (progenitor)."
),
minBudget: z
.number()
.min(0)
.optional()
.describe("Presupuesto mínimo en la moneda del usuario."),
maxBudget: z
.number()
.min(0)
.describe("Presupuesto máximo en la moneda del usuario."),
interests: z
.array(
z
.string()
.min(1)
.describe(
"Etiqueta breve de interés, por ejemplo: videogames, boardgames, books."
)
)
.min(1)
.describe("Lista de intereses del destinatario."),
});
Ahora tienes:
- Validación en runtime: SuggestGiftsInputZod.parse(input);
- Tipo de TypeScript: type SuggestGiftsInput = z.infer<typeof SuggestGiftsInputZod>;
- JSON Schema para el modelo: zodToJsonSchema(SuggestGiftsInputZod).
Usemos esto al registrar la herramienta:
type SuggestGiftsInput = z.infer<typeof SuggestGiftsInputZod>;
const suggestGiftsInputSchemaJson = zodToJsonSchema(
SuggestGiftsInputZod,
"SuggestGiftsInput"
);
server.registerTool(
"suggest_gifts",
{
title: "Suggest gifts",
description:
"Sugiere ideas de regalos según el presupuesto, el tipo de relación y los intereses del destinatario.",
inputSchema: suggestGiftsInputSchemaJson,
},
async ({ input }) => {
// aquí input ya se puede validar adicionalmente con Zod:
const args = SuggestGiftsInputZod.parse(input) as SuggestGiftsInput;
// a partir de aquí trabajamos con args tipado
}
);
Este enfoque da precisamente ese single source of truth — una única fuente de verdad: describes el esquema una vez, y el tipo de TypeScript y el JSON Schema se generan automáticamente.
En el mundo real además añadirás tests que verifiquen que zodToJsonSchema produce la estructura esperada, pero eso ya es tema del módulo de testing.
Insight: ChatGPT funciona mal con parámetros opcionales
Una de las prácticas más dolorosas en producción: en cuanto empiezas a usar activamente campos optional en los esquemas de herramientas, la calidad de las llamadas a herramientas baja notablemente. El modelo en teoría «entiende» qué son los parámetros opcionales, pero en la práctica a menudo simplemente no los envía — incluso cuando por la lógica de negocio los necesitas mucho.
En el Response API resolvieron esto de forma elegante: simplemente eliminaron los campos opcionales — todos los parámetros de la herramienta deben declararse como required. Pero el problema no desaparece: la idea de «marcaré la mitad de los campos como opcionales y el modelo decidirá qué rellenar» choca con la realidad: normalmente simplemente no envía nada.
5. Dónde termina el «esquema» y empieza el «diseño de interfaz»
Hasta ahora hemos hablado todo el tiempo de inputSchema, es decir, de qué argumentos debe generar el modelo para lanzar la herramienta. Pero después de invocar la herramienta la vida no termina: el resultado hay que renderizarlo en el UI.
Aquí conviene separar dos niveles:
- El esquema de la herramienta describe los argumentos de entrada que el modelo debe generar. Siempre es JSON que vive en el espacio de MCP / tool‑call.
- El componente de UI (widget) lee toolOutput.structuredContent y, a partir de ahí, construye la interfaz. El formato de structuredContent también lo diseñas tú, pero ya no es un JSON Schema para el modelo (aunque puedes formalizarlo para ti).
A veces los desarrolladores intentan matar dos pájaros de un tiro con un único objeto JSON — combinar tanto las entradas para el modelo como el formato de datos para el UI. Esto rara vez termina bien. Es más cómodo separarlo:
- inputSchema — sobre lo que necesita el modelo para ejecutar la herramienta;
- structuredContent — sobre lo que necesita el UI para renderizar el resultado.
Por ejemplo, el inputSchema de suggest_gifts no contiene ningún id de regalos. Y structuredContent, al contrario, contiene una lista de tarjetas con id, title, price, enlace de compra, etc.
6. Anotaciones y _meta: cómo influir en el UX y la seguridad
Además del propio esquema de parámetros y la estructura de respuesta hay otra capa — cómo la plataforma trata la herramienta y cómo se la muestra al usuario. De esto se encargan los metadatos y las anotaciones.
Además de los campos estándar title, description, inputSchema, una herramienta puede tener metadatos adicionales y annotations. En Apps SDK y MCP parte de estas cosas viven en _meta (por ejemplo, securitySchemes), y otra parte — en campos especiales como sugerencias específicas de OpenAI, como readOnlyHint y destructiveHint.
Es importante entender: estas anotaciones no cambian el JSON Schema, pero influyen en cómo ChatGPT muestra la herramienta al usuario y cómo afronta su invocación.
Ejemplo: readOnlyHint y destructiveHint
Supongamos que tienes dos herramientas:
- list_gifts — simplemente obtener una lista de regalos (segura);
- create_order — crear un pedido (potencialmente peligrosa: dinero, dirección, es algo serio).
Puedes marcarlas aproximadamente así (pseudocódigo):
server.registerTool(
"list_gifts",
{
title: "List gift suggestions",
description: "Obtiene la lista de regalos disponibles según los filtros especificados.",
inputSchema: listGiftsInputSchema,
_meta: {
readOnlyHint: true,
},
},
async ({ input }) => { /* ... */ }
);
server.registerTool(
"create_order",
{
title: "Create gift order",
description:
"Crea un pedido para un regalo concreto en nombre del usuario. Úsalo solo tras una confirmación explícita.",
inputSchema: createOrderInputSchema,
_meta: {
destructiveHint: true,
},
},
async ({ input }) => { /* ... */ }
);
La semántica es la siguiente. readOnlyHint le señala a ChatGPT que la herramienta no modifica nada y es segura; el modelo y el UI pueden invocarla con más libertad. destructiveHint indica que la herramienta realiza acciones irreversibles o críticas, por lo que al usuario le aparecerán confirmaciones más a menudo y el modelo será más prudente.
En tu aplicación de regalos, suggest_gifts es claramente de solo lectura (read‑only), mientras que las herramientas de tramitación de pedidos, cobros y cambios de datos de usuario conviene marcarlas como potencialmente destructivas.
openWorldHint y campos similares
En algunos casos quieres indicar al modelo que la herramienta funciona en un «mundo abierto», es decir, que sus resultados no son exhaustivos. Por ejemplo, search_products nunca devolverá todos los productos que existen en el mundo, sino solo los relevantes.
Estas anotaciones ayudan al modelo a no sacar conclusiones fuertes como «si un producto no se encuentra en search_products, entonces no existe». Es un matiz fino de UX, pero en aplicaciones de producción la diferencia se nota.
_meta alrededor de la presentación del UI
Cuando tu herramienta devuelve un resultado, puedes indicar además en _meta ajustes que afectan al widget. Por ejemplo: qué plantilla HTML usar como output‑template, si hacen falta bordes, qué texto mostrar durante la invocación, etc.
Por ejemplo, en el ejemplo oficial el servidor registra el HTML del widget por separado como recurso MCP y luego se hace referencia a él mediante _meta["openai/outputTemplate"].
server.registerTool(
"suggest_gifts",
{
title: "Suggest gifts",
description: "Sugiere ideas de regalos.",
inputSchema: suggestGiftsInputSchemaJson,
_meta: {
"openai/outputTemplate": "ui://widget/gifts.html", // Es el id del recurso MCP: server.registerResource(...)
"openai/toolInvocation/invoking": "Buscando regalos…", // Se muestra durante la búsqueda
"openai/toolInvocation/invoked": "He encontrado opciones de regalos", // Se muestra cuando ha terminado la búsqueda
},
},
async ({ input }) => {
// ...
return {
content: [],
structuredContent: { items: gifts },
};
}
);
Así, en un solo lugar describes:
- la forma de los datos de entrada para el modelo (inputSchema);
- cómo se verá y se comportará la herramienta en el UI (_meta).
7. Diseño de esquemas: qué pedirle al modelo y qué no
Una de las trampas típicas es intentar trasladarle todo el trabajo al modelo. Por ejemplo, describes en el inputSchema un campo giftId, y en el description pones: «UUID del regalo de nuestra base de datos». El modelo, por supuesto, intentará generar un UUID como "0f21b5f0-5a3a-4d1b-8f0b-9f1a6e3c1234", solo que el problema es que ese regalo probablemente no existe en tu sistema.
Una buena regla: no le pidas al modelo que genere identificadores técnicos ni datos ligados a tu mundo interno.
En su lugar conviene hacer un escenario en varios pasos:
- suggest_gifts devuelve una lista de regalos con id, title, price, etc.;
- el UI/modelo permiten al usuario elegir una de las opciones propuestas;
- create_order recibe un giftId de un conjunto ya existente.
Desde el punto de vista de los esquemas esto significa que:
- El inputSchema de herramientas que miran «hacia fuera» (al usuario) describe solo lo que una persona puede introducir razonablemente: parámetros de búsqueda, filtros, criterios;
- El inputSchema de herramientas que operan con entidades internas se apoya en id ya conocidos, en lugar de exigirle al modelo que los invente.
Para tu aplicación de regalos esto significa que en suggest_gifts no le pides al modelo «inventar un código SKU», sino solo los parámetros de la consulta. Los SKU los añadirás en el backend, y el UI se los mostrará al usuario.
Nota: SKU es un código único de producto a nivel interno. Ejemplo "GFT-CHC-500-BS".
8. Bloque práctico pequeño: lo juntamos todo
Vamos a reunir en un solo lugar todo lo que hemos comentado: el esquema en Zod, la generación de JSON Schema, el registro de la herramienta con _meta y el uso del esquema en la lógica de negocio. Construyamos un ejemplo mínimo pero coherente para la aplicación de regalos.
Primero el esquema en Zod y el tipo:
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
const SuggestGiftsInputZod = z.object({
relationship: z
.enum(["friend", "partner", "sibling", "colleague", "parent"])
.describe("Tipo de relación con el destinatario del regalo."),
maxBudget: z
.number()
.min(0)
.describe("Presupuesto máximo en la moneda del usuario."),
interests: z
.array(
z
.string()
.min(1)
.describe("Etiqueta corta de interés, por ejemplo: videogames.")
)
.min(1)
.describe("Lista de intereses del destinatario."),
});
type SuggestGiftsInput = z.infer<typeof SuggestGiftsInputZod>;
const suggestGiftsInputSchemaJson = zodToJsonSchema(
SuggestGiftsInputZod,
"SuggestGiftsInput"
);
Después — el registro de la herramienta con _meta para el UI:
server.registerTool(
"suggest_gifts",
{
title: "Suggest gifts",
description:
"Úsalo cuando necesites sugerir ideas de regalo según presupuesto, relación e intereses.",
inputSchema: suggestGiftsInputSchemaJson,
_meta: {
"openai/outputTemplate": "ui://widget/gifts.html",
"openai/toolInvocation/invoking": "Buscando regalos…",
"openai/toolInvocation/invoked": "He encontrado opciones de regalos",
readOnlyHint: true,
},
},
async ({ input }) => {
const args = SuggestGiftsInputZod.parse(input) as SuggestGiftsInput;
const gifts = await findGifts(args); // tu lógica de negocio
return {
content: [],
structuredContent: {
items: gifts,
},
};
}
);
En algún lugar cercano tendrás la función de negocio tipada:
async function findGifts(input: SuggestGiftsInput) {
// aquí puedes usar input.relationship, input.maxBudget, input.interests
// y devolver un array de objetos del tipo Gift
return [
{
id: "gift-1",
title: "Juego de mesa sobre videojuegos",
price: 45,
currency: "USD",
},
];
}
En el lado del widget, luego tomarás window.openai.toolOutput.structuredContent.items y renderizarás las tarjetas, pero hablaremos de ello con más detalle dentro de un par de lecciones.
9. Errores típicos al describir herramientas
Error n.º 1: descripciones de campos demasiado generales o sin sentido.
Si escribes description: "Fecha" o description: "Parámetro de filtro", el modelo recibe aproximadamente cero información útil. Es como una documentación del tipo «el método hace algo importante». Usa descripciones que respondan a «qué poner aquí» y «en qué formato». Por ejemplo: «Fecha ISO 8601 en formato YYYY-MM-DD, p. ej. "2025-02-14"» o «Importe en la moneda del usuario, ejemplo: 49.99».
Error n.º 2: ausencia de enum donde resulta evidente.
A menudo los desarrolladores se resisten a convertir cadenas en enum y dejan type: "string". Como resultado, el modelo inventa sus propios valores, el backend se sorprende y el UI se rompe. Si tienes un conjunto fijo de opciones (relationship, tipos de estados, modos de ordenación) — casi siempre tiene sentido usar enum y enumerar los valores posibles. Esto mejora mucho la previsibilidad de las llamadas a herramientas.
Error n.º 3: dos fuentes de verdad para el esquema y los tipos.
Clásico: en TypeScript cambias el campo maxBudget a priceMax, y en el JSON Schema lo olvidas. El modelo sigue enviando maxBudget, el código espera priceMax, y todo falla. Este tipo de errores a menudo se descubren ya en producción. Por eso es mejor desde el principio usar Zod o una herramienta similar que genere tanto el tipo como el JSON Schema a partir de una única declaración.
Error n.º 4: pedirle al modelo que genere identificadores internos.
Campos como userId, giftId, orderId, si los describes como «UUID del usuario en nuestro sistema», inevitablemente serán rellenados por el modelo con valores inventados. Incluso si añades un pattern para UUID, el modelo simplemente empezará a generar UUID «con aspecto correcto» que no corresponden a nada. Es mejor rellenar estos campos en el backend según el contexto (autenticación, tool‑call anterior), y no pedírselo al modelo.
Error n.º 5: esquemas «divinos» gigantes para todos los casos.
A veces apetece hacer una única herramienta do_everything con un objeto enorme, la mitad de los campos nullable, la mitad optional. El modelo se ahoga en esto. Es mejor dividir la funcionalidad en varias herramientas más estrechas y comprensibles: una se encarga de buscar regalos, otra de obtener detalles de un regalo concreto, y una tercera de crear el pedido.
Error n.º 6: ignorar _meta y las anotaciones.
Muchos desarrolladores se limitan a name, description y inputSchema, pasando por alto campos _meta como openai/outputTemplate y sugerencias como destructiveHint. Como resultado, las herramientas que realizan acciones peligrosas «en silencio» no van acompañadas de avisos y confirmaciones en el UI. Esto reduce la confianza del usuario y crea riesgo de operaciones inesperadas. Usa anotaciones para marcar explícitamente las herramientas de solo lectura y las peligrosas, y para establecer estados de ejecución amigables.
Error n.º 7: ausencia de validación de entrada en el servidor.
Incluso si el JSON Schema y Zod lo describen todo, confiar solo en el modelo es arriesgado. A veces el modelo puede devolver datos parcialmente válidos o tú mismo cambias el esquema y te olvidas de las restricciones de negocio. Envolver el manejador en un try { parse } catch { ... } con un error amigable le da al modelo una oportunidad de corregir los argumentos, y a ti — la oportunidad de no tumbar todo el servicio por una llamada desafortunada a una herramienta.
GO TO FULL VERSION