MCP ne sait pas envoyer de fichiers. Comment le faire quand même avec un agent IA

ai-agents

Écrire du contenu via un serveur MCP marche remarquablement bien. Vous demandez à votre agent une entrée de journal des modifications : il lit les trois dernières, en rédige une dans le même ton et vous donne un lien.

Puis vous lui demandez d’ajouter la capture d’écran qui traîne dans votre dossier Téléchargements, et il se heurte à un mur.

Les appels d’outils MCP transportent des arguments JSON. La spécification actuelle n’a ni type fichier, ni message d’envoi, ni moyen pour un client de transmettre des octets à un serveur. Le groupe de travail File Uploads a un brouillon (SEP-2631) qui en propose un, mais il n’est pas dans la spécification, et aucun client ne l’implémente encore.

Si vous voulez qu’un agent mette des images dans le contenu qu’il écrit, il faut donc concevoir le transfert vous-même. Je viens de le faire pour le serveur MCP de Kookee. Voici ce que j’ai essayé, ce que j’ai livré, et les détails qui se sont révélés importants.

Pourquoi les réponses évidentes ne marchent pas

Du base64 dans un argument d’outil. Les arguments d’outils sont générés par le modèle, token par token. Une capture d’écran de 100 Ko fait environ 136 Ko en base64, soit des dizaines de milliers de tokens de sortie avant que quoi que ce soit soit stocké. Pire, le modèle n’a en général pas du tout les octets. Il peut voir une image que vous avez collée dans la conversation, mais il ne peut pas en reproduire les octets ; il les inventerait.

« Envoyez-moi une URL, je la récupère. » L’import côté serveur transforme votre API en proxy qui télécharge tout ce qu’un agent lui désigne, y compris ce qu’une injection de prompt l’amène à demander. Adresses internes, endpoints de métadonnées cloud, redirections vers les deux : une surface SSRF qu’il faut ensuite défendre avec de l’épinglage DNS, des limites de redirection et une détection du type de contenu. Et de toute façon, le fichier sur votre ordinateur n’a pas d’URL.

Relever la limite de taille des requêtes. La limite de taille du corps sur un endpoint MCP s’applique avant l’authentification. La relever pour les envois la relève aussi pour toutes les requêtes anonymes.

Le schéma qui marche : signer, envoyer, confirmer

Les navigateurs ont réglé ça il y a des années. Un tableau de bord ne fait pas transiter une image par son serveur d’API : le serveur signe un envoi à usage unique, le navigateur poste les octets directement vers le stockage objet, puis prévient le serveur que c’est fait.

Un agent avec un shell, c’est un navigateur avec curl. La version MCP suit donc les mêmes trois étapes :

Plain Text
create_upload  { fileName, mimeType, size }  →  fileId, POST présigné, commande curl prête à lancer
curl … -F 'file=@"./diagram.png"'            →  204 renvoyé par le stockage
confirm_upload { fileId, alt }               →  URL publique + un extrait qui intègre le fichier

Les octets ne passent jamais par le serveur MCP. Les appels d’outils restent à quelques centaines d’octets, la limite de taille ne bouge pas, et les envois arrivent dans le même stockage, avec les mêmes quotas et le même nettoyage que les fichiers ajoutés depuis le tableau de bord.

Le coût, pour être honnête : il faut un agent capable d’exécuter des commandes. Claude Code, Cursor en mode agent et Codex CLI le sont. Une application de chat qui ne peut pas exécuter de commandes sur votre machine ne peut rien envoyer : la description de l’outil demande donc à l’agent de le dire et de renvoyer l’utilisateur vers le tableau de bord, plutôt que d’improviser.

C’est aussi la direction que prend le protocole. Le brouillon SEP-2631 autorise un envoi, renvoie une destination présignée, fait envoyer les octets par le client, et passe à l’outil une référence vers le fichier. Même schéma, avec le client qui exécute le curl à la place du modèle. La moitié serveur que vous construisez aujourd’hui restera valable.

Quatre détails qui décident si c’est sûr

1. Ne pas croire « c’est fait »

On fait généralement confiance à l’appel de confirmation d’un navigateur : s’il dit que l’envoi est terminé, le serveur le croit. Un agent confirmera tout aussi volontiers après un curl qui a échoué, puis intégrera une image qui n’existe pas.

La confirmation interroge donc directement le stockage — un HeadObject sur la clé — et refuse s’il n’y a rien. La taille déclarée est aussi fixée dans la policy d’envoi. C’est une question d’argent : une policy de POST présigné accepte n’importe quelle taille jusqu’à votre limite par fichier, sauf si vous signez un content-length-range exact, alors que les quotas de stockage comptent en général la taille déclarée. Déclarez un octet, envoyez 25 Mo, et votre quota n’y voit rien. Signez la taille exacte, et le stockage refuse l’écart avec une 400.

2. Le type de contenu que vous signez est celui que sert votre CDN

Chaque champ d’un POST présigné devient une condition de correspondance exacte de la policy, Content-Type compris. C’est une bonne chose — celui qui envoie ne peut pas le changer — et cela veut dire que le type signé est exactement celui que servira votre CDN public.

Le type MIME envoyé par l’agent doit donc figurer sur une liste d’autorisation avant que vous ne le signiez. Sinon, un agent, ou une injection de prompt qu’il a lue trois appels d’outils plus tôt, peut publier du text/html sur l’origine de votre CDN. J’accepte exactement les types qu’accepte le tableau de bord, et rien d’autre.

3. Échapper la commande générée comme si elle était hostile

Le serveur donne à l’agent une commande prête à lancer, parce que demander à un modèle d’assembler un POST multipart à partir d’un objet JSON, c’est s’exposer à une erreur subtile. Cette commande contient un nom de fichier choisi par l’agent, signé dans un champ de formulaire, et c’est là que se trouvent les pièges.

Avec curl -F, une valeur qui commence par @ ou < n’est pas une valeur : c’est une instruction de lire un fichier. Un nom de fichier comme @notes.png dans un champ signé ferait lire à curl un fichier sur le disque. Chaque champ signé part donc en --form-string, toujours littéral, et la partie fichier vient en dernier, parce que S3 ignore les champs qui la suivent :

TypeScript
const shellQuote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`;

const command = [
  `curl -sS --fail -o /dev/null -w '%{http_code}\\n' ${shellQuote(uploadUrl)}`,
  ...Object.entries(fields).map(([name, value]) => `--form-string ${shellQuote(`${name}=${value}`)}`),
  `-F 'file=@"<LOCAL_FILE_PATH>"'`,
].join(' \\\n  ');

Le chemin local est l’autre moitié. Il se retrouve entre les guillemets simples du shell et dans le nom de fichier entre guillemets doubles de curl : un chemin contenant ' casse le shell, et un chemin contenant " ou \ casse curl. Plutôt qu’une troisième couche d’échappement que le modèle raterait, la règle est simple : si le chemin contient l’un de ces caractères, copiez le fichier vers un chemin temporaire sans caractères spéciaux et envoyez la copie.

Les noms de fichiers sont validés dans le handler, pas seulement dans le schéma. Je refuse les barres obliques et les caractères de contrôle, y compris la plage C1, de U+0080 à U+009F, qu’un test naïf « inférieur à 0x20 » laisse passer sans broncher. Et gardez la regex du schéma en ASCII pur : le motif est exporté dans le JSON Schema du client sans le flag u, si bien qu’une classe Unicode comme \p{Cc} est mal lue par les validateurs côté client, qui rejettent alors tout nom contenant un p ou un c.

4. Obliger l’agent à lier ce qu’il envoie

C’est celui-là qui m’a piégé.

Dans l’éditeur, une image envoyée est insérée avec l’id de son fichier, et c’est par cet id que le système sait qu’une entrée utilise le fichier. Un fichier que rien ne référence est supprimé automatiquement au bout de quelques jours. C’est ce qui nettoie les envois abandonnés en cours de route.

Donnez une URL à un agent et il écrira ce qu’écrivent tous les modèles : ![alt](https://cdn…/diagram.png). Pas d’id. L’image s’affiche bien aujourd’hui, le fichier n’est jamais lié, et trois jours plus tard il disparaît d’un article publié. Aucune erreur nulle part — une perte de données silencieuse et différée.

confirm_upload renvoie donc un extrait qui porte l’id, et les outils d’écriture refusent le Markdown qui introduit l’URL d’un envoi sans id, avec une erreur qui dit à l’agent de coller l’extrait.

Ma première version de ce garde-fou refusait toutes les URL du CDN de Kookee sans id. Elle bloquait aussi des écritures sans aucun risque : dupliquer ou traduire un article dont l’image avait été collée par URL, ou un commentaire avec un lien vers un PDF qu’une autre entrée utilisait déjà. L’agent recevait une erreur pour du Markdown qu’il avait copié tel quel, sans aucun id qu’il aurait pu ajouter. La correction : chercher le fichier et ne refuser que s’il n’est encore rattaché à rien, le seul cas qui fait réellement perdre des données.

La leçon vaut au-delà de ce cas : un garde-fou écrit pour un agent doit viser la panne, pas la forme de l’entrée. Un agent ne peut pas négocier avec une règle plus stricte que le problème.

À quoi ça ressemble

Envoyez ./diagram.png, placez-le sous le titre Architecture avec un texte alternatif court, et utilisez ./cover.png comme couverture.

L’agent appelle deux fois create_upload, lance deux commandes curl, confirme les deux, insère le diagramme avec edit_entry_content et définit la couverture avec update_entry. Le corps contient alors un bloc comme celui-ci, qui se relit à l’octet près et autour duquel on peut éditer comme n’importe quel autre :

HTML
<p><img src="https://cdn.kookee.dev/…/diagram.png" alt="Flux des requêtes de l’agent vers le stockage" data-id="…"/></p>

Si vous ajoutez l’envoi de fichiers à votre propre serveur MCP

  • Présignez. Ne faites jamais passer d’octets de fichier par un appel d’outil MCP.

  • Confirmez en interrogeant le stockage, et signez la taille déclarée sous forme de content-length-range exact.

  • Passez les types MIME par une liste d’autorisation avant de signer ; le type signé est celui que sert votre CDN.

  • Envoyez les champs signés avec --form-string, mettez la partie fichier en dernier, et dites à l’agent quoi faire des chemins problématiques.

  • Validez les noms de fichiers dans le handler ; gardez les motifs du schéma en ASCII.

  • Limitez le débit des présignatures. Les envois jamais confirmés échappent souvent aux quotas de stockage.

  • Renvoyez un extrait qui lie le fichier, et refusez la forme non liée — mais seulement pour les fichiers qui seraient vraiment perdus.

  • Indiquez dans la description de l’outil que l’envoi demande un shell, pour que les clients limités au chat préviennent l’utilisateur au lieu d’échouer à mi-chemin.


Le serveur MCP de Kookee accepte désormais les images et les fichiers envoyés depuis Claude Code, Cursor et les autres agents capables d’exécuter des commandes. Consultez Envoyer des images et des fichiers depuis un assistant IA pour la configuration, ou Préparer le contenu de votre produit pour les agents IA pour une vue d’ensemble.