Incident-walkthrough
Stale MCP-cache: hoe een agent 1.920 SKU's herschreef
Om 09:14 op een woensdag startte de content-agent van een Zwols e-commerce bureau zijn wekelijkse rewrite. Om 16:08 klonk elke productpagina in een Shopify-store als een ander bedrijf.

Om 16:08 op een woensdag in mei opende de marketinglead van een Zwols e-commerce bureau van 23 mensen haar store op haar telefoon om een screenshot naar een leverancier te sturen. Het hero-product, een rvs koffieschep, beschreef zichzelf nu in de jij-vorm, in de tegenwoordige tijd, met drie emoji per alinea en een afsluitende regel die opriep om 'iemand te taggen die betere ochtenden verdient'. De toon was vriendelijk, casual en compleet verkeerd. Hun merkstem was ingetogen en Nederlands-direct. De beschrijving van die schep was die ochtend nog goed geweest.
Ze refreshte het volgende product. Fout. Het volgende. Fout. Ze opende de Shopify-admin en sorteerde op updated at: 1.920 productbeschrijvingen waren die dag herschreven tussen 09:14 en 16:08. Het bureau had geen campagne ingepland. Geen mens had de store aangeraakt.
De content-agent had gedraaid. Net als alles wat ze erbovenop hadden gebouwd.
De opzet
Dit bureau runt Shopify-storefronts voor veertien DTC-merken. Twee jaar geleden bouwden ze een kleine vloot interne agents voor het werk dat eerder in spreadsheets leefde: wekelijkse SKU-beschrijvingen verversen, alt-teksten bijwerken, blogs hergebruiken, sale-banners updaten. De orchestrator is een Node-service op een kleine Fly-machine. Domeintools zijn ontsloten via het Model Context Protocol (MCP), elke tool een apart proces dat de orchestrator bij boot opstart.
Eén van die tools, style-guide, geeft de actieve merkstem terug voor een gegeven client_id. Het team bouwde hem achttien maanden geleden. Stijlgidsen veranderen zelden, dus de tool cachet agressief: in-memory LRU, TTL van twee uur, gekeyed op client_id:locale. Een tweede laag cachet naar disk zodat de tool warm kan starten na een restart.
Je ziet het probleem al. Het team niet, totdat we de post-mortem met ze doorliepen.
Wat de tool teruggaf
In maart offboardden ze een klant. Noem deze Klant A. Klant A had een casual, vriendelijke merkstem, veel emoji en de jij-vorm. Het bureau haalde de storefront van Klant A uit de joblijst van de orchestrator, archiveerde het Slack-kanaal, stuurde de afscheidsmail.
Wat ze niet deden, omdat niemand eraan had gedacht: de on-disk cache voor Klant A invalideren. Er was geen admin-taak voor, want de tool had er nooit eentje nodig gehad. De merkstem van Klant A zat tien weken lang vrolijk in /var/cache/style-guide/client_a.json.
Op de woensdagochtend van het incident dispatchte de orchestrator de wekelijkse description-refresh job voor Klant B (de koffieschep-klant). Hij bouwde een prompt die ongeveer zo begon:
const tools = [
await mcp.call("style-guide", { client_id: "client_b", locale: "nl-NL" }),
await mcp.call("product-catalog", { client_id: "client_b" }),
// ...
]Het style-guide tool-proces was 's nachts geherstart (Fly recycelt machines elke 24 uur). Bij het opstarten las het zijn on-disk cache-index. Dat indexbestand was een jaar geleden geschreven door een developer in een debug-sessie, en het had een default entry: als client_id onbekend was, val terug op de meest recent gebruikte stijlgids. Die fallback was de stem van Klant A. Een code path waar niemand sindsdien aan had gezeten.
Waarom was client_b 'onbekend'? Omdat de in-memory LRU nog niet was opgewarmd, de disk-index stijlgidsen keyde op een interne UUID die zes weken eerder was geroteerd toen het team van environment wisselde, en de lookup stilletjes miste. De tool gaf een 200 terug met de stijlgids van Klant A. Geen error. Geen warning-log. Het cache-contract zei: geef altijd een stijlgids terug.
De orchestrator deed precies wat de bedoeling was: het tool-resultaat voor waar aannemen en 1.920 productbeschrijvingen schrijven in de stem die hij aangereikt kreeg.
Zeven uur stilte
Waarom zeven uur? Drie redenen, geen ervan technisch.
Het bureau had een Slack-kanaal genaamd #agent-alerts waar de orchestrator naartoe postte. Hij postte job-start, job-end en elke exception. Die ochtend postte hij 'weekly-refresh started, 1,920 SKUs queued' en niemand las het, want het kanaal was teambreed gemute na een lawaaiige week in februari. Het bureau had ook een dashboard op agents.internal/agency/runs dat de throughput toonde. Het stond op groen. De throughput was gezond. Niemand keek naar de output van de runs, alleen naar de hartslag.
En cruciaal: in de oorspronkelijke spec voor deze job zat een sample-review stap. De eerste productbeschrijving wordt naar Slack gepost voor een menselijke duim-omhoog voordat de rest draait. Die stap was acht maanden eerder gebypassed toen het team midden in een sprint zat, omdat de menselijke reviewer op vakantie was en de queue volliep. De bypass was een config-aanpassing van één regel. De config was sindsdien meermaals opnieuw gedeployed. De bypass overleefde.
De eerste die het opmerkte was de marketinglead, op haar telefoon, omdat de productpagina op de website verkeerd oogde. Tegen die tijd had de orchestrator zijn job afgerond en was netjes afgesloten. De job draaide, de job stopte, de job logde success.
Een agent die op schaal kan schrijven heeft een gate nodig die afgaat bij het eerste artefact, niet bij het laatste. Tegen de tijd dat een post-hoc check de stem van Klant A op de koffieschep zou hebben gepakt, was de run al voorbij.
Het herstel
Shopify bewaart de vorige body_html bij elke productrevisie via de API, maar daar bouwde het bureau niet op. Achttien maanden geleden, toen ze de agent voor het eerst op Shopify aansloten, had het team dertig minuten besteed aan een stap die de vorige waarde in een metafield wegzet vóór elke mutatie. Het voelde toen als overkill. Het was de enige reden dat de rollback vier uur kostte in plaats van vier dagen. Diezelfde avond schreven ze een rollback-script tegen de Admin GraphQL API:
import { GraphQLClient } from "graphql-request"
const shopify = new GraphQLClient(process.env.SHOPIFY_GRAPHQL_URL, {
headers: { "X-Shopify-Access-Token": process.env.SHOPIFY_TOKEN },
})
const QUERY = `
query($id: ID!) {
product(id: $id) {
id
handle
metafields(first: 1, namespace: "abn", key: "prev_description") {
edges { node { value } }
}
}
}
`
// For each affected product: read the stashed metafield, write it back
// as descriptionHtml, then delete the metafield once the restore confirms.Om 22:30 die avond waren 1.920 producten hersteld. De volgende ochtend om 07:30 stuurden ze een kort bericht naar de Slack van de getroffen klant, vóór die het zelf opmerkte.
Wat er veranderde in de agent
De post-mortem leverde een lijst op. Niets ervan is slim. Alles is achteraf voor de hand liggend. Elk punt stond al ergens in de backlog van het bureau.
Tool-responses krijgen een tenant-signature
Elke MCP-tool ondertekent zijn response nu met de client_id waarvoor hij denkt te hebben geantwoord, en de orchestrator controleert dat de beantwoorde id matcht met de gevraagde. Bij een mismatch faalt de call luidruchtig.
// In every MCP tool's response
return {
requested: { client_id, locale },
resolved: { client_id: actualClientLookedUp, locale: actualLocale },
payload: styleGuide,
}
// In the orchestrator, after every tool call
if (response.requested.client_id !== response.resolved.client_id) {
throw new ToolContractViolation(
`style-guide returned data for ${response.resolved.client_id} ` +
`when ${response.requested.client_id} was requested`
)
}Nooit meer stille fallbacks
De 'als onbekend, geef de meest recente terug' tak is verwijderd. Een onbekende client_id is nu een error. De orchestrator beslist wat te doen met een onbekende klant. De tool mag niet gokken.
De sample-review stap is niet meer via config te bypassen
Het is een harde wait op een Slack-reactie. Is het kanaal gemute of slaapt de reviewer, dan stalt de job en wordt de on-call engineer na tien minuten gepaged. De throughput zakte iets. Het vertrouwen steeg veel.
Cache-invalidatie hoort bij offboarding
Hun offboarding-checklist eindigt nu met een script dat elke tool-cache leegmaakt voor de vertrekkende client_id. Het script controleert ook dat die id nergens meer voorkomt in actieve job-configs. Is dat wel zo, dan kan de offboarding niet afronden.
Een brand-voice classifier draait als gate op elke batch
Dit is de wijziging die we het hardst hebben aangeraden. Voordat de orchestrator een batch naar Shopify schrijft, leest een apart klein model de gegenereerde beschrijving en de canonieke stijlgids, en beantwoordt één vraag: matchen ze. Is het antwoord nee bij meer dan één sample op twintig, dan stopt de batch en wordt iemand gepaged. Tweehonderd milliseconden per product. Elke milliseconde waard.
De cache-TTL was niet het echte probleem
Het is verleidelijk om dit te lezen en te concluderen dat de cache-TTL te lang stond. Dat was niet zo. Een TTL van twee uur op een stijlgids die maandelijks wijzigt is prima. Het echte probleem was dat de tool geen concept had van 'geen data' als geldig antwoord. Elke cache, elke tool, elke integratie krijgt uiteindelijk een moment waarop het juiste antwoord 'ik weet het niet' is. Tools die dat moment behandelen als een error om weg te poetsen, zijn precies hoe stille incidenten beginnen.
De MCP-specificatie dwingt geen specifiek tool-contract af. Dat is bewust: het protocol is transport, geen policy. De policy schrijf je zelf. Doe dat ook.
De kleinste verandering die je vandaag kunt doorvoeren
Pak één van de tools van je agent en lees het happy path. Lees daarna het error path. Bevat dat ook maar één zin in de trant van 'val terug op default' of 'gebruik de laatst bekende waarde', dan heb je dezelfde bug. Open een issue. Vervang de fallback door een expliciete error, en laat de caller bepalen wat 'geen data' in context betekent. Dat is twintig minuten werk, en het verschil tussen een incident van zeven uur en een tool-call die in de eerste seconde van de run faalt.
Toen we vorig voorjaar de agent-vloot bouwden voor een Nederlandse fashion-retailer, liepen we tegen een bijna identieke versie van dit verhaal aan: een MCP-tool met een redelijk uitziende default die voor één van hun zeven merken de verkeerde default bleek. We losten het op met het tenant-signature patroon hierboven en een brand-voice classifier als harde gate vóór elke write. Hang je AI-agents aan een CMS of PIM, dan is die gate het deel dat je éérst bouwt. Het eigenlijke werk, de agents die copy schrijven, facturen najagen of tickets triëren, is de makkelijke helft.
Kern
Een agent die op schaal kan schrijven heeft een gate nodig die afgaat bij het eerste artefact, niet bij het laatste.
FAQ
Wat is MCP?
Model Context Protocol, een open specificatie om tools te koppelen aan een LLM-gedreven orchestrator. Elke tool draait als eigen proces en biedt een getypeerde interface die het model kan aanroepen.
Kan dit ook gebeuren met een ander agent-framework?
Ja. De bug is niet MCP-specifiek. Elke tool-laag die een default-waarde teruggeeft als context ontbreekt, heeft hetzelfde faalpatroon.
Hoe rol je duizenden productbeschrijvingen veilig terug?
Zet de vorige body_html weg in een Shopify-metafield voordat een agent schrijft. Die ene gewoonte maakt van een rollback een script in plaats van een noodgeval.
Wat is een brand-voice classifier?
Een klein model dat een gegenereerde beschrijving en de canonieke stijlgids leest en antwoordt of ze matchen. Goedkoop, snel, en de juiste plek voor de gate voordat een batch wordt weggeschreven.
Waarom was dat gemute Slack-kanaal zo bepalend?
Alerts horen in kanalen die mensen lezen. Staat het enige alarm van de agent in een gemute kanaal, dan bestaat het alarm niet. Stuur kritische alerts naar een kanaal dat de on-call niet kan muten.