Guillaume Duvernay

Votre MCP ne peut pas recevoir de fichiers binaires : voici la solution

MCPagentssecurityAI Glot

Mon SaaS AI Glot traduit des fichiers (CSV, XLSX, Word, PDF et quelques dizaines d’autres formats) et dispose d’un serveur MCP. La première version avait une lacune que je connaissais : l’agent pouvait m’envoyer un lien public vers un fichier ou un petit extrait de texte, mais il n’y avait aucun moyen d’envoyer un fichier stocké sur votre ordinateur.

J’ai corrigé ça la semaine dernière. L’astuce est simple, un peu bricolée, et je pense qu’elle fonctionne pour tout MCP qui doit recevoir un fichier. La voici.

Pourquoi un fichier ne peut pas être transmis dans un appel d’outil

Les arguments d’un outil MCP sont au format JSON. Pour y intégrer un fichier binaire, il faut l’encoder en base64, donc le convertir en texte. Le modèle doit alors générer lui-même ce texte, caractère par caractère, dans l’appel d’outil.

Les 2.7 millions correspondent au base64 lui-même : quatre caractères pour trois octets. Je ne les ai pas convertis en jetons, car cela dépend du modèle.

C’est le même problème que les 200 lignes CSV dans pourquoi j’ai cessé d’utiliser MCP pour la plupart de mes tâches, sauf qu’ici un seul appel suffit déjà à accomplir toute la tâche.

Et encore, à condition que l’agent puisse lire votre fichier. Connecter un serveur MCP ne donne pas à l’agent accès à vos pièces jointes ni à votre disque. C’est l’application qui l’entoure qui le permet.

L’astuce : le MCP fournit une URL, l’agent y envoie le fichier

L’agent n’a pas besoin de faire transiter le fichier par MCP. Il doit simplement savoir où l’envoyer. Le MCP renvoie donc une URL d’envoi, et l’agent transfère lui-même le fichier avec une requête HTTP classique.

Le modèle ne génère qu’un petit JSON. Le fichier passe directement de votre machine au stockage, sans que le modèle le lise.

Dans AI Glot, le premier outil est create_upload_url. Il faut lui fournir le nom du fichier et sa taille exacte en octets :

{ "filename": "catalogue.xlsx", "size_bytes": 15961 }

Il renvoie un upload_id, une URL, la méthode (PUT), des en-têtes temporaires, deux échéances et des instructions destinées à l’agent. Celui-ci exécute ensuite une commande du genre :

curl -X PUT "$UPLOAD_URL" \
  -H "Authorization: $TEMP_UPLOAD_TOKEN" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @catalogue.xlsx

Ensuite, il appelle create_translation en fournissant uniquement l’upload_id. Pas de nom de fichier, pas de contenu, pas de base64. Il récupère un plan, vous demande de le confirmer, puis approve_translation dépense les crédits.

Je l’ai testé en direct le jour de la sortie avec un XLSX de 15,961 octets contenant deux mots. Le PUT a renvoyé 200, le plan a compté deux mots et, après approbation, la traduction a coûté un crédit. La création de l’envoi et du plan n’a rien coûté.

L’autre méthode, et pourquoi elle ne suffit pas

AI Glot acceptait déjà un file_url : vous fournissez un lien et mon serveur télécharge le fichier lui-même. Cette méthode fonctionne bien quand le fichier est déjà en ligne.

Mais la plupart du temps, le fichier se trouve sur votre ordinateur. Pour utiliser file_url, il faudrait d’abord le téléverser quelque part, le rendre accessible au public, puis coller le lien. Ça fait beaucoup d’étapes à demander à quelqu’un qui veut simplement dire « traduis ce tableur ». Les petits fichiers texte peuvent toujours être transmis directement comme contenu, et c’est tout.

Il y a donc maintenant trois façons de fournir un fichier : le contenu texte, une URL publique et l’URL de téléversement pour un fichier qui n’existe que sur votre machine.

Quand l’agent ne peut pas envoyer de PUT

C’est là que se situe la limite. L’agent doit lire votre fichier et envoyer une requête HTTP. Claude Code ou Codex le peuvent, car ils disposent d’un shell. Une fenêtre de chat sans environnement de code isolé ne le peut pas, même si le MCP est connecté.

Le MCP doit donc l’indiquer partout où l’agent est susceptible de lire : dans la description de l’outil, le résultat de l’outil, la documentation de l’API et llms.txt. Les miens indiquent à l’agent que s’il ne peut pas envoyer la pièce jointe, il doit le dire clairement à l’utilisateur et proposer les autres solutions : l’application web, la CLI ou un lien public. Ils lui disent aussi de ne jamais prétendre qu’un fichier a été téléversé si ce n’est pas le cas, et de ne jamais valider un paiement sans fichier et forfait vérifiés.

Sans cela, un agent incapable d’envoyer la requête PUT échoue sans rien signaler, ou pire, fait croire que tout a fonctionné.

Comment sécuriser le système

Vous autorisez un client HTTP inconnu à écrire dans votre stockage : c’est donc cette partie qui m’a demandé le plus de travail. L’idée, c’est que le secret renvoyé ne permette de faire qu’une seule chose, et rien d’autre.

L’agent détient les deux, et ils ne sont jamais échangés. Le fichier passe par le secret limité, les instructions par le secret puissant.

Voici ce que j’ai fait et ce que je referais sur n’importe quel serveur :

  • Le secret se trouve dans un en-tête, jamais dans l’URL. Les URL se retrouvent dans les journaux et l’historique. L’URL seule ne donne accès à rien.
  • Il ne fonctionne que pour une seule session. Il autorise un PUT sur le point de terminaison de cette session, et rien d’autre. J’ai vérifié : envoyé à l’API du compte, il renvoie une réponse 401.
  • Je stocke une empreinte, pas le secret. C’est un HMAC salé avec l’identifiant de session. Le texte en clair est renvoyé une seule fois avec Cache-Control: no-store, et mes journaux ne contiennent que des identifiants et des états.
  • Une seule tentative par session. Un deuxième PUT renvoie une réponse 409 et ne peut pas remplacer le fichier. Si la première tentative échoue, l’agent demande une nouvelle session.
  • La taille est déclarée, puis vérifiée. Le serveur rejette les extensions non prises en charge et les fichiers trop volumineux avant d’émettre quoi que ce soit. Il compte chaque octet reçu, refuse les corps compressés, trop courts ou trop longs, et abandonne après 60 secondes.
  • Tout expire. Le secret après 10 minutes, le fichier après une heure s’il n’est pas utilisé pour une traduction, et une tâche quotidienne supprime ce qui reste après 24 heures.
  • L’appelant ne choisit pas la destination. Aucun nom de fichier, chemin ni espace de travail dans la requête. Je génère la clé de stockage dans le préfixe propre à cet espace de travail.
  • Seul le même identifiant peut utiliser le téléversement. Si une autre connexion ou un autre espace de travail tente de créer une traduction à partir de ce fichier, la demande est refusée.
  • La révocation fonctionne. Si quelqu’un supprime une connexion ou ses autorisations, les secrets de téléversement inutilisés associés cessent de fonctionner.
  • Des quotas. 20 téléversements en attente par espace de travail et 120 par heure, en plus des limites de débit habituelles.
  • Le téléversement ne coûte rien. Seule la validation consomme des crédits : personne ne peut donc vider un solde en téléversant des fichiers.

Je n’ai pas utilisé d’URL de stockage pré-signée standard, car elle peut être réutilisée jusqu’à son expiration, et je voulais limiter les tentatives à une seule et imposer une limite de taille. J’ai donc placé un petit Worker devant le compartiment de stockage, qui conserve cet état dans la base de données.

Chaque flèche correspond à une opération compare-and-set dans la base de données : deux requêtes ne peuvent donc pas réussir pour la même session, et aucun retour à un état antérieur n’est possible.

Réponses perdues

La plupart des bugs auxquels j’ai dû réfléchir concernaient des requêtes qui avaient abouti sans que l’appelant le sache.

Si l’agent perd la réponse à son PUT, il ne téléverse pas le fichier une deuxième fois. Il essaie d’abord create_translation avec le même upload_id. Si le fichier est arrivé, l’opération réussit. Sinon, il demande une nouvelle session. Impossible de remplacer un ancien fichier.

La création de la traduction est également idempotente : upload_id sert de clé, et répéter l’appel renvoie la même traduction. J’ai répété l’opération dans mon test et je n’en ai obtenu qu’une seule. Un nouvel appel ne relance jamais non plus la planification avec des instructions modifiées. Pour cela, il existe un outil distinct.

Et lorsque le serveur ne sait pas ce qui s’est passé, il ne supprime jamais le fichier. Si une transaction a pu être validée dans la base de données, les octets sont conservés et la tâche de nettoyage s’en occupe plus tard.

Si vous voulez reprendre cette approche

Voici le minimum que je mettrais en place :

  1. Un outil qui renvoie une URL d’envoi et les en-têtes à utiliser, pour que le modèle ne voie jamais les octets.
  2. Un secret à usage unique, à durée de vie courte, associé à une session et absent de l’URL.
  3. Une taille limite imposée par le serveur, et un chemin de stockage choisi par le serveur.
  4. Des instructions dans le résultat de l’outil pour les agents incapables d’envoyer une requête PUT.
  5. Une création associée à l’ID d’envoi, afin que les nouvelles tentatives soient sans risque.

La partie MCP tient en quelques lignes. L’essentiel du travail a consisté à gérer les cas d’échec.

Sources