1. Strumento come contratto: che cosa stiamo descrivendo esattamente
Quando registri uno strumento sul server MCP, lo descrivi con un piccolo oggetto. Una struttura semplificata per il TypeScript‑SDK appare così:
server.registerTool(
"suggest_gifts",
{
title: "Suggest gifts",
description: "Suggerisce regali in base al profilo del destinatario.",
inputSchema: {
type: "object",
// su questo ora andremo ad approfondire
},
},
async ({ input }) => {
// il tuo codice
}
);
Il modello non sa cosa c’è dentro il gestore async ({ input }) => { ... }. Per lui ci sono solo tre cose:
- name/title — come si chiama lo strumento.
- description — quando è opportuno usarlo.
- inputSchema — quali argomenti passare e in quale formato.
Tutto ciò che facciamo in questa lezione riguarda il punto 3 (e un po’ i metadati _meta/annotations, di cui parleremo più avanti).
È importante capire: JSON Schema nel contesto di una ChatGPT App non è un noioso validatore, ma parte del prompt per il modello. Il modello legge davvero il description dei campi, capisce cos’è un enum, nota minItems, format ecc.
Cioè non stai solo proteggendo il backend da dati storti, stai spiegando al modello di IA come chiamare correttamente la tua funzione.
2. JSON Schema di base per lo strumento suggest_gifts
Iniziamo da qualcosa di semplice. Supponiamo di avere questo scenario:
L’utente scrive:
«Scegli un regalo per mio fratello di 25 anni, budget 50–70 dollari, ama i videogiochi e i giochi da tavolo».
Lo strumento suggest_gifts dovrebbe accettare all’incirca questi argomenti:
- età del destinatario;
- tipo di relazione (fratello, collega, partner ecc.);
- budget minimo e massimo;
- elenco degli interessi.
Descriviamolo come JSON Schema in modo diretto, senza Zod, con un oggetto puro:
const suggestGiftsInputSchema = {
type: "object",
properties: {
age: {
type: "integer",
minimum: 0,
maximum: 120,
description: "Età del destinatario del regalo in anni.",
},
relationship: {
type: "string",
enum: ["friend", "partner", "sibling", "colleague", "parent"],
description:
"Tipo di relazione con il destinatario: friend, partner, sibling (fratello/sorella), colleague, parent.",
},
minBudget: {
type: "number",
minimum: 0,
description: "Budget minimo nella valuta dell’utente.",
},
maxBudget: {
type: "number",
minimum: 0,
description: "Budget massimo nella valuta dell’utente.",
},
interests: {
type: "array",
items: {
type: "string",
description:
"Nome breve dell’interesse, ad esempio: videogames, boardgames, books.",
},
minItems: 1,
description: "Elenco degli interessi del destinatario.",
},
},
required: ["relationship", "maxBudget"],
};
Alcuni punti importanti da chiarire subito.
Per prima cosa, il description dei campi. In una normale API potresti anche non scriverlo — lo sviluppatore frontend leggerà Swagger e capirà. Ma qui il «client» è il modello, che cerca di estrarre il senso da nome e descrizione. Più chiaramente dirai «età in anni», «budget nella valuta dell’utente», «enum con valori fissi», meno argomenti strani vedrai a runtime.
In secondo luogo, enum è uno degli strumenti più potenti per guidare il modello. Se permetti al modello di scrivere qualsiasi stringa in relationship, otterrai «bro», «girlfriend», «bestie», «teammate» e qualcosa di ancora più fantasioso. Se imposti un enum, il modello con altissima probabilità sceglierà solo tra quei valori. È una riduzione diretta delle «allucinazioni» negli argomenti.
In terzo luogo, non è necessario rendere tutto required. Per esempio, age può essere facoltativo: se l’utente non l’ha indicata, il modello non si metterà a inventare «un’età approssimativa» dal nulla (se lo specifichi nella descrizione). Qui inizia l’arte: bilanciare flessibilità e rigore.
Ora usiamo questo schema nella registrazione dello strumento:
server.registerTool(
"suggest_gifts",
{
title: "Suggest gifts",
description:
"Suggerisce idee regalo in base al budget, al tipo di relazione e agli interessi del destinatario.",
inputSchema: suggestGiftsInputSchema,
},
async ({ input }) => {
// qui input corrisponde già approssimativamente allo schema
// ...
}
);
Un oggetto «manuale» di questo tipo è ottimo per esperimenti veloci. Ma con la crescita dell’applicazione si trasforma in un mondo a parte, che può divergere facilmente dai tuoi tipi TypeScript. Tra poco torneremo su questo problema e vedremo come risolverlo con Zod e la generazione di JSON Schema dai tipi.
3. JSON Schema come prompt: come scrivere description per non far soffrire il modello
Formalmente JSON Schema riguarda la validazione. Informalmente, nel mondo delle LLM, è anche un prompt strutturato. Alcune regole pratiche:
- Il campo description deve rispondere alla domanda «cosa mettere qui e in quale formato».
Una formulazione tipo «Data» non aiuta. «Data ISO 8601 nel formato YYYY-MM-DD, ad esempio "2025-02-14"» — aiuta moltissimo. - Se il campo riguarda il denaro — specifica le unità.
È meglio scrivere esplicitamente «Importo nella valuta dell’utente» o «Importo in dollari USA». Altrimenti il modello può benissimo scrivere 50 e tu ti chiederai se sono 50 yen o 50 euro. - Le «categorie» testuali quasi sempre è meglio farle con enum.
Se il campo è una stringa con una «categoria», è meglio creare un enum e descrivere ogni valore nel description dello strumento. Per esempio, per relationship puoi scrivere nella descrizione dello strumento: «relationship: uno tra friend (amico), partner (partner), sibling (fratello o sorella), colleague (collega), parent (genitore). Non inventare altri valori.» - Per gli array è utile impostare minItems e spiegare che lista è.
Se il campo è un array, è utile indicare minItems e spiegare brevemente che lista sia. Ad esempio, interests non è «una descrizione in prosa della persona», ma «un insieme di tag brevi».
Tutto questo può sembrare un po’ pedante, ma in pratica la differenza tra «ci sono descrizioni» e «non ci sono descrizioni» è la differenza tra un’app stabile e una lotteria eterna «cosa manderà oggi il modello».
Insight
Gli strumenti MCP hanno limiti severi di dimensione — e sono proprio questi a causare più spesso crash «misteriosi», errori strani e il fatto che l’assistente smetta all’improvviso di vedere i tuoi tools.
La regola chiave è semplice: lo strumento deve entrare interamente in ~4 KB di JSON. Non è solo il testo del description, ma l’intera struttura:
- descrizione dello strumento,
- schema degli argomenti (inputSchema),
- oggetti annidati e enum,
- _meta e annotations.
Se il tuo strumento cresce troppo, la piattaforma inizia a comportarsi in modo imprevedibile: compaiono errori come "Tool description is too long", "Schema validation failed", "Manifest exceeds size limits", e a volte ChatGPT smette semplicemente di caricare lo strumento o «si dimentica» della sua esistenza.
Raccomandazione: mantieni description entro 1000–2000 caratteri e l’intero strumento entro i ~4 KB «sicuri». Se la descrizione diventa troppo lunga, è quasi sempre un segno che lo strumento fa troppe cose contemporaneamente. Gli strumenti separati dovrebbero essere stretti e chiarissimi — in questo modo il modello capisce meglio i loro confini e sbaglia meno sui dati di input.
4. TypeScript e Zod: un’unica fonte di verità invece di due
Scrivere a mano uno schema JSON è doloroso per uno sviluppatore TypeScript. Devi mantenere due mondi paralleli:
- i tipi nel codice TS;
- il JSON Schema per il modello.
Con la crescita dell’applicazione iniziano a divergere. Oggi cambi un campo nel tipo TypeScript, domani ti dimentichi di aggiornare lo schema — e tra una settimana prendi un crash in produzione.
Lo standard de‑facto nel mondo TS è usare Zod e convertire Zod -> JSON Schema.
Installiamo le dipendenze (se non l’hai già fatto):
npm install zod zod-to-json-schema
Descriviamo lo schema di input per 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("Età del destinatario del regalo in anni."),
relationship: z
.enum(["friend", "partner", "sibling", "colleague", "parent"])
.describe(
"Tipo di relazione: friend (amico), partner (partner), sibling (fratello/sorella), colleague (collega), parent (genitore)."
),
minBudget: z
.number()
.min(0)
.optional()
.describe("Budget minimo nella valuta dell’utente."),
maxBudget: z
.number()
.min(0)
.describe("Budget massimo nella valuta dell’utente."),
interests: z
.array(
z
.string()
.min(1)
.describe(
"Tag di interesse breve, ad esempio: videogames, boardgames, books."
)
)
.min(1)
.describe("Elenco degli interessi del destinatario."),
});
Ora hai:
- Validazione a runtime: SuggestGiftsInputZod.parse(input);
- Tipo TypeScript: type SuggestGiftsInput = z.infer<typeof SuggestGiftsInputZod>;
- JSON Schema per il modello: zodToJsonSchema(SuggestGiftsInputZod).
Usalo nella registrazione dello strumento:
type SuggestGiftsInput = z.infer<typeof SuggestGiftsInputZod>;
const suggestGiftsInputSchemaJson = zodToJsonSchema(
SuggestGiftsInputZod,
"SuggestGiftsInput"
);
server.registerTool(
"suggest_gifts",
{
title: "Suggest gifts",
description:
"Suggerisce idee regalo in base al budget, al tipo di relazione e agli interessi del destinatario.",
inputSchema: suggestGiftsInputSchemaJson,
},
async ({ input }) => {
// qui è possibile convalidare ulteriormente l'input con Zod:
const args = SuggestGiftsInputZod.parse(input) as SuggestGiftsInput;
// poi lavoriamo con args tipizzato
}
);
Questo approccio dà proprio la unica fonte di verità: descrivi lo schema una sola volta, e il tipo TypeScript e il JSON Schema vengono generati automaticamente.
Nel mondo reale aggiungerai anche test che verificano che zodToJsonSchema produca la struttura attesa, ma questo è argomento del modulo sui test.
Insight: ChatGPT gestisce male i parametri opzionali
Una delle cose più dolorose in produzione: non appena inizi a usare attivamente campi optional negli schemi degli strumenti, la qualità delle chiamate agli strumenti (tool‑calls) cala visibilmente. In teoria il modello «capisce» cosa siano i parametri facoltativi, ma in pratica spesso non li invia proprio — anche quando per la logica di business sono necessari.
Questo problema è stato risolto elegantemente nel Response API: lì hanno semplicemente rimosso i campi opzionali — tutti i parametri dello strumento devono essere dichiarati come required. Ma il problema non scompare: l’idea «segnerò metà dei campi come facoltativi e il modello deciderà cosa compilare» si scontra con la realtà: di solito non invia nulla.
5. Dove finisce lo «schema» e inizia il «design dell’interfaccia»
Finora abbiamo parlato sempre di inputSchema — cioè di quali argomenti il modello deve generare per avviare lo strumento. Ma dopo la chiamata allo strumento la vita non finisce: il risultato va anche renderizzato nell’UI.
Qui è utile separare due livelli:
- Lo schema dello strumento descrive gli argomenti di input che il modello deve generare. È sempre JSON, che vive nello spazio MCP / tool‑call.
- Il componente UI (widget) legge toolOutput.structuredContent e costruisce l’interfaccia sulla sua base. Il formato di structuredContent lo progetti anche tu, ma non è il JSON Schema per il modello (anche se puoi formalizzare anche questo per te stesso).
A volte gli sviluppatori cercano di prendere due piccioni con una fava con un unico oggetto JSON — combinando sia gli input per il modello sia il formato dati per l’UI. Raramente finisce bene. È più comodo separare:
- inputSchema — ciò che serve al modello per avviare lo strumento;
- structuredContent — ciò che serve all’UI per renderizzare il risultato.
Per esempio, l’inputSchema per suggest_gifts non contiene alcun id dei regali. E structuredContent, al contrario, contiene un elenco di card con id, title, price, link all’acquisto ecc.
6. Annotazioni e _meta: come influire su UX e sicurezza
Oltre allo schema dei parametri e alla struttura della risposta c’è un altro livello — come la piattaforma tratta lo strumento e come lo mostra all’utente. Di questo si occupano metadati e annotazioni.
Oltre ai campi standard title, description, inputSchema, uno strumento può avere metadati e annotazioni aggiuntivi. Nell’Apps SDK e in MCP parte di queste cose vive in _meta (ad esempio, securitySchemes), un’altra parte — in campi speciali come gli hint specifici OpenAI tipo readOnlyHint e destructiveHint.
Qui è importante capire: queste annotazioni non cambiano il JSON Schema, ma influenzano come ChatGPT mostra lo strumento all’utente e come si rapporta alla sua chiamata.
Esempio: readOnlyHint e destructiveHint
Supponiamo di avere due strumenti:
- list_gifts — ottenere semplicemente l’elenco dei regali (sicuro);
- create_order — creare un ordine (potenzialmente pericoloso: soldi, indirizzo, cose serie).
Puoi contrassegnarli all’incirca così (pseudocodice):
server.registerTool(
"list_gifts",
{
title: "List gift suggestions",
description: "Ottiene l’elenco dei regali disponibili in base ai filtri specificati.",
inputSchema: listGiftsInputSchema,
_meta: {
readOnlyHint: true,
},
},
async ({ input }) => { /* ... */ }
);
server.registerTool(
"create_order",
{
title: "Create gift order",
description:
"Crea un ordine per un regalo specifico per conto dell’utente. Usalo solo dopo una conferma esplicita.",
inputSchema: createOrderInputSchema,
_meta: {
destructiveHint: true,
},
},
async ({ input }) => { /* ... */ }
);
La semantica è la seguente. readOnlyHint segnala a ChatGPT che lo strumento non modifica nulla ed è sicuro; il modello e l’UI possono chiamarlo più liberamente. destructiveHint indica che lo strumento esegue azioni irreversibili o critiche, quindi all’utente compariranno più spesso conferme e il modello sarà più cauto.
Nella tua app Gift suggest_gifts è chiaramente read‑only, mentre gli strumenti per creare ordini, addebitare denaro e modificare i dati dell’utente è meglio contrassegnarli come potenzialmente destructive.
openWorldHint e campi simili
In alcuni casi vuoi suggerire al modello che lo strumento opera in un «mondo aperto», cioè i suoi risultati non sono esaustivi. Per esempio, search_products non restituirà mai tutti i prodotti esistenti al mondo, ma solo quelli pertinenti.
Tali annotazioni aiutano il modello a non trarre conclusioni forti del tipo «se un prodotto non è trovato in search_products, allora non esiste». È un dettaglio di UX sottile, ma nelle applicazioni in produzione la differenza si nota bene.
_meta per la visualizzazione dell’UI
Quando il tuo strumento restituisce un risultato, puoi indicare in _meta impostazioni che influenzano il widget. Per esempio: quale template HTML usare come output‑template, se servono bordi, quale testo mostrare durante la chiamata, ecc.
Per esempio, nell’esempio ufficiale il server registra separatamente l’HTML del widget come risorsa MCP e poi vi fa riferimento tramite _meta["openai/outputTemplate"].
server.registerTool(
"suggest_gifts",
{
title: "Suggest gifts",
description: "Suggerisce idee regalo.",
inputSchema: suggestGiftsInputSchemaJson,
_meta: {
"openai/outputTemplate": "ui://widget/gifts.html", // Questo è l'id di una risorsa MCP: server.registerResource(...)
"openai/toolInvocation/invoking": "Sto cercando regali…", // Mostrato durante la ricerca
"openai/toolInvocation/invoked": "Ho trovato opzioni di regalo", // Mostrato quando la ricerca è terminata
},
},
async ({ input }) => {
// ...
return {
content: [],
structuredContent: { items: gifts },
};
}
);
In questo modo descrivi in un unico posto:
- la forma dei dati di input per il modello (inputSchema);
- come lo strumento apparirà e si comporterà nell’UI (_meta).
7. Progettazione degli schemi: cosa chiedere al modello e cosa no
Una trappola tipica è cercare di scaricare tutto il lavoro sul modello. Per esempio, descrivi nell’inputSchema il campo giftId, e nel description scrivi: «UUID del regalo dal nostro database». Il modello, certo, proverà onestamente a generare un UUID tipo "0f21b5f0-5a3a-4d1b-8f0b-9f1a6e3c1234", solo che il problema è che un regalo del genere probabilmente da te non esiste.
Una buona regola: non chiedere al modello di generare identificatori tecnici e dati legati al tuo mondo interno.
Invece conviene fare uno scenario a più passaggi:
- suggest_gifts restituisce un elenco di regali con id, title, price ecc.;
- UI/modello permettono all’utente di scegliere una delle opzioni proposte;
- create_order accetta un giftId da un insieme già esistente.
Dal punto di vista degli schemi questo significa che:
- L’inputSchema degli strumenti che guardano «verso l’esterno» (verso l’utente) descrive solo ciò che una persona può ragionevolmente inserire: parametri di ricerca, filtri, criteri;
- L’inputSchema degli strumenti che operano su entità interne si basa su id già noti, e non richiede al modello di inventarli.
Per la tua app Gift questo significa che in suggest_gifts non chiedi al modello di «inventare un codice SKU», ma solo i parametri della richiesta. Gli SKU li collegherai lato backend e l’UI li mostrerà all’utente.
Nota: SKU è un codice univoco internazionale del prodotto. Esempio "GFT-CHC-500-BS".
8. Piccolo blocco pratico: mettiamo tutto insieme
Raccogliamo in un unico posto tutto ciò di cui abbiamo parlato sopra: schema Zod, generazione del JSON Schema, registrazione dello strumento con _meta e uso dello schema nella logica di business. Mettiamo insieme un esempio minimo ma coerente per l’app Gift.
Per prima cosa lo schema Zod e il 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 di relazione con il destinatario del regalo."),
maxBudget: z
.number()
.min(0)
.describe("Budget massimo nella valuta dell’utente."),
interests: z
.array(
z
.string()
.min(1)
.describe("Tag di interesse breve, ad esempio: videogames.")
)
.min(1)
.describe("Elenco degli interessi del destinatario."),
});
type SuggestGiftsInput = z.infer<typeof SuggestGiftsInputZod>;
const suggestGiftsInputSchemaJson = zodToJsonSchema(
SuggestGiftsInputZod,
"SuggestGiftsInput"
);
Poi — registrazione dello strumento con _meta per l’UI:
server.registerTool(
"suggest_gifts",
{
title: "Suggest gifts",
description:
"Usa quando serve proporre idee regalo in base a budget, relazione e interessi.",
inputSchema: suggestGiftsInputSchemaJson,
_meta: {
"openai/outputTemplate": "ui://widget/gifts.html",
"openai/toolInvocation/invoking": "Sto cercando regali…",
"openai/toolInvocation/invoked": "Ho trovato opzioni di regalo",
readOnlyHint: true,
},
},
async ({ input }) => {
const args = SuggestGiftsInputZod.parse(input) as SuggestGiftsInput;
const gifts = await findGifts(args); // la tua logica di business
return {
content: [],
structuredContent: {
items: gifts,
},
};
}
);
Da qualche parte vicino avrai una funzione di business tipizzata:
async function findGifts(input: SuggestGiftsInput) {
// qui puoi usare input.relationship, input.maxBudget, input.interests
// e restituire un array di oggetti di tipo Gift
return [
{
id: "gift-1",
title: "Gioco da tavolo ispirato ai videogiochi",
price: 45,
currency: "USD",
},
];
}
Sul lato del widget prenderai poi window.openai.toolOutput.structuredContent.items e renderizzerai le card, ma di questo parleremo più in dettaglio tra un paio di lezioni.
9. Errori tipici nella descrizione degli strumenti
Errore n. 1: descrizioni dei campi troppo generiche o prive di senso.
Se scrivi description: "Data" o description: "Parametro di filtro", il modello riceve praticamente zero informazioni utili. È come una documentazione del tipo «il metodo fa qualcosa di importante». Usa descrizioni che rispondono alla domanda «cosa mettere qui» e «in quale formato». Per esempio: «Data ISO 8601 nel formato YYYY-MM-DD, es. "2025-02-14"» oppure «Importo nella valuta dell’utente, esempio: 49.99».
Errore n. 2: assenza di enum dove sarebbe ovvio.
Spesso gli sviluppatori sono pigri nel trasformare le stringhe in enum e lasciano type: "string". Di conseguenza il modello inventa i propri valori, il backend si stupisce, l’UI si rompe. Se hai un insieme di opzioni fisso (relationship, tipi di stato, modalità di ordinamento) — quasi sempre ha senso creare un enum ed elencare i valori possibili. Questo aumenta molto la prevedibilità delle tool‑calls.
Errore n. 3: due fonti di verità per schema e tipi.
Classico: in TypeScript cambi il campo maxBudget in priceMax, ma nel JSON Schema te ne dimentichi. Il modello continua a mandare maxBudget, il codice si aspetta priceMax, e tutto cade. Spesso questi errori si scoprono solo in produzione. Perciò è meglio usare fin dall’inizio Zod o strumenti analoghi che generano sia il tipo sia il JSON Schema da un’unica dichiarazione.
Errore n. 4: chiedere al modello di generare identificatori interni.
Campi come userId, giftId, orderId, se li descrivi come «UUID dell’utente nel nostro sistema», inevitabilmente verranno compilati dal modello con valori inventati. Anche se aggiungi un pattern per l’UUID, il modello inizierà semplicemente a generare UUID «dall’aspetto corretto» che non corrispondono a nulla. Questi campi è meglio riempirli sul backend in base al contesto (autenticazione, precedente tool‑call), non chiederli al modello.
Errore n. 5: schemi «onnipotenti» giganteschi per tutti i casi.
A volte viene voglia di fare un unico strumento do_everything con un enorme oggetto, metà dei campi nullable, metà optional. Il modello affoga in tutto questo. Meglio dividere la funzionalità in più strumenti con schemi più stretti e comprensibili: uno per la ricerca di regali, un altro per ottenere i dettagli di un regalo specifico, un terzo per creare un ordine.
Errore n. 6: ignorare _meta e le annotazioni.
Molti sviluppatori si limitano a name, description e inputSchema, trascurando i campi _meta come openai/outputTemplate e gli hint come destructiveHint. Di conseguenza strumenti che eseguono azioni pericolose «in silenzio» non sono accompagnati da avvisi e conferme nell’UI. Questo peggiora la fiducia dell’utente e crea rischio di operazioni inattese. Usa le annotazioni per contrassegnare esplicitamente gli strumenti read‑only e quelli pericolosi, e per impostare stati di esecuzione amichevoli.
Errore n. 7: mancanza di validazione dell’input sul server.
Anche se JSON Schema e Zod sembrano descrivere tutto, affidarsi solo al modello è rischioso. A volte il modello può produrre dati solo parzialmente validi oppure tu stesso modifichi lo schema e dimentichi i vincoli di business. Racchiudere il gestore in un try { parse } catch { ... } con un errore amichevole dà al modello la possibilità di correggere gli argomenti, e a te — la possibilità di non far cadere l’intero servizio per una singola tool‑call andata male.
GO TO FULL VERSION