Bien décrire les tools d'un serveur MCP
Claude et ChatGPT ne voient jamais votre system prompt. Dans un serveur MCP, c'est la description des tools qui guide le modèle : exemple réel et checklist.
Le system prompt de l’agent de Geckosocial, je l’ai peaufiné pendant des heures : chaque formulation testée, des MAJUSCULES pour être bien sûr d’être entendu… Puis j’ai exposé le produit en serveur MCP, et là, plus personne ne le lit.
Claude, ChatGPT et les autres clients MCP appellent vos tools avec leur modèle et leur system prompt. Le vôtre reste chez vous. Le protocole prévoit bien un champ instructions côté serveur, mais il est récent, peu documenté, et tous les clients ne le transmettent pas au modèle. Ce qui arrive jusqu’au modèle, c’est la liste de vos tools : un nom, une description, un schéma d’entrée, et ce que le tool renvoie.
Bref, dans un serveur MCP, le prompt engineering se fait dans les tools.
Dans ce post, j’aimerais vous montrer jusqu’où pousser l’idée avec un exemple tiré de Geckosocial : l’upload d’une image depuis Claude. Vous allez voir qu’une description de tool peut porter un workflow complet.
(Le code est simplifié pour l’article, mais l’idée est exactement celle qui tourne en production.)
Un fichier dans un tool, ça ne passe pas
L’utilisateur est dans Claude Code et demande : « Ajoute cette image au post de jeudi ». L’image est sur son disque.
Sauf qu’un appel de tool MCP, c’est du JSON. Faire transiter 4 Mo d’image en base64 dans les arguments, c’est lent, ça mange des tokens, et la plupart des clients ne le permettent pas de toute façon.
Côté backend, la solution est classique : une URL présignée vers le stockage, le client y envoie le fichier, puis on finalise. Trois étapes, dont une qui ne passe même pas par MCP.
Reste à faire faire ces trois étapes, dans le bon ordre, à un modèle sur lequel je n’ai aucune prise.
Le mode d’emploi dans la description
Voici la description du premier tool :
export const initializeMediaUpload = {
name: "initialize_media_upload",
description: `
Initialize a media file upload. Returns a presigned URL and a ready-to-run curl command.
This is STEP 1 of a 2-step upload process:
1. Call this tool with the file's path, MIME type and size.
2. Execute the returned \`curlCommand\` in bash to upload the file.
3. Call \`finalize_media_upload\` with the returned \`mediaId\`.
Before calling this tool, determine the file size and MIME type:
stat -f%z /path/to/file # macOS
stat -c%s /path/to/file # Linux
file --mime-type -b /path/to/file
Supported media types:
| Type | MIME types | Max size |
| Image | image/png, image/jpeg, ... | 10 MB |
| Video | video/mp4 | 300 MB |
`,
inputSchema: {
filePath: z.string().describe("Absolute path to the local file"),
mimeType: z.string().describe("MIME type of the file (e.g. image/png)"),
fileSize: z.number().int().positive().describe("File size in bytes"),
},
};Ça dépasse largement la petite phrase de doc. Il y a là une vraie procédure :
- les étapes sont numérotées, et le tool dit où il se situe ;
- les prérequis viennent avec les commandes exactes (le modèle n’a pas à deviner comment avoir la taille d’un fichier sur macOS) ;
- les limites sont posées avant l’appel : une vidéo de 500 Mo, le modèle peut la refuser sans même essayer ;
- le tool suivant est nommé, pas besoin de le chercher.
Le modèle lit tout ça au moment où il choisit quoi appeler. Le workflow est dans le contrat.
La réponse du tool en remet une couche
La description guide le premier appel. Pour la suite, c’est ce que renvoie le tool qui prend le relais :
async function handle({ filePath, mimeType, fileSize }) {
const { mediaId, signedUrl } = await medias.initializeUpload({ mimeType, fileSize });
return {
mediaId,
uploadUrl: signedUrl,
curlCommand: `curl -X PUT '${signedUrl}' -H 'Content-Type: ${mimeType}' --data-binary '@${filePath}'`,
message:
"Upload initialized. Now execute the curl command to upload the file, " +
`then call finalize_media_upload with mediaId "${mediaId}".`,
};
}Deux petites choses qui changent beaucoup :
- le tool renvoie la commande
curlprête à lancer, et pas juste l’URL. Moins le modèle improvise (headers, échappement…), moins il se trompe ; - le champ
messagerappelle l’étape suivante, avec l’identifiant déjà rempli.
Oui, c’est redondant avec la description. C’est fait exprès : la description est lue au début, alors que le message tombe pile au moment où le modèle se demande quoi faire. Quelques tokens de plus pour ne pas le perdre en route, ça vaut le coup.
Quand le traitement prend du temps
Le second tool finalise l’upload, et sa description prévoit le cas pénible des vidéos à transcoder :
export const finalizeMediaUpload = {
name: "finalize_media_upload",
description: `
Finalize a media upload and optionally attach it to a post variant.
This is STEP 2. Call this AFTER you have executed the curl command.
To attach media to a post, use \`get_post\` to find the variant IDs.
If processing takes longer than 30 seconds (common for videos),
the tool returns \`status: 'processing'\`. Call it again with the same
\`mediaId\`: the tool is idempotent.
`,
inputSchema: {
mediaId: z.string().describe("The media ID returned by initialize_media_upload"),
postId: z.string().nullable().describe("Post to attach the media to. Pass null to skip."),
postItemId: z
.string()
.nullable()
.describe("Variant to attach the media to. Required with postId. Use get_post to find it."),
},
};Le modèle sait qu’un processing n’est pas une erreur, qu’il peut rappeler le tool sans risquer de doublon, et où trouver l’ID de variante dont il a besoin.
Vous remarquerez aussi que les .describe() des paramètres répètent les règles importantes. C’est là que le modèle regarde quand il remplit les arguments, pas trois paragraphes plus haut 😉
Une limite à garder en tête
Les instructions de ces tools présupposent que le modèle dispose d’autres outils : un shell pour lancer stat et curl, un accès aux fichiers de l’utilisateur. Mon serveur MCP ne les fournit pas, il compte sur le client pour ça.
Quand le client ne les a pas, le workflow ne peut pas aller au bout, et le modèle le dit simplement à l’utilisateur. C’est une limitation, mais c’est toujours mieux que pas d’upload du tout depuis le chat.
Bonus : l’UX aussi passe par les tools
Allez, un dernier exemple. Sur Geckosocial, la description de create_post embarque des règles d’expérience utilisateur :
export const createPost = {
name: "create_post",
description: `
Create a new social media post as a draft.
- Generate a short title from the content. Do NOT ask the user for the title.
- If list_social_accounts returns only 1 account, use it automatically.
Otherwise, ask the user which ones to use.
IMPORTANT: before calling this tool, present the full post text to the user
and wait for their explicit approval.
`,
};Ne pas poser de question inutile, choisir à la place de l’utilisateur quand il n’y a qu’une option, toujours faire valider avant d’écrire : ce sont des décisions produit. Dans un agent intégré à l’app, elles iraient dans le system prompt. Dans un serveur MCP, elles n’ont qu’un seul endroit où vivre : le tool.
Checklist pour bien décrire un tool MCP
Quand je conçois un tool, je repasse sur ces points :
- Le nom dit ce que fait le tool, avec un verbe :
initialize_media_upload, pasmedia. - La description dit quand l’appeler, et ce qui vient avant et après. Si le tool fait partie d’une séquence, les étapes sont numérotées et les autres tools sont nommés.
- Les prérequis sont donnés avec la façon de les obtenir, commandes comprises.
- Les contraintes métier sont écrites (formats, tailles, limites), pour que le modèle puisse refuser avant d’appeler.
- Chaque paramètre a un
.describe()qui dit d’où vient la valeur (« returned by… », « use get_post to find it »). - La réponse du tool contient l’étape suivante, identifiants déjà remplis.
- Les états intermédiaires et la reprise sont documentés : que faire si c’est encore en cours, si on peut réessayer.
- Les règles d’UX qui comptent sont dans le tool, et pas seulement dans un system prompt que les clients MCP ne verront jamais.
Voilà ! Sur un serveur MCP, une bonne partie du boulot se passe dans ces quelques paragraphes que le modèle lit avant d’agir. Ça vaut le coup de les soigner autant que le code des handlers 🤗
Reste à vérifier que le modèle suit vraiment ces instructions. Pour ça, il faut des évals, et ce sera le sujet d’un prochain article.
Happy prompting ;)