MCP Gateway Insee
by Mirlon382
README.md
# MCP Gateway Insee
*Rassembler les différents services MCP de l'Insee*
---
<!-- ─── BETA BADGE ───────────────────────────────────────────────────────── -->

> **⚠️ BÊTA – Ce projet est un prototype**
> Ce projet est **un prototype**. Il ne doit **pas** être considéré comme un produit fini ou officiellement supporté par l’INSEE.
---
Passerelle [FastMCP 4](https://gofastmcp.com) qui agrège les serveurs MCP du Service
Statistique Public derrière un point d'entrée unique : un seul endpoint MCP, un
catalogue lisible, un endpoint de santé et des métriques exploitables.
## Lancer le projet
```{bash}
uv sync
uv run mcpgateway # HTTP sur 0.0.0.0:8000, endpoint MCP /mcp
uv run mcpgateway --help
```
Vérifier le registre sans démarrer la passerelle (utile en CI) :
```{bash}
uv run mcpgateway --check # résumé JSON : serveurs, domaines, limites, fonctionnalités
```
Options utiles : `--registry <chemin>`, `--host`, `--port`, `--path`, `--transport`,
`--log-level`, `--text-logs` (logs lisibles au lieu du JSON).
## 1. Registre déclaratif (`registry.yaml`)
La configuration n'est plus un module Python mais un fichier YAML **validé au
démarrage** ([`src/mcpgateway/registry.py`](src/mcpgateway/registry.py)). Ajouter un
serveur est une modification de données, pas de code.
```yaml
gateway:
name: SSP Gateway
tool_search: {enabled: true, max_results: 5}
catalog: {enabled: true, include_tool_inventory: true, include_guide: true, guide_path: guide-ssp.md}
health: {enabled: true, path: /health, probe_interval_seconds: 60, failure_threshold: 2}
metrics: {log_level: INFO, json_logs: true}
concurrency: {global_limit: 16, acquire_timeout_seconds: 5}
servers:
- name: mcpdiffusion # sert aussi de préfixe d'outils
url: https://mcpdiffusion.lab.sspcloud.fr/mcp
transport: http # http | sse
domain: diffusion # regroupement fonctionnel
description: Serveur pour exposer Melodi, Rmes et insee.fr
tags: [dataset, publications] # liste ou chaîne unique
enabled: true # false : gardé au registre, non exposé
timeout: 30.0 # délai par appel amont (s)
max_concurrency: 4 # appels simultanés vers ce serveur
cache_ttl: 300 # cache des listes de composants (s)
```
Le fichier est cherché dans `--registry`, sinon `$MCPGATEWAY_REGISTRY`, sinon
`registry.yaml` / `registry.yml` / `mcpgateway.yaml` dans le répertoire courant.
**Validation** — une erreur arrête le démarrage avec un message précis plutôt que de
produire une erreur de proxy difficile à diagnostiquer :
| Cas | Comportement |
| --- | --- |
| Schéma d'URL non `http(s)`, hôte manquant | rejeté |
| Nom invalide (tirets, espaces, chiffre initial) ; majuscules | rejeté / normalisé en minuscules |
| Nom de serveur en doublon | rejeté (les noms sont des préfixes, ils doivent être uniques) |
| Clé inconnue (`transports:` au lieu de `transport:`) | rejeté (`extra="forbid"`) |
| `timeout` ≤ 0, `max_concurrency` < 1, `cache_ttl` < 0 | rejeté |
| `tags: carte` (chaîne) ou `tags: [carte]` | les deux acceptés |
`name` est aussi le préfixe de tous les composants exposés : le serveur `mcpdiffusion`
expose `mcpdiffusion_<outil>`, et ses ressources deviennent `scheme://mcpdiffusion/…`.
## 2. Santé et joignabilité des amonts
`GET /health` ([`src/mcpgateway/health.py`](src/mcpgateway/health.py)) répond à deux
questions : le processus est-il vivant, et ses amonts sont-ils joignables.
```bash
curl localhost:8000/health # résumé : statut, compteurs, amonts injoignables
curl 'localhost:8000/health?details=1' # + détail par amont et instantané des métriques
curl 'localhost:8000/health?refresh=1' # sonde immédiate avant de répondre
```
* La joignabilité est mesurée par un **aller-retour réel** (`list_tools()`), pas par
`ping` : sur le protocole sans session (`2026-07-28`) que les clients FastMCP
négocient par défaut, `ping` n'est pas routé et lève `Method not found`.
* Une boucle de fond sonde les amonts (`probe_interval_seconds`) et mémorise état,
latence, dernier succès, dernier échec et échecs consécutifs.
* Un amont est déclaré `down` après `failure_threshold` échecs consécutifs ; un échec
isolé reste `degraded`, pour qu'un incident transitoire ne fasse pas redémarrer la
passerelle.
* Codes HTTP : `200` si aucun amont n'est `down`, `503` sinon. Le corps porte
`status` (`ok` / `degraded` / `down` / `empty`), les compteurs et `unreachable`.
* Le résumé **n'expose pas les URL** des amonts : un endpoint de santé sans
authentification (les routes personnalisées FastMCP ne passent jamais par
l'authentification, c'est voulu pour les sondes) n'a pas à servir d'annuaire.
## 3. Journalisation structurée des métriques
Tout événement opérationnel est une ligne JSON (`{"ts","level","logger","event",…}`,
[`src/mcpgateway/logging_utils.py`](src/mcpgateway/logging_utils.py)) :
```json
{"ts":"…","level":"INFO","event":"tool_call","upstream":"mcpdiffusion","tool":"mcpdiffusion_search_melodi_datasets",
"duration_ms":412.7,"status":"ok"}
{"ts":"…","level":"INFO","event":"upstream_call","upstream":"geodata","operation":"probe","duration_ms":918.46,
"status":"ok","tool_count":10,"url":"https://geollm.beta.ign.fr/geocontext/mcp"}
{"ts":"…","level":"WARNING","event":"health_check","status":"degraded","http_status":503,"servers":{…}}
```
Événements émis : `tool_call`, `upstream_call` (`probe` / `inventory` / `read_resource` /
`get_prompt` / `list_tools`), `health_check`, `catalog_read`, `concurrency_rejected`,
`probe_cycle`, `gateway_started` / `gateway_stopped` / `gateway_assembled`.
Métriques conservées en mémoire ([`src/mcpgateway/metrics.py`](src/mcpgateway/metrics.py)) :
compteurs, erreurs et latences (moyenne / min / max / p50 / p95) par `(amont, outil)`
pour les appels d'outils et par `(amont, opération)` pour les appels sortants. Elles
sont lisibles via `/health?details=1` et `catalog://servers/{nom}`. `--text-logs`
bascule sur un format lisible qui conserve les mêmes champs.
## 4. Délais et limites de concurrence
[`src/mcpgateway/middleware.py`](src/mcpgateway/middleware.py) et
[`src/mcpgateway/upstreams.py`](src/mcpgateway/upstreams.py) :
* **délai par appel amont** — `servers[].timeout`, appliqué au client proxy et aux
opérations propres à la passerelle (sonde, inventaire) ;
* **limite par amont** — `servers[].max_concurrency`, sémaphore partagé par les appels
d'outils proxifiés *et* par les sondes/inventaires ;
* **plafond global** — `gateway.concurrency.global_limit` ;
* **attente bornée** — au-delà de `acquire_timeout_seconds`, l'appel reçoit une erreur
explicite (`Gateway is busy: '<amont>' refused …`) au lieu de patienter
indéfiniment ; l'événement `concurrency_rejected` est journalisé ;
* les créneaux sont toujours libérés (y compris sur exception) et l'attente est
comptabilisée séparément comme opération `slot_wait`.
## 5. Catalogue en ressources MCP
[`src/mcpgateway/catalog.py`](src/mcpgateway/catalog.py) — optionnel, activé par
`gateway.catalog.enabled`, l'inventaire des outils par
`gateway.catalog.include_tool_inventory` :
| Ressource | Contenu |
| --- | --- |
| `catalog://servers` | tous les serveurs : domaine, description, tags, `enabled`, préfixe, limites, état de joignabilité |
| `catalog://domains` | serveurs regroupés par domaine fonctionnel |
| `catalog://servers/{name}` | détail d'un serveur + ses métriques (outils, sondes) |
| `catalog://servers/{name}/tools` | inventaire des outils : nom amont, **nom à appeler** (`{serveur}_{outil}`), description, paramètres |
| `catalog://guide` | guide d'utilisation : mécanique de la passerelle, puis les démarches propres au déploiement |
Le catalogue est exposé en **ressources** et non en outils : cela ne coûte rien au
contexte du client tant qu'il ne lit pas, et cela reste lisible quand la recherche
d'outils masque `tools/list`. Un amont injoignable ne casse pas la ressource : elle
répond `status: "unavailable"` avec l'erreur.
> **Limite connue** — la recherche d'outils (BM25) indexe les noms, descriptions et
> paramètres des outils, pas le vocabulaire du registre. Une requête comme `diffusion`
> peut donc ne rien renvoyer alors que le domaine existe : les instructions générées et
> `catalog://domains` restent la voie de découverte par domaine.
### Le guide (`catalog://guide`)
Le guide est en deux couches, dans une seule ressource :
1. **la mécanique, fournie par le paquet** (`src/mcpgateway/guide.md`) — nommage
`{serveur}_{outil}`, catalogue masqué derrière `search_tools`/`call_tool`, ressources
du catalogue, pièges courants. Elle ne cite **aucun** serveur ni outil de ce
déploiement : elle reste donc vraie quel que soit le registre, et une mise à jour du
paquet la corrige.
2. **les démarches du déploiement** (`guide-ssp.md`, via `gateway.catalog.guide_path`),
ajoutées après un séparateur — workflows multi-serveurs, noms d'outils réels, spécificités
des amonts.
Le guide de déploiement contient du **récit et des renvois**, jamais d'inventaire : celui-ci
reste généré (`instructions`, `catalog://servers*`), pour éviter deux sources de vérité.
`guide_path` est relatif au fichier de registre et vérifié au démarrage ; les deux fichiers
sont relus à chaque lecture, donc éditables sans redémarrage. `include_guide: false` retire
la ressource (et la mention dans les instructions).
Ce contenu est le même fichier qui pourra être exposé via l'API *skills* de FastMCP (le
frontmatter `name`/`description` est déjà présent) quand les clients sauront lire
`skills/list` — aujourd'hui `SkillsClientExtension` n'existe pas dans la version publiée.
Deux garde-fous empêchent la dérive du guide de déploiement : un test hermétique vérifie
que chaque serveur cité est bien activé dans le registre, et un test d'intégration vérifie
que **chaque outil cité existe encore** dans l'inventaire réel des amonts (il est ignoré si
un amont est injoignable).
## Empreinte sur le contexte
Un serveur MCP consomme du contexte **au moment de la connexion**, avant que le modèle
n'ait rien demandé. Les chiffres ci-dessous sont mesurés, pas estimés :
```bash
uv run python scripts/measure_context.py # registre réel : 22 outils amont
```
| Charge utile | Recherche d'outils activée | désactivée |
| --- | --- | --- |
| `instructions` | 856 o | 722 o |
| `tools/list` | 1 374 o (2 outils : `search_tools`, `call_tool`) | **59 241 o (22 outils)** |
| `resources/list` | 917 o (3 ressources, en-têtes seuls) | 917 o |
| `resources/templates/list` | 687 o (2 gabarits) | 687 o |
| **Total à la connexion** | **3,7 Ko** | **60,1 Ko** |
Soit un facteur **×16,1** entre les deux configurations : masquer le catalogue derrière
la recherche est le levier le plus rentable de toute la liste, et c'est pourquoi
`tool_search.enabled` est à `true`.
Le coût de l'agrégation, lui, ne dépend pas de ce réglage : chaque amont est sondé et son
inventaire lu à la connexion (mesuré : `mcpdiffusion` 206–346 ms, `geodata` 813–968 ms,
en parallèle et borné par `timeout`). C'est ce coût qui augmente si l'on ajoute des
serveurs amont — pas la taille envoyée au modèle.
### Ce qui ne part *pas* à la connexion
* **Le contenu des ressources.** `resources/list` ne renvoie que les en-têtes (nom, URI,
description, type MIME) ; le texte n'est transféré que sur un `resources/read` explicite.
Mesuré : `catalog://guide` = 8 302 caractères, soit 2,2× tout ce qui part à la connexion,
et uniquement si le client le lit.
* **Le contenu des prompts.** Les prompts sont à *contrôle utilisateur* dans le protocole :
ils apparaissent dans l'interface, pas dans le contexte du modèle.
* **Les schémas des outils masqués.** Avec la recherche, le client ne reçoit que les deux
méta-outils ; les 22 schémas amont (59 Ko) ne sont jamais envoyés, et l'inventaire
complet reste lisible dans `catalog://servers/{nom}/tools`.
> **Réserve honnête : joignable ≠ dans le contexte.** Les ressources étant à *contrôle
> applicatif*, un client qui ignore les ressources ne verra jamais le guide — le modèle
> n'apprend son existence que par la ligne des `instructions` qui y renvoie. Seul un
> client qui expose une lecture de ressource (ou `ResourcesAsTools`, cf. ci-dessous) rend
> le guide réellement accessible au modèle.
## Tests
```bash
uv run pytest # 99 tests unitaires, sans réseau
uv run pytest -m integration # 8 tests bout-en-bout (ports locaux + sous-processus)
```
Les tests d'intégration démarrent un vrai serveur MCP amont et la passerelle en
sous-processus, puis vérifient : outils proxifiés et préfixés, appel d'outil de bout en
bout, `/health` (états, latences, code 503), ressources du catalogue (résumés, domaines,
inventaires, paramètres), refus borné sous saturation de la concurrence, et contenu des
journaux JSON.
## Structure
```
registry.yaml registre des serveurs amont (déclaratif, validé)
guide-ssp.md guide de déploiement (démarches types), servi dans catalog://guide
scripts/measure_context.py mesure de l'empreinte sur le contexte (chiffres ci-dessus)
src/mcpgateway/
registry.py modèles Pydantic + chargement/validation du registre
server.py assemblage de la passerelle + CLI
upstreams.py un proxy par amont, sondes, inventaires, sémaphores
middleware.py métriques et plafond de concurrence
metrics.py compteurs et latences (percentiles bornés)
logging_utils.py logs JSON structurés (et repli texte)
health.py endpoint /health
catalog.py ressources catalog://… (dont catalog://guide)
guide.md guide générique (mécanique), embarqué dans le paquet
tests/
test_registry.py test_metrics.py test_middleware.py test_upstreams.py
test_health.py test_catalog.py test_server.py test_guide.py test_end_to_end.py
(+ fixtures/demo_upstream.py)
```
## Notes d'implémentation
* Un `ProxyProvider` + `ProxyClient` par amont, monté via `mount(proxy, namespace=nom)` :
la composition est vivante (les changements côté amont sont visibles) et l'espace de
noms donne le préfixe attendu par les clients.
* Le cache des listes de composants (`cache_ttl`) évite un aller-retour amont à chaque
résolution d'outil ; `cache_ttl: 0` le désactive pour un amont dynamique.
* `ProxyMetadataMiddleware` transmet les instructions et `_meta` des amonts sans
remplacer l'identité de la passerelle.
* Rien n'est secret dans le registre : l'authentification vers les amonts n'est pas
gérée à ce stade (à traiter avant tout déploiement exposé).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessWithin a week