1. Perché proteggere il perimetro di una ChatGPT App
Nella classica applicazione web il cliente è il browser, che chiama i vostri endpoint in modo abbastanza prevedibile. Nel mondo delle ChatGPT Apps compare un nuovo tipo di client: una LLM che decide autonomamente quando e quali strumenti chiamare.
Il modello può:
- chiamare più volte lo stesso tool all’interno di una singola conversazione;
- sperimentare: «e se chiamassimo di nuovo suggest_gifts con parametri leggermente diversi?»;
- lavorare in parallelo su centinaia di utenti.
Aggiungete bot potenziali, script di test, errori nel vostro codice (ad esempio un loop infinito che innesca continuamente un tool‑call) e ottenete quasi la ricetta perfetta per un DoS in buona fede.
La ciliegina sulla torta è il costo. Ogni tool‑call può:
- chiamare API esterne a pagamento (corrieri, pagamenti, cataloghi),
- invocare altre LLM (ad esempio, ricerca RAG),
- avviare job in background pesanti.
Senza limiti e protezione del perimetro un singolo client «sfortunato» può:
- far cadere tutti i vostri servizi backend dietro il gateway (Gift API, Commerce API ecc.),
- bruciare i limiti delle API esterne,
- e incidere pesantemente sul budget del modello.
L’obiettivo di questa lezione è mostrare come gateway/proxy + rate limiting + code + backpressure trasformino questa potenziale catastrofe in un sistema controllabile.
Insight
La piattaforma ChatGPT non fornisce alcun meccanismo di protezione del vostro server MCP dal traffico esterno. Qualsiasi client Internet può inviare richieste, inclusi strumenti come MCP Jam.
Tutto ciò che ChatGPT può offrire è limitare il traffico in ingresso per indirizzi IP, configurando un reverse proxy (ad esempio, NGINX) per lavorare con una allowlist. Se il filtro IP non è configurato, il vostro server MCP rimane completamente aperto, il che non è sicuro. Né per voi, né per i vostri utenti.
2. Proxy/Gateway come «scudo» davanti ai servizi backend e agli agent
Per prima cosa ricordiamo lo schema, ma ora attraverso la lente della sicurezza.
Immaginate uno schema tipico:
flowchart LR
ChatGPT["ChatGPT / Widget"]
--> GW["MCP Gateway (Auth, Rate Limit, Logs)"]
GW --> GiftAPI["Gift REST API (selezione dei regali)"]
GW --> CommerceAPI["Commerce REST API (checkout, ACP)"]
GW --> Analytics["Analytics Service / REST API"]
GW --> Queue["Coda dei job"]
Queue --> Worker["Background workers"]
Il gateway sta tra il mondo esterno (ChatGPT, webhook, client di test) e tutto il resto. Esso:
- vede assolutamente tutte le richieste in entrata;
- per primo controlla il token e il formato della richiesta;
- sa scartare cose impossibili in partenza (host non autorizzato, path anomalo, body troppo grande);
- decide a quale servizio interno REST/HTTP abbia senso inoltrare la richiesta.
Allo stesso livello compaiono anche:
- rate limiting — limitiamo quante richieste si possono fare per intervallo di tempo;
- backpressure di base — rifiutiamo se i servizi a valle stanno già annaspando;
- passaggio all’asincrono — le operazioni pesanti vanno subito in coda, risposta al client: «accettato, attendi».
In altre parole, il gateway non è solo un «router», ma anche un «giubbotto antiproiettile». L’importante è non trasformarlo nel «monolite di tutto il business», come già discusso nella lezione precedente.
3. Quali flussi di traffico controllare
Nell’ecosistema di una ChatGPT App di solito ci sono tre tipi principali di traffico che ci interessano in ottica di limiti e protezione.
In primo luogo, ci sono le MCP tool‑call da ChatGPT. È tutto ciò che arriva tramite il protocollo MCP: chiamate a suggest_gifts, get_product_details, create_checkout_session e altri strumenti. Il modello può generarli piuttosto rapidamente, soprattutto se sotto il cofano ci sono anche Agents.
In secondo luogo, ci sono le richieste in uscita dai nostri backend verso API esterne. All’interno del servizio potremmo avere limiti propri per i sistemi di terze parti: cataloghi, logistica, pagamenti. Infrangerli significa ottenere blocchi, penali o un peggioramento della qualità.
In terzo luogo, ci sono i webhook in entrata — notifiche da ACP, provider di pagamento (Stripe, ecc.), corrieri. Arrivano indipendentemente dall’attività degli utenti. Se il nostro endpoint è lento o risponde con errore, il sistema esterno inizierà a effettuare nuovi tentativi (retry) e può scatenare una «tempesta» di notifiche ripetute.
Per GiftGenius questo appare così:
- l’utente e il modello chiamano frequentemente suggest_gifts e find_similar_gifts;
- il tool di checkout chiama ACP/il backend commerciale;
- dopo il pagamento il provider invia webhook payment.succeeded / payment.failed.
Tutti questi flussi convergono in un unico punto — il Gateway, dunque è proprio lì che ha senso mettere «contatori, filtri e valvole».
4. Rate limiting: protezione di base e risparmio
Che cos’è il rate limiting nel nostro contesto
Il rate limiting è un meccanismo che limita il numero di richieste da un client specifico per unità di tempo. L’idea è vecchia quanto Internet, ma nel contesto delle ChatGPT Apps risolve subito tre problemi:
- impedisce a un singolo client (o bug) di far cadere i vostri servizi;
- aiuta a rispettare i limiti delle API esterne;
- protegge il vostro portafoglio da chiamate incontrollate ai modelli.
Algoritmi classici:
- finestra fissa (Fixed Window),
- finestra scorrevole (Sliding Window),
- secchiello di token (Token Bucket),
per noi è importante soprattutto il concetto: «in un minuto non più di N richieste», «ogni richiesta consuma un token, i token si ricaricano a velocità X al secondo», ecc. L’implementazione in genere è a carico di una libreria o di un API Gateway.
Dove impostare i limiti
I limiti possono essere impostati a diversi livelli.
A livello di reverse proxy (Nginx, Cloudflare, AWS API Gateway) è comodo:
- tagliare il traffico più selvaggio per IP;
- limitare la dimensione del corpo della richiesta;
- proteggersi da pattern DDoS semplici.
A livello di MCP Gateway (applicazione) è utile un rate limiting più «intelligente»:
- per utente (userId dal token),
- per organizzazione (tenantId),
- per tipo di operazione (ad esempio, create_checkout_session è fortemente limitato, search — più morbido),
- per sorgente (webhook vs tool‑call).
Si possono inoltre aggiungere limiti dentro i microservizi stessi per operazioni particolarmente costose, ma questo è un livello successivo di dettaglio.
Come scegliere la chiave per i limiti
L’errore più comune è limitare per indirizzo IP. Nel caso di ChatGPT è piuttosto inutile:
- tutte le richieste possono provenire da un unico intervallo OpenAI,
- utenti diversi «condivideranno» lo stesso IP.
Ci interessa molto di più:
- userId — utente specifico nella vostra app;
- tenantId — organizzazione (se fate B2B e una chat è usata da molti dipendenti);
- token API o clientId, se avete più integrazioni.
In GiftGenius di solito bastano userId + tenantId, estratti dal token che ChatGPT invia nelle chiamate MCP.
Implementazione semplice di rate limiting in TypeScript
Poniamo di avere un piccolo MCP Gateway in Express. Aggiungiamo un rate limiting basilare: non più di 30 tool‑call al minuto per utente.
// Rate limiting primitivo: N richieste al minuto per userId
const WINDOW_MS = 60_000;
const MAX = 30;
const hits = new Map<string, { ts: number; count: number }>();
function rateLimit(req: Request, res: Response, next: NextFunction) {
const userId = (req.headers["x-user-id"] as string) ?? "anonymous";
const now = Date.now();
const rec = hits.get(userId) ?? { ts: now, count: 0 };
if (now - rec.ts > WINDOW_MS) { // La finestra è scaduta: ripartiamo da zero
rec.ts = now;
rec.count = 0;
}
rec.count += 1;
hits.set(userId, rec);
if (rec.count > MAX) {
return res.status(429).json({
error: "rate_limit_exceeded",
retryAfterSec: 60,
message: "Too many tool calls, please retry later."
});
}
next();
}
Ora usiamolo sulla route MCP:
// Applichiamo il middleware a tutti i MCP tool-calls
app.post("/mcp/tools/call", rateLimit, async (req, res) => {
const result = await callBackendForTool(req.body); // Chiamata REST verso Gift/Commerce/Analytics API
res.json(result);
});
Punti chiave:
- restituiamo un’errore significativo (error: "rate_limit_exceeded"), non semplicemente un 500;
- il modello sarà in grado di leggere l’errore, capire cosa è successo e spiegarlo correttamente all’utente, invece di iniziare a inventare.
In produzione reale i contatori, ovviamente, non vivono nella memoria di un singolo processo, ma in Redis o in un altro archivio condiviso, in modo da funzionare in cluster. Ma per capire il principio questo basta.
Il rate limiting e i limiti a livello di gateway ci proteggono da valanghe di richieste, ma non risolvono un altro problema — alcune operazioni possono comunque essere molto pesanti e durare a lungo. Qui il solo HTTP sincrono non basta, ed entrano in scena code e job asincroni.
5. Code e job asincroni: quando il sincrono non basta più
Il problema dei timeout di ChatGPT
Anche se configurate con cura il rate limiting, ChatGPT (e in generale i client HTTP) non amano quando la risposta arriva troppo tardi. La piattaforma limita il tempo di esecuzione di un tool‑call e, se aspettate che finisca «un super algoritmo di raccomandazione», allora:
- l’utente vedrà uno spinner infinito;
- la piattaforma interromperà la richiesta per timeout;
- il modello deciderà che «qualcosa è andato storto» e inizierà a inventare spiegazioni.
La soluzione è spostare le operazioni pesanti in modalità asincrona. Pattern classico:
- Il gateway riceve la richiesta.
- Inserisce un job in coda.
- Restituisce subito una risposta 202 Accepted con jobId.
- Un worker separato preleva i job dalla coda ed elabora.
- Il client (il nostro widget o anche ChatGPT tramite un tool aggiuntivo) interroga periodicamente lo stato tramite jobId o riceve notifiche tramite eventi MCP.
Nei termini di una ChatGPT App questo appare di solito come due strumenti: il primo tool accetta la richiesta, mette il job in coda e restituisce il jobId, il secondo consente al modello o al widget di conoscere lo stato tramite quel jobId e prelevare il risultato. Facoltativamente, gli stessi eventi di avanzamento possono essere duplicati tramite notifiche MCP.
Mini‑coda per GiftGenius (esempio di codice)
Supponiamo di avere uno strumento pesante generate_large_gift_report, che può lavorare decine di secondi. In una App reale restituirebbe solo il jobId, e un tool separato, get_report_status, consentirebbe al modello o al widget di conoscere lo stato tramite quel jobId e prelevare il risultato. A livello di gateway creiamo per esso un endpoint dedicato con coda.
type Job = { id: string; payload: any };
const queue: Job[] = [];
const MAX_QUEUE = 100;
app.post("/mcp/tools/generate_report", (req, res) => {
if (queue.length >= MAX_QUEUE) {
return res.status(503).json({
error: "system_busy",
message: "System is busy, please retry later."
});
}
const job: Job = { id: crypto.randomUUID(), payload: req.body };
queue.push(job);
res.status(202).json({ jobId: job.id, status: "accepted" });
});
E un worker primitivo che ogni 200 ms prende un job:
async function processJob(job: Job) {
// Qui invochiamo il vero servizio backend o un workflow di agent tramite REST
await handleHeavyGiftReport(job.payload);
}
setInterval(async () => {
const job = queue.shift();
if (!job) return;
await processJob(job);
}, 200);
È chiaro che questo è un esempio molto semplificato:
- nel mondo reale la coda vive in Redis, SQS, Kafka, ecc.;
- lo stato del job è memorizzato altrove, in modo da poterlo interrogare;
- i worker sono di solito più di uno.
Ma il concetto è chiaro: il gateway non tiene la richiesta aperta finché tutto non è fatto. Accetta, mette in lavorazione e risponde rapidamente.
6. Backpressure: come non affogare nella propria coda
In cosa il backpressure differisce dal rate limiting
Il rate limiting risponde soprattutto alla domanda: «quante richieste può fare un singolo client per intervallo di tempo?». È la difesa contro «un utente troppo attivo» o un bug lato client.
Il backpressure risponde: «quante richieste/job in totale il nostro sistema è in grado di digerire contemporaneamente senza disfarsi?». Riguarda il volume complessivo del carico, indipendentemente dalla sua origine.
Esempio:
- rate limiting: «l’utente non può chiamare suggest_gifts più di 30 volte al minuto»;
- backpressure: «in coda non possono esserci più di 100 job in sospeso, altrimenti iniziamo a rifiutare tutte le nuove richieste».
Idealmente questi meccanismi si completano a vicenda: il rate limit tiene a freno i client, il backpressure salva il sistema quando comunque arriva troppa gente.
Implementazione semplice del limite alle attività attive
Una delle varianti più semplici di backpressure è limitare il numero di chiamate attive sotto il cofano. Per esempio: non mantenere più di 50 tool‑call attive contemporaneamente verso un determinato servizio backend/REST (Gift API, Commerce API ecc.).
let activeCalls = 0;
const MAX_ACTIVE = 50;
app.post("/mcp/tools/call", async (req, res) => {
if (activeCalls >= MAX_ACTIVE) {
return res.status(429).json({
error: "gateway_overloaded",
message: "Gateway is temporarily overloaded, please retry later."
});
}
activeCalls += 1;
try {
const result = await callBackendForTool(req.body); // Chiamata REST verso Gift/Commerce/Analytics API
res.json(result);
} catch (err) {
console.error("Tool call error", err);
res.status(500).json({ error: "internal_error" });
} finally {
activeCalls -= 1;
}
});
Cosa succede qui:
- finché il numero di richieste in esecuzione è inferiore a MAX_ACTIVE, facciamo passare la nuova call;
- se il limite è esaurito, rispondiamo subito con un errore significativo;
- è importante decrementare il contatore nel finally, per non perdere «slot» in caso di errori.
Questo è il backpressure più semplice: diciamo onestamente al client: «non posso adesso, riprova più tardi», invece di accettare tutto alla cieca e morire.
In seguito si può:
- impostare MAX_ACTIVE diversi per tipi di operazione differenti (ad esempio, far passare quasi sempre il checkout, ma limitare più rigidamente la generazione di report),
- cambiare i limiti dinamicamente in base alle metriche di carico.
7. Webhook e «tempeste»: protezione degli eventi in ingresso
Finora abbiamo guardato soprattutto alle richieste avviate da noi o da ChatGPT (tool‑call, richieste in uscita, job asincroni). Ma nella vita c’è un’altra importante fonte di carico sul gateway — i webhook in entrata dai sistemi esterni.
I webhook sono il rovescio della medaglia: se i tool‑call li iniziamo noi (tramite il modello), i webhook li inizia un servizio esterno. È proprio quel terzo tipo di traffico della sezione 4 che non controlliamo nel tempo e nella frequenza, ma che dobbiamo essere in grado di gestire senza cadere. Provider di pagamento, ACP, logistica — tutti inviano notifiche (webhook) al nostro endpoint a ogni cambiamento significativo: «pagamento riuscito», «ordine creato», «consegna ha aggiornato lo stato».
I problemi iniziano quando:
- il nostro endpoint risponde lentamente;
- risponde con errore;
- è periodicamente non disponibile.
Allora il servizio esterno, secondo le best practice, comincia a fare retry. E se va male, otterrete una «tempesta di webhook» — decine o centinaia di eventi ripetuti che cercano di «raggiungervi» a ogni costo.
Per non soccombere a tanta sollecitudine, a livello di gateway conviene:
- Limitare i webhook in entrata per sorgente: ad esempio, «non più di 10 eventi al minuto per un event_type da un dato provider».
- Verificare la firma prima del parsing JSON: una firma HMAC o meccanismo analogo consente di scartare richieste fasulle.
- Rendere l’elaborazione degli eventi idempotente: tramite event_id o campo analogo, così che i duplicati non creino ordini o pagamenti doppi.
- In caso di tempesta forte, attivare backpressure aggiuntivo: rispondere temporaneamente «503: riprovare più tardi» se i servizi a valle non tengono il passo.
Esempio semplicissimo (idea, non codice di produzione):
app.post("/webhooks/stripe", rateLimitWebhook, (req, res) => {
const sig = req.headers["stripe-signature"] as string;
if (!isValidSignature(req.rawBody, sig)) {
return res.status(400).send("Invalid signature");
}
const event = JSON.parse(req.body.toString());
if (isAlreadyProcessed(event.id)) {
return res.json({ received: true }); // idempotenza
}
handleStripeEvent(event);
res.json({ received: true });
});
Qui, a livello di gateway, noi:
- applichiamo una politica di rate limiting separata per i webhook;
- validiamo la firma prima di fidarci del contenuto;
- ci proteggiamo dai duplicati tramite isAlreadyProcessed.
8. Applicazione a GiftGenius: esempio di politica per limiti e code
Ora lasciamo da parte le astrazioni e vediamo come potrebbe apparire per il nostro GiftGenius didattico.
Immaginiamo tre scenari chiave:
- Ricerca regali (suggest_gifts, find_similar_gifts).
- Creazione ordine / checkout (create_checkout_session, confirm_order).
- Ricezione dei webhook dal provider di pagamento e da ACP.
Per ciascuno scenario è sensato definire:
- in base a quale chiave contiamo il limite;
- quante richieste al minuto permettiamo;
- cosa facciamo in caso di superamento.
Ad esempio:
| Scenario | Chiave del limite | Limite al minuto | Comportamento in caso di superamento |
|---|---|---|---|
| Ricerca regali | userId | 30 | 429 + suggerimento «restringi i parametri di ricerca» |
| Creazione ordine | userId + tenantId | 5 | 429 + testo «troppi tentativi, controlla gli ordini» |
| Webhook in entrata | provider + eventType | 10 | 429/503, log, possibile degradazione |
Per i webhook di solito è più logico limitare per combinazione «provider + tipo evento» e tagliare i duplicati con un meccanismo separato di idempotenza tramite event_id.
Nel codice questo si traduce in middleware differenti: rateLimitSearch, rateLimitCheckout, rateLimitWebhook.
Per le operazioni pesanti, come «generare un grande report PDF sui regali dell’anno», usiamo la coda e il pattern asincrono già mostrato sopra. In tal caso il gateway:
- accetta la richiesta da ChatGPT;
- inserisce il job in coda;
- restituisce il jobId e un suggerimento al modello su come ottenere lo stato;
- limita la dimensione della coda (backpressure), per non saturare il sistema.
È importante ricordare: sia il rate limiting sia il backpressure non riguardano solo sicurezza e affidabilità, ma anche la UX. È molto meglio sentire dall’assistente: «Il servizio è attualmente sovraccarico, proviamo tra un minuto», che restare a fissare una rotella fino al timeout o vedere «Internal Server Error».
9. Mini‑pratica: aggiungiamo protezione al nostro MCP Gateway
Per non lasciare la materia nella teoria, mettiamo insieme una mini‑pratica che potete implementare nel vostro progetto didattico.
Rate limiting per tutti i MCP tool‑call
Aggiungete il middleware rateLimit (come sopra) e collegatelo a /mcp/tools/call. All’inizio potete impostare un limite molto semplice: 30 richieste al minuto per userId. Poi sperimentate:
- riducete il limite e guardate come reagiscono la vostra App e il modello;
- impostate limiti diversi per tipi di tools differenti, passando ad esempio toolName al middleware.
Backpressure basilare sulle chiamate attive
Aggiungete il contatore activeCalls e il limite MAX_ACTIVE. Provate a simulare il carico (ad esempio con uno script che invia un lotto di richieste) e osservate quando il gateway comincia a rispondere con gateway_overloaded.
Qui è importante il comportamento: non aspettate che tutto crolli, ma rifiutate di accettare nuovi job, dicendo onestamente al client che in questo momento fa troppo caldo.
Coda per uno strumento pesante
Scegliete un’operazione pesante (o rendetela «artificialmente» pesante — inserendo setTimeout/un fetch lungo) e portatela al pattern «coda + jobId». Minimo:
- endpoint POST /mcp/tools/generate_report — mette il job in coda e restituisce il jobId;
- endpoint GET /jobs/:id — restituisce lo stato (pending, done, error, e magari anche il risultato);
- un worker che ogni X millisecondi invoca processJob.
Questo è sufficiente per capire come apparirà l’integrazione reale con BullMQ o un altro motore di code.
10. Errori tipici nella protezione del perimetro
Errore n. 1: Limitare solo per IP.
Nel mondo delle ChatGPT Apps è quasi inutile: la maggior parte delle richieste arriva dagli indirizzi di OpenAI, e tutti i vostri utenti risulteranno dietro lo stesso IP. Alla fine qualcuno brucerà il limite per tutti e il vero colpevole resterà ignoto. È più corretto limitare per userId, tenantId o token, e usare l’IP solo come filtro molto grossolano a livello di reverse proxy.
Errore n. 2: Restituire un semplice 500 invece di un errore significativo.
Se, al superamento del limite o in caso di sovraccarico, inviate semplicemente un 500 Internal Server Error, il modello non capisce nulla e inizia a inventare. Un errore strutturato con codice (rate_limit_exceeded, gateway_overloaded) e una descrizione leggibile permette alla LLM di spiegare correttamente la situazione all’utente e, se necessario, riprovare più tardi.
Errore n. 3: Creare una coda infinita senza backpressure.
A volte sembra: «mettiamo tutto in coda e poi vediamo». In pratica la coda cresce a migliaia di job, le latenze aumentano, la memoria finisce e gli utenti non vedono il risultato. Limitate sempre la dimensione della coda e il numero di operazioni attive. È meglio rifiutare onestamente le nuove richieste con 503 o 429, che trasformare la coda in un buco nero.
Errore n. 4: Affidarsi solo al rate limiting e ignorare i webhook.
Molti proteggono solo il traffico in entrata da ChatGPT, lasciando i webhook «in qualche modo». Quando il provider di pagamento comincia a fare retry, proprio i webhook possono scatenare una vera tempesta. Gli endpoint dei webhook hanno bisogno di limiti propri, verifica della firma e elaborazione idempotente. Altrimenti è facile ottenere una decina di duplicati dello stesso ordine.
Errore n. 5: Conservare tutti i contatori e la coda solo nella memoria di un’istanza.
Per un progetto didattico va bene, ma in produzione, scalando il gateway a più istanze, i contatori su ogni nodo inizieranno a «vivere di vita propria», i limiti smetteranno di essere globali e il riavvio di un nodo azzererà la coda. In un sistema reale per lo stato dei limiti e delle code si usa uno storage condiviso (Redis, code cloud, ecc.). Ne parleremo ancora nelle lezioni su scalabilità e produzione.
Errore n. 6: Infilare la logica di business dentro il gateway «dato che è già il mediatore ovunque».
A volte la tentazione è: «decidiamo direttamente nel gateway quali regali mostrare, tanto le richieste arrivano lì». Alla fine il gateway diventa un monolite pieno di logica, che è insieme router, cervello di business e logger. Questo complica molto scalabilità e manutenzione. Il gateway deve rimanere uno strato di rete/infra: autenticazione, autorizzazione, limiti, cache, instradamento — sì; selezione dei regali — no.
Errore n. 7: Pensare che «siamo piccoli, non ci riguarda».
Spesso si pensa: «Non abbiamo un milione di utenti, possiamo fare a meno di gateway/limiti». In realtà basta un bug nel codice client (o in un prompt che porta il modello a chiamare un tool in loop) per causarvi un piccolo ma localissimo apocalisse. Il rate limiting di base e almeno un backpressure primitivo non sono un lusso, ma lo spazzolino da denti della produzione: vanno usati fin dall’inizio, prima che inizi a far male.
GO TO FULL VERSION