Quand vous exposez votre API en tant qu'outils MCP pour des clients IA, vous faites face à un problème de maintenance : chaque endpoint a deux schémas. Le schéma REST (OpenAPI) et le schéma d'outil MCP (JSON Schema dans inputSchema). Ils décrivent la même opération mais vivent dans des fichiers différents, écrits par des personnes (ou des sessions) différentes, à des moments différents. Ils finiront par diverger.
Cet article décrit comment sh0 a résolu ce problème en générant les définitions d'outils MCP directement depuis les annotations OpenAPI, en utilisant le système d'extensions de utoipa.
Le problème : 12 outils, 24 schémas
Le serveur MCP de sh0 (Phase 1) a été livré avec 12 outils soigneusement définis à la main. Chaque outil avait :
- Une définition
McpTooldanstools.rsavec un JSON Schema écrit manuellement - Un handler REST dans
handlers/*.rsavec des annotations utoipa générant un schéma OpenAPI
Les deux décrivaient les mêmes paramètres pour la même opération. L'outil list_apps avait page et per_page comme entiers optionnels. L'endpoint GET /api/v1/apps avait PaginationParams avec les mêmes champs. Deux sources de vérité pour une seule réalité.
Ajouter un nouvel outil MCP nécessitait de toucher trois emplacements : l'annotation utoipa, la fonction tool_definitions(), et le bras de correspondance execute_tool(). En rater un et vous obtenez un outil que l'IA peut appeler mais que le serveur ne peut pas exécuter, ou un schéma qui promet des paramètres que le handler ignore.
La solution : les extensions OpenAPI comme métadonnées MCP
utoipa v5 prend en charge les extensions OpenAPI personnalisées dans les annotations #[utoipa::path] :
rust#[utoipa::path(
get,
path = "/api/v1/apps",
tag = "Apps",
params(PaginationParams),
responses(...),
security(("bearer" = [])),
extensions(
("x-mcp-enabled" = json!(true)),
("x-mcp-risk" = json!("read")),
("x-mcp-description" = json!("List all deployed applications."))
)
)]
pub async fn list_apps(...) -> ... { ... }L'extension x-mcp-enabled: true marque cet endpoint comme un outil MCP. Au démarrage, sh0 parse sa propre spécification OpenAPI et génère les définitions d'outils MCP à partir des opérations annotées. Les paramètres du handler deviennent le inputSchema de l'outil. La description devient la description de l'outil. L'operationId devient le nom de l'outil.
Une annotation. Un schéma. Zéro dérive.

Le protocole d'extensions
Nous avons défini cinq extensions :
| Extension | Objectif |
|---|---|
x-mcp-enabled | Marque un endpoint comme outil MCP |
x-mcp-risk | Niveau de risque (read, write, admin) pour l'application future des clés à portée limitée |
x-mcp-name | Remplace le nom de l'outil quand il diffère de l'operationId |
x-mcp-description | Remplace la description avec un texte spécifique à MCP |
x-mcp-param-map | Renomme les paramètres (ex. le paramètre de chemin id devient app_id) |
L'extension x-mcp-param-map mérite une explication. Les paramètres de chemin OpenAPI utilisent souvent des noms génériques comme {id}. Mais les outils MCP bénéficient de noms descriptifs : app_id indique à l'IA quel type d'identifiant fournir. Le mapping est déclaratif :
rust("x-mcp-param-map" = json!({"id": {"name": "app_id", "description": "App ID or app name"}}))Le générateur : 150 lignes de Rust
Le module openapi.rs est intentionnellement simple. Il n'essaie pas d'être un convertisseur OpenAPI-vers-MCP universel. Il lit la spécification spécifique de sh0 et produit les outils spécifiques de sh0 :
- Itérer tous les chemins et opérations de la spécification OpenAPI
- Filtrer par
x-mcp-enabled: true - Pour chaque opération correspondante, construire un
McpTool: - - Nom depuis
x-mcp-nameouoperationId - - Description depuis
x-mcp-description,summary, oudescription - -
inputSchemadepuis les paramètres de chemin et de requête, avec renommage - Ajouter les outils définis manuellement (un seul outil,
get_app_logs, appelle Docker directement et n'a pas d'endpoint REST)
Le résultat : 12 outils, identiques aux versions écrites à la main, dérivés des mêmes annotations qui génèrent la spécification OpenAPI.
L'approche hybride : définitions automatiques, exécution manuelle
Un système entièrement automatique acheminerait aussi automatiquement les appels d'outils vers les handlers. Nous avons choisi de ne pas faire cela pour la Phase 2. Les définitions d'outils (ce que l'IA voit) sont générées depuis OpenAPI. L'exécution des outils (ce qui se passe quand l'IA appelle un outil) reste dans la fonction de dispatch manuelle execute_tool().
Cela signifie qu'ajouter un nouvel outil MCP nécessite encore deux étapes :
1. Ajouter les extensions utoipa au handler
2. Ajouter le bras d'exécution dans tools.rs
Mais le schéma n'est jamais écrit à la main. La forme des arguments, leurs types, lesquels sont requis -- tout est dérivé des annotations utoipa existantes du handler.
Pourquoi pas un routage entièrement automatique ? Parce que l'exécuteur MCP fait plus que simplement appeler le handler REST. Il résout les applications par nom (pas seulement par ID), récupère des données liées (domaines, nombre de variables d'environnement), et formate la sortie différemment de la réponse REST. La logique d'exécution mérite d'être écrite explicitement. La logique de définition, non.
Vérification : tests unitaires de parité
La partie la plus risquée de cette migration est le changement subtil de schéma. Si le schéma généré pour list_apps a un nom de propriété ou un type différent de la version écrite à la main, le client IA pourrait envoyer des arguments que l'exécuteur n'attend pas.
Quatre tests unitaires vérifient la parité :
- Les 12 noms d'outils attendus sont présents
- get_app a app_id comme paramètre requis (renommé depuis id)
- list_apps a les propriétés page et per_page
- get_server_status a un objet de propriétés vide
Ces tests s'exécutent contre la vraie spécification OpenAPI générée par utoipa, détectant toute dérive entre les annotations et les attentes.

L'audit trouve ce que le constructeur a manqué
C'est ici que la méthodologie multi-sessions justifie son existence. La session principale a construit la fonctionnalité et est passée à autre chose. Une session d'audit séparée -- contexte frais, aucun attachement à l'implémentation -- a immédiatement repéré un problème de performance : la spécification OpenAPI était parsée à chaque requête tools/list, alors que la spécification est statique au runtime (elle est dérivée des annotations utoipa au moment de la compilation).
La correction a été un cache LazyLock : parser la spécification une fois lors du premier accès, servir le résultat en cache pour chaque appel suivant. Trois lignes de code, zéro allocation par requête après la première.

C'est la valeur du workflow construire-auditer-auditer : le constructeur optimise pour la correction. L'auditeur optimise pour tout le reste. Aucune session seule n'aurait produit du code à la fois correct et efficient.
Ce que nous avons appris
Les extensions sont sous-utilisées. Les extensions OpenAPI (x-*) sont un mécanisme standard que la plupart des codebases ignorent. Elles sont le bon endroit pour les métadonnées spécifiques à votre système mais ne faisant pas partie du standard OpenAPI. Métadonnées d'outils MCP, indices de rate limiting, feature flags, calendriers de dépréciation -- tout s'intègre naturellement comme extensions.
Le générateur doit être spécifique, pas générique. Un convertisseur OpenAPI-vers-MCP générique devrait gérer les corps de requête, les schémas de réponse, les flux d'authentification et des dizaines de cas particuliers. Notre générateur gère les paramètres de chemin, les paramètres de requête et cinq extensions personnalisées. Il fait 150 lignes et fait exactement ce dont nous avons besoin.
Le nommage des paramètres compte pour l'ergonomie IA. La différence entre id et app_id est la différence entre une IA qui devine et une IA qui sait quoi fournir. L'extension x-mcp-param-map permet à l'API REST de garder ses conventions RESTful tandis que l'outil MCP utilise des noms d'arguments descriptifs.
Ce qui vient ensuite
La Phase 3 utilisera l'extension x-mcp-risk pour les clés API à portée limitée. Une clé avec la portée read ne verra que les outils avec x-mcp-risk: "read". Une clé avec la portée write verra les outils read et write. Les métadonnées de risque sont déjà dans la spécification OpenAPI, intégrées dans chaque endpoint annoté. La couche d'application n'a qu'à filtrer la liste d'outils.
Les fondations sont posées. Chaque futur outil MCP est à cinq lignes d'annotation près.