← Tous les articles

Donner les clés à une IA sans lui donner l'immeuble : comment nous avons conçu un accès MCP sécurisé pour note2it

Architecture du connecteur MCP de note2it : OAuth 2.1 avec PKCE, portées en masque sur le RBAC, locataire tiré du jeton et audit de chaque appel d'outil.

Quand nous avons connecté note2it à Claude et à ChatGPT par le Model Context Protocol (MCP), la question produit était simple. Les gens veulent que leur assistant IA cherche dans leurs notes, lise un résumé de réunion et rédige la page de suivi. La question d'ingénierie l'était moins : comment laisser un modèle de langage agir dans une application SaaS multilocataire sans en faire le maillon faible de votre modèle de sécurité?

Un serveur MCP est une API dont l'appelant est un modèle. Le modèle suit des instructions, parfois des instructions qu'il a trouvées dans le contenu même qu'on lui demandait de lire. Il réessaie, il devine des arguments, et il s'exécute sur l'infrastructure de quelqu'un d'autre avec un jeton que vous avez émis. Nous avons donc conçu le connecteur sur une seule hypothèse : le modèle n'est pas une frontière de sécurité. Tout ce qui tient les notes d'un client à l'écart de celles d'un autre, et tout ce qui rend une erreur réparable, doit tenir même si le modèle est confus, manipulé ou simplement dans l'erreur.

Voici comment ça fonctionne dans note2it aujourd'hui.

La forme du problème

note2it est une application de prise de notes multilocataire : carnets, sections et pages, avec enregistrements de réunions, tâches et partage. Elle est conçue pour être prête pour l'audit (périmètre SOC 2), ce qui nous impose des règles non négociables, antérieures à MCP :

Un seul chemin de données. Interface → client d'API → route /api/v1 → couche de services → base de données. Rien n'interroge la base de données en dehors de la couche de services.

Chaque route exécute les mêmes cinq étapes : authentifier → autoriser → valider → exécuter → journaliser.

L'isolation des locataires vient de la session, jamais de ce que le client envoie.

Refus par défaut. Pas de permission, pas d'accès.

Chaque modification est journalisée, succès comme échec, en ajout seulement, sans aucune donnée personnelle dans la piste d'audit.

La règle pour MCP était simple : le connecteur n'a droit à aucune exception. Ce n'est pas une porte dérobée avec ses propres requêtes à la base de données. C'est un client de plus des mêmes services, avec un contrôle de plus.

Principe 1 : un seul chemin de données, un contrôle de plus

Chaque outil MCP est une petite spécification : un nom, une description écrite pour le modèle, un schéma d'entrée Zod, la permission RBAC requise, la portée de jeton requise, et un gestionnaire qui appelle lib/services/* et rien d'autre. Les outils ne touchent jamais à la base de données.

Un appel d'outil passe ensuite par un pipeline calqué sur nos routes REST :

authenticate (jeton porteur)
  → rate-limit (par jeton : un quota de lecture, plus un quota d'écriture plus serré)
  → scope mask (ce JETON autorise-t-il cette permission?)
  → authorize (ce RÔLE autorise-t-il cette permission?)
  → validate (Zod)
  → execute (le même service que l'application web)
  → audit-log (succès et échec)

Les quatre dernières étapes sont littéralement la même fonction que celle de notre API web. Le pipeline MCP ajoute devant elle les contrôles propres aux jetons. Cette réutilisation compte plus que n'importe quel contrôle pris isolément. Quand nous corrigeons ou resserrons quelque chose dans le pipeline commun, l'application web, l'application iOS et chaque assistant IA reçoivent la correction en même temps, et il n'existe pas de seconde implémentation qui pourrait diverger.

Principe 2 : le locataire vient du jeton, jamais des arguments

La ligne la plus importante de tout le connecteur est un commentaire :

/** The authenticated caller derived from a bearer token. `userId` comes from
 *  the token record ONLY — tool input can never influence tenancy. */

Aucun outil n'accepte d'identifiant d'utilisateur. Les outils acceptent des identifiants de page, des noms de carnet, des requêtes de recherche. L'utilisateur est déterminé côté serveur à partir du jeton porteur, et chaque requête de service part de cet utilisateur. La recherche, par exemple, construit son filtre à partir de l'appelant et de rien d'autre :

export async function accessFilter(userId: string): Promise<Filter<PageDoc>> {
  const sharedIds = await listSharedNotebookIds(userId);
  return sharedIds.length
    ? { $or: [{ ownerId: userId }, { notebookId: { $in: sharedIds } }], ...NOT_TRASHED }
    : { ownerId: userId, ...NOT_TRASHED };
}

Un modèle qu'on a piégé pour qu'il demande « la page 6a1f… d'un autre client » obtient la même réponse qu'un modèle qui demande une page inexistante : introuvable. Pas « interdit », ce qui confirmerait que la page existe. La question de savoir à qui appartiennent ces données n'atteint jamais le modèle.

Principe 3 : l'autorisation est une intersection, pas une union

note2it a une matrice de permissions par rôle : user (propriétaire), viewer (lecture seule) et superadmin (cycle de vie des comptes seulement; il ne peut jamais lire vos notes ni vos fichiers). Les jetons MCP ajoutent des portées. La tentation serait d'en faire un second système de permissions, indépendant. Nous ne l'avons pas fait. Les portées sont un masque appliqué au RBAC :

// A tool call is allowed only when BOTH checks pass:
//   hasPermission(role, permission) AND scopeAllows(token.scopes, permission)
export function scopeAllows(scopes: readonly McpScope[], permission: Permission): boolean {
  return scopes.some((s) => SCOPE_PERMISSIONS[s]?.includes(permission) ?? false);
}

Un jeton ne peut que restreindre ce que son propriétaire peut faire, jamais l'élargir. Un viewer qui détient un jeton notes:write ne peut toujours pas écrire. Un propriétaire qui détient un jeton notes:read ne peut toujours rien modifier. Une portée inconnue ou une permission inconnue vaut « non ».

Il existe exactement deux portées MCP : notes:read et notes:write. Ce qui ne peut pas du tout s'exprimer sous forme de portée MCP compte tout autant :

Les actions de compte et d'administration. Aucun jeton ne peut modifier votre compte, consulter la facturation ni atteindre une fonction d'administration.

Le partage. Un assistant IA ne peut partager votre carnet avec personne.

Nos propres fonctions d'IA, qui ont leur propre comptage.

La suppression définitive de médias.

Et une absence délibérée qui nous semble le bon choix par défaut pour tout SaaS : la suppression de compte n'est possible que depuis une session de navigateur. La permission ne figure dans aucune table de portées de jeton, donc aucun client MCP, aucun jeton personnel et aucun jeton d'application mobile ne peut supprimer un compte. Le pire que puisse faire une intégration IA compromise est borné par la conception, pas par la bonne conduite du modèle.

Deux façons d'entrer : jetons personnels et OAuth 2.1

Les utilisateurs connectent un assistant de l'une de deux façons.

Les jetons d'API personnels, pour Claude Code, les scripts et les clients sans OAuth. Un jeton, ce sont 32 octets aléatoires derrière un préfixe n2i_ (pour que les détecteurs de secrets puissent en repérer un dans un journal qui a fuité). Il est affiché une seule fois, et seule son empreinte SHA-256 est conservée. Les jetons ont des portées et une expiration facultative, sont révocables un à un, plafonnés à dix actifs par utilisateur, et enregistrent leur dernière utilisation.

OAuth 2.1, pour Claude.ai, ChatGPT et tout client qui prend en charge le flux d'autorisation de MCP. Nous avons bâti un serveur d'autorisation volontairement réduit plutôt que d'adopter une solution généraliste, parce que chaque fonction que nous n'avons pas construite est une fonction qui ne peut pas être mal configurée :

Code d'autorisation + PKCE seulement, avec S256 obligatoire. Pas de flux implicite, pas d'octroi par mot de passe, pas de secrets client (tous les clients sont publics), pas de jetons d'identité, pas de clés de signature.

Enregistrement dynamique des clients (RFC 7591) avec des URI de redirection strictes : https, http en boucle locale pour les outils locaux, ou un schéma privé pour les applications natives. Jamais de http simple vers un hôte distant, et jamais d'URI contenant des identifiants.

Les codes d'autorisation vivent 60 secondes et sont à usage unique, consommés de façon atomique (findOneAndDelete : un code rejoué ne trouve rien). Chaque code est lié au client, à l'URI de redirection et au défi PKCE, et porte les portées et la ressource RFC 8707 pour lesquelles il a été émis. Toute discordance fait échouer l'échange.

Les jetons d'accès vivent une heure; les jetons d'actualisation vivent 30 jours et sont remplacés à chaque utilisation, avec détection de réutilisation. Réclamer un jeton d'actualisation est une seule mise à jour conditionnelle, donc deux actualisations simultanées ne peuvent pas réussir toutes les deux. Si un jeton déjà remplacé est présenté de nouveau, nous le traitons comme un vol et révoquons l'autorisation au complet, comme le recommande OAuth 2.1.

La découverte suit les standards. Les métadonnées de ressource protégée (RFC 9728) dirigent les clients vers le serveur d'autorisation, et une réponse 401 du point de terminaison MCP leur indique où regarder.

L'écran de consentement est notre propre interface, authentifiée par session et vérifiée par origine. Un formulaire d'un autre site ne peut pas approuver une autorisation, et le serveur revalide chaque champ au lieu de se fier à ce que la page de consentement a affiché.

Pourquoi des jetons opaques plutôt que des JWT

Chaque jeton d'accès est une chaîne aléatoire opaque, conservée sous forme d'empreinte seulement et vérifiée à chaque requête. Cela coûte une lecture en base de données par appel. En échange, nous obtenons trois choses qui comptent davantage pour nous que cette lecture :

1. La révocation est instantanée. Déconnectez une application dans les réglages et son appel suivant échoue. Pas besoin d'attendre qu'un JWT expire.

2. Les changements de compte s'appliquent dès la requête suivante. Chaque vérification relit l'utilisateur, donc une désactivation, un changement de rôle ou un passage à un forfait inférieur prend effet dès le prochain appel d'outil.

3. Il n'y a rien à faire fuiter au repos. Une copie de la base de données contient des empreintes, pas des identifiants, et il n'y a aucune clé de signature à voler.

Deux types de clients, tenus à part

note2it a deux types très différents de clients à jeton porteur :

Les applications maison (nos applications iPhone et Apple Watch) parlent à /api/v1, utilisent leur propre ensemble de portées et sont des clients enregistrés que nous contrôlons.

Les clients MCP tiers (Claude, ChatGPT et tout ce qu'un utilisateur connecte d'autre) ne parlent qu'au point de terminaison MCP et ne portent que les portées MCP.

Nous les gardons comme des profils de confiance distincts et appliquons la séparation dans les deux sens. Un jeton maison présenté au point de terminaison MCP est refusé et journalisé comme un jeton sur la mauvaise surface. Un client tiers est limité aux portées MCP dès le consentement, donc une autorisation trop large n'est même jamais enregistrée. Si un jeton tiers porte malgré tout une portée réservée aux applications maison, cette portée est retirée à la vérification, et un jeton à qui il ne reste rien est refusé.

Cette rigueur, nous l'avons apprise à nos dépens, et nous préférons le dire. Les deux ensembles de portées partagent les noms notes:read et notes:write. En août, une revue de sécurité interne a constaté qu'un jeton tiers détenant notes:write aurait pu être évalué selon le masque de permissions maison sur /api/v1, qui accorde davantage (la gestion des tâches, par exemple). La correction : faire de « ce jeton a-t-il été émis pour un client maison enregistré? » une propriété explicite et vérifiée de chaque principal, et n'appliquer le masque maison que lorsqu'elle est vraie.

Les mêmes noms partagés ont causé un bogue en production deux jours plus tard. Le connecteur MCP est une fonction des forfaits payants, et la vérification du forfait regardait les noms de portées. Chaque jeton iPhone porte notes:read, si bien que les utilisateurs du forfait gratuit se retrouvaient bloqués hors de l'application mobile dès leur premier appel d'API. La correction : la vérification du forfait ne s'applique qu'aux jetons de profil MCP, et le point de terminaison MCP refuse d'emblée les jetons maison. Sur la surface MCP, l'authentification est la vérification du forfait.

La leçon vaut au-delà de notre cas : un nom de portée n'est pas une identité. Si deux types de clients peuvent détenir la même chaîne de portée, ce qui les distingue doit être vérifié côté serveur, pas déduit.

Une piste d'audit qui couvre le modèle

Chaque appel d'outil MCP est journalisé, succès comme échec, dans une piste d'audit en ajout seulement. Chaque entrée consigne une action stable (page.search, page.update), le résultat, l'utilisateur et le rôle qui agissent, la cible, un identifiant de requête et { via: "mcp", clientId, tokenId }. Aucun contenu, aucune adresse courriel, aucune donnée personnelle.

Les échecs propres au pipeline sont journalisés aussi : dépassements de quota, portées manquantes et jetons porteurs refusés. Un jeton refusé est étiqueté soit comme jeton porteur invalide, soit comme jeton maison valide présenté sur la mauvaise surface, parce que ce sont deux enquêtes différentes. L'écriture d'audit de ces refus se fait au mieux, pour qu'un échec d'audit ne puisse jamais transformer une 401 en 500. Une fois les contrôles franchis, l'audit fait partie de la requête.

Cela répond à la question que posent vraiment les responsables de la sécurité : « Si un assistant IA a modifié quelque chose, pouvez-vous me montrer quoi, quand et par quelle connexion? » Oui, jusqu'au jeton près. Les utilisateurs peuvent aussi consulter leur propre piste dans les réglages.

Des écritures réversibles

L'accès en lecture est une question d'isolation. L'accès en écriture est aussi une question de réversibilité, parce qu'un modèle finira par faire quelque chose que son utilisateur ne voulait pas.

Écrire exige à la fois la portée notes:write et un rôle de propriétaire. Les pages et carnets partagés avec vous en lecture seule n'acceptent jamais d'écriture, quoi qu'en dise le jeton.

Chaque modification du contenu d'une page est précédée d'un instantané, qui apparaît dans l'historique de la page et peut être restauré.

Les modifications partielles utilisent la concurrence optimiste. Le modèle doit fournir le updatedAt qu'il a lu en dernier, pour ne pas écraser en silence une modification faite par l'utilisateur une minute plus tôt. Il reçoit plutôt un conflit et relit la page.

« Supprimer » veut dire mettre à la corbeille. L'outil de corbeille MCP est réversible. La suppression définitive est une action du propriétaire, dans l'application seulement.

Les outils portent des annotations honnêtes (readOnlyHint, destructiveHint), pour que des hôtes comme Claude puissent demander une confirmation avant tout ce qui modifie des données.

Nous plafonnons aussi les résultats d'outil à environ 25 000 jetons et indiquons au modèle comment préciser sa demande (« ajoutez des filtres, réduisez la limite, récupérez une seule page »). Cela protège le contexte du modèle et empêche qu'une requête qui s'emballe devienne une exportation massive.

Le seul outil qui sort sur Internet

Un outil MCP importe une image dans une page à partir d'une URL. Toute fonction où l'appelant choisit une URL que votre serveur ira chercher est une primitive SSRF, et cet outil est construit comme tel :

https sur le port 443 seulement, sans identifiants intégrés et sans repli vers http.

Chaque réponse DNS doit être publique. Boucle locale, plages privées, adresses lien-local (y compris l'adresse des métadonnées infonuagiques), CGNAT, multidiffusion et plages réservées sont refusées en IPv4, en IPv6 et sous forme IPv4 mappée. Si une seule adresse de la réponse échoue, toute la requête échoue.

La connexion ne peut atteindre qu'une adresse que nous avons approuvée. La résolution DNS du socket lui-même résout et valide de nouveau, ce qui neutralise le DNS rebinding.

Les redirections sont suivies manuellement, plafonnées, et chaque saut est entièrement revalidé.

La taille est plafonnée à mesure que les octets arrivent, sans se fier à content-length.

Le type déclaré et les octets magiques du fichier doivent concorder, selon une liste d'autorisation limitée aux images matricielles (pas de SVG).

Les messages d'erreur sont constants. Ils ne révèlent jamais à quoi un hôte a été résolu.

Ce que nous avons délibérément choisi de ne pas construire

Certaines des décisions les plus importantes ont été des omissions :

Pas d'usurpation d'identité, pas de porte dérobée pour le soutien. Notre rôle superadmin gère le cycle de vie des comptes et voit des décomptes par compte. Il n'a aucune permission de lire vos notes, ni par MCP ni ailleurs. Seule exception : notre visionneuse interne du journal d'audit affiche les titres des pages, carnets et sections nommés dans les événements d'audit. Ces titres sont recherchés au moment de la consultation et ne sont jamais stockés dans le journal.

Pas de sessions sur le transport MCP. Le point de terminaison est en Streamable HTTP sans état. Chaque appel est authentifié séparément, donc il n'y a aucune session à détourner ou à épingler.

Pas de secrets client, pas de JWT, pas de clés de signature. Moins de choses à renouveler, moins de choses à faire fuiter.

Aucune portée pour ce qui change qui peut voir vos données. Le partage, les réglages du compte et la suppression restent dans l'application, derrière une session de navigateur connectée.

Un interrupteur d'urgence. Toute la surface MCP, métadonnées OAuth comprises, dépend d'un seul indicateur et répond 404 lorsqu'il est désactivé.

Une liste de vérification pour votre propre serveur MCP

Si vous placez un serveur MCP devant un produit multilocataire, voici ce que nous vous dirions :

1. Faites de MCP un client de votre couche de services existante, pas un nouveau chemin de données.

2. Déterminez le locataire à partir du jeton, côté serveur. N'acceptez jamais un identifiant d'utilisateur ou de locataire comme argument d'outil.

3. Faites des portées un masque sur vos permissions (intersection, pas union), et refusez par défaut.

4. Gardez le compte, le partage, la facturation, l'administration et la suppression hors de toute portée de jeton.

5. Utilisez OAuth 2.1 avec PKCE obligatoire, des codes à usage unique liés à leur demande et des jetons d'actualisation remplacés à chaque usage, avec détection de réutilisation.

6. Privilégiez des jetons opaques conservés sous forme d'empreinte si vous voulez une révocation instantanée et une vérification du compte à chaque requête.

7. Gardez les clients maison et tiers comme des profils de confiance distincts et vérifiés. Un nom de portée n'est pas une identité.

8. Journalisez chaque appel d'outil, succès comme échec, avec le jeton et le client par lesquels il est passé, et sans contenu.

9. Rendez les écritures réversibles : instantanés, concurrence optimiste, suppression douce.

10. Traitez tout outil qui va chercher une URL comme une primitive SSRF, et protégez-le en conséquence.

11. Partez du principe que le modèle se trompera ou sera manipulé, et assurez-vous que votre réponse à « quel est le pire qu'il puisse faire? » soit courte.

Le connecteur MCP de note2it est offert avec les forfaits Premium et Pro. Pour l'essayer avec Claude, lisez Connectez Claude à vos notes, ou passez directement à la page de configuration de chaque client pris en charge. Pour un portrait en langage clair de ce qu'un assistant peut voir et ne pas voir, lisez Ce que votre assistant IA peut voir. Commencez gratuitement sur note2it.com.