Ce qu’il a fallu pour construire un serveur MCP DynamoDB sûr
Le rend trivial le fait de confier ta base de données à un . C’est bien là le problème. La plupart des serveurs MCP DynamoDB sont un mince processus qui détient tes identifiants AWS bruts, avec un chemin d’écriture et aucune étape de revue — nous avons écrit sur pourquoi ce schéma est risqué et comment choisir en toute sécurité du côté de l’utilisateur.
Cet article, c’est l’autre côté : ce qu’il a réellement fallu pour construire la version sûre. DynoTable est un client DynamoDB de bureau dont le serveur MCP expose sa boîte à outils IA sous contrôle à des agents externes — Claude Code, Cursor, Codex — sans que l’agent ne touche jamais aux identifiants AWS. Y parvenir a signifié livrer un serveur d’autorisation à l’intérieur d’une app Electron, concevoir un type d’identité qui rend les mélanges d’identifiants impossibles à représenter, et décider, plus d’une fois, que le choix par défaut pratique était le mauvais.
Si tu construis un serveur MCP autour de quoi que ce soit de sensible, ce sont des décisions que tu rencontreras toi aussi.
Le HTTP en loopback n’est pas automatiquement sûr
Le serveur écoute sur 127.0.0.1, ce qui semble sûr mais ne suffit pas.
N’importe quelle page web que tu visites peut tenter des requêtes vers
localhost, et le DNS rebinding permet à une page hostile de les habiller avec un
Host plausible. La première couche est donc un garde d’origine : rejeter toute
requête portant un vrai Origin de page web, rejeter les en-têtes Host
non-loopback — mais autoriser les requêtes sans aucun Origin, parce que les
clients CLI légitimes comme Claude Code n’en envoient pas.
Cette dernière clause, c’est la partie honnête : un garde d’origine ne bloque que la classe d’attaques par navigateur. Il ne peut authentifier personne. Le token porteur est le vrai verrou, ce qui soulève la question de savoir d’où viennent les tokens.
Nous avons livré un serveur d’autorisation OAuth 2.1 à l’intérieur de l’app
L’authentification des serveurs distants de MCP repose sur OAuth 2.1, et pour un serveur de bureau local, il n’y a pas de fournisseur d’identité en amont sur lequel s’appuyer. L’app est donc le serveur d’autorisation : enregistrement dynamique des clients, (S256), codes d’autorisation, refresh tokens, révocation, et les documents de métadonnées RFC 8414 / RFC 9728 qui permettent aux clients de tout découvrir.
Des règles que nous avons fixées tôt et gardées :
- Ne pas réimplémenter le protocole à la main. Le SDK MCP fournit toute la surface du serveur d’autorisation sous forme de routeur ; nous n’implémentons que le fournisseur de stockage/politique en dessous. Une conséquence que nous avons acceptée : notre framework MCP parle un stack HTTP en interne et le routeur du SDK en parle un autre, donc le serveur d’autorisation tourne comme un second listener loopback, le serveur de ressources y pointant les clients via les métadonnées de ressource protégée. Deux listeners, c’est un coût cadré et explicite ; re-dériver PKCE et la grammaire des tokens à la main, c’en est un sans limite.
- Les redirections sont loopback uniquement (selon la RFC 8252 pour les apps natives), et une redirection invalide n’obtient jamais de redirection d’erreur — elle obtient un 400 direct, si bien que l’URI d’un attaquant n’est jamais une destination.
- Les codes d’autorisation sont à usage unique — un code rejoué est un grant invalide, point final.
- Le refresh peut restreindre la portée mais jamais l’élargir, et il réémet le même payload d’identité à l’identique — un token rafraîchi ne peut pas acquérir discrètement une région différente.
- La révocation est revérifiée à chaque requête, pas mise en cache à la connexion — cliquer sur « Revoke » dans les Réglages tue le prochain appel du client.
Les builds de développement utilisent à la place un chemin d’authentification plus simple, conçu pour ne pas pouvoir exister dans un binaire de release — un token non authentifié à portée complète dans un logiciel livré serait une porte dérobée locale derrière un garde d’origine qui admet délibérément les requêtes sans Origin.
Une connexion, une identité AWS — transportée en entier, jamais devinée
La propriété de sécurité centrale : un agent externe se connecte à un profil, pas à « DynoTable ». Chaque connexion est liée à un profil AWS et une région, adossée à son propre client DynamoDB non partagé, si bien que deux agents sur des profils différents ne peuvent pas déborder l’un sur l’autre.
Le binding a commencé sa vie sous forme de champs épars — awsProfile,
region, profileId, un port local optionnel — redéclarés dans chaque
enregistrement du chemin. C’est désormais un seul type d’identité en union
discriminée avec deux branches : online (profil + région + id) et offline
(port local + id, pour les profils DynamoDB Local). Le
caractère offline est le discriminant lui-même, si bien qu’une identité online
sans identifiants ou une identité offline portant une région est une erreur de
type, pas une surprise à l’exécution. Ce type unique voyage intact à travers
consentement → token → grant → chaque requête.
Deux conséquences que nous considérons comme portantes :
- La région est gelée au consentement. L’utilisateur approuve « le profil X
dans la région Y », et ce tuple est épinglé sur le token — survivant au
refresh, jamais re-résolu depuis un état mutable sur le chemin critique.
Modifier la région du profil exige un nouveau consentement. L’alternative —
résoudre la région à chaque requête ou retomber sur une valeur par défaut —
c’est ainsi qu’un agent finit discrètement par interroger
us-east-1. - Les tokens malformés donnent un 401, jamais une coercition. Si les claims
d’identité d’un token ne se parsent pas en une branche valide, la requête
n’est pas autorisée. Pas de profil
'default', pas de région par défaut, pas de chaîne vide. Chaque endroit où l’ancien code aurait deviné est désormais un échec net.
Le consentement est une surface produit, pas une boîte de dialogue
La première invite de consentement était la boîte de message native de la plateforme. Elle gelait le processus principal d’Electron tant qu’elle était ouverte, ne pouvait pas s’aligner sur le design system de l’app, et ne pouvait même pas être capturée en screenshot pour la doc. C’est maintenant une modale dans l’app : le nom du client qui se connecte, un sélecteur de profils limité exactement aux profils que l’utilisateur a choisi d’exposer, et trois portées proposées de la plus restreinte à la plus large — lecture seule, lecture + stage, complète. Les demandes de consentement expirées ou en double se résolvent en refus plutôt que de bloquer le flux OAuth.

Le sélecteur a sa propre petite frontière de confiance : un client peut suggérer un profil dans l’URL d’autorisation, mais le grant est forgé à partir de ce que l’utilisateur a réellement sélectionné, validé par rapport au snapshot qui lui a été présenté — une suggestion falsifiée ne peut pas lier un profil qui n’a jamais été à l’écran.
Nous sommes tout aussi explicites sur ce que le consentement ne te donne pas : l’autorisation porte uniquement sur la portée, approuvée une fois par connexion. Les invites de permission par appel de l’assistant dans l’app ne s’appliquent pas au trafic MCP — un token à portée complète qui fuite peut écrire (dans le staging) sans qu’un humain voie chaque appel. Des durées de vie de token courtes, une révocation par client, une piste d’audit toujours active qui marque chaque action issue de MCP, et la lecture seule comme offre par défaut sont les mesures d’atténuation. Écrire cela comme un risque résiduel était plus utile que de prétendre que la modale de consentement l’avait éliminé.
Garde-fous pour le travail headless
Un agent externe n’a pas de renderer, pas de popups de confirmation, pas d’utilisateur qui regarde. Tout ce qui est interactif avait besoin d’un équivalent headless :
- Bridage de licence en direct, à chaque appel. Le grant stocke la portée que l’utilisateur a approuvée ; la portée effective est dérivée à chaque appel d’outil depuis l’état de licence actuel de l’app — une licence en lecture seule bride un grant complet vers le bas, un état déconnecté rejette avec un 401 avant toute branche de token (pour que le token de dev ne puisse pas la contourner), et une réactivation en cours de session ré-élargit sans nouveau consentement. Une asymétrie délibérée : l’export dans l’app reste disponible sous une licence en lecture seule (tes données sont tes données), mais l’export en masse headless via MCP est bridé — un droit de portabilité interactif et un canal de sortie piloté par un agent sont des surfaces de confiance différentes.
- Des budgets de Scan au lieu de popups de confirmation. Dans l’app, une lecture de table entière demande d’abord. En headless, un budget d’items/octets/durée borne chaque Scan, et le SQL sur les tables passe en flux par un moteur adossé au disque plutôt que par une limite en mémoire.
- La déconnexion interrompt honnêtement. Si le client se déconnecte en plein Scan, le serveur s’interrompt à la frontière de page suivante — jamais au milieu d’une instruction — et libère l’état temporaire.
- La se complète à distance. Quand les identifiants AWS ont besoin d’un code MFA et que le client prend en charge l’élicitation MCP, le serveur élicite le code à 6 chiffres via l’UI de l’agent lui-même — Claude Code sollicite l’humain — et réessaie. Le SSO est délibérément exclu de ce chemin : il nécessite un device flow via navigateur, et prétendre le contraire ne ferait qu’un message d’erreur pire. Les appels concurrents qui ont besoin des mêmes identifiants se regroupent en une seule invite.
Exposer une base de données via MCP est-il seulement sûr ?
Notre réponse honnête : seulement si le serveur est conçu autour de l’hypothèse que l’agent est confus au mieux et adversarial au pire. La rend l’intention d’un agent non fiable, et c’est pourquoi l’architecture ne compte jamais sur le bon comportement de l’agent : les identifiants restent dans l’app, les écritures n’atterrissent jamais que dans une zone de staging vérifiable, les portées brident ce qu’un appel peut faire quel que soit ce que le modèle demande, et chaque action est auditée. Le guide côté connexion couvre ce que cela signifie en pratique pour chaque client.
Ce qu’il faut exiger de tout serveur MCP qui touche à ta base de données
- Où vivent les identifiants — dans le processus de l’agent, ou derrière le serveur ?
- Y a-t-il un consentement par connexion avec de vraies portées, ou un seul fichier de config accorde-t-il tout ?
- Une écriture peut-elle s’exécuter sans étape de revue humaine ?
- L’identité (profil, région) est-elle épinglée au consentement, ou résolue depuis des valeurs par défaut au moment de l’appel ?
- Que se passe-t-il avec un token malformé ou révoqué — un échec net, ou un repli ?
- Y a-t-il une piste d’audit qui distingue les actions issues d’un agent ?
- Qu’est-ce qui borne un Scan incontrôlé, et que se passe-t-il quand le client se déconnecte en pleine opération ?
Si un serveur ne peut pas répondre à ces questions, l’intégration pratique est l’intégration risquée. Pour évaluer ce qu’un Scan sans limite te coûterait réellement, le calculateur de tarifs fait réfléchir — et si tu préfères voir tourner la version sûre plutôt que la lire, télécharge DynoTable, active le serveur MCP, et connecte un client en portée lecture seule en moins d’une minute.


