Written before the code, from spec-decisions-mcp-server.md and the reviewed spec-plan-mcp-server.md (local, git-ignored). Status Proposed; to pass Accepted when the spec-mcp-server milestone merges. Indexed in docs/architecture.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
11 KiB
ADR 0024 — Serveur MCP : un mode --mcp du binaire, stdio seul, accès par profil, Premium
- Status: Proposed (écrit avant le code, à passer Accepted au merge de la milestone
spec-mcp-server) - Date: 2026-10-12
- Issues: milestone
spec-mcp-server(à créer depuisspec-plan-mcp-server.md), revue/review-specdu 2026-10-12 - S'appuie sur l'ADR 0017 (gating par édition,
FEATURE_TIERScôté Rust), l'ADR 0021 (prepare_at, rattrapage des profils secondaires) et l'ADR 0022 (canal d'événements de données), qu'il amende
Contexte
Simpl'Résultat garde toutes les données financières en local et n'envoie rien à un serveur tiers. Les agents IA (Claude Code, Claude Desktop, Hermes, Cursor) parlent un protocole commun, le Model Context Protocol (MCP), pour appeler des outils exposés par un programme local. Aujourd'hui rien ne relie l'app à ces agents : pour demander « où déborde mon budget », l'utilisateur exporte un CSV et le colle.
Trois faits du code structurent la décision :
- La logique métier vit dans la webview, en TypeScript, via
tauri-plugin-sql(src/services/*). Côté Rust, seulrusqliteouvre les bases, pour les migrations des profils secondaires et le rendement Modified Dietz. - Le PIN ne protège que l'interface : les bases SQLite sont en clair sous
app_config_dir(). - L'édition se résout en Rust depuis
license.key(et le cache de compte), avec révocation et fail-closed.
Et un fait du protocole : en MCP, ce n'est pas l'app qui transmet des données, c'est l'hôte. Chaque résultat d'outil part chez le fournisseur du modèle de l'agent. Le principe privacy-first de l'app reste vrai au sens strict, mais l'utilisateur doit l'activer en connaissance de cause.
Décision
Un mode --mcp de l'exécutable existant sert un serveur MCP sur stdio avec rmcp (SDK Rust officiel, spec 2026-07-28), ouvre en direct la base SQLite d'un profil que l'utilisateur a explicitement autorisé dans l'app, et expose des outils de lecture, deux outils d'écriture et deux prompts. Réservé à l'édition Premium.
1. Un binaire autonome, pas un serveur hébergé par l'app
simpl_result_lib::entry(args) choisit mcp::run_stdio() si --mcp est présent, sinon run(). Aucun tauri::Builder, aucun plugin single-instance ni deep-link dans ce mode. Le serveur marche app fermée, et rien n'est à empaqueter : pas de sidecar externalBin, pas de changement dans release.yml.
Le coût accepté : le SQL des outils est réécrit en Rust. Les services TypeScript restent la référence, et des tests de parité exécutent le texte SQL de référence (compareDeltaSql pour le résumé mensuel, getExpensesByCategory pour la répartition) sur une fixture commune et le comparent aux outils. Chaque constante SQL Rust porte un commentaire // ref: src/services/<fichier>.ts:<ligne>.
2. stdio seul
Aucun port, aucune authentification à inventer, pas de rebinding DNS depuis une page web (CVE-2025-66414 sur les serveurs MCP HTTP locaux ; les trois CVE connues de rmcp touchent ses transports HTTP, absents des features retenues). Tout client stdio local fonctionne avec la même commande ; Paramètres affiche celle de Claude Code et le JSON de Claude Desktop. Un hôte distant (Hermes sur le VPS) est hors scope : il demanderait Streamable HTTP, un jeton et un tunnel.
Stdout appartient au protocole : rien d'autre que rmcp n'y écrit, les journaux vont sur stderr. Un test lance le binaire et vérifie que le premier octet reçu ouvre un objet JSON.
3. Accès par profil, explicite, avec avertissement
Un drapeau agent_access dans profiles.json (#[serde(default)], absent = faux), posé dans l'app par un interrupteur sur la fiche du profil. Pour tout profil, avec PIN ou non ; la saisie du PIN précède l'activation quand le profil en a un. L'avertissement dit ce qui se passe : l'agent pourra lire toutes les transactions, catégories, budgets et notes du profil, en modifier la catégorisation, et ces données seront transmises au fournisseur du modèle de cet agent ; Simpl'Résultat n'envoie rien lui-même.
Le serveur ne voit que les profils autorisés (list_profiles), en sélectionne un par session (select_profile), et relit le drapeau à chaque appel d'outil : désactivé dans l'app, l'appel suivant répond profile_not_allowed et la session se ferme.
Le PIN n'est jamais demandé par le serveur ni transmis à l'hôte.
4. Premium, vérifié par le même résolveur que l'app
("mcp", &[EDITION_PREMIUM]) dans FEATURE_TIERS. La chaîne de résolution (dev-override → compte → clé, révocation, fail-closed) est extraite en license_commands::entitlements_in(data_dir), appelée par l'app et par le serveur : un seul résolveur. Le serveur démarre et répond à initialize en Free ou Base, mais chaque outil répond license_required ; l'édition est relue à chaque appel, donc une clé saisie dans l'app prend effet sans redémarrer l'hôte. Le fichier license.check n'est écrit que par l'app : une révocation en ligne n'est détectée qu'au prochain démarrage de l'app.
En mode --mcp, le keyring est lu sans jamais créer d'entrée, toute erreur vaut « pas de compte vérifié », et l'appel est borné par un délai : un trousseau verrouillé donne license_required, jamais un blocage de initialize.
Le binaire release ignore toute variable d'environnement : les dossiers de test (SR_MCP_DATA_DIR, SR_MCP_CONFIG_DIR) ne sont lus que sous la feature dev-override, comme SR_DEV_EDITION (CWE-489).
5. Lecture et écritures ciblées
Lecture : profils, résumé mensuel (par type de catégorie, comme les rapports : revenus et transferts en somme signée, dépenses en valeur absolue, résultat avant et après transferts), répartition hiérarchique par catégorie, transactions filtrées et paginées, non-catégorisées, budget vs réel, catégories, règles de mots-clés. Écriture : set_transaction_category (feuille active et saisissable seulement, is_manually_categorized = 1, drapeaux en 1/0) et create_keyword_rule (mêmes règles que l'app : 2 à 64 caractères, priorité 100, réactivation d'une règle existante vers la même catégorie, refus vers une autre ; application aux transactions existantes par LIKE ? ESCAPE puis frontière de mot, en dry-run tant que apply_to_existing est faux). Rien d'autre : ni suppression, ni import, ni budget, ni catégorie nouvelle.
Les libellés, notes et noms de catégorie sont des données importées, jamais des consignes, et les instructions du serveur le disent : un libellé piégé ne peut provoquer qu'une catégorisation, réversible dans l'app.
Les refus sont des résultats d'outil (isError: true) avec un code stable et un libellé fixe, sans chemin ni message SQLite (le détail va sur stderr, passé par scrub), pour que le modèle les lise et se corrige. Les erreurs JSON-RPC sont réservées aux fautes de protocole.
6. Bases en WAL, cohérence avec l'app ouverte
Les bases passent en journal_mode = WAL, posé par l'app seule (prepare_at et après chaque Database.load) : lecteurs et écrivain ne se bloquent plus, deux écrivains se sérialisent par busy_timeout. Le serveur refuse une base qui n'y est pas (profile_not_prepared) ou dont les migrations ne sont pas au dernier niveau (profile_not_migrated) : il ne migre ni ne prépare jamais une base qu'il n'a pas créée. VACUUM INTO emporte les pages encore dans le WAL, sans checkpoint (vérifié).
L'app détecte les écritures externes au retour du focus : une connexion rusqlite sentinelle, ouverte paresseusement en tauri::State, lit PRAGMA data_version, qui est par connexion (le pool de tauri-plugin-sql ne convient pas). Si la valeur a changé, notifyDataChanged() ; la resynchronisation après une écriture interne se fait en un seul point, dans notifyDataChanged. Faux positif toléré, courte fenêtre de faux négatif acceptée.
Amendement de l'ADR 0022 : §2, un nouvel émetteur, le contrôle au focus ; §5, de nouveaux abonnés, les hooks des pages que le serveur peut modifier (useTransactions, useDashboard, useBudget, liste des non-catégorisées), avec les règles existantes (attente de 150 ms, réponses périmées ignorées, désabonnement au démontage, un test par hook).
7. Ce que le serveur ne fait pas
Pas de transport HTTP, pas de resources MCP, pas d'outils Bilan, pas d'écriture hors catégorisation, pas de masquage des libellés, pas de PIN par élicitation, pas de chiffrement des bases. Chaque point est une phase 2 possible, listée dans spec-decisions-mcp-server.md.
Conséquences
- Positives : un accès agent standard, utilisable par tout client stdio local ; un consentement explicite par profil ; une seule chaîne d'édition ; le WAL profite aussi à l'app (une lecture ne bloque plus une écriture) ; les hooks de page gagnent un rafraîchissement ciblé réutilisable au-delà du MCP.
- Négatives : du SQL en double, tenu par les tests de parité ; trois fichiers par base (
.db,-wal,-shm), à dire dans le guide pour les sauvegardes à la main ; une base jamais ouverte par la version courante de l'app est refusée par le serveur ; le binaire Windows release est compilé sans console, et l'héritage des pipes de l'hôte se prouve en QA (repli : retirerwindows_subsystemet masquer la console au démarrage GUI). - Privacy : la politique de confidentialité (la-compagnie-maximus#184) doit dire que l'accès agent, une fois activé par l'utilisateur, fait transiter les données du profil par l'hôte MCP et son fournisseur de modèle.
Alternatives écartées
- Serveur hébergé par l'app en marche (sidecar stdio relié par socket, pont Rust ↔ webview vers les services TS) : zéro duplication et
dataEventsnatif, mais l'app doit tourner, un binaire de plus dansexternalBinetrelease.yml, et un pont à concevoir. - Hybride (lecture directe app fermée, relais app ouverte) : deux chemins à tester ; réservé à une phase 2 si le binaire autonome montre ses limites.
- Streamable HTTP : un port ouvert sur les données financières, un jeton à gérer, le rebinding DNS à contrer ; aucun hôte local n'en a besoin.
- PIN demandé par élicitation MCP : le PIN transiterait par l'hôte, et tous les clients ne supportent pas l'élicitation.
- Profils sans PIN seulement : un utilisateur qui protège son profil perdrait la fonctionnalité.
- Agrégats seulement par défaut : catégoriser exige le libellé ; deux niveaux d'exposition à expliquer pour un gain incertain.
- Remonter
refreshKeyau focus : trois lignes, mais la page perdrait filtres et défilement à chaque retour. wal_checkpoint(TRUNCATE)avantVACUUM INTO: inutile (la copie lit la vue de la connexion, WAL compris) et nuisible (échoue enSQLITE_BUSYdès qu'un lecteur est ouvert).