Implémenter des Tools : JSON Schema et Handlers
Mis à jour le 29 juillet 2026
L’anatomie d’un tool MCP
Un tool MCP ressemble à une fonction Python, mais ce n’en est pas une du point de vue du modèle. C’est un contrat en quatre parties : un nom, une description, un schéma d’entrée JSON et un handler qui exécute la logique. Le modèle ne voit jamais votre code — il voit ce contrat, et rien d’autre. Un tool parfaitement implémenté mais mal décrit ne sera jamais appelé, ou le sera à contretemps. La qualité du contrat détermine donc l’efficacité du serveur bien plus que la qualité de l’implémentation.
Ce que le décorateur fabrique pour vous
FastMCP construit ce contrat automatiquement à partir de votre signature de fonction et de votre docstring. Vous écrivez du Python ordinaire :
from mcp.server.fastmcp import FastMCP
app = FastMCP("Mon Serveur")
@app.tool()
def search_documents(
query: str,
max_results: int = 10,
language: str = "fr",
include_archived: bool = False,
) -> str:
"""Recherche des documents dans la base de connaissances.
Args:
query: Termes de recherche (2-500 caractères)
max_results: Nombre maximum de résultats (1-100, défaut: 10)
language: Code langue ISO 639-1 (défaut: fr)
include_archived: Inclure les documents archivés (défaut: false)
Returns:
JSON avec la liste des documents trouvés et le nombre total
"""
# Logique de recherche
...
Et le modèle reçoit ceci :
{
"name": "search_documents",
"description": "Recherche des documents dans la base de connaissances.",
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Termes de recherche (2-500 caractères)"
},
"max_results": {
"type": "integer",
"description": "Nombre maximum de résultats (1-100, défaut: 10)",
"default": 10
},
"language": {
"type": "string",
"description": "Code langue ISO 639-1 (défaut: fr)",
"default": "fr"
},
"include_archived": {
"type": "boolean",
"description": "Inclure les documents archivés (défaut: false)",
"default": false
}
},
"required": ["query"]
}
}
Comparez les deux blocs ligne à ligne : chaque annotation de type est devenue un type JSON, chaque valeur par défaut un champ default, chaque ligne de la section Args la description d’un paramètre, et le seul argument sans valeur par défaut s’est retrouvé dans required. Autrement dit, tout ce que vous omettez dans la docstring disparaît purement et simplement du contrat.
Nommer, puis décrire
Le nom du tool est le nom de la fonction, et le modèle s’en sert comme premier indice. Un verbe d’action suivi d’un objet clair ne laisse aucune ambiguïté, là où un nom générique ou technique oblige le modèle à deviner :
# Bon : verbe d'action + objet clair
@app.tool()
def create_issue(title: str, body: str) -> str: ...
@app.tool()
def search_repositories(query: str) -> str: ...
@app.tool()
def get_user_profile(username: str) -> str: ...
# Mauvais : vague, ambigu, technique
@app.tool()
def process(data: str) -> str: ... # Trop vague
@app.tool()
def handle_request(req: str) -> str: ... # Incompréhensible pour le LLM
@app.tool()
def db_query(sql: str) -> str: ... # Trop technique
La docstring prend ensuite le relais et constitue la pièce la plus critique de tout votre serveur. Elle doit répondre à trois questions dans cet ordre : quand utiliser ce tool, quels paramètres fournir avec leur format et leurs contraintes, et quoi attendre en retour. L’exemple suivant montre ce que cela donne sur un cas réel :
@app.tool()
def book_restaurant(
restaurant_name: str,
date: str,
party_size: int,
special_requests: str = "",
) -> str:
"""Réserve une table dans un restaurant.
Effectue une réservation complète incluant la confirmation
par email. Utilisez ce tool quand l'utilisateur veut
réserver un restaurant spécifique.
Args:
restaurant_name: Nom exact du restaurant
date: Date et heure au format ISO 8601 (ex: 2026-04-15T19:30:00)
party_size: Nombre de convives (1-20)
special_requests: Demandes spéciales (allergies, occasion, etc.)
Returns:
JSON avec le numéro de réservation, la confirmation
et les détails du restaurant
"""
...
Le paragraphe qui suit la première ligne est ce qui manque le plus souvent aux serveurs amateurs. Sans lui, le modèle sait ce que fait le tool mais pas dans quelle situation le déclencher. Et l’exemple de date au format ISO 8601 vous épargnera des dizaines d’appels avec "15 avril à 19h30".
Types, handlers asynchrones et validation
FastMCP traduit les annotations Python usuelles vers leurs équivalents JSON Schema.
| Python | JSON Schema | Exemple |
|---|---|---|
str | string | "texte" |
int | integer | 42 |
float | number | 3.14 |
bool | boolean | true |
list[str] | array of string | ["a", "b"] |
dict | object | {"key": "val"} |
Optional[str] | string (nullable) | "texte" ou null |
Dès qu’un tool sort de son processus — appel réseau, requête base de données, lecture de fichier distant — déclarez-le async. Un handler synchrone qui attend une réponse HTTP bloque le serveur entier, et vos autres utilisateurs avec lui :
import httpx
@app.tool()
async def fetch_api_data(endpoint: str) -> str:
"""Récupère des données depuis une API externe.
Args:
endpoint: Le chemin de l'endpoint API (ex: /users/123)
Returns:
Les données JSON de l'API
"""
async with httpx.AsyncClient() as client:
response = await client.get(f"https://api.example.com{endpoint}")
response.raise_for_status()
return response.text
Le schéma JSON contraint les types, jamais les valeurs : rien n’empêche le modèle d’envoyer un nom de 3 000 caractères ou un rôle inventé. Validez donc systématiquement à l’entrée du handler, et retournez un message qui indique la correction à apporter :
import json
@app.tool()
def create_user(name: str, email: str, role: str = "viewer") -> str:
"""Crée un nouvel utilisateur.
Args:
name: Nom complet (2-100 caractères)
email: Adresse email valide
role: Rôle : viewer, editor, admin (défaut: viewer)
Returns:
JSON avec les détails de l'utilisateur créé
"""
# Validation
if len(name) < 2 or len(name) > 100:
return json.dumps({"error": "Le nom doit faire entre 2 et 100 caractères"})
if "@" not in email or "." not in email:
return json.dumps({"error": "Adresse email invalide"})
valid_roles = ("viewer", "editor", "admin")
if role not in valid_roles:
return json.dumps({"error": f"Rôle invalide. Choix : {', '.join(valid_roles)}"})
# Création...
return json.dumps({"success": True, "user": {"name": name, "email": email, "role": role}})
Le troisième message rappelle les valeurs autorisées. Le modèle peut alors se corriger seul et rappeler le tool, au lieu de rendre les armes devant un « Rôle invalide » sans explication.
Soigner le retour
Un tool MCP retourne toujours du texte, mais ce texte peut être du JSON structuré, que le modèle interprète très bien. Le contraste entre ces deux retours résume l’essentiel :
# Bon : JSON structuré et lisible
return json.dumps({
"success": True,
"user": {
"name": "Alice Dupont",
"email": "[email protected]",
"role": "editor",
},
"message": "Utilisateur créé avec succès"
}, ensure_ascii=False)
# Mauvais : données brutes difficilement exploitables
return "OK:user_id=abc123:status=created"
Privilégiez toujours des valeurs lisibles par un humain plutôt que des identifiants internes : le modèle exploite "Alice Dupont" immédiatement dans sa réponse, alors qu’un UUID l’oblige à un appel supplémentaire pour savoir de qui il parle. À l’inverse, méfiez-vous des retours pléthoriques — cinquante champs par enregistrement remplissent le contexte sans rien apporter. Le bon réflexe, en écrivant un tool, est de se demander ce que le modèle devra dire à l’utilisateur, et de ne retourner que cela.
Points clés à retenir
- Le décorateur
@app.tool()génère automatiquement le JSON Schema des paramètres - Le nom de la fonction = nom du tool (verbe + objet)
- La docstring est cruciale : elle guide le modèle sur quand et comment appeler le tool
- Utilisez des annotations de type Python pour le schéma (str, int, float, bool, list, dict)
- Validez toujours les entrées et retournez des erreurs exploitables
- Retournez du JSON structuré avec des valeurs lisibles par un humain