Tier System (Tier-1 vs Tier-2)
Trackily classifie tous ses tools MCP en Tier-1 (auto-execute, reads + writes "safe") ou Tier-2 (preview + confirm_token requis avant exécution). C'est la deuxième couche de défense après les scopes, pensée pour rendre un LLM hallucinant inoffensif sur les opérations destructives ou financières.
Concept
Les LLM hallucinent. C'est un fait. Si tu donnes à un agent le pouvoir d'appeler delete_campaign directement, tu te réveilles un matin avec 40 campagnes supprimées parce que l'agent a "interprété" une question d'analyse comme une instruction de cleanup. Pas glorieux.
Réponse de Trackily : un contrat two-step pour les opérations critiques.
TIER-2 CONTRACT
───────────────
First call Second call
────────── ───────────
┌──────────────────┐ ┌──────────────────┐
│ tool: foo │ │ tool: foo │
│ args: { ... } │ │ args: { ... } │
│ ──────── │ │ + confirm_token │
│ no token │ │ ──────── │
└────────┬─────────┘ └────────┬─────────┘
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ Status: │ │ Validate token │
│ confirmation_ │ │ + idempotency │
│ required │ │ │
│ │ │ then │
│ + preview │ │ │
│ + confirm_token │ │ EXECUTE SQL │
│ (TTL 5min) │ │ │
└──────────────────┘ └──────────────────┘
Concrètement :
- First call — l'agent appelle
delete_campaign({ id: 42 }). Le tool ne mute RIEN. Il calcule un preview ("Will delete campaign #42 'Black Friday FR' — 12 active flows, 4523 lifetime clicks, $1247 lifetime revenue"), génère unconfirm_token(TTL 5min, lié au token MCP + au nom du tool + au hash des params), et renvoiestatus: "confirmation_required". - Affichage humain — le LLM montre le preview à l'opérateur, demande validation explicite.
- Second call — si l'opérateur valide, l'agent rappelle
delete_campaign({ id: 42, confirm_token: "cfm_..." }). Le tool valide le token, exécute le SQL, retournestatus: "ok".
Le LLM ne peut jamais générer un confirm_token valide tout seul — il vient forcément du premier round. Et le token est lié au hash des args : si le LLM change un arg entre les deux calls (de la id: 42 à id: 43), le token est rejeté params_changed.
Tier-1 — quoi qu'est dedans
Tous les reads sont Tier-1 (pas de mutation, rien à confirmer).
Les writes "safe" sont Tier-1 :
pause_campaign/resume_campaign(réversible, low-risk)create_campaign,create_landing,create_email_list, etc. (création — facile à supprimer si erreur)update_email_list,add_email_sequence_step(modifications routine)enroll_email_subscriber,bind_landing_to_email_list(état facilement réversible)generate_ai_landingetc. (génération AI — coûteux mais non-destructif)
Auto-execute : le LLM appelle le tool, le serveur exécute immédiatement, renvoie le résultat.
Tier-2 — quoi qu'est dedans
Toute opération qui est destructive, financière ou touche des credentials :
| Catégorie | Tools |
|---|---|
| Delete d'entités importantes | delete_email_list, delete_collection, delete_product (Shopify), delete_cloaking_workflow, delete_email_conversion_trigger |
| Modification de payout / cost | update_offer_payout, update_campaign_cost |
| Modification de budget ad | update_source_campaign_budget, update_source_campaign_bid (impactent une vraie dépense €) |
| Ad networks pause/resume | pause_source_campaign, resume_source_campaign (impacte la délivrance immédiate) |
| Refund / cancel | refund_order, cancel_order, refund_native_order, cancel_native_order |
| Credentials | create_email_smtp_server (password stocké chiffré) |
| Bulk operations | bulk_update_product_prices, cross_platform_scale_plan |
La règle de pouce : "Si le pire des cas est une perte de data, d'argent, ou une réputation cramée, c'est Tier-2."
Le contrat — détails
First call (sans confirm_token)
L'agent appelle le tool normalement, sans confirm_token dans les args.
Le handler du tool fait :
- Validation des params (types, valeurs requises)
- Calcul du preview en lisant l'état actuel de la DB
tier2.issueToken({ tokenId, toolName, params, preview })— génère un confirm_token et le stocke dans la mappendingConfirmations- Retourne
previewResponse({ tool, summary, preview, token })
Le payload de retour :
{
"status": "confirmation_required",
"tool": "update_offer_payout",
"summary": "Will update payout of offer #12 'Sweepstakes FR Gold' from $4.50 to $6.20 (+37.8%).",
"preview": {
"offer_id": 12,
"offer_name": "Sweepstakes FR Gold",
"current_payout": "4.50",
"new_payout": "6.20",
"delta_pct": 37.8,
"active_campaigns_using_this_offer": 4
},
"confirm_token": "cfm_a1b2c3d4e5f6g7h8i9j0k1l2",
"confirm_instructions": "Review the preview above. If correct, call \"update_offer_payout\" again with the SAME arguments plus \"confirm_token\": \"cfm_a1b2c3d4e5f6g7h8i9j0k1l2\". Token expires in 5 minutes and is single-use."
}
Second call (avec confirm_token)
L'agent rappelle le tool avec les mêmes args + le confirm_token :
{
"name": "update_offer_payout",
"arguments": {
"offer_id": 12,
"new_payout": 6.20,
"confirm_token": "cfm_a1b2c3d4e5f6g7h8i9j0k1l2"
}
}
Le handler du tool fait :
tier2.consumeToken({ confirm_token, tokenId, toolName, params })— valide- Si OK : exécute le SQL (UPDATE), récupère le résultat
tier2.recordCompletion(confirm_token, result)— cache le résultat pour idempotent replay- Retourne
doneResponse({ tool, message, details })
Le payload de retour :
{
"status": "ok",
"tool": "update_offer_payout",
"message": "Offer payout updated successfully",
"offer": {
"id": 12,
"name": "Sweepstakes FR Gold",
"payout": "6.20",
"updated_at": "2026-05-18T14:33:21.812Z"
}
}
Idempotent replay
Le cas réel : l'agent envoie le second call, le serveur exécute le SQL, mais la réponse HTTP se perd (timeout réseau, crash du client). L'agent retry le même call. Que se passe-t-il ?
Réponse : le même result est retourné sans re-exécuter l'opération. C'est l'idempotent replay, implémenté dans autopilot-tier2.js.
Le mécanisme :
- Après le commit,
recordCompletion(confirm_token, result)stocke le résultat danscompletedConfirmations(TTL 5min). - Si le même
confirm_tokenest rejoué dans les 5min,consumeTokendétecte la cache hit et renvoie le résultat sans toucher à la DB.
Sécurité : la cache hit est gardée par 3 checks de métadonnées :
tokenIddoit matcher — un attaquant qui vole un confirm_token mais a un autre MCP token est rejeté (wrong_owner)toolNamedoit matcher — utiliser le token sur un autre tool est rejeté (wrong_tool)paramsHashdoit matcher — modifier les args entre les deux calls est rejeté (params_changed)
Ce dernier check matche le contrat Stripe's idempotency-key : si tu retry avec des args modifiés, tu reçois une erreur claire, pas un résultat stale silencieusement servi.
Reasons de rejet
consumeToken() peut retourner { ok: false, reason: ... } avec :
| Reason | Sens |
|---|---|
unknown_or_used |
Token jamais émis, ou émis puis consommé sans recordCompletion |
wrong_owner |
Token émis pour un autre MCP token (tokenId différent) |
wrong_tool |
Token émis pour un autre tool (toolName différent) |
params_changed |
Args modifiés entre preview et execute |
expired |
TTL 5min dépassé |
Le tool transforme ça en réponse user-friendly :
{
"status": "rejected",
"tool": "update_offer_payout",
"error": "Arguments changed between the preview and the confirm call. Re-call without confirm_token to get a new preview.",
"reason": "params_changed"
}
L'agent peut alors rappeler sans confirm_token pour récupérer un nouveau preview avec les nouveaux args.
Vie du token
Issue Consume (1st time) Cache hit (replay)
───── ─────────── ──────────────────
┌────────────┐ ┌────────────┐ ┌────────────┐
│ │ 5min │ │ 5min │ │
│ pending │──────▶ │ in_flight │ ──────────▶ │ completed │
│ │ │ │ │ │
└────────────┘ └────────────┘ └────────────┘
│ │ │
│ janitor │ janitor │ janitor
│ (1min tick) │ (1min tick) │ (1min tick)
▼ ▼ ▼
deleted after deleted if recordCompletion deleted after
5min TTL not called (= bug) 5min TTL
Le janitor tourne toutes les 60 secondes pour nettoyer les entries périmées dans les 3 maps. Sans janitor, la mémoire fuirait sur les longues sessions.
Implémentation pour un nouveau tool
Si tu ajoutes un tool destructif, suis ce template :
async function toolDeleteFoo({ params, ctx }) {
const { id, confirm_token } = params;
// Second call — execute
if (confirm_token) {
const validation = tier2.consumeToken({
confirm_token,
tokenId: ctx.token.id,
toolName: 'delete_foo',
params,
});
if (!validation.ok) {
return tier2.rejectionResponse({ tool: 'delete_foo', reason: validation.reason });
}
// Cache hit — return cached result without re-running
if (validation.completed) return validation.completed;
// Real execute
const result = await db.deleteFoo(id);
const response = tier2.doneResponse({
tool: 'delete_foo',
message: `Foo ${id} deleted`,
details: { foo: result },
});
tier2.recordCompletion(confirm_token, response); // ← critical, after SQL commit
return response;
}
// First call — preview
const foo = await db.getFoo(id);
if (!foo) return tier2.okText({ status: 'rejected', error: 'Foo not found' });
const preview = {
foo_id: id,
foo_name: foo.name,
related_things_count: foo.related_count,
};
const token = tier2.issueToken({
tokenId: ctx.token.id,
toolName: 'delete_foo',
params,
preview,
});
return tier2.previewResponse({
tool: 'delete_foo',
summary: `Will delete foo #${id} '${foo.name}' and ${foo.related_count} related things.`,
preview,
token,
});
}
Comportement côté Claude Desktop
Claude Desktop affiche le preview brut au moment du first call, et demande à l'utilisateur une validation explicite avant de faire le second call. Tu vois quelque chose comme :
Trackily a proposé :
update_offer_payoutSummary : Will update payout of offer #12 'Sweepstakes FR Gold' from $4.50 to $6.20 (+37.8%).
Preview : ...
[Approve] [Decline]
Si tu cliques Approve, Claude rappelle le tool avec le confirm_token. Si tu cliques Decline, rien ne se passe (le token finira par expirer dans 5min).
C'est cette double-vérification (LLM côté UX + serveur côté contrat) qui rend l'opération sûre.
Erreurs courantes
- Tool destructif qui exécute directement sans preview — bug dans le handler : oubli du check
if (!confirm_token). Toujours tester avec et sans confirm_token. - Token "expired" très rapidement — TTL est 5 min. Si l'opérateur réfléchit 10 min avant de valider, le token expire. Rappelle sans confirm_token pour un nouveau preview.
params_changedalors que rien n'a bougé visiblement — l'ordre des clés JSON compte côté hash. Utilise la même sérialisation entre les deux calls (le LLM le fait naturellement, sauf si tu reformat le payload).wrong_tool— tu as appelédelete_offeravec un confirm_token issu dedelete_landing. Token cross-tool est interdit by design.- Cache hit qui ne se produit pas après retry — le handler n'a pas appelé
recordCompletion(confirm_token, result)après le SQL. Bug à fixer dans le handler.
Voir aussi
- Concept — le protocole JSON-RPC sous-jacent
- Tokens — les MCP tokens (à ne pas confondre avec les confirm_tokens)
- Scopes — la couche de permissions complémentaire
- Tools Reference — quels tools sont Tier-2