Aller au contenu
Dimitri Robiere PerrietD. R. PerrietCaen, FR — --:--:--FR · EN

Un serveur MCP pour piloter Google Ads, Meta et TikTok

Un serveur MCP pour piloter Google Ads, Meta et TikTok — illustration
Fig. 01 — illustration
(00) — En bref

Comment j’ai exposé 27 outils MCP au-dessus de trois API publicitaires : scopes lecture et écriture, journal d’audit, normalisation, et les bugs corrigés.

ADS Mediapilote, la plateforme SEA de l’agence, synchronise les campagnes Google Ads, Meta et TikTok de nos clients dans une base MySQL commune. Elle a déjà son assistant intégré, avec dix outils et des réponses en graphiques. En juillet 2026, j’ai ajouté un serveur MCP (Model Context Protocol) pour que les assistants que l’équipe utilise déjà, Claude Code, Claude Desktop ou claude.ai, puissent lire et modifier ces campagnes directement depuis une conversation. Voici comment il est construit, où j’ai placé les garde-fous, et ce qui a cassé une fois en service.

Un adaptateur fin au-dessus de clients existants

Le serveur tient dans une route Next.js, exposée en Streamable HTTP, sans session : chaque requête arrive avec son jeton, est authentifiée seule, et rien n’est gardé en mémoire entre deux appels. J’utilise mcp-handler au-dessus du SDK officiel, avec une durée maximale de 300 secondes par appel, parce qu’une création de campagne Google enchaîne plusieurs mutations.

La route ne contient aucune logique métier. Elle déclare les schémas zod des outils, lit le scope du jeton, puis appelle des fonctions rangées dans src/lib/mcp/tools/, découpées en cinq modules : lecture, métriques, écriture, lecture live et audit. Ce découpage vient d’un premier fichier devenu trop gros, mais il a surtout un intérêt pratique : tout se teste sans le SDK MCP, avec de simples appels de fonction.

L’essentiel du travail existait déjà. Les trois clients Google Ads, Meta et TikTok implémentent une même interface, AdPlatformClient, que la synchronisation et l’interface web utilisent depuis des mois. Le serveur MCP n’a fait que brancher une nouvelle porte d’entrée sur ces clients et sur les fonctions de mutation partagées avec les routes REST. C’est ce qui m’a permis de livrer en une journée un serveur que les assistants pouvaient utiliser pour de vrai.

27 outils, quatre familles

Le serveur expose 27 outils. Je les ai rangés selon deux questions : l’outil lit-il la base ou appelle-t-il la régie, et peut-il modifier quelque chose ?

FamilleExemplesSourceScope
Lecturelist_campaigns, get_metrics_daily, get_geo_metricsbase synchroniséeread
Lecture liveget_campaign_live, get_metrics_live, google_ads_queryAPI de la régieread
Écritureset_campaign_status, update_campaign_budget, create_adbase puis régiewrite
Accès brutplatform_api_requestAPI de la régiewrite

La distinction entre base et live compte plus qu’il n’y paraît. Un assistant laissé libre appelle volontiers l’outil le plus frais à chaque question, et les quotas des régies ne sont pas infinis. Les outils de lecture en base répondent vite et coûtent zéro appel API. Les outils live sont décrits comme à réserver aux cas où la fraîcheur prime : juste après une écriture, ou pour découvrir une campagne pas encore synchronisée. Ces descriptions sont le seul moyen d’orienter le choix de l’assistant, alors je les ai rédigées comme une documentation d’API, en anglais, avec la bonne alternative citée à chaque fois (« pour une ventilation jour par jour, utilisez get_metrics_daily »).

google_ads_query exécute une requête GAQL libre, pour le reporting que les outils structurés ne couvrent pas : termes de recherche, quality score, mots-clés. platform_api_request va plus loin : c’est une trappe de secours vers l’API brute de la régie, pour les audiences, les extensions ou les règles que les outils structurés ne couvrent pas.

Lecture, écriture : où poser les garde-fous

Première barrière, le scope. Un jeton porte read, ou write qui inclut la lecture. Chaque outil d’écriture commence par la même vérification, et chaque appel d’écriture, réussi ou non, est journalisé :

src/lib/mcp/tools/write.tsts
export async function setCampaignStatusTool(scope, params, ctx) {
  assertWriteScope(scope); // lève "forbidden" si le jeton n'est que "read"

  return withAudit("set_campaign_status", ctx, params, async () => {
    const result = await setCampaignStatus(params.campaignId, params.status);
    if (!result.ok) throw new McpToolError("Campaign not found", "not_found");

    return {
      campaignId: params.campaignId,
      status: params.status,
      platformUpdateSucceeded: result.platform.success,
      platformError: result.platform.error ?? null,
    };
  });
}

withAudit enregistre l’appelant, l’outil, les arguments et le résultat. L’écriture du journal est attendue avant de répondre, mais son échec ne fait jamais échouer l’outil. Les lectures, elles, ne sont pas journalisées : c’est une décision figée, notée en commentaire, pour ne pas remplir la table de milliers de consultations.

Deuxième barrière, l’ordre des opérations, qui diffère selon le type d’écriture. Une mise à jour (statut, budget, nom, ciblage) écrit d’abord en base, puis répercute sur la régie en best-effort. Si la régie refuse, l’outil ne lève pas d’exception : il renvoie platformUpdateSucceeded: false et le message d’origine, et c’est à l’assistant de le dire. Une création fait l’inverse : régie d’abord, parce qu’il faut l’identifiant qu’elle renvoie, et échec complet si l’appel échoue. Aucune ligne locale ne peut exister sans identifiant de régie valide.

Troisième barrière, les entrées. platform_api_request et google_ads_query ne reçoivent qu’un chemin relatif, toujours résolu contre l’URL de base codée en dur du client : un chemin absolu, un //hôte ou un .. est rejeté avant tout appel réseau. Sans cela, un assistant mal guidé pourrait envoyer une requête ailleurs avec les identifiants OAuth de la régie. google_ads_query refuse aussi toute requête qui ne commence pas par SELECT. L’endpoint de recherche GAQL ne sait de toute façon pas muter, mais je préfère valider que faire confiance. upload_asset, enfin, n’accepte qu’un identifiant de média déjà présent dans la médiathèque, jamais une URL publique.

Reste la confirmation. Je n’ai pas écrit de mécanisme de confirmation côté serveur : pas de mode simulation, pas de jeton de validation à rappeler. La confirmation repose sur le client MCP, qui demande l’accord de l’utilisateur avant chaque appel d’outil, et sur le scope, qui permet de donner un accès en lecture seule à ceux qui n’ont pas à modifier. C’est un choix assumé pour une équipe interne, mais c’est aussi la première chose que je reprendrais, j’y reviens plus bas.

L’authentification a suivi deux étapes. D’abord des clés API statiques créées par les administrateurs, dont seule l’empreinte est stockée. Dix jours plus tard, OAuth 2.1 avec découverte des métadonnées, enregistrement dynamique des clients et PKCE, adossé au login Google existant : il suffit désormais de donner l’URL du serveur au client MCP, le consentement se fait dans le navigateur. Un compte client de l’agence ne peut pas autoriser d’application, quel que soit le mode.

Trois API, un seul vocabulaire

Pour un assistant, trois régies qui parlent trois dialectes, c’est trois fois plus d’occasions de se tromper. Toute la normalisation vit donc dans les clients, pas dans les outils : un statut vaut enabled, paused, removed ou draft, un budget est daily ou lifetime, un montant est en unités monétaires.

Derrière, chaque régie a ses conventions. Google Ads exprime les montants en micros (un euro vaut 1 000 000), Meta en centimes, TikTok en unités. Google écrit ENABLED, Meta ACTIVE, TikTok ENABLE. TikTok ne parle pas de budget journalier mais de BUDGET_MODE_DAY et BUDGET_MODE_TOTAL. Les objectifs Meta (OUTCOME_SALES, OUTCOME_LEADS…) sont ramenés à un vocabulaire commun (conversions, leads). Chaque client porte ses tables de correspondance dans les deux sens :

src/lib/api/meta-ads.tsts
const OUR_STATUS_TO_META: Record<CampaignStatus, string> = {
  enabled: "ACTIVE",
  paused: "PAUSED",
  removed: "DELETED",
  draft: "PAUSED", // Meta n'a pas de brouillon : on met en pause
};

function centsToMajor(cents: string | number | undefined): number {
  if (cents == null) return 0;
  return Number(cents) / 100;
}

Tout ne se normalise pas. Le détail propre à chaque régie reste accessible dans un champ rawSettings, et les outils de création acceptent un objet settings transmis tel quel. Pour Google, il est fusionné dans la ressource campagne et prime sur les valeurs par défaut. C’est la soupape qui évite d’ajouter un paramètre au schéma à chaque type de campagne.

Ce qui a cassé une fois en service

Le body qui n’était jamais envoyé. Le lendemain de la mise en service, platform_api_request fonctionnait en lecture et jamais en écriture. Le paramètre body était déclaré en z.unknown(), qui produit un JSON Schema vide, {}. Le client MCP ne l’annonçait donc pas comme un paramètre à remplir, et l’assistant n’envoyait jamais de corps de requête. Aucune mutation :mutate ni aucun appel Graph API en écriture n’aboutissait. Le correctif tient en une ligne, un z.record() typé comme le paramètre query, qui marchait déjà :

src/app/api/mcp/route.tsts
body: z
  .union([z.record(z.string(), z.unknown()), z.string()])
  .optional()
  .describe("Request body as a JSON object. A JSON string is also accepted."),

La même journée, un second correctif a ajouté l’union avec z.string(). Certains clients avaient mis en cache l’ancien schéma et sérialisaient le body en chaîne JSON ; l’outil la reparse désormais en objet. La leçon dépasse ce cas : dans MCP, le schéma n’est pas une simple validation, c’est l’interface que le modèle voit. Un type trop permissif ne l’assouplit pas, il le rend invisible.

Demand Gen. En août, la création d’une campagne Google Demand Gen échouait. La première version créait le budget puis la campagne, en deux appels : quand le second échouait, il restait un budget orphelin, et la tentative suivante butait sur DUPLICATE_NAME. Budget et campagne partent maintenant dans une seule mutation googleAds:mutate, reliés par un identifiant temporaire (-1). Le budget est créé non partagé, faute de quoi Demand Gen le refuse. Une stratégie d’enchères maximizeConversions s’applique par défaut, parce que l’API l’exige à la création, et le champ sur la publicité politique européenne, obligatoire lui aussi, reçoit une valeur par défaut que settings peut surcharger. Le ciblage géographique et les audiences de ce type de campagne se règlent au niveau du groupe d’annonces : la documentation du serveur renvoie vers platform_api_request pour ces cas.

Ce que je referais / ce que je changerais

Je referais l’architecture en adaptateur fin. Le serveur MCP a été rapide à écrire parce qu’il ne faisait que brancher des clients existants, et les tests portent sur des fonctions ordinaires. Je referais aussi la séparation entre lecture en base et lecture live, et la trappe platform_api_request : sans elle, chaque besoin non couvert aurait exigé un nouvel outil et un redéploiement.

Je changerais trois choses. D’abord, la confirmation ne devrait pas reposer uniquement sur le client MCP. Les annotations d’outil du protocole (lecture seule, destructif) ne sont pas renseignées, et un paramètre de simulation sur les mutations de budget et de statut permettrait à l’assistant de montrer l’effet avant de l’appliquer. Ensuite, toutes les tables de correspondance entre statuts devraient être typées sur l’union complète des valeurs possibles, pour que le compilateur signale un oubli plutôt qu’un repli silencieux. Enfin, platform_api_request exige le scope write même pour un GET, parce que je ne peux pas garantir qu’un chemin brut est sans effet. C’est prudent, mais cela oblige à donner l’écriture à quelqu’un qui veut seulement lire une audience. Une liste de chemins en lecture connus réglerait la question.

Le reste de la plateforme, synchronisation, rapports et suivi de production, est décrit sur la fiche du projet ADS Mediapilote.

Étude de cas liéeADS Mediapilote, plateforme Google Ads, Meta et TikTok →